Learn Java with Tests

A TDD course for Java, in the spirit of Learn Go with Tests.

Chapter 1 of 18

01 — Hello, world

Your first JUnit test, assertEquals, and the RED-GREEN-REFACTOR loop.

Kata: a greet(String name) function that returns a greeting. New ideas: your first JUnit test, @Test, assertEquals, @DisplayName, the TDD loop.

The TDD loop

This chapter exists to build muscle memory for the loop:

  1. RED — write a test that describes behaviour you want, run it, watch it fail.
  2. GREEN — write the minimum code to make that test pass.
  3. REFACTOR — clean up the code, keeping the tests green.

Notice what writing the test first does: it forces you to decide the shape of your API (the method name, its parameter, its return type) before you’ve written any implementation. That’s the whole game.

Step 1 — write a failing test

package hello;

import org.junit.jupiter.api.Test;

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

class HelloTest {

    @Test
    void greetsByName() {
        String greeting = Hello.greet("Ada");

        assertEquals("Hello, Ada!", greeting);
    }
}

A few JUnit facts you’ll use every chapter:

  • Test classes go in src/test/java/<package>/, production code in src/main/java/<package>/.
  • Tests are plain methods annotated with @Test, not functions starting with Test.
  • assertEquals(expected, actual) — note the order: expected first, actual second. The failure diff reads “wanted X, got Y”, so the order keeps the message truthful.
  • Test classes don’t need to be public; JUnit 5 runs package-private classes happily.

Run it:

gradle test --tests "hello.HelloTest"

RED. The class Hello doesn’t exist… the test doesn’t even compile. A test that cannot compile is a failing test — you know the behaviour isn’t there yet.

Step 2 — make it pass

The minimum code. Cheat? No — the minimum:

package hello;

public final class Hello {

    private Hello() {
    }

    public static String greet(String name) {
        return "Hello, Ada!";
    }
}

Run the tests: GREEN. The test passes… because you hard-coded it.

Step 3 — the real refactor

Now write a second test that forces honest behaviour — greet someone else:

@Test
@DisplayName("greets the whole world when no name is given")
void greetsTheWorldByDefault() {
    assertEquals("Hello, World!", Hello.greet(""));
}

Run it: RED (the hard-coded string is wrong for ""). Now generalise:

public static String greet(String name) {
    if (name == null || name.isBlank()) {
        return "Hello, World!";
    }
    return "Hello, " + name + "!";
}

The second test drove the real implementation out — the test came first, then the code earned the right to exist.

Java-specific notes

  • String.isBlank() (Java 11+) — true for "", " ", etc.
  • name == null first: a null check must come before calling methods on the reference.
  • private Hello() {} — a utility class that should never be instantiated. Java has no package-level free functions, so static methods on a final class are how you expose them.
  • assertEquals with a String: fails on exact content. In your tests, always decide the exact contract you want to lock in.

Run it

gradle test --tests "hello.HelloTest"

Key takeaway: the test is not a chore — it’s a specification. It defines the method signature and the behaviour. Write it first and the design follows.

Next: 02 — integers, where one failing test teaches assertEquals and parameterized tests.