A Hands-On Guide to Writing Better Code with TDD

Chamila Ambahera
4 min readJun 18, 2024

--

Imagine you’re a chef. Before you cook a dish, you need to know what it’s supposed to taste like. You might start with a recipe, but you’ll keep tasting and adjusting as you go. Writing software can be a lot like that, and Test-Driven Development (TDD) is the recipe that guides you to the perfect dish.

What is Test-Driven Development?

Test-driven development is a bit like creating a roadmap for your journey before you even start your car. Instead of diving straight into writing code, you begin by writing tests. These tests define what your code should do. Once you have your tests in place, you write just enough code to pass them. After that, you clean up your code to make it better.

Here’s a simple breakdown:

  1. Red: Write a test that fails.
  2. Green: Write the simplest code to pass the test.
  3. Refactor: Improve the code without changing its behaviour.

Why Use TDD?

Think of TDD as a safety net. Here’s why you might want it:

  1. Improved Code Quality: By writing tests first, you’re forced to think through the requirements and potential issues from the start, leading to fewer bugs.
  2. Better Design: TDD encourages you to consider the design and structure of your code upfront, resulting in cleaner, more organized code.
  3. Confidence: With a comprehensive suite of tests, you can make changes and add features without worrying about breaking existing functionality.
  4. Documentation: Tests double as documentation, explaining what your code is supposed to do.

The TDD Cycle in Action: A Simple Example in Java

Let’s get our hands dirty with an example. Suppose we want to write a function that checks if a number is prime.

Step 1: Write a Failing Test (Red)

First, we write a test for our function. Since we haven’t written the actual function yet, this test will fail. This is like setting up a target.

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

public class PrimeTest {
@Test
public void testIsPrime() {
assertEquals(true, Prime.isPrime(5));
assertEquals(false, Prime.isPrime(4));
}
}

Run the test, and you’ll see it fail. This is expected because we haven’t created the Prime class or isPrime method yet.

Step 2: Write the Minimum Code to Pass the Test (Green)

Now, we write the simplest code to make our test pass. This is like throwing the first dart at our target.

public class Prime {
public static boolean isPrime(int n) {
if (n <= 1) return false;
for (int i = 2; i < n; i++) {
if (n % i == 0) return false;
}
return true;
}
}

Run the test again, and it should pass. Great! We’ve hit our target, but our throw was a bit rough.

Step 3: Refactor

Finally, we refine our code to make it more efficient and elegant without changing its behaviour. This is like perfecting your dart throw.

public class Prime {
public static boolean isPrime(int n) {
if (n <= 1) return false;
if (n == 2) return true;
if (n % 2 == 0) return false;
int maxDivisor = (int) Math.sqrt(n) + 1;
for (int i = 3; i < maxDivisor; i += 2) {
if (n % i == 0) return false;
}
return true;
}
}

Let’s expand our test cases to make sure our function is robust.

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

public class PrimeTest {
@Test
public void testIsPrime() {
assertEquals(true, Prime.isPrime(5));
assertEquals(false, Prime.isPrime(4));
assertEquals(true, Prime.isPrime(2));
assertEquals(false, Prime.isPrime(1));
assertEquals(false, Prime.isPrime(0));
assertEquals(false, Prime.isPrime(-3));
assertEquals(true, Prime.isPrime(11));
}
}

Run the tests again. They should all pass, confirming that our code is solid.

Python Code examples

Red

def test_is_prime():
assert is_prime(5) == True
assert is_prime(4) == False

test_is_prime()

Green

def is_prime(n):
if n <= 1:
return False
for i in range(2, n):
if n % i == 0:
return False
return True

def test_is_prime():
assert is_prime(5) == True
assert is_prime(4) == False

test_is_prime()

Refactor

def is_prime(n):
if n <= 1:
return False
if n == 2:
return True
if n % 2 == 0:
return False
max_divisor = int(n**0.5) + 1
for i in range(3, max_divisor, 2):
if n % i == 0:
return False
return True

def test_is_prime():
assert is_prime(5) == True
assert is_prime(4) == False
assert is_prime(2) == True
assert is_prime(1) == False
assert is_prime(0) == False
assert is_prime(-3) == False
assert is_prime(11) == True

test_is_prime()

Final words

Starting with TDD can feel awkward, like trying to dance to a new rhythm. But you'll see the benefits once you get the hang of it. It’s like having a conversation with your code: you ask it questions (write tests), it gives you answers (you write code to pass the tests), and then you refine the conversation to make it as clear and precise as possible.

Think of TDD as a way to slow down in the fast-paced world of software development, ensuring that each step you take is deliberate and well-considered. By implementing TDD, you’re not just writing code; you’re crafting solutions that are resilient, reliable, and ready for the future.

--

--

Chamila Ambahera

Principle Automation Engineer | Arctic Code Vault Contributor | Trained Over 500 engineers