Learn Java with Tests

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

Chapter 16 of 18

16 — Reflection

Class, Field, and Method — the machinery behind frameworks you almost never need yourself.

Kata: an ObjectRenderer that turns any object into field=value lines — and can call a method by name — without knowing the class at compile time. New ideas: Class, Field, Method, setAccessible, and the honest rule: reflection is for frameworks, not your application code.

This chapter is a kata — write the code yourself. If you’re following along inside the solutions repo, the finished class also ships in src/main/java/reflection — don’t open it yet. Write each step from here first. Two REDs below, and both fix the next test, not the current one.

Step 1 — RED: the test names the behaviour

We don’t compile against Person; we hand the renderer any Object:

@Test
void rendersFieldsOfAnyObject() {
    Person person = new Person("Ada", 42);

    String rendered = new ObjectRenderer().render(person);

    assertTrue(rendered.contains("name=Ada"));
    assertTrue(rendered.contains("age=42"));
}

With the test’s record Person(String name, int age) in place, run gradle test --tests "reflection.ObjectRendererTest":

error: cannot find symbol
        String rendered = new ObjectRenderer().render(person);
                              ^
  symbol:   class ObjectRenderer

RED. ObjectRenderer doesn’t exist, and neither does the invoke method the other two tests refer to. You’re about to build both.

Step 2 — RED: getFields() finds nothing

The first attempt uses the innocent-looking getFields(). On a record, all fields are private final, and getFields() returns only public fields — so it returns none:

for (Field field : value.getClass().getFields()) {
    result.append(field.getName()).append('=').append(field.get(value))...
}

Run rendersFieldsOfAnyObject again:

ObjectRendererTest > rendersFieldsOfAnyObject() FAILED
org.opentest4j.AssertionFailedError: expected: <true> but was: <false>

(Bland message, isn’t it? assertTrue tells you nothing about what was missing — compare that with chapter 18, where AssertJ prints the actual objects.) The rendered string is empty because the public-only API skipped every field.

Step 3 — GREEN: getDeclaredFields + setAccessible

for (Field field : value.getClass().getDeclaredFields()) {
    field.setAccessible(true);
    result.append(field.getName()).append('=')
          .append(field.get(value)).append(lineSeparator);
}
  • getClass() → the runtime Class<...> for the actual object.
  • getDeclaredFields() returns all fields, including private (a record’s fields are private final).
  • field.get(value) reads the value; setAccessible(true) unlocks the private fields — exactly what JSON or ORM libraries do under the hood.

Run it: rendersFieldsOfAnyObject is GREEN.

Step 4 — RED: the method round-trip swallows the real error

The suite also expects invoke(target, "name") to call the method, and invoke(target, "panic") to throw the real exception:

@Test
void unwrapsTheCauseOfAThrowingMethod() {
    IllegalStateException exception = assertThrows(
            IllegalStateException.class,
            () -> new ObjectRenderer().invoke(new Person("Ada", 42), "panic"));

    assertEquals("boom", exception.getMessage());
}

A first cut:

return target.getClass().getMethod("name").invoke(target);

fails at compile time (Method.invoke throws checked exceptions) — RED #1 — so you wrap:

try {
    return target.getClass().getMethod(name).invoke(target);
} catch (InvocationTargetException e) {
    throw new IllegalStateException(e.getCause());
}

Run the suite. invokesAMethodByName passes, then:

RED #2:

ObjectRendererTest > unwrapsTheCauseOfAThrowingMethod() FAILED
org.opentest4j.AssertionFailedError: expected: <boom> but was: <java.lang.IllegalStateException: boom>

invoke wraps any exception the target throws in InvocationTargetException — but new IllegalStateException(cause) builds its message from cause.toString(). The real boom is one layer down and your wrapper swallowed it.

Step 5 — GREEN: unwrap, keeping the message

} catch (InvocationTargetException e) {
    throw new IllegalStateException(e.getCause().getMessage(), e.getCause());
}

Now the caller sees the genuine IllegalStateException("boom") — type, message, and cause — and the test is GREEN.

REFACTOR — and now, don’t use it

For any specific class, reflection is strictly worse than the straightforward direct code: no compile-time checks, slower, and a NoSuchMethodException at runtime instead of a compiler error. So the mature version touches the type directly:

public String describe(Person person) {
    return "name=" + person.name() + lineSeparator + "age=" + person.age();
}

Keep the generic machinery only where the type genuinely cannot be known: test frameworks (JUnit finding @Test methods), JSON mappers (Jackson), DI containers (Spring wiring beans). That is reflection’s natural habitat — and now you know what they’re all actually doing.

Java-specific notes

  • Reflection trades compile-time safety for runtime flexibility. In application code, “I don’t know the type” is usually a smell that a design — an interface, a Map, a Function — would fix with static types.
  • Module system respects setAccessible only when the package is open; records make this explicit. Frameworks supply --add-opens, which is why you see it in app launchers.
  • Notice the TDD shape of this chapter: each failure taught something specific — getFields() vs getDeclaredFields(), and message vs cause. The tests didn’t just verify; they uncovered.

Run it

gradle test --tests "reflection.*"

Key takeaway: knowing Class/Field/Method demystifies every framework you’ll ever use — but reach for them in your code only when no static type can do the job.

Next: 17 — webserver — a real HTTP server, built test-first.