avatarLuís Soares

Summary

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:

  1. ✅ → 🔴: only to add a test that fails;
  2. 🔴 → ✅: only to implement a failing test;
  3. ✅ → ✅: 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 other words, make sure the default state is green and follow lean development.

📝 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.
  • Decide the method name and if it’s a command or a query. A query yields an output; a command changes the system state. In other words, read operations are queries and create/write/delete operations are commands (usually without output).

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”).

2. ASSERT

Start with a single assertion. Ignore the compilation errors for now — except if you need a mock/stub, which you’d do at the beginning of the test method. Defining the desired scenario is a way to make sure you do solely what’s needed to get there.

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.

  1. The test skeleton will be like this (we start with a really simple test):
// TemperatureConverterTest.kt
@Test
fun `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
@Test
fun `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
@Test
fun `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:

@Test
fun `converts the 0F to aproximately -17,8C`() {
   val temperatureConverter = TemperatureConverter()

   val converted = temperatureConverter.fahrenheitToCelsius(0)
   assertEquals(-17.8f, converted, 0.1f)
}
// TemperatureConverter.kt
class TemperatureConverter {
    fun fahrenheitToCelsius(f: Float): Float = TODO("implement me")
}

We run the test and it fails (we get an exception of not implemented). 🔴

Notice the newlines separating the Arrange, Act, and Assert.

5. Let’s make it pass: ✅

class TemperatureConverter {
   fun fahrenheitToCelsius(f: Float): Float {
      return (f - 32f) / 1.8f
   }
}

📝 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: ✅

class TemperatureConverter {
    fun fahrenheitToCelsius(f: Float) = ((f - 32) / 1.8).toFloat()
}

📝 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.

  1. The test skeleton will look like this:
// UpdateEmailTest.kt
@Test
fun `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 ❗️
@Test
fun `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:

@Test
fun `calls the data layer to update customer`() {
   val repo = mockk<ClientRepo> {
      every { setEmail(42, "[email protected]") } just Runs
   }
  
   verify { repo.setEmail(42, "[email protected]") }
}
// ClientRepo.kt
class ClientRepo {
   fun setEmail(id: Int, email: String) {
      TODO("Not yet implemented")
   }
}

3. Act. We need to act — run the method under testing— so that the repository is called:

// compilation error
@Test
fun `calls the data layer to update customer`() {
   val repo = mockk<ClientRepo> {
      every { setEmail(42, "[email protected]") } just Runs
   }

   updateEmail.execute(42, "[email protected]")

   verify { repo.setEmail(42, "[email protected]") }
}

4. Let’s fix the arrange part, creating the test subject and auto-generating its class and method:

@Test
fun `calls the data layer to update customer`() {
   val repo = mockk<ClientRepo> {
      every { setEmail(42, "[email protected]") } just Runs
   }
   val updateEmail = UpdateEmail(repo)

   updateEmail.execute(42, "[email protected]")

   verify { repo.setEmail(42, "[email protected]") }
}
// UpdateEmail.kt
class UpdateEmail(repo: ClientRepo) {
   fun execute(id: Int, email: String) {
      TODO("Not yet implemented")
   }
}

We should have a red test now. 🔴

5. Let’s implement it: ✅

class UpdateEmail(private val repo: ClientRepo) {
   fun execute(clientId: Int, newEmail: String) {
      repo.setEmail(clientId, newEmail)
   }
}

6. Great. Now we can refactor it to make it nicer. Let’s use Kotlin’s invoke operator: ✅

@Test
fun `calls the data layer to update customer`() {
   val repo = mockk<ClientRepo> {
      every { setEmail(42, "[email protected]") } just Runs
   }
   val updateEmail = UpdateEmail(repo)

   updateEmail(42, "[email protected]")

   verify { repo.setEmail(42, "[email protected]") }
}
// UpdateEmail.kt
class UpdateEmail(private val repo: ClientRepo) {
   operator fun invoke(clientId: Int, newEmail: String) {
      repo.setEmail(clientId, newEmail)
   }
}

📝 You can find all the code in a GitHub repository I created for this exercise.

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.

Learn more

Test Automation
Test Driven Development
Unit Testing
Recommended from ReadMedium