Chapter 4 — Reps
Conditioning, not grading. JUnit 5 reps this week.
Ground rules:
- Type every line yourself. No copy-paste of test boilerplate.
- Run every test after writing it. Watch it pass. Then break the production code on purpose. Watch the test fail. Then fix.
- AI stays OFF. Phase 1. Test-writing is one of the highest-leverage muscles in software; you have to grow it yourself.
- Expected goes first in every
assertEquals. Build the muscle memory now.
JUnit 5 setup details for OnlineGDB and local JDKs live in Appendix B. Everything in this file assumes you can run a JUnit test class without manual classpath wrangling.
Reps 1–3: First Tests
Rep 1 — Hello, JUnit
Create two files:
Greeter.java (download Greeter.java):
public class Greeter {
public String greet(String name) {
return "Hello, " + name + ".";
}
}
GreeterTest.java:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class GreeterTest {
@Test
void greetsByName() {
Greeter g = new Greeter();
assertEquals("Hello, Maya.", g.greet("Maya"));
}
@Test
void greetsAnotherName() {
Greeter g = new Greeter();
assertEquals("Hello, Marcus.", g.greet("Marcus"));
}
}
Run the test class. Confirm both pass. Then deliberately break Greeter by changing the format string (“Howdy, ” instead of “Hello, ”). Re-run. Confirm both tests fail with useful messages.
Restore. Re-run. Confirm green.
Rep 2 — Arrange / Act / Assert
Write a test for the following method:
public class Math {
public static int max(int a, int b) {
return a > b ? a : b;
}
}
Write four tests, each clearly using the Arrange / Act / Assert structure (with blank lines or comments to separate):
max(2, 3)returns 3.max(3, 2)returns 3.max(5, 5)returns 5.max(-1, -2)returns -1.
Notice that even though three of these tests look very similar, each one tests a different case (a < b, a > b, a == b, both negative). Name each test for its case.
Rep 3 — Boolean Assertions
Write a class:
public class NumberChecker {
public boolean isPositive(int n) { return n > 0; }
public boolean isEven(int n) { return n % 2 == 0; }
}
Write a test class with four tests covering each method:
isPositive(5)is true.isPositive(-3)is false.isEven(4)is true.isEven(3)is false.
Use assertTrue and assertFalse, not assertEquals(true, ...). The boolean assertions read better.
Reps 4–5: Testing Exceptions
Rep 4 — assertThrows
Take the BankAccount.withdraw method you wrote in Chapter 3 (Rep 4). Write a test class with these tests:
- Normal withdraw decreases the balance.
- Withdraw of a negative amount throws
IllegalArgumentException. - Withdraw exceeding the balance throws
IllegalStateException. - The exception message from #3 includes the current balance.
For #2 and #3, use assertThrows. For #4, capture the exception and assert on its message:
@Test
void overdraftMessageIncludesBalance() {
BankAccount a = new BankAccount("Maya", 100);
IllegalStateException e =
assertThrows(IllegalStateException.class, () -> a.withdraw(500));
assertTrue(e.getMessage().contains("100"));
}
This is how you verify Chapter 3’s “include the actual value in the message” discipline.
Rep 5 — Test Your Custom Exception
Take the CatechismLoadException you wrote in Chapter 3 Rep 5, and the loadCatechism method that throws it. Write tests:
- Loading a real file (you’ll need to create a tiny one — see hint below) succeeds and returns its content.
- Loading a non-existent file throws
CatechismLoadException. - The thrown exception’s
getCause()is an instance ofIOException.
For test #1, use java.nio.file.Files.writeString to create a temp file at the start of the test, and Files.delete at the end. (Or pull this setup into a @BeforeEach and the cleanup into an @AfterEach.)
Reps 6–7: @BeforeEach and the Red-Green Cycle
Rep 6 — Shared Setup
Take the Roster class from your Chapter 2 reps (or write a fresh one — add(String), remove(String), contains(String), size()).
Write a test class with @BeforeEach setting up a fresh, empty Roster. Then write tests:
- Empty roster has size 0.
- After adding one name, size is 1 and
containsreturns true for that name. - After adding the same name twice, size is still 1.
- After adding and removing a name, size is back to 0.
- Removing a name not in the roster does not throw and does not change the size.
Each test must be independent — the @BeforeEach resets state between every test. Verify this by running the tests in different orders (most JUnit runners shuffle by default; if yours doesn’t, swap two @Test methods and re-run). The tests must pass in any order.
Rep 7 — Red, Green, Refactor
Practice the TDD cycle on a simple class. Build a Stack<T> with push, pop, peek, size, isEmpty.
Do it in this order:
- Red: Write
pushThenSizeIsOne(test that pushing once givessize() == 1). Without writing any Stack code yet, run the test class — it won’t compile. That’s a special case of “red”; it counts. - Minimum code: Write the empty Stack class with stub methods. The test now compiles and fails. That’s red.
- Green: Implement
pushandsizeminimally. Test passes. - Red: Write
pushThenPopReturnsValue. - Green: Implement
pop. Tests pass. - Red: Write
popOnEmptyThrows. - Green: Add the throw.
- Red: Write
pushThenPeekDoesNotRemove. - Green: Implement
peek. - Refactor: Look at your Stack. Is anything duplicated? Anything unclear? Clean it up. Re-run all tests after every change.
By the end you have 4-6 tests and a working Stack. The discipline of writing the test, watching it fail, writing the code, watching it pass — that’s the rep.
Reps 8–9: Float Tolerances and Multi-Asserts
Rep 8 — Floating Point
Write a class with two methods (download FloatMath.java):
public class FloatMath {
public static double mean(double[] values) {
double total = 0;
for (double v : values) total += v;
return total / values.length;
}
public static double standardDeviation(double[] values) {
double m = mean(values);
double sumSquares = 0;
for (double v : values) sumSquares += (v - m) * (v - m);
return java.lang.Math.sqrt(sumSquares / values.length);
}
}
Write tests. Every assertion involving a double must use the three-argument assertEquals(expected, actual, tolerance). Pick a tolerance like 0.0001.
Tests:
mean({2, 4})is3.0(within tolerance).mean({0.1, 0.2})is0.15(the canonical “floating point isn’t exact” case — this will fail without tolerance).standardDeviation({5, 5, 5})is0.0(within tolerance).standardDeviation({2, 4})is1.0(within tolerance).
If you forget the tolerance argument, the compiler will accept your code (because double, double matches Object, Object via auto-boxing) but the assertion will use .equals() on Double, which is exact — and you’ll get spurious failures.
Rep 9 — assertAll
Take any small class you’ve written this week. Pick one method that produces state changes affecting multiple fields. Write one test that uses assertAll to verify several properties after the method runs:
@Test
void depositUpdatesAllRelevantState() {
BankAccount a = new BankAccount("Maya", 100);
a.deposit(50);
assertAll(
() -> assertEquals(150.0, a.getBalance(), 0.001),
() -> assertEquals(1, a.transactionCount()), // if your class has this
() -> assertFalse(a.isOverdrawn())
);
}
The point of assertAll: if your code is broken in multiple ways, the test reports all the failures, not just the first. Useful for verifying multiple aspects of a single state change.
(If your class doesn’t have multiple related fields, write a class that does for this rep. A Point2D with x, y, and distanceFromOrigin() is fine.)
Rep 10 — TDD a Class From Scratch
Below is the spec for a small class. Do not write the class. Instead:
- Write the test class with at least 7 tests, covering every claim in the spec.
- Run the test class. It won’t compile (the class doesn’t exist). Create the class with stub methods. Re-run. All tests should fail.
- Implement the class one method at a time, watching tests turn green one by one.
Spec
/**
* A counter that tracks how many times each word has been added.
*
* <p>Words are compared case-insensitively. Whitespace-only words are
* ignored. This class is not thread-safe.
*/
public class WordCounter {
/** Constructs an empty counter. */
public WordCounter() { ... }
/**
* Increments the count for the given word.
*
* @param word the word to count; must not be null.
* If {@code word.trim().isEmpty()}, this is a no-op.
* @throws NullPointerException if {@code word} is null
*/
public void add(String word) { ... }
/**
* Returns the count for the given word (0 if never added).
*
* @param word the word to query; must not be null
* @throws NullPointerException if {@code word} is null
*/
public int countOf(String word) { ... }
/** Returns the number of distinct words counted. */
public int distinctCount() { ... }
}
Your test class should have at least one test per Javadoc claim. Examples of tests you should write:
- Empty counter:
distinctCount() == 0,countOf("anything") == 0. - Add a word;
countOf(sameWord) == 1. - Case-insensitivity:
add("Maya")andcountOf("maya") == 1. - Whitespace-only:
add(" ")does nothing. - Null handling:
add(null)throwsNullPointerException. - Multiple distinct words:
distinctCount()reflects the count of unique words.
Once your tests are written and failing, implement WordCounter to make them pass. Notice how the spec, the tests, and the implementation are three views of the same thing — and the tests force the implementation to honor the spec.
Rep 11 — Make a Test That Catches a Subtle Bug
Take this method (download StringReverser.java, provided as-is with the bug intact):
public static String reverse(String s) {
char[] chars = s.toCharArray();
int n = chars.length;
for (int i = 0; i < n / 2; i++) {
char tmp = chars[i];
chars[i] = chars[n - i];
chars[n - i] = tmp;
}
return new String(chars);
}
There’s an off-by-one bug. Find it without running the method. Write a specific JUnit test that catches the bug. Then fix the method.
(Hint: try reverse("ab") in your head. Trace it line by line.)
Done? One Last Thing.
From scratch, no looking — write a Calculator class with a divide(int a, int b) method that:
- Returns
a / bfor positiveb. - Throws
ArithmeticExceptionforb == 0. - Has a Javadoc documenting both behaviors.
Then write a test class with three tests:
- Normal division.
- Division by zero throws.
- The exception’s message contains the word “zero”.
Write the tests first. Then the class. Watch the tests fail (empty class), then pass (implemented class). If you can do this cold — spec → test → code, in that order — you have the move.
Up next: Project 4 — Project 4: The Test-First Calculator.