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 had read back then. I’ll present a systematic approach to writing unit tests.

Luís Soares
CodeX

--

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 the test is also known as spec (i.e., specification) and it drives the design of the APIs.

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, ensure 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 problem, 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 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 if it fails.

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 other scenarios, including errors.

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 an elementary test):
// TemperatureConverterTest.kt
@Test
fun `converts the 0F to around -17,8C`() {

}

2. Assert. Let’s go 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, which will result from acting— the actual call to the method under test.
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, which fails (we get a not-implemented exception). 🔴

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 similar scenarios, but we won’t do it today for 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, "new@email.com") }
}

We’ll make 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, "new@email.com") } just Runs
}

verify { repo.setEmail(42, "new@email.com") }
}
// 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, "new@email.com") } just Runs
}

updateEmail.execute(42, "new@email.com")

verify { repo.setEmail(42, "new@email.com") }
}

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, "new@email.com") } just Runs
}
val updateEmail = UpdateEmail(repo)

updateEmail.execute(42, "new@email.com")

verify { repo.setEmail(42, "new@email.com") }
}
// 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, "new@email.com") } just Runs
}
val updateEmail = UpdateEmail(repo)

updateEmail(42, "new@email.com")

verify { repo.setEmail(42, "new@email.com") }
}
// 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 you can easily understand whenever you see it. Although this recipe applies primarily to unit tests, you could quickly adapt it to higher-level testing. Once you become more confident in unit testing, beware of its anti-patterns.

Learn more

--

--

Luís Soares
CodeX
Writer for

I write about automated testing, Lean, TDD, CI/CD, trunk-based dev., user-centric dev, domain-centric arch, coding good practices,