Unit testing is a great idea. It provides for code coverage, is a resource for documentation, and, paired with TDD, it provides a vehicle for good design. There are a lot of articles and blogs talking about why unit tests are important; however, it’s hard to know how to write good unit tests. This blog will talk about how to build a suite of robust unit tests that still allow for refactoring.
Here are some tips and tricks that have allowed me to leverage the value of unit tests while still having the flexibility to refactor during the development process.
When writing a unit test, frame your mind around what your piece of code should do. When the unit test explains what behavior we want to get out of a function, it decouples the test from how that behavior was achieved. This way, when the how changes, if the resulting what is still the same, the test should still pass. Conversely, if the test fails, then the refactoring most likely caused a loss of behavior.
Be sure to cover all branching logic in your unit tests. For example, if you have three possible paths in your function, make sure to write a unit test for each path. You never know which path could end up breaking with a subsequent change. This is guaranteed through TDD because every line of code you write is covered with a unit test.
Run Unit Tests Frequently
Frequently running the unit tests during the development process will indicate a loss of behavior much sooner and help pinpoint exactly where and when the failure occurred. It’s much easier to figure out what started failing after 30 seconds of coding than after two hours of it.
Many test runners can be configured to run every time a file is saved, which makes running tests frequently an automatic process.
Use Distinct Assertions
When we purposely refactor the behavior of a function, failing tests are expected. However, when one behavior changes and an abundance of tests fail, something is wrong.
It is important to not duplicate the same assertion across different tests. So, for example, if expecting that the return value of a function is X, given a specific scenario, then having one assertion in one test is warranted. Having this checked in every test that happens to call this function is not.
Treat Tests As Your First Customer
Your unit test will show you the pattern that actual consumers will need to follow in order to use your new code. In a given test, think about what you need to do and set up. You will identify issues such as whether you require unnecessary parameters or way too much setup. Fixing these issues will lead you to a better design.
Test The Public Interface
Test the various behaviors that your public-facing function is expected to have. Now, often, these public functions have private helper functions that help the public functions achieve their desired outcome. However, it is important to remember that private functions are, well, private. They are not supposed to be known by the outside world.
If you feel like you need to test a private function or turn it public in order to test a certain behavior, or even feel the need to mock it out, stop. This may be a symptom of poor design. It may be that your current public function is doing way too much. It also may be that the collaborators were not mocked out correctly.
Verify Dependency Usage
Limit tests that verify implementation to the usage of dependencies. Write a test that verifies a collaborator’s function is called when it should be. This safe-guards against half-baked refactoring. Think of it this way: If a collaborator is defined during dependency injection and its function is no longer being used after a refactoring, then a test should indicate this change. The test failure should give us pause to think about why that test is failing. One would need to reassess whether that code change is correct or make additional refactoring to remove that dependency altogether. A collaborator should not be included as a dependency if it is no longer being used by the code.
Write Robust Test Names
It is easy to remember the why for a test that was written yesterday, or a few days ago—but what about a test that was written a month ago, a year ago, or by someone else?
The name of the test should tell you what the test is doing without you having to read every line of code to figure it out. It should explain the prerequisites. It should explain what behavior we expect from the function under test. It should help you frame your mind so that when a tests fails you can more easily figure out what broke.
Use a standardized approach to test names. This helps maintain consistency across the various tests and improves readability.
Two approaches are outlined below.
Approach 1, Gherkin Syntax, is a business-readable, domain-specific language that is used to describe behavior. It is useful when you want to know the prerequisites of a test first.
Approach 2, Action Syntax, is used when you want to know what action you are testing first. It is also the best option to use when you can’t use folders to group tests since the tests become self-organizing.
Approach 1: Gherkin Syntax
Gherkin Syntax explains behavior using the GivenWhenThen approach. This approach aligns with the pattern used to structure the unit test, via Arrange, Act, Assert.
- Given: Given a certain setup or precondition exists for the test.
- When: When an action is performed.
- Then: Then the indicated result is expected.
So, for example, a test written for a Tic Tac Toe application, looking like this:
is much more understandable than:
In this first example, I know the state of the game at the time the test is run—the board is empty and the computer player is the first one to play. I know the action that is performed—the game is played. I know what I expect to happen—the computer picks the center tile.
Approach 2: Action Syntax
The template for this approach is ActionShouldWhen. This comes in handy when custom grouping mechanisms are not available in your chosen language. The tests become self-organizing, based on what action is being tested.
- Action: An action is performed. This matches the method name.
- Should: Should have the expected result.
- When: When a certain setup or precondition exists for the test.
For the same scenario above, the test name can be written like this:
Since test runners group alphabetically by default, any additional tests for this function will be grouped along with the above test because they all start with the same function name, “PlayGame_”.
At the end, when a test is complete, revisit the test name and see if it still makes sense. It is natural for a test to evolve into something else while you are writing it. If the result doesn’t match the original concept, take the opportunity to refactor the test name to indicate what it has evolved into.
Approaching unit testing in this way can help build a robust unit test suite that still detects breaking changes, still allows for the flexibility of refactoring, and makes the development process flow much more smoothly.