Learn Java with Tests

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

Chapter 7 of 18

07 — Maps

Word counting with Map, merge, and a streams groupingBy refactor.

Kata: WordCount.count(sentence) returns {word -> occurrences}. New ideas: Map<String, Integer>, HashMap, merge, and a streams groupingBy refactor.

Step 1 — write the test first

@Test
void countsEachWordOnce() {
    Map<String, Integer> counts = WordCount.count("the quick brown fox");

    assertEquals(Map.of(
            "the", 1, "quick", 1, "brown", 1, "fox", 1
    ), counts);
}

RED (WordCount missing), then minimal:

public static Map<String, Integer> count(String sentence) {
    Map<String, Integer> counts = new HashMap<>();
    for (String word : sentence.toLowerCase().split("\\s+")) {
        counts.put(word, 1);
    }
    return counts;
}

GREEN for the first test. But look at the code — it’s a lie. Every word gets count 1, even repeats. The test deliberately didn’t cover that. Time for the second test:

@Test
void countsRepeatedWords() {
    Map<String, Integer> counts = WordCount.count("one two two three three three");
    assertEquals(Map.of("one", 1, "two", 2, "three", 3), counts);
}

RED. Now put isn’t enough — you need “increment value seen before”:

counts.merge(word, 1, Integer::sum);

Map.merge(key, value, remapFn) is the idiomatic Java “get-or-put-and-update” one-liner.

GREEN.

Step 2 — Map.of your test fixtures

Map.of("the", 1, "quick", 1) builds an immutable map literal — no hand-built fixtures. Test expectations read as plain data.

REFACTOR — streams go both ways

Chapters 04 and 08 show loops → streams. Here’s the loop-free version, driven by the same tests:

public static Map<String, Long> countWithStreams(String sentence) {
    return Arrays.stream(sentence.toLowerCase().split("\\s+"))
            .filter(word -> !word.isBlank())
            .collect(Collectors.groupingBy(word -> word, Collectors.counting()));
}

The hand-written merge loop becomes groupingBy(word, counting()). Two implementations, one contract — your test proves them equal because the test is written against the behaviour, not the algorithm.

Java-specific notes

  • Generics recap: Map<String, Integer> — keys and values typed; new HashMap<>() builds it.
  • Use new HashMap<>() for building; the interface type (Map<...>) is what you expose. That’s the interface-first habit from chapter 06 again.
  • split("\\s+") breaks on one-or-more whitespace; empty string yields [""] — hence the !word.isBlank() guard. Edge cases like this are exactly what extra tests should pin down.

Run it

gradle test --tests "maps.WordCountTest"

Key takeaway: merge and groupingBy are the two answers to “count things in Java” — write the naive loop first, then refactor to the library call under green tests. Doing it in that order is what builds fluency without cargo-cult.

Next: 08 — streams — the functional style done properly.