Dependencies in Unit Testing
Podcast: Play in new window | Download (55.0MB) | Embed
Subscribe: Apple Podcasts | Spotify | Email | RSS | More
Unit tests are a great way to make sure that your code is more stable. Not only do they let you check your assumptions when you make changes, but they also tend to force you to structure your code in a way that keeps it cleaner. As you get more test coverage, this pays off by making it easier to do larger reorganization of your code with less fear of breakage. The cleaner structure can also make it easier to diagnose problems in the code without doing a bunch of manual tracing. Finally, unit tests can serve as a bit of “living documentation” for the expectations around your code, which can often make it easier for newer developers to pick it up.
However, the previous advantages are something you will only enjoy so long as your code doesn’t interact with a file system, database, network, third party app, or physical hardware. Each of these situations can introduce intermittent errors, limits to the number of calls you can execute, interference from state changes, and they all will generally slow down your tests. As you get more tests, these disadvantages pile up and make it less likely that unit tests will be run, because they become time consuming and unreliable.
Fortunately, because we are talking about unit tests, it’s expected that you are only testing how your code reacts to different conditions, rather than testing underlying conditions that may not be reliable. This means that you can effectively “fake” a dependency in such a way that your tests only touch the code that you are trying to test. Moreover, if you do it right, this approach can also make your tests faster, which makes your team more likely to run them in the first place.
Managing dependencies in unit tests is something you pick up over time. Like most other code, your approach to dependencies in a unit test scenario should start with something simple and only add complexity when it is valuable and necessary.
Working with Dependencies in Unit Testing
Unit tests versus other types of tests
It’s probably necessary at the start to explain the difference between unit tests and other test types, simply because a lot of so-called “unit tests” are anything but. A “unit test” tests a single, logical unit of code, not including its dependencies. It’s intended to be fast, to only have its results change based upon the way that the code works, and to make it easy to determine where and why a failure occured.
An integration test tests the way that multiple pieces of code work together. It’s intended to be a bit slower, more vulnerable to latency, and most importantly, to test how several pieces of code work together. It may be harder to troubleshoot than a unit test and it WILL almost certainly run slower.
A performance test tests how a system responds to load, how quickly a process can run, and how resources are used by the process. Generally, this is done over several components, although sometimes you will see single-unit performance benchmarking in the wild.
Functional Tests focus on the business requirements of the application being tested. They only check final outputs, rather than intermediate steps. End-to-End tests attempt to replicate the behavior of a user on a system and make sure that longer workflows work properly.
Why dependencies make things difficult.
Dependencies introduce latency. Making calls to real dependencies will slow down your unit tests, sometimes drastically. Dependencies introduce breaking changes that aren’t relevant to your tests. Dependencies may change under the hood, causing your test to fail, even if the actual code being tested is not the issue. While it’s helpful to catch this, it’s annoying to troubleshoot.
Dependencies can introduce intermittent errors to your code. If the dependency itself has some kind of limits (or worse, if it deals with hardware), it may periodically “flake out”. This could be due to anything from a temporary network issue to a drive being full or a service limit being reached. Dependencies themselves have their own dependencies, some of which may be hard to discern. While you do need to make sure that these things work, it’s extremely wasteful to do so while you are rapidly iterating on your own code.
You may not even be able to perform a reasonable test of dependencies in your code from your development machine. This is especially common when you are using third party services, such as payment processors, that have onerous security restrictions around them.
Why dependency injection and polymorphism matters.
If you are using dependency injection, you request an instance of an object from a container that knows how to build it. This keeps you from making your code overly coupled to the types that it uses, rather than just their interface contracts. Polymorphism is important here, because it allows you to swap out a different implementation of the same interface contract in different situations.
Taken together, these two techniques can make it much easier to isolate the code under test, without your tests having to deal with any complexity due to the dependencies of your code. This can also make it a lot easier to create unlikely or difficult scenarios that your dependencies may create in the wild, in a predictable manner. This can make it much easier to test how your code will react in less likely scenarios, such as service outages.
Understanding Test Doubles
When explaining the types of test doubles, we’ll be taking the case of a UserRepository that allows searching, sorting, filtering, adding, updating, deleting, etc. of a set of users. The real thing would use a database, but the test double avoids that problem. In a real world situation, you start with a stub and continue to add complexity only when it serves the purposes of the test.
Extremely lightweight. Returns predefined outputs regardless of inputs. A good example of this for a UserRepository would be one where the FindUsers method returns the same set of data, regardless of the filter criteria passed in.
Like a stub, but can change behavior based on input. Can mimic all possible behaviors base on input. A good example of this for a UserRepository would include several different static sets of users that are returned under different filter criteria. Note that because you are not using the real implementation, this is a good place to have certain incoming data values that trigger a certain type of result. For instance, user ids that are divisible by 10 could return an administrative user while others return regular users.
An advanced version of a fake which is stateful. This can be used for things such as retry logic or for determining that a method was called multiple times. An example of this for a UserRepository would be where the repository returns a static set of items, but removes an item from that set if you delete it in order to mimic behavior. Note that you are again not using the real behavior, that this means you can also track the frequency of calls to the code under test, which is useful if a particular method is costly in some way.
Like a spy, but with more flexibility. Behavior can be changed dynamically based on scenarios. Gives full control over the the behavior of the mocked objects, including objects that the mock returns. For a UserRepository, you might use a mock when you want certain user ids to return different kinds of users, users in particular states, or different kinds of errors. The logic for this can get very complex very quickly, and you probably don’t want to use a mock right off the bat. You may be forced to set things up specifically in the order that the caller will invoke this components as well, making your tests brittle.
Using Test Doubles
Why not always use Mocks/Spies/Fakes?
Tests become more complex and brittle the more complex you get. Tests become harder to read. Tests can become overly dependent on the code under tests. Tests may not give useful diagnostic criteria on the reasons for failure. The more complex the test scenarios are, the longer they take to write, and the more maintenance is required when you make changes.
Use cases for each of these.
- Stub – you are starting out, testing the happy path, and just want to get some semi-realistic data back to see that the basics are covered.
- Fake – rather than just the happy path, now you need to test a few simple scenarios that require variation in output based on input.
- Spy – You’ve started testing things that can change state and you need to be able to assert that certain methods were called, and how often they were called.
- Mock – You are testing complex scenarios that may involve error states, complex workflows, or annoying interface designs.
Why to avoid doing any of this? Sometimes test doubles are harder than other approaches.
When you can’t create a test double for a dependency due to the way it is implemented. This is common for sealed types or cases where dependency injection is not being used. You are doing something other than a unit test. If you aren’t trying to isolate the code under test, then you probably don’t need a test double. The interface you are working with is “syntax magic” over the top of the REAL dependency (fluent interface sitting over a DbSet in Entity Framework, for instance). You may want to mock the underlying thing and use the real version of the interface to avoid headaches.
Tricks of the Trade
Be watchful of how you say things to people. You may mean one thing but it be interpreted as something different. This is especially true in todays world of remote work where most communication is via text so intonation and emphasis aren’t available. Sarcasm is definitely not your friend here. Even when you are on a call or in person you don’t know what the other person has going on or the conversations they may have had before you spoke to them.