Learn Java with Tests

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

Chapter 6 of 18

06 — Interfaces

Polymorphism behind a Shape contract, tested through the interface.

Kata: take the shapes from structs and make them polymorphic behind a Shape interface, then format them into a table. New ideas: interface types, @Override, behaviour revealed by tests.

Why interfaces matter

An interface is a named contract: “any object that implements this promises these methods.” One interface, many implementations — Shape is the canonical example.

public interface Shape {
    String name();
    double area();
}

Step 1 — test the polymorphism from the start

Notice: declare the variable as the interface, not the concrete record. The test then proves you can use a Rectangle where a Shape is asked for:

@Test
void rectangleArea() {
    Shape rectangle = new Rectangle(3.0, 4.0);
    assertEquals(12.0, rectangle.area(), 0.0001);
}

REDShape doesn’t exist. Write the interface, then make the records implement it:

public record Rectangle(double width, double height) implements Shape {
    @Override
    public String name() { return "Rectangle"; }
    @Override
    public double area()  { return width * height; }
}

GREEN. Shape rectangle = new Rectangle(...) — a variable with interface type holding a concrete value. That’s the moment the record implements a contract.

Step 2 — a function that doesn’t care what it gets

The goal: a function that formats any shape without knowing what area() does underneath. That’s your AreaTable.render:

public static String render(List<Shape> shapes) {
    StringBuilder out = new StringBuilder();
    for (Shape shape : shapes) {
        out.append(String.format("%-12s %.1f%n", shape.name(), shape.area()));
    }
    return out.toString();
}

shape.name() and shape.area() — the runtime type decides the answer. Feed it a mix:

@Test
void rendersMixedShapesIntoAColumn() {
    List<Shape> shapes = List.of(new Rectangle(3.0, 4.0), new Circle(2.0));
    String table = AreaTable.render(shapes);

    String expected = String.format("%-12s %.1f%n", "Rectangle", 12.0)
                    + String.format("%-12s %.1f%n", "Circle", 4 * Math.PI);
    assertEquals(expected, table);
}

GREEN — one List<Shape>, two different area() implementations, one render does both.

Java-specific notes

  • String.format("%-12s %.1f%n", ...) is the printf-style formatter: left-align 12 wide, one decimal, platform line separator. If expected uses \n you may trip on Windows — build it with the same %n as render does (as above).
  • var (Java 10+) — var rectangle = new Rectangle(...) is legal; but here Shape rectangle is not the same, it’s the point: program to the interface.
  • @Override is a compile-time check, not a comment. Forgetting the exact signature fails the build — that’s what makes refactoring interfaces safe.

The interview lesson

“I’d define an interface, inject it, and have a real impl and a fake impl for tests” — the exact sentence interviewers want to hear. Shape is that design in miniature: the test passes a concrete Rectangle through an interface-typed seam, and AreaTable never learns about records.

Run it

gradle test --tests "interfaces.*"

Key takeaway: polymorphism is a runtime decision (shape.area() dispatches by actual type) and an interface is the seam that makes your code testable. You just wrote dependency-free polymorphism — the seed of dependency injection (chapter 11).

Next: 07 — maps — hash maps and word counting.