The provided content outlines a systematic approach to Test-Driven Development (TDD) for writing unit tests effectively, emphasizing the importance of separating test specification from implementation and adhering to a disciplined TDD cycle.
Abstract
The article "Writing a test with TDD" presents a structured methodology for developers to create unit tests using Test-Driven Development (TDD). It underscores the significance of distinguishing between the 'what' (test specifications) and the 'how' (implementation details) to maintain focus on one aspect at a time. The TDD cycle is highlighted as a core process involving state transitions between test/design, implementation, and refactoring, with strict rules for state changes to ensure code quality. The article emphasizes the necessity of understanding the problem and designing the system before diving into coding. It provides a recipe for TDD that includes naming the system under test (SUT), deciding on method names and their types (commands or queries), and following the Arrange-Act-Assert (AAA) pattern for test construction. Examples are given using Kotlin with JUnit to demonstrate the application of TDD principles in both query and command scenarios, advocating for a minimal implementation to pass tests initially, followed by refactoring for elegance while keeping the tests green.
Opinions
The author conveys that TDD is not just a technical practice but also a design approach, as it can be seen as Test-Driven Design.
There is a strong emphasis on the importance of proper test titling to encourage thoughtful consideration of the test's purpose.
The article suggests that writing tests first, starting with assertions, helps developers define the desired outcome and work backward to the implementation.
The author advocates for the use of mocks and stubs judiciously, creating them as needed during the test arrangement phase.
The opinion is expressed that refactoring should only occur in a 'green' state where all tests pass, ensuring that changes do not introduce errors.
The author promotes the mockist approach to TDD, using mocking libraries like MockK for testing commands, while acknowledging the existence of the classicist approach as an alternative.
The article advises developers to commit their code to source control only after all tests pass, indicating a commitment to maintaining a stable codebase.
The author warns against global variables and unnecessary repetition in tests, favoring evident data and clear test subjects.
The use of Kotlin's invoke operator is recommended for making command classes more concise and idiomatic to the language.
The article concludes by cautioning against common automated testing anti-patterns, encouraging developers to be mindful of best practices even as they grow more confident in their testing abilities.
CODEX
Writing a test with TDD
I see some people struggling to write a unit test. I remember that pain some time ago. This is the article I wish I have read back then. I’ll present a systematic approach to writing unit tests.
Test-Driven Development — TDD puts the test in the spotlight: it’s how you drive your implementation. TDD forces you to separate the “what” (test) from the “how” (implementation) so you can focus on one at a time. That’s why it can also be seen as Test Driven-Design and why the test is also known as spec (i.e. specification).
When learning TDD, you’ll hear a lot about the TDD cycle. What is that? It’s just a fancy name for the typical TDD state transitions between test/design → implement → refactor:
In the TDD cycle diagram, you see phases, not states. The system can be in a green state ✅ — all tests pass or in a red state 🔴— at least a test is failing. Only the following state transitions are valid:
✅ → 🔴: only to add a test that fails;
🔴 → ✅: only to implement a failing test;
✅ → ✅: only to refactor an implementation or a test.
The consequences of this are:
you never refactor in a red state;
you never add a test in a red state;
you only implement what’s required to return to green.
📝 In the code examples, I’ll use Kotlin with JUnit, but the recipe is the same regardless of language and testing library.
The recipe
Before starting, I can’t stress enough the importance of properly understanding the problem and working on a possible approach, possibly starting on a whiteboard. Doing TDD doesn’t imply you don’t think beforehand about the system design. Start with the user requirements, and move on to the high-level components and their relationships, until you reach the lower levels. Then, you’d focus on a specific component to implement:
Decide its name. In testing, we call the component under test “SUT” (System Under Test) or test subject — it represents the concept (e.g. class) you’re testing. Its set of public methods is its API. We start with one of them.
Now we’re ready to test → implement → refactor that method:
1. Create a test function with an empty body
Don’t copy-paste test titles from other tests. Devising a proper title makes you think about what you need. A good title starts with the action verb (e.g. “returns the sum”). You can also make it more formal with a “when → then” style (“register user → calls data layer to store it”).
Write assertions first. […] Although it’s intuitive to think about writing documents from top to bottom, with tests it is actually better to start from the bottom. Write the outputs, the assertions and the checks first. Then try to explain how to get to those outputs. […] When tests are written from the outputs towards the inputs and contextual information, people tend to leave out all the incidental detail. Fifty Quick Ideas To Improve Your Tests
3. ACT
The assertion forced you to invoke the actual method being tested; do it above the assert. Ignore the compilation errors for now. 🔴
4. ARRANGE
This is where you prepare the things you need, namely your test subject and the mocks/stubs that you haven’t created yet. Do it above the Act part.
📝Do not create globals. Avoid variables. Repetition is acceptable for the sake of evident data.
Run the test, and see it fail.
5. Implement it
… with a quick and dirty implementation!
Run the test; it should be green now. ✅
6. Refactor the implementation
… to make it nicer. Don’t do more than the test requires. Run the test — it should be kept green. ✅
Write a test, make it run, make it right. To make it run, one is allowed to violate principles of good design. Making it right means to refactor it. Test-driven Development
📝 You can apply the same recipe to any other scenarios, including the error ones.
Example: testing a query
Let’s create a test for a method that converts Fahrenheit to the international unit, Celsius. This is a query, so we’ll assert a method output.
The test skeleton will be like this (we start with a really simple test):
// TemperatureConverterTest.kt@Testfun `converts the 0F to around -17,8C`() {
}
2. Assert. Let’s to our first assertion:
(this forces us to create converted just to fix the error — in step 3)
// compilation error@Testfun `converts the 0ºF to aproximately -17,78ºC`() {
assertEquals(-17.8f, converted , 0.1f)
}
3. Act. The compilation error is because we don’t have that variable at all, which will be the result of acting— the actual call to the method being tested.
This forces us to create temperatureConverter just to fix the error, in step 4.
// compilation error@Testfun `converts the 0F to aproximately -17,8C`() {
val converted = temperatureConverter.fahrenheitToCelsius(0f)
assertEquals(-17.8f, converted, 0.1f)
}
4. Arrange. We need to set up temperatureConverter. We do it in the arrange part which forces us to create the corresponding class and method:
@Testfun `converts the 0F to aproximately -17,8C`() {
val temperatureConverter = TemperatureConverter()
val converted = temperatureConverter.fahrenheitToCelsius(0)
assertEquals(-17.8f, converted, 0.1f)
}
📝 Ideally, you’d make the bare minimum to make it pass: return a constant → add another test → fix it (triangulation pattern). You could also evolve to data-driven testing, to avoid having similar scenarios, but we won’t do it today for the sake of simplicity.
6. In TDD, we can refactor only in a green state, which we are. Let’s simplify a bit and run the tests: ✅
📝 Once all tests pass, you can commit to your source control.
Example: testing a command
Let’s do a similar exercise, but now we want a test for a command. We’ll create a feature to update the customer’s email, so it belongs to the service layer of a typical project. It’s a command because it’s supposed to affect the system state. We’re not focused on the output of the method.
📝 I’ll present the mockist approach. There's also the classicist approach that does not rely on mocking but I’ll leave that for another article.
The test skeleton will look like this:
// UpdateEmailTest.kt@Testfun `calls the data layer to update customer`() {
}
2. Assert. Let’s to the first assertion. In this case, let’s verify that the other layer — the repository — was called. I’ll use MockK as the mocking library.
// compilation error ❗️@Testfun `calls the data layer to update customer`() {
verify { repo.setEmail(42, "[email protected]") }
}
We’ll do a detour because we need to have a mock — in the Arrange part— we’ll create the ClientRepo, the setEmail method and mock it:
@Testfun `calls the data layer to update customer`() {
val repo = mockk<ClientRepo> {
every { setEmail(42, "[email protected]") } just Runs
}
verify { repo.setEmail(42, "[email protected]") }
}
The bottom line is having a repeatable set of steps that allows us to achieve a result that you can easily understand any time you see it. Although this recipe applies primarily to unit tests, you could easily adapt it to higher-level testing. Once you become more confident in unit testing, beware of its anti-patterns.