One of the keys to doing test-first development successfully and having the tests that you create to support you in refactoring code rather than breaking when you refactor code, is to write tests against the behaviors you want to create rather than how you implement those behaviors.
This is an important insight but not always obvious when you’re building software. It’s easy when writing code to get lost in the weeds and one of the ways that I get myself out is to ask myself, “What am I trying to accomplish here? What is my goal or desired outcome?”
This usually helps refocus me on what I’m trying to do.
One of the main ways that I stay focused when building a feature is to write my unit tests around the behaviors that I want to create. When I do this I find that my unit tests are more flexible and when I refactor my code they don’t break and instead validate that my new implementations are consistent and work as expected. This is one of the main benefits of writing unit tests against behaviors rather than how we implement those behaviors.
When doing test-first development, it all starts with the test so we look at our requirements, the story that we are trying to fulfill, and we think about what the acceptance criteria should be. Then we simply write a test for those acceptance criteria using the “Given, When, Then” format. The test then becomes the guiding light for implementing that behavior.
I see a lot of developers following the practice of creating a test class for every one of their production classes and a test method for every one of their public production methods. I don’t think this is generally good practice. This is not the kind of test coverage we want to get because we’re getting it at too low a level. Instead, we want our tests to cover behaviors.
This can show up in subtle ways. For example, a test called testConstructor() does not tell me much. A better name may be validateAndRetrieveValuesAfterConstruction().
Writing behavioral tests also sets me up to think about how I can encapsulate implementation and only expose what I need to in order for callers to use my services effectively. This allows me to create strong contracts and tight interfaces.
Focusing on the behaviors or perhaps better stated, the acceptance criteria that I’m trying to fulfill, helps me write more focused code. And even though I don’t explicitly test many of the component classes or methods directly, they end up getting tested indirectly and so my code coverage is high.
For example, a behavioral test that asserts a user can register in a system doesn’t need an explicit test to validate the factory for creating the user if the creation of the user is part of registration which it is. This means that we’ve indirectly tested the factory. The tests are if a user can register or not. It doesn’t matter the mechanism used to make this happen.
This also means that if we started our implementation by simply newing up a user and later decided to migrate the creation of users to a factory or a dependency injection framework then we would not have to change our existing test or write any additional tests because how we create a user is an implementation detail for a test that is validating that a created user can be registered. In fact, if we run a code coverage tool after refactoring the design to use a factory we will see that our test covers the factory instantiation without any additional changes.
This is the benefit of writing behavioral tests that are not dependent on implementation details. This is the clearest way that I found to think about doing test-first development and I find that writing behavioral tests in this way can generate a good suite of regression tests as well as serve as living specifications in the system. What these tests do not do on their own is to provide a complete set of regression that may be needed in some situations. In other words, the way I am advocating doing test-first software development is really valuable for developers and also helpful for regression testing but it is not complete testing and so for many situations, we need to go back and think about what could go wrong so we can also write tests for those scenarios.
Quality assurance and quality engineering which we typically do as a separate step from the coding step can focus on testing implementation details and other nonfunctional requirements. But the tests that I write when I do test-first development, I try to keep focused on validating behaviors.
Note: This blog post is based on one of the “Seven Strategies…” sections in my book, Beyond Legacy Code: Nine Practices to Extend the Life (and Value) of Your Software.
Previous Post: « Show What’s Important
Next Post: Use Mocks to Test Workflows »