Watching a directory: testability

Spread the love

Previously, I introduced DirectoryWatcher but it was woefully untestable. Tight coupling to the file system is something that would be at odds with a microtesting practice as described by “Geepaw” Hill. I agree with him and nearly always avoid extraneous file operations in TDD code.

Luckily DirectoryWatcher in its current form is actually pretty close to being decoupled from real file system operations. If you look closely, you will see that only the constructor really has any trouble here. Given this situation, it seems natural to reach for a rarely used tool in the modern era — an abstract base class. To keep ourselves honest, we will build the abstract class up from nothing and write tests along the way. At the end, we can remake DirectoryWatcher in terms of this new class (sort of an “extract superclass” refactoring).

To begin, we need a FakeDirectoryWatcher which will become the reference implementation of the abstract class for testing purposes. Then we can write the first unit test:

    [TestClass]
    public sealed class DirectoryWatcherBaseTest
    {
        [TestMethod]
        public void CreateNullPath()
        {
            DirectoryInfo path = null;

            Action act = () => new FakeDirectoryWatcher(path);

            act.Should().Throw<ArgumentNullException>().Which.ParamName.Should().Be("path");
        }

        private sealed class FakeDirectoryWatcher
        {
            public FakeDirectoryWatcher(DirectoryInfo path)
            {
            }
        }
    }

We’re starting simple here. We don’t even need the base class yet. Making this test pass is just a matter of adding a null check and throw statement. The next test will force the issue of the base class:

        [TestMethod]
        public void CreateAndDispose()
        {
            DirectoryWatcherBase watcher = new FakeDirectoryWatcher(new DirectoryInfo(@"X:\root"));

            Action act = () => watcher.Dispose();

            act.Should().NotThrow();
        }

We can continue like this for each additional feature/requirement: CreateNullPath, CreateAndDispose, UpdateZeroSubscriptions, UpdateIrrelevantFileOneSubscription, UpdateRelevantFileOneSubscription, UpdateRelevantFileOneSubscriptionDifferentCase, UpdateTwoFilesTwoSubscriptions, UpdateAfterDispose, UpdateTwoAfterOneSubscriptionDispose, AfterSubscriptionDisposeSubscribeAgainAndUpdate, SubscribeSameFileTwice, SubscribeSameFileTwiceDifferentCase, SubscribeNullFile, SubscribeNullCallback, SubscribeBadFileName, SubscribeEmptyFileName, SubscribeDotFileName, SubscribeFileRelativeSubDir, SubscribeFileRelativeOutsideDir, SubscribeFileRootedPath.

The final implementation is similar but not exactly what we had before in the untested/untestable version. (There are definitely more error cases covered now.) As a final touch, we need to remove the semantically duplicated code in DirectoryWatcher and implement the base class instead. Running the sample app gives the same behavior as before, so I think we can safely call this refactoring done!

Now, to address a few implementation questions that you might be wondering about.

Is ConcurrentDictionary absolutely necessary?

The default thread-safety policy in .NET is to assume nothing is thread safe. (You probably recall seeing the “any instance members are not guaranteed to be thread safe” warning quite often in the MSDN documentation.) Even FileSystemWatcher itself is not fully thread safe (evident from the implementation of, say, EnableRaisingEvents). Why should we bother with the trouble of a concurrent collection here? The answer is that even though thread safety is not something we can assume most of the time, we actually have to be thread safe here to avoid state corruption. The background events that FileSystemWatcher generates will bubble up to our layer at unpredictable times. Since our subscription matching mechanism relies on looking up dictionary keys, we have to assume that someone might be subscribing (i.e. writing to the dictionary) at the exact same time. It is not safe to read and write to a standard Dictionary concurrently.

That said, we don’t have to use ConcurrentDictionary. We could have instead used a standard Dictionary with explicit locking. But in this particular case it is actually easier to write the code with the concurrent collection model — the caller just has to be sure to invoke the correct methods (GetOrAdd, TryGetValue) and the locking will be handled completely internally.

Why use callback functions (delegates) instead of events?

In this instance, using an event would not really make sense. What we want is the ability to be notified on a specific file update. Events do not allow any input parameters or extra metadata when registering a handler and it would be awkward to try to make this work. As a bonus we get the option of returning a nice IDisposable object to handle the unsubscribe (kind of like CancellationToken.Register).

Why disallow duplicate subscriptions?

We don’t want someone to introduce a bug where they inadvertently subscribe to the same file from different parts of the same program and then try to puzzle over why their subscription was magically disposed by the rogue caller. This would be rather astonishing to an average user. If we didn’t disallow it completely, we would have to implement a reference counting mechanism to avoid the aforementioned disposal bug. Frankly, that’s more work than just blocking it by throwing an exception immediately.

So now we have a nice DirectoryWatcher[Base] building block on which we can compose more interesting and complex behaviors. We’ll continue exploring this in later posts.

2 thoughts on “Watching a directory: testability

  1. Pingback: Watching a directory: composition and thread-safety – WriteAsync .NET

  2. Pingback: Watching a directory: debouncing – WriteAsync .NET

Leave a Reply

Your email address will not be published. Required fields are marked *