Unit Test
When coding a Small Development Task, you want a way to verify that the work you are doing is what’s expected and avoid breaking the code line. This pattern describes a structure that helps you identify when a task is done and gives you assurance that your change won’t break any contracts.
Identifying Completeness and Verifying Stability
Software is complex. It can be hard to know that you implemented the work for a task correctly unless you define what to expect. Specifications are subject to interpretation, but examples can identify and clarify ambiguities.
Checking that an existing class, module, or function does what you expect and still works after you make a change is a basic part of maintaining stability in your software. It is easier to understand the source of an error in a small section of code than at a system level, but testing small-scale units can become tedious, and tests take time to execute.
Errors often show up in integration, even will each unit has passing tests. Testing in the context of a full system might seem most realistic, but setting up end-to-end tests can be slow, and it can be expensive (time and resource-wise) to create multiple sets of inputs to test. Integration tests seem more complete, but it can be challenging to cover subtle error conditions.
Some of the tools you use to isolate units for testing, such as mocks, stubs, and fakes, can give you a false sense of confidence if not written correctly. But it can be challenging to recreate a test condition for code deep in the system based on high level inputs.
Time spent writing tests for small units of functionality can seem wasteful, especially when the function seems simple. But errors at higher levels often stem from those at lower ones, and a unit test lets you identify failure points.
Writing tests means updating tests when implementation or contracts change but finding issues before integration or deployment means a lower cost to fix. Writing tests early can help ensure a more modular, testable design, as less modular systems tend to be harder to test.
What a “unit” is can be a matter of judgment, and not all team members may agree on what’s appropriate for a test.
We want to isolate integration issues from local changes and test each component’s contracts.
Test the Contract
** Develop and run Unit Tests for any changes you make. Unit tests should be quick and easy to execute in a developer workspace. Execute the tests as part of an Integration Build.**
A Unit Test test tests fine-grained elements of a component to see that they follow their contract. The tests verify the behavior of work in development and provide a mechanism to confirm that a new change hasn’t broken other code.
A good unit test (Beck and Andres, Extreme Programming Explained). is:
- Automatic and self-evaluating. It should pass or fail, and a user should not need to look at detailed test results unless there is an error.
- Fine-grained. A unit tests should test one thing, such as a significant interface method on a class, or a function that implements complex business logic.
- Isolated from other tests. A unit test does not interact with other tests (though you can use good code design to reuse setup and tear-down)
- Simple to run in a developer’s workspace.
- Self-configuring and repeatable: The process that runs a unit test should set up any dependencies.
Tests should also be quick to run. Aim for 5 mins or fewer to run all the Unit Tests for a module. If it’s longer evaluate why.
Run Unit tests:
- While you are coding, to verify incremental changes
- Just before checking in a change (after updating your code to the current version.
- As part of an Integration Build
You can identify what to test by considering:
- Significant interfaces.
- Critical configuration.
- Errors that arise when developing and running ntegration tests. When an integration test identifies an error consider writing at unit test around the lowest level component that was in the error chain
- Errors that arise during use. When fixing an error or bug it can be useful to write a unit test around the smallest element of code that relates to the problem.
Two common concerns about Unit tests relate to overhead and fragility:
The isolation and self-configuring aspect of unit tests means that you may need to define mocks and stubs to ensure that you can test code. Mocks can be fragile if not implemented thoughtfully. Tools like Test Containers let you avoid mocks by running full services. This has the benefit of stability and robustness but can slow down test execution.
Coverage
When talking about unit tests the topic of test coverage often comes up. “Coverage”— the percentage of lines of code that running tests cover — is a useful metric but shouldn’t be taken to an extreme. While a low coverage number is concerning, a high number isn’t always better. Consider the value of the test and the effort to test. You may discover some code paths — in particularly those that involve external services with complex interfaces that are difficult to mock – might be easier to test using a smoke test. While there are no absolute numbers, a rule of thumb is:
- below 50%: probably worth considering adding tests
- above 90% be cautious about the effort.
If your coverage is low, and you work to add tests, keep track of how many potential bugs you uncover in your Unit Test cases. If you are finding potential errors, then the effort is worth it.
If you add tests as you develop code, the overhead of high coverage is mitigated by improved development speed.
{aside} (Story: Really Dumb Tests, and simple errors that caused big ones errors) Since adding tests isn’t always valuable you should always be asking whether it makes sense to test a function, with a bias towards avoiding testing trivial functions and methods. When on doubt, two questions you can ask are:
- Have I seen this issue in integration tests?
- How costly is it to debug when you find it in integration?
As a rule, the code-test-fix cycle is much faster when using unit tests than when a problem appears when running the full application. Two examples I’ve come across are:
- syntax validation in code generation applications and
- database integration
I once worked on a VXML application where the back end server generated VXML elements in response to certain events. We discovered that the back end code would generate invalid Voice XML code causing the voice server to stop. We added a series of one line unit tests to the back end that called the “generateXML()” method with the various inputs and validated the result to the VXML Schema. While this seemed trivial, it ended up saving a lot of time.
A similar situation comes up when interacting with Database. Failures can happen in database applications for a range of issues, such as field names not being correct or when the database scheme changes and the code has not been updated or if a code path generates values that are not aligned with constraints. By writing simple tests on the database communication layer with Test Containers you can debug these kind of issues quickly. {/aside}
{aside}
Names
In a few teams I’ve seen long discussions about types of tests: Unit/Component etc…
What really matters is:
- How involved the set up is
- Time/scope tradeoffs
{/aside}
Cautions
- When using mocks and other simulated data, be aware of limitations.
- Don’t fret over what a “Unit is” but rather focus on the issues like speed and ease of execution,
- While test coverage can be a useful metric, do not overemphasize it
Next Steps
- Identifying errors that Unit Tests might not cover, as well as errors at a Larger Scale: Integration Test