Learn Java with Tests

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

Chapter 17 of 18

17 — Real project: an HTTP server

The JDK's HttpServer and HttpClient — I/O at the edge, decisions in a testable core.

Kata: a tiny server that answers GET / and GET /notes and 404s everything else — over real HTTP, with no framework. New ideas: the JDK’s built-in HttpServer and HttpClient, keeping I/O at the edge, and a test that needs no fixed port, no daemons, and no network.

This chapter is a kata — write the code yourself. If you’re following along inside the solutions repo, the finished classes also ship in src/main/java/webserver — don’t open them yet. Write the Router first; the RED below catches a router that says “no” to everything.

Step 1 — RED: write the routing test first (no sockets)

The decision — “which response for which path?” — doesn’t need a server. Make it a plain function and test that. The chapter’s RouterTest starts:

@Test
void servesTheRootGreeting() {
    assertEquals(200, Router.route("/").status());
    assertEquals("hello, tests", Router.route("/").body());
}

Run gradle test --tests "webserver.RouterTest":

error: cannot find symbol
        assertEquals(200, Router.route("/").status());
                              ^
  symbol:   class Router

RED #1Router doesn’t exist, and neither does the Response type it returns. The tests declare the API: Router.route(path)Response with .status() and .body().

Step 2 — the types the test demands

Response is a record with two factory methods:

public record Response(int status, String body) {

    public static Response ok(String body) {
        return new Response(200, body);
    }

    public static Response missing(String path) {
        return new Response(404, path + " not found");
    }
}

Step 3 — RED: the all-404 router

The naive implementation — “answer nothing, politely”:

public static Response route(String path) {
    return Response.missing(path);
}

Run the tests:

RouterTest > answersUnknownPathsWith404() PASSED
RouterTest > servesTheRootGreeting() FAILED
org.opentest4j.AssertionFailedError: expected: <200> but was: <404>

The 404 test passes — and that’s the trap: one green test can make a broken router look finished. Adding the second route before returning the wrong answer is what ships bugs.

Step 4 — GREEN: the routes, as a switch

public static Response route(String path) {
    return switch (path) {
        case "/" -> Response.ok("hello, tests");
        case "/notes" -> Response.ok("buy oat milk, learn Java");
        default -> Response.missing(path);
    };
}

Run gradle test --tests "webserver.RouterTest": GREEN — in milliseconds, with zero I/O. Every behaviour rule in the whole server now lives in this one pure function.

Step 5 — the wiring (thin, and only here)

com.sun.net.httpserver.HttpServer ships with the JDK, in module jdk.httpserver:

server = HttpServer.create(new InetSocketAddress(port), 0);
server.createContext("/", this::handle);

The handler’s only job is translation — parse the path, ask Router, write the bytes:

private void handle(HttpExchange exchange) throws IOException {
    Response response = Router.route(exchange.getRequestURI().getPath());
    byte[] body = response.body().getBytes(StandardCharsets.UTF_8);
    exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
    exchange.sendResponseHeaders(response.status(), body.length);
    try (OutputStream out = exchange.getResponseBody()) {
        out.write(body);
    }
}

All decisions stayed in Router; the server is a thin adapter. That is the I/O-at-the-edge habit from chapters 11–12 applied at process scale.

Step 6 — the honest test: over a real socket

One integration test starts the server on port 0 — “give me any free port” — then talks to it with the JDK’s HttpClient:

NotesServer server = new NotesServer(0);
server.start();
try {
    assertEquals("hello, tests", get(server, "/"));
    assertEquals(404, status(server, "/missing"));
} finally {
    server.stop();
}

port() returns the port the OS actually assigned, and the request goes to localhost. No fixed port, no daemons, no mocking — the loop is closed: the same code a real caller would hit, exercised in-process, on every gradle test. (If the server has no /notes route wired up, this test is the one that catches it — its failing message: expected: <buy oat milk, learn Java> but was: </notes not found>.)

Java-specific notes

  • HttpServer gives each request its own thread; on JDK 21 with virtual threads that concurrency is cheap.
  • Every rule lives in RouterTest; the integration test proves the plumbing, not the logic — fast, stable, portable across machines.
  • A “real project” is mostly a stack of thin seams like this. Leaning on a pure testable core (Router) is what keeps code testable without Docker, mock HTTP layers, or a framework.

Run it

gradle test --tests "webserver.*"

Key takeaway: the JDK already has the web primitives — HttpServer to serve, HttpClient to call. Build the decision logic as plain testable code, then wrap it in the thinnest possible I/O layer. And remember the Step 3 trap: a single green test (404) didn’t mean the router worked.

Next: 18 — assertj — assertions that read like sentences.