Test-Driven Development (TDD): A Step-by-Step Guide

Jyoti Sheoran
Getir
Published in
4 min readAug 22, 2023

Test-Driven Development (TDD) is a software development methodology that emphasizes writing tests before writing the actual code. The main idea behind TDD is to create a feedback loop that guides the development process and helps ensure the reliability and correctness of the software being developed. TDD follows a cyclical pattern, often referred to as the “Red-Green-Refactor” cycle, which consists of the following steps:

TDD Cycle
  1. Red: In this phase, you start by writing a test that defines the desired behavior or functionality of a specific piece of code. Initially, this test will fail because the corresponding code hasn’t been written yet. This failing test is often referred to as a “red” test.
  2. Green: Once you have a failing test, your next step is to write the minimum amount of code necessary to make the test pass. This code may not be perfect or efficient; the goal is to satisfy the test’s conditions and make it pass. When the test passes, it becomes a “green” test, indicating that the desired functionality has been implemented.
  3. Refactor: After making the test pass, you can improve the code’s design, structure, and efficiency while keeping the test green. Refactoring involves making changes to the code without changing its external behavior. The tests act as a safety net, helping you catch unintended side effects of your changes.

The TDD cycle is then repeated, starting with the creation of a new test for the next piece of functionality. This process helps ensure that your code is always backed by tests, making it easier to catch bugs and regressions as the codebase evolves.

Let’s explore TDD through a simple example:

Imagine we need to create a simple function that adds two numbers. Here’s how we can apply TDD to develop this function:

  1. Write the Test: We start by writing a test that defines the behavior of the add function. In a file named AdderTest.kt, we could have:
import org.junit.Test
import kotlin.test.assertEquals

class AdderTest {
@Test
fun `test addition`() {
val result = 2.add(3)
assertEquals(5, result)
}
}

Notice that the add function is referenced even though it's not defined yet.

2. Implement the Minimum Code: Next, we implement the add function in a separate file named Adder.kt:

fun Int.add(a: Int): Int = this + a

The goal here is to make the test pass as quickly as possible.

3. Run the Test:

Run the tests, and the test addition test should pass, confirming that the add function works as expected.

4. Adding More Tests: To further validate sum functionality with more test cases:

@Test
fun `test addition with overflow`() {
val result = 2.add(Int.MAX_VALUE)
assertTrue(result < 0)
}

5. Refactor: We can refactor code based on requirement, whether we want overflow value or can filter/validate input values.

Let’s take a more complex scenario involving a user authentication system. We’ll implement a login feature using TDD.

  1. Write the Test: In a file named LoginTest.kt, we start with a test for user login:
import org.junit.Test
import org.junit.Assert.assertEquals

class LoginTest {

@Test
fun `test valid login`() {
val authService = AuthService()
val result = authService.login("username", "password")
assertEquals(LoginResult.SUCCESS, result)
}
}

Run the test to make sure it fails (since we haven’t implemented the AuthService and LoginResult yet).

2. Implement the Minimum Code: Create a class named AuthService

class AuthService {

fun login(username: String, password: String): LoginResult {
// Implement the login logic here
return LoginResult.SUCCESS
}
}

Create an enum class named LoginResult to represent the possible login outcomes:

enum class LoginResult {
SUCCESS,
INVALID_CREDENTIALS,
NETWORK_ERROR
}

3. Running Tests: Run the test you created earlier (test valid login). It should now pass because you've implemented the necessary code in the AuthService.

4. Adding More Tests: To further validate the login functionality, add more tests to the LoginTest class:

@Test
fun `test invalid credentials`() {
val authService = AuthService()
val result = authService.login("username", "wrong_password")
assertEquals(LoginResult.INVALID_CREDENTIALS, result)
}

@Test
fun `test network error`() {
val authService = AuthService()
val result = authService.login("username", "password")
assertEquals(LoginResult.NETWORK_ERROR, result)
}

5. Enhancing the Implementation: Now that your tests are passing, you can enhance the AuthService implementation to handle various scenarios, including invalid credentials and network errors.

By following this TDD approach, you ensure that the login functionality is thoroughly tested and that any future changes won’t inadvertently break existing behavior.

Remember that this is a simplified example. In a real-world application, you would likely have more complex logic, integration with a backend, and more comprehensive tests. TDD helps you build a robust and reliable login system by systematically verifying its behavior before and after each change.

Benefits of TDD: Here are the benefits of Test-Driven Development (TDD):

1. Improved Code Quality: TDD enforces a focus on writing clean, maintainable, and modular code from the outset. By writing tests first, developers must think critically about the design and architecture of their code, leading to higher code quality and fewer design flaws.

2. Reduced Bugs and Defects: With TDD, bugs and defects are identified early in the development process as tests are written before code implementation. This proactive approach helps catch issues before they propagate and become more challenging and costly to fix.

3. Faster Debugging and Development: TDD accelerates the debugging process by pinpointing issues in smaller, isolated sections of code. This leads to quicker identification and resolution of problems, ultimately speeding up the overall development cycle.

4. Confident Refactoring: TDD provides the confidence to refactor code without fear of breaking existing functionality. If tests pass after refactoring, developers can be assured that their changes haven’t introduced new defects, resulting in a more maintainable and adaptable codebase.

These benefits make Test-Driven Development a powerful practice for creating high-quality software with fewer defects, faster development cycles, and increased developer confidence.

--

--