Tests provide value beyond verifying behavior and catching bugs. The process of reading, writing, and interacting with tests empower developers to observe – and therefore gain knowledge about – their complex codebase. This knowledge is even more valuable if you work on a team. It saves you time and effort extending existing behavior or debugging an issue introduced by someone else.
We already practice interacting with our application when we develop in our local environment. We see how changing a line of code leads to a change in behavior. Let’s learn how to iteratively leverage the same strategies with our test suite.
Reading tests
Try reading tests in code review or before making a change to supplement how you understand existing code. A great test tells a story that reveals both explicit and implicit information. The four phases of a test lay out details of inputs, outputs, and side effects. Test descriptions explain behavior under certain conditions and may even include an edge case that’s not obvious from the application code alone.
Analyze tests for intention and context. They embody what the developer deemed important enough to cover and protect from bugs. For example, let’s say a method has two branching paths, yet the test only covers one. That implicit decision may highlight default behavior, or perhaps reveal an assumption that the other condition is unlikely or low-risk. These nuggets are additional puzzle pieces to integrate into your mental model of your codebase.
As a side note, you may also infer qualities of the engineering culture based on the state and quality of the test suite. It could be that a developer didn’t have the time, resources, or attention to test a tricky code path. These insights enable you to understand the environment in which the software is built and how to navigate it moving forward.
Running tests
Tests provide a cheap and fast environment to probe the system. You don’t need to spend time and effort setting up your local environment to execute a specific code path. Worse if you find yourself cleaning up side effects like database persistence as well. Automated tests do that work for you!
Scope down the behavior you’re targeting to a single test file, or even a single test case. Running that test is a quick and easy way to exercise the code as many times you need. Use breakpoints in either the application code or test code to inspect objects and their state. Combined with reading the test, this strategy helps connect the dots between implementation details and described behavior.
Imagine a test description that includes the context “when an account is inactive”. You may not know how the inactive state gets set. Inspecting the state of the account during execution might lead you to discover a deactivated_at
attribute. You now have the language for the abstraction of an inactive account, as well as the knowledge of how it’s implemented.
Writing tests
You learn the most about the codebase by writing tests yourself. Writing tests means critically engaging with the code enough to discern behavior and determine its risk. You can do this exercise with untested code or even try rewriting existing tests from scratch.
In that process, you interact with the method and figure out what it does in different circumstances. You synthesize that information by describing it in your test case. You must actively decide what to test and how much, knowing that future readers may look to your test as documentation. The value of testing is in the meaning-making of correctness, and you can take that wisdom with you as you integrate with other parts of the application.
When you work in a complex codebase, remember this crawl-walk-run approach to engaging with tests. The practice of reading tests fills in context. The act of running tests provides quick feedback on application behavior. And the hands-on experience of writing tests builds confidence to safely change code you didn’t write (or even code you did!). Your test environment is a well of untapped knowledge, if only you’re willing to explore it.