Tests & Web Frameworks

A test is code that checks other code. The payoff is not in writing it — it is in changing code later without guessing what you broke.

JUnit 5

<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter</artifactId>
  <version>5.11.4</version>
  <scope>test</scope>
</dependency>

Tests live in src/test/java, mirroring the package structure.

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

class DiscountTest {

    @Test
    void tenPercentOffOneHundred() {
        var discount = new Discount(0.10);          // arrange

        var result = discount.applyTo(10_000);      // act

        assertEquals(9_000, result);                // assert
    }

    @Test
    void negativeDiscountIsRejected() {
        assertThrows(IllegalArgumentException.class, () -> new Discount(-0.1));
    }
}
mvn test

One behaviour per test, and the method name states the rule — not the method being called.

Assertions

assertEquals(expected, actual);
assertEquals(3.14, value, 0.001);      // delta for doubles
assertTrue(x);   assertFalse(x);
assertNull(x);   assertNotNull(x);
assertArrayEquals(new int[]{1, 2}, result);

assertThrows(IllegalArgumentException.class, () -> method());
assertDoesNotThrow(() -> method());

assertAll("person",
    () -> assertEquals("Ann", p.name()),
    () -> assertEquals(30, p.age()));

AssertJ reads better:

assertThat(result).isEqualTo(9_000);
assertThat(names).hasSize(3).contains("Ann").doesNotContain("Zoe");
assertThatThrownBy(() -> new Discount(-1))
    .isInstanceOf(IllegalArgumentException.class)
    .hasMessageContaining("negative");

Lifecycle

@BeforeEach void setUp()      { cart = new ShoppingCart(); }
@AfterEach  void tearDown()   {  }
@BeforeAll  static void once() {  }
@AfterAll   static void done() {  }

Parameterised tests

@ParameterizedTest
@ValueSource(ints = {2, 4, 6, 100})
void evenNumbers(int n) {
    assertTrue(Numbers.isEven(n));
}

@ParameterizedTest
@CsvSource({"2020, true", "1900, false", "2000, true", "2023, false"})
void leapYears(int year, boolean expected) {
    assertEquals(expected, Calendars.isLeapYear(year));
}

Other useful annotations: @DisplayName, @Disabled, @Nested, @Tag, @Timeout.

Test doubles

@Test
void orderIsStored() {
    var repo = mock(OrderRepository.class);
    when(repo.nextId()).thenReturn(42L);

    new OrderService(repo).place(new Order("Ann", 1000));

    verify(repo).save(any(Order.class));
}
Tip

Mock sparingly — the more you mock, the more you test your assumptions instead of your code. Pure logic needs no mocks at all.

What to test

  • edge cases: empty, null, 0, negative, maximum
  • error paths: is the right exception thrown?
  • every business rule, one test each
  • every bug you find: first the failing test, then the fix

Skip getters, setters and generated record code.

Web frameworks

Javalin — minimal

void main() {
    var app = Javalin.create().start(7070);

    app.get("/hello", ctx -> ctx.result("Hello World"));
    app.get("/person/{name}", ctx -> ctx.json(new Person(ctx.pathParam("name"), 30)));
    app.post("/person", ctx -> {
        var p = ctx.bodyAsClass(Person.class);
        ctx.status(201).json(p);
    });
}

Spring Boot — the industry default

@RestController
public class PersonController {

    private final PersonRepository repo;

    public PersonController(PersonRepository repo) { this.repo = repo; }

    @GetMapping("/people")
    public List<Person> all() { return repo.findAll(); }

    @GetMapping("/people/{id}")
    public Person one(@PathVariable Long id) {
        return repo.findById(id).orElseThrow(() -> new NotFoundException(id));
    }

    @PostMapping("/people")
    @ResponseStatus(HttpStatus.CREATED)
    public Person create(@RequestBody @Valid Person p) { return repo.save(p); }
}

Generate a project skeleton at start.spring.io.

No framework at all

void main() throws Exception {
    var server = HttpServer.create(new InetSocketAddress(8080), 0);

    server.createContext("/hello", exchange -> {
        var body = "Hello World".getBytes();
        exchange.sendResponseHeaders(200, body.length);
        try (var os = exchange.getResponseBody()) { os.write(body); }
    });

    server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    server.start();
}

Where to go next

  • build something you actually want to use
  • connect Spring Boot or Quarkus to a database
  • run your tests in CI (GitHub Actions: mvn verify)
  • read the JDK sources — they ship with the JDK and are surprisingly readable

See Resources.

★ Exercises

  1. Test isPalindrome from chapter 3 — including empty string, null and punctuation.
  2. Write a parameterised leap-year test with at least six cases.
  3. Write a failing test for a method that does not exist yet, then implement it.
  4. Fully test the ShoppingCart from chapter 11.
  5. Start the built-in HTTP server and call it with curl.
  6. Add a /time endpoint returning the current time as JSON.