Module 2: Unit Testing: Test Driven Development

Module Overview

Explore the test-driven development approach and learn how to write tests before implementing features.

Learning Objectives

  • Understand the principles of test driven development (TDD)
  • Understand the advantages and potential disadvantages to enforcing a TDD approach
  • Learn the red-green-refactor cycle of TDD
    • Red: Write a failing test first
    • Green: Write minimal code to make the test pass
    • Refactor: Improve the code while keeping tests passing
  • Know how to write effective unit tests
  • Know how to read a TDD unit test and understand what changes to the application are required
  • Understand nested tests and how to organize test hierarchies
  • Master JUnit annotations and their uses
    • @Test - Mark methods as test cases
    • @BeforeEach - Setup common test fixtures
    • @AfterEach - Clean up resources after tests
    • @BeforeAll - One-time setup before all tests
    • @AfterAll - One-time teardown after all tests
  • Develop skills for writing clean, testable code

Test Driven Development

At this point, you're used to reading tests and having your code pass those tests.

Ever wonder why those instructors make those tests so hard to pass? What about writing those tests yourself?

Test Driven Development is a cyclical process that can be invaluable in any software engineering team to make sure that code stays robust and is scalable as time passes.

Code..... Code always changes.

Even in interviews where solutions are less than 100 lines long, assumptions and edge cases are commonly lost. What about real life code!?

Commonly, developers are scarred by bloated, complicated unit tests and don't write any at all. They host that a summer intern and give them a small ticket. The intern refactors a utility function like the ticket says. And BAM! The entire system crashes, paging the developer in the middle of the night. Why? Because there aren't any unit tests!

To make sure we aren't breaking any edge cases and keep the behavior of our code consistent, we want to write unit tests that are simple, and test the behavior of our code.

By keeping our unit tests limited to testing one logical concept and only behavior, we keep our units(core pieces of our code):

  • easy and safe to refactor
  • decoupled from other units
  • continuously documented

What is Test Driven Development?

Test Driven Development is the process of:

  1. Writing a test case for a small, tightly scoped, expected behavior.
  2. Checking that test fails.
  3. Writing code so that the test passes.
  4. Refactoring if needed.
  5. Repeat

Hello Test Driven Development

Here we have the classic Hello World Java program.

class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, World!"); 
    }
}

Imagine we're building a SAAS (Software as a Service) startup where we want to expand and sell the functionality of our Hello World program. We just received funding for our start-up with the program we built above!

We might want to start separating the functionality of our code and turn it into a function.

class HelloWorld {

    public static String hello() {
        return "Hello, World!";
    }

    public static void main(String[] args) {
        System.out.println(hello()); 
    }
}

We're really going to have to scale big but we still have to take things one step at a time.

After lots of back and forth between you and your founder, we decide to add a dash of TDD due to that one unforgettable summer experience.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class HelloWorldTest {

    @Test
    void hello(){
        String expected = "Hello, World!";
        String actual = hello();

        assertEquals(expected, actual);

    }

}

One of your early customers, Bob, makes a requests that this program work for individuals. Of course, they don't want to say hello to the entire world.

Abiding by TDD, we write this failing test:

class HelloWorldTest {

    @Test
    void hello(){
        String expected = "Hello, World!";
        String actual = hello();

        assertEquals(expected, actual);
    }

    @Test
    void helloBob(){
        String expected = "Hello, Bob!";
        String actual = hello("Bob");

        assertEquals(expected, actual);

    }

}

Now, we can refactor our code so that this test passes:

class HelloWorld {

    public static String hello(String name) {
        return "Hello, " + name + "!";
    }

    public static void main(String[] args) {
        System.out.println(hello()); 
    }
}

Our customer will be happy! However, because of TDD, you notice you would be missing our default case and disappoint all of your legacy customers.

Instead of hiring another engineer to help you out, you refactor it to:

class HelloWorld {

    public static String hello(String name) {
        if(name == null){
            name = "World";
        }

        return "Hello, " + name + "!";
    }

    public static void main(String[] args) {
        System.out.println(hello()); 
    }
}

Congrats! You have added that dash of critical unit testing that keeps your startup's code robust without slowing down development.

Surely, telling your investors this will get you more funding!

Code Example: Test-Driven Development

// 1. Write a Failing Test (Red)
@Test
void shouldAddTwoNumbers() {
    // Arrange
    Calculator calculator = new Calculator();
    
    // Act
    int result = calculator.add(2, 3);
    
    // Assert
    assertEquals(5, result);
}

// 2. Write Minimal Implementation (Green)
public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
}

// 3. Refactor (if needed)
public class Calculator {
    /**
     * Adds two integers and returns the sum.
     * 
     * @param a first operand
     * @param b second operand
     * @return the sum of the operands
     */
    public int add(int a, int b) {
        return a + b;
    }
}

// Example of @BeforeEach usage
class CalculatorTests {
    private Calculator calculator;
    
    @BeforeEach
    void setup() {
        calculator = new Calculator();
    }
    
    @Test
    void shouldAddTwoNumbers() {
        assertEquals(5, calculator.add(2, 3));
    }
    
    @Test
    void shouldSubtractTwoNumbers() {
        assertEquals(1, calculator.subtract(3, 2));
    }
}

Mastery Task 2: Implement TDD Unit Test Behavior

Test Driven Development takes place over two parts, testing, and developing. For this Mastery Task, the testing has been done for you, and it is up to you now to do the development.

The LibraryService is lacking in functionality, only the save() method has been implemented correctly, the rest of the methods are returning null values or dummy data.

Open the tst.com.bloomtech.library.services.LibraryServiceTest file and read through the unit tests. Each test describes a particular behavior the LibraryService methods should be able to perform. First understand these behaviors and then develop the LibraryService class in order to make the tests all pass.

Completion

Run the gradle command:

./gradlew -q clean :test --tests 'com.bloomtech.library.services.LibraryServiceTest'

and make sure all tests pass.

Resources