Learn Java with Tests

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

Chapter 8 of 18

08 — Streams

filter, map, and reduce — the functional way to process collections.

Kata: transform and reduce lists functionally — filter, map, distinct, sum. New ideas: the Stream API, method references, and the three verbs of functional collection processing.

The Stream API

The Stream API is a pipeline of operations — source → filters/maps → reduce — that reads like the intent rather than the mechanism.

Every stream exercise is written test-first, so the API is self-explanatory:

@Test
void evensFiltersTheList() {
    assertEquals(List.of(2, 4, 6), Numbers.evens(List.of(1, 2, 3, 4, 5, 6)));
}

RED → implement:

public static List<Integer> evens(List<Integer> numbers) {
    return numbers.stream()
            .filter(n -> n % 2 == 0)
            .toList();
}

GREEN. The three stream verbs, all test-first:

Operation Mindset Java
filter keep stream().filter(predicate)
map transform each stream().map(function)
terminal produce a result .toList(), .count(), .sum()

Step 2 — map and reduce

@Test
void sumOfSquaresReducesToASingleValue() {
    assertEquals(14, Numbers.sumOfSquares(List.of(1, 2, 3)));
}
public static int sumOfSquares(List<Integer> numbers) {
    return numbers.stream()
            .mapToInt(n -> n * n)   // maps AND unboxes to int stream
            .sum();                 // reduces to one number
}

.mapToInt is the specialized stream avoiding boxing. And sum is a reduce in disguise.

Step 3 — distinct collects

public static long countDistinct(List<Integer> numbers) {
    return numbers.stream().distinct().count();
}

Java-specific notes

  • Method references: stream().map(s -> s.length()) can be written stream().map(String::length). Use them where they read cleanly.
  • .toList() (Java 16+) returns an unmodifiable list; .collect(Collectors.toList()) is the older form. Prefer .toList().
  • Laziness: streams are lazy (nothing runs until a terminal op). filter first means map runs on fewer elements — performance matters as inputs grow. Tests won’t show you this; it’s a design habit.
  • Parallel: stream().parallel()... exists, but don’t reach for concurrency until a test proves you need it — see the concurrency chapter for how to do that properly.

Why this matters for interviews

Live-coding a correct, readable pipeline (filter/map/reduce) in 30 seconds is a strong signal. Combine it with records (ch. 05) and Optional (ch. 09) and you answer most “write me a function that processes X” questions with one clean chain.

Run it

gradle test --tests "streams.NumbersTest"

Key takeaway: streams replace loops the way loops replaced goto — the test describes the what, the pipeline describes the how. If a pipeline starts feeling clever, your tests will (out)date you about it.

Next: 09 — optionals — missing values without null.