25 May 2011

How to Stub Dependency Event Handlers in Integration Tests

Integration tests help you ensure dependencies behave as expected.  When creating unit tests, developers go to great lengths to eliminate dependencies, but that's not our objective.  The application should already have passed unit tests, before proceeding with integration tests. We want to ensure that integrating the application with its dependencies works as expected.

"Dependencies" includes other modules, APIs, frameworks, hardware, databases, networks, etc.  We want to ensure the application works with these other resources as expected.  This demonstration code implements constructor injection, to simplify testing.  Dependency injection is essential to unit testing, but also proves useful for integration testing.  The injection is important, because we want to isolate a specific dependency and inject a controlled mock object for others.


Set Up Projects

  1. Create a new Class Library project named "ClassLibrary1"
  2. Copy the following code into the Class1.cs library:

    using System.IO;
     
    namespace ClassLibrary1
    {
        public class Class1
        {
            FileSystemWatcher watcher;
            
            public Class1(string directoryPathToWatch)
            {
                FileCreated = false;
                watcher = new FileSystemWatcher(directoryPathToWatch);
                watcher.Created += new FileSystemEventHandler(watcher_Created);
                watcher.Filter = "*.txt";
                watcher.EnableRaisingEvents = true;
            }
     
            void watcher_Created(object sender, FileSystemEventArgs e)
            {
                FileCreated = true;
            }
     
            private bool _fileCreated;
            public bool FileCreated
            {
                get { return _fileCreated; }
                set
                {
                    _fileCreated = value;
                }
            }
        }
    }
    
    
  3. Right-click the watcher_Created method name -- the context menu appears
  4. Select the Create Unit Tests... option in the context menu
  5. When prompted, create a test project named TestProject1.
There is a good reason for creating the test project using this method.  We need to access the private members of Class1 from the test method.  The Visual Studio test framework transparently created an object named Class1_Accessor, that exposes the private objects.<

Create Integration Test

We need to test the FileSystemWatcher object, to ensure it is correctly calling the watcher_Created event handler.  (Sure, this is a .NET Framework object that should be reliable, but this is simply for demonstration purposes.)
  1. Open Class1Test.cs, if it is not already open
  2. Add using statements for the following namespaces:

    using System.IO;
    using System.Threading;
  3. Delete everything inside Class1Test
  4. Create a test method, named WatcherReportsCreationOfTxtFiles:

    [TestMethod]
    public void WatcherReportsCreationOfTxtFile()
    {
        // Arrange.
     
        // Act.
     
        // Assert.
        Assert.Inconclusive("This test is not complete.");
    }
     
  5. Let's arrange a Class1_Accessor object and some other variables.  Because we are watching for file creation events, we want to specify a controlled directory instead of another one that may be in active use:

    [TestMethod]
    public void WatcherReportsCreationOfTxtFile()
    {
        // Arrange.
        Class1_Accessor target = new Class1_Accessor(@"C:\Temp\FileTest");
        var path = @"C:\Temp\FileTest\test.txt";
        var result = false;
     
        // Act.
     
        // Assert.
        Assert.Inconclusive("This test is not complete.");
    }
     
  6. We are watching for file creation events.  If we create a file that already exists, our watcher will not see this as a new file being created.  Rather, this is considered a file modification.  Therefore, it is necessary to delete our target file, for a good test.  Don't forget, we must wait for the file to be removed from disk!  Add the following lines to the Act section:

    File.Delete(path);
    Thread.Sleep(100); // Allow time to delete the file.
  7. When we create a new file, the watcher is going to call its handler.  We aren't interested in the handler being called.  In fact, we should disable it, to prevent executing more code than absolutely necessary.  Therefore, we want to unsubscribe the original handler from the delegate:

    target.watcher.Created -= target.watcher_Created;
    
  8. We need to determine if the Created event is raised.  We may do so by creating our own, anonymous method.  This method will set the value of the result boolean to true, indicating the call happened.  We can use lambda expressions, to make this a one-line solution, rather than creating another method in the test class:

    target.watcher.Created += (object sender, FileSystemEventArgs e) => result = true;
    
  9. It's time to act!  Create the test file, and wait for the file to be written to disk:

    // Act.
    File.Create(path);
    Thread.Sleep(100); // Allow time to create the file.
  10. Finally, we must assert the value of result is true.  Remove any existing assertion from the method, and then assert:

    // Assert.
    Assert.IsTrue(result);

     
We're not done, yet!  Notice the file watcher has a filter applied, that should cause it to raise events only for files with names ending in ".txt".  Naturally, we want to create another test for this case.  Simply paste a copy of the WatcherReportsCreationOfTxtFiles test method, and name it WatcherDoesNotReportCreationOfCsvFile.  Change the path object value to a .csv extension, rather than a .txt extension.  We also want to reverse the assertion to test for IsFalse, since the delegate should not be called when the test.csv file is created.

The complete Class1Test.cs file should look like this:


using System.IO;
using System.Threading;
using ClassLibrary1;
using Microsoft.VisualStudio.TestTools.UnitTesting;
 
namespace TestProject1
{
    [TestClass]
    public class Class1Test
    {
        [TestMethod]
        public void WatcherReportsCreationOfTxtFile()
        {
            // Arrange.
            Class1_Accessor target = new Class1_Accessor(@"C:\Temp\FileTest");
            var path = @"C:\Temp\FileTest\test.txt";
            var result = false;
 
            File.Delete(path);
            Thread.Sleep(100); // Allow time to delete the file.
 
            target.watcher.Created -= target.watcher_Created;
            target.watcher.Created += (object sender, FileSystemEventArgs e) => result = true;
 
            // Act.
            File.Create(path);
            Thread.Sleep(100); // Allow time to create the file.
 
            // Assert.
            Assert.IsTrue(result);
        }
 
        [TestMethod]
        public void WatcherDoesNotReportCreationOfCsvFile()
        {
            // Arrange.
            Class1_Accessor target = new Class1_Accessor(@"C:\Temp\FileTest");
            var path = @"C:\Temp\FileTest\test.csv";
            var result = false;
 
            File.Delete(path);
            Thread.Sleep(100); // Allow time to delete the file.
 
            target.watcher.Created -= target.watcher_Created;
            target.watcher.Created += (object sender, FileSystemEventArgs e) => result = true;
 
            // Act.
            File.Create(path);
            Thread.Sleep(100); // Allow time to create the file.
 
            // Assert.
            Assert.IsFalse(result); // NOTE WE ARE TESTING TO ENSURE THE HANDLER WAS *NOT* CALLED!
        }
    }
}


Execute the tests.  They should both pass!

Summary

This code accomplished several things:

  • Tested a specific dependency (the local file system)
  • Exposed private members of the target class
  • Minimized target code execution
  • Subscribed an anonymous method for the sole purpose of validating the test
  • Employed a lambda expression, which is always a cool thing to do

No comments:

Post a Comment

Please provide details, when posting technical comments. If you find an error in sample code or have found bad information/misinformation in a post, please e-mail me details, so I can make corrections as quickly as possible.