Testing as Discipline
How do we know what is true?
Chapter 4 — Testing as Discipline
“Tests are not how you prove your code works. Tests are how you trust your code works.” — Michael Feathers (paraphrased)
“…but test everything; hold fast what is good.” — 1 Thessalonians 5:21
Why This Matters
Three weeks of Phase 1. You can read code. You can specify code. You can handle exceptions in code. You have not yet, in any disciplined way, verified that the code you wrote actually does what you said it does.
That stops this week.
A test is a small piece of code that says: “if I call this method with these arguments, it should produce this result.” If the test passes, the method behaves as expected for that case. If it fails, you know — instantly, automatically, before you ship — that something has drifted between the spec (Chapter 2) and the implementation.
Tests are the verification layer. They are the experiments that prove the promises. Without tests, your specifications are aspirations. With tests, your specifications are checked.
A working programmer at year five writes tests for almost everything they ship. A working senior engineer writes tests first for almost everything they ship — often before they write the implementation at all. The discipline is called test-driven development (TDD), and you’ll meet it formally in §4.5.
And here is why this matters for Coding 2 specifically: in Phase 2, you will be asking an AI to write code for you. The AI is fast. The AI is fluent. The AI is also, sometimes, confidently wrong. The only way to know whether the code you got back is correct is to test it. Tests are the verification layer that lets you trust AI-generated code. Without tests, you are vibing. With tests, you are engineering.
The apologetic frame: how do we know what is true? This is the question of epistemology — the branch of philosophy that asks how knowledge is possible at all. The Christian tradition has a particular answer that runs through 1 Thessalonians 5:21 (“test everything; hold fast what is good”), the disciplines of fides quaerens intellectum (“faith seeking understanding”), and the entire patristic and Reformation hermeneutic of testing claims against Scripture, against tradition, against the consensus of the church. Testing is not skepticism. Testing is the correct response to claims someone has made. You verify because the claim deserves the seriousness of being checked.
Code is no different. You wrote a spec. Now test the spec.
4.1 — What Is a Test?
The simplest possible Java test:
public class SimpleTest {
public static void main(String[] args) {
int result = 2 + 2;
if (result != 4) {
System.out.println("FAIL: expected 4, got " + result);
} else {
System.out.println("PASS");
}
}
}
That’s a test. It calls some code, compares the result to the expected value, and reports pass or fail.
Three problems with this approach as written:
- It only tests one case. Real code needs dozens to hundreds of tests, and you don’t want to write each one this verbosely.
- No reporting infrastructure. When you have 50 tests, you want a summary — “47 passed, 3 failed, here are the failures.” You don’t want to scroll through 50 lines of output.
- No setup/teardown. Many tests share common initialization (create a test object, populate it with sample data) or cleanup (delete temporary files). Doing this by hand in every test is tedious.
A testing framework solves these problems. The Java standard is JUnit 5 (often written JUnit Jupiter — same thing). For the rest of this book, when we say “tests,” we mean JUnit 5 tests.
4.2 — JUnit 5 Basics
JUnit 5 is not part of the JDK — it’s a third-party library you add to your project. In OnlineGDB, JUnit 5 is preinstalled and ready to use; see Appendix B for the workflow. If you’re running locally, you can pull JUnit 5 from Maven Central, but the rest of this chapter assumes the OnlineGDB-style workflow.
A minimal JUnit 5 test class:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class CalculatorTest {
@Test
void addsTwoPositiveNumbers() {
Calculator c = new Calculator();
int result = c.add(2, 3);
assertEquals(5, result);
}
@Test
void addsTwoNegativeNumbers() {
Calculator c = new Calculator();
int result = c.add(-2, -3);
assertEquals(-5, result);
}
}
Breaking it down:
import org.junit.jupiter.api.Test;— pulls in the@Testannotation.import static org.junit.jupiter.api.Assertions.*;— pulls inassertEquals,assertTrue,assertThrows, etc., as bare static methods. Use the static import. It makes tests dramatically more readable.@Test— marks a method as a test. The framework finds every@Test-annotated method and runs it.- Test methods are conventionally
void, take no parameters, and have descriptive names. JUnit 5 lets them be package-private (nopublicneeded). - The test method name should describe what is being tested, not be
test1,test2,test3.addsTwoPositiveNumberstells you what the test is for at a glance.
Running this test class produces output like:
[OK] addsTwoPositiveNumbers()
[OK] addsTwoNegativeNumbers()
Tests run: 2, Failures: 0
A failing test produces:
[X] addsTwoPositiveNumbers()
expected: <5> but was: <6>
at CalculatorTest.addsTwoPositiveNumbers(CalculatorTest.java:9)
Tests run: 2, Failures: 1
The failure message tells you what was expected, what was produced, and the line. That’s the diagnostic you’ll learn to read fluently.
Coach’s Note — Test names are documentation.
addsTwoPositiveNumbersis the spec, in the form of an executable claim. When the test fails, the name tells the next reader (which may be you, in six months) exactly what behavior just broke. Don’t name teststest1.
4.3 — The Assertion Vocabulary
JUnit 5 provides a small but expressive set of assertions. The ones you’ll use constantly:
assertEquals(expected, actual)
The workhorse. Asserts that actual equals expected. Expected goes first. This is the convention every JUnit programmer has internalized — get it right.
assertEquals(5, c.add(2, 3));
assertEquals("Hello, Maya", greeter.greet("Maya"));
assertEquals(List.of("a", "b"), parser.parse("a, b"));
For objects, assertEquals uses .equals() — not ==. (Chapter 13 of Coding 1, Chapter 7 accelerated — the rule applies to tests too.)
For floating-point, never use assertEquals(expected, actual) directly — floating-point math is approximate. Use the three-argument overload with a tolerance:
assertEquals(0.1 + 0.2, 0.3, 0.0001); // tolerance of 0.0001
Otherwise you’ll get the famous 0.1 + 0.2 != 0.3 failure that has confused generations of programmers.
assertTrue(condition) / assertFalse(condition)
For booleans. Use them when assertEquals(true, ...) would read awkwardly.
assertTrue(roster.contains("Maya"));
assertFalse(roster.isEmpty());
Both take an optional second argument: a message that’s shown on failure. Useful when the condition alone isn’t self-explanatory.
assertTrue(account.getBalance() > 0, "balance should be positive after deposit");
assertNull(actual) / assertNotNull(actual)
For null checks. Read more naturally than assertEquals(null, ...).
assertNotNull(parser.parse("valid input"));
assertNull(map.get("nonexistent key"));
assertThrows(expectedType, executable)
Asserts that the executable throws an exception of the given type. This is the test for your @throws Javadoc claims.
@Test
void addRejectsNull() {
Roster r = new Roster();
assertThrows(NullPointerException.class, () -> r.add(null));
}
@Test
void withdrawRejectsNegative() {
BankAccount a = new BankAccount("Maya", 100);
assertThrows(IllegalArgumentException.class, () -> a.withdraw(-50));
}
The () -> r.add(null) is a lambda expression — a small one-line function. JUnit calls it; if it throws the expected type, the test passes. If it throws nothing or the wrong type, the test fails.
assertThrows also returns the caught exception, so you can inspect it:
@Test
void withdrawErrorMessageIncludesAmount() {
BankAccount a = new BankAccount("Maya", 100);
IllegalArgumentException e =
assertThrows(IllegalArgumentException.class, () -> a.withdraw(-50));
assertTrue(e.getMessage().contains("-50"));
}
This is how you verify that error messages contain useful information — a contract claim from Chapter 3.
assertArrayEquals(expected, actual)
For arrays. Compares element-by-element (because array.equals(otherArray) would compare references).
assertArrayEquals(new int[]{1, 2, 3}, sorter.sort(new int[]{3, 1, 2}));
assertAll(...)
Runs multiple assertions and reports all failures together, instead of stopping at the first.
@Test
void newAccountHasExpectedDefaults() {
BankAccount a = new BankAccount("Maya", 100);
assertAll(
() -> assertEquals("Maya", a.getOwner()),
() -> assertEquals(100.0, a.getBalance(), 0.001),
() -> assertFalse(a.isOverdrawn())
);
}
Useful when you want to verify several properties of one object’s state. Don’t overuse it — most tests verify a single behavior and don’t need it.
4.4 — The Arrange / Act / Assert Pattern
A well-written test has three sections, in this order, often visually separated by blank lines:
- Arrange — set up the inputs and objects the test needs.
- Act — call the method under test.
- Assert — verify the result.
@Test
void depositIncreasesBalance() {
// Arrange
BankAccount account = new BankAccount("Maya", 100);
// Act
account.deposit(50);
// Assert
assertEquals(150, account.getBalance(), 0.001);
}
The blank lines (and sometimes the comments) make the structure visible. Every test should read this way. If you can’t tell which line is the act and which is the assert, the test is hard to read — and a hard-to-read test is a hard-to-trust test.
A common variant when there’s no separate setup: Given / When / Then (the BDD-style naming). Same three sections, different vocabulary. Pick one and stick with it.
Coach’s Note — A test that tests two things at once is a test that doesn’t tell you which thing broke. Keep one assertion-of-behavior per test. (Multiple
assertEqualson the same behavior — like verifying multiple fields after construction — is fine. Multiple separate behaviors — like “deposit works and withdraw works” — should be two tests.)
4.5 — Test-Driven Development (TDD)
The classic TDD cycle, sometimes called red-green-refactor:
- Red. Write a failing test for the next small piece of behavior you want. Run it; it fails (because the code doesn’t exist yet, or doesn’t behave the way the test expects).
- Green. Write the simplest possible code that makes the test pass. Don’t over-engineer. Run all the tests; everything passes.
- Refactor. Now that the tests are green, clean up the code. Improve names, extract helpers, eliminate duplication. Run the tests after every change — refactoring without tests is just changing the code and hoping.
Then repeat. Each cycle adds one tested behavior. After 30 cycles, you have a class with 30 tests covering 30 behaviors and an implementation that grew up surrounded by them.
A small worked example. Suppose you’re building a Stack<T>. The TDD sequence:
// Red:
@Test
void newStackIsEmpty() {
Stack<String> s = new Stack<>();
assertTrue(s.isEmpty());
}
// (Stack doesn't exist yet — write the bare class with isEmpty() returning true.)
// Green: minimal Stack with isEmpty() that always returns true. Test passes.
// Red:
@Test
void pushThenNotEmpty() {
Stack<String> s = new Stack<>();
s.push("a");
assertFalse(s.isEmpty());
}
// (Test fails — push doesn't exist or isEmpty still always returns true.)
// Green: implement push to add to internal storage; implement isEmpty
// to check the storage. Both tests pass.
// Red:
@Test
void pushThenPopReturnsValue() {
Stack<String> s = new Stack<>();
s.push("a");
assertEquals("a", s.pop());
}
// Green: implement pop. All three tests pass.
// And so on.
By the time the stack supports push, pop, peek, size, and isEmpty, you have a test for each — accumulated organically, one rep at a time.
The TDD discipline is hard at first. Most students reflexively want to write the code first and the tests second. The training-wheel exercise to break this habit: write the test, watch it fail, then write the code. The watching-it-fail step matters — it confirms that the test would actually catch a bug. A test that passes against an empty implementation is a test that isn’t testing anything.
Project 4 is a TDD project. You’ll write the test suite first, then the implementation. The grader will check the file timestamps to confirm the order.
Coach’s Note — TDD isn’t a religion. Real working engineers don’t always write tests first — sometimes the code’s right shape isn’t clear yet and they sketch the implementation first to find out. But the muscle of TDD — being able to write the test first when you want to — is what makes the engineer trustworthy. This week we’re training that muscle hard, because you’ll need it for every Phase 2 project.
4.6 — What to Test (and What Not To)
Every method’s tests should cover:
- The happy path. The normal case. One or two tests where the inputs are valid and the output is the expected one.
- Boundary cases. Empty inputs. Single-element inputs. Maximum-size inputs. Off-by-one corners.
- Documented preconditions. Each
@throwsclaim in your Javadoc deserves a test that verifies the exception is thrown when the precondition is violated. UseassertThrows. - Documented postconditions. Each promise the method makes (“returns -1 if not found,” “leaves the list in insertion order”) deserves a test that checks it.
What you should not test:
- The standard library. Don’t test that
ArrayList.addworks. Java did that for you. - Trivial getters and setters. If
getName()just returnsthis.name, a test is noise. - Private implementation details. Test behavior through the public API. If you test a private helper directly, you’ve coupled your tests to the implementation — and the moment you refactor (Chapter 2’s Hard tier), the tests break needlessly.
- Things you can’t actually verify. “Test that the algorithm is efficient” is hard to test in a unit test. Performance is a different topic.
The rule of thumb: test every behavior your spec describes; test no behavior your spec doesn’t describe. The spec and the tests are two views of the same thing. If you find yourself testing something not in the spec, either add it to the spec or remove the test.
4.7 — Unit Tests vs Integration Tests
A unit test verifies one small piece of code — usually one method on one class — in isolation. It runs quickly (microseconds). It doesn’t touch the disk, the network, the database, or any other component. Most of the tests you write are unit tests.
An integration test verifies that multiple components work together. It might read a real file, hit a real database, or exercise an entire workflow from input to output. Integration tests are slower (milliseconds to seconds) and more fragile (they depend on the environment), but they catch a different class of bugs — wiring bugs, configuration bugs, contract mismatches between components.
A small worked distinction. For a VerseLookup class:
Unit test (tests one method on one class):
@Test
void parseExtractsBookAndVerse() {
VerseParser parser = new VerseParser();
Verse v = parser.parse("Romans 8:28");
assertEquals("Romans", v.book());
assertEquals(8, v.chapter());
assertEquals(28, v.verseNumber());
}
Integration test (tests the loading + parsing + lookup pipeline):
@Test
void lookupReturnsExpectedVerse() throws IOException {
Path tempFile = Files.createTempFile("verses", ".csv");
Files.writeString(tempFile, "Romans 8:28,All things work...\n");
VerseLookup lookup = new VerseLookup(tempFile.toString());
String text = lookup.find("Romans 8:28");
assertEquals("All things work...", text);
Files.delete(tempFile);
}
The unit test exercises one parser method against a string. The integration test exercises the whole pipeline — load file, parse lines, store results, look up — against a real temp file.
Both have a place. Phase 1 projects will focus mostly on unit tests (they’re faster to write and run). The midterm (Project 8) will require both.
4.8 — @BeforeEach: Shared Setup
When many tests need the same initial state, repeat the setup with @BeforeEach:
public class RosterTest {
private Roster roster;
@BeforeEach
void newRoster() {
roster = new Roster();
}
@Test
void newRosterIsEmpty() {
assertEquals(0, roster.size());
}
@Test
void addIncreasesSize() {
roster.add("Maya");
assertEquals(1, roster.size());
}
@Test
void addThenContains() {
roster.add("Maya");
assertTrue(roster.contains("Maya"));
}
}
@BeforeEach runs before each test method. The roster field is freshly initialized for every test. This is critical — tests must be independent. A test that depends on another test having run first is a fragile test.
(@BeforeAll, @AfterEach, and @AfterAll also exist for less common setup/teardown needs. You won’t need them often this semester.)
4.9 — Testing Exceptions (More Patterns)
The full vocabulary for testing exception behavior:
@Test
void verifyExceptionType() {
assertThrows(IllegalArgumentException.class, () -> account.withdraw(-50));
}
@Test
void verifyExceptionTypeAndMessage() {
IllegalArgumentException e =
assertThrows(IllegalArgumentException.class, () -> account.withdraw(-50));
assertTrue(e.getMessage().contains("non-negative"),
"message should mention non-negativity: " + e.getMessage());
}
@Test
void verifyExceptionTypeAndCause() {
CatechismLoadException e =
assertThrows(CatechismLoadException.class, () -> loader.load("nope.csv"));
assertNotNull(e.getCause());
assertTrue(e.getCause() instanceof IOException);
}
@Test
void verifyNoExceptionThrown() {
assertDoesNotThrow(() -> account.withdraw(50));
}
The last one — assertDoesNotThrow — is sometimes useful when a method is not supposed to throw under a particular condition. Most of the time you don’t need it; just call the method and assert on the result.
Notice how assertThrows returns the caught exception. That’s how you test the contents of an exception — its message, its cause, any custom fields. This is the test counterpart to Chapter 3’s discipline of throwing exceptions with useful messages.
4.10 — Epistemology, Compactly
The chapter’s apologetic frame: how do we know what is true?
This is the oldest hard question in philosophy. The Greeks worked it. The medievals worked it. The Reformation rethought it. Modern Christian epistemology — the discipline of asking how Christians know what they know about God, the world, and themselves — has gone through Augustinian (knowledge by illumination), Thomist (knowledge by reasoning from sense data), and Reformed (knowledge by the testimony of the Holy Spirit confirming Scripture) shapes, among others. The Lutheran confessional tradition tends to land in a place where Scripture is the norma normans (the norming norm) and other sources are tested against it — the move described by 1 Thessalonians 5:21: test everything; hold fast what is good.
Notice what that verse asks of you. It does not say believe whatever you’re told. It does not say believe nothing. It says test. Examine. Verify. Then hold onto what survives the testing.
That is what tests in software do. A method’s spec is a claim. A test is an examination of the claim. A passing test is verification that the claim, under the conditions of the test, holds. A failing test is a falsification. The discipline of running the tests — every time, on every change, automatically — is the discipline of refusing to believe the code without checking.
And, crucially: this discipline does not get less important when an AI is the one writing the code. It gets more important. The AI can produce a hundred fluent lines in a second. None of those lines are guaranteed to be correct. The test suite is what tells you which ones are. In Phase 2, the test suite is what stands between you and shipping confident garbage.
Epistemology in the church and verification in software run on the same posture: claims deserve seriousness, and seriousness includes the willingness to check.
Coach’s Note — I am not collapsing biblical truth-claims into software claims. The stakes are not equivalent. I am saying the method of careful checking is shared. The student who has learned to test claims against Scripture has the basic move already. Apply it.
4.11 — Common Bugs (Testing Edition)
Bug: A test that passes against any implementation, including a broken one.
Example: assertNotNull(account) after construction — passes whether the account is well-formed or garbage.
Fix: Assert on the actual behavior or state. assertEquals(100.0, account.getBalance(), 0.001).
Bug: Using == instead of assertEquals to compare objects.
Example: assertTrue(parsed == expected) — almost always false even when the contents match.
Fix: assertEquals(expected, parsed). Let JUnit use .equals().
Bug: Comparing floats with assertEquals without a tolerance.
Example: assertEquals(0.3, 0.1 + 0.2) — fails because floating-point arithmetic.
Fix: assertEquals(0.3, 0.1 + 0.2, 0.0001).
Bug: Reversing expected and actual in assertEquals.
Example: assertEquals(account.getBalance(), 100.0) — runs, but the failure message reads backwards (“expected 100.0 but was [whatever the balance is]”).
Fix: Expected first. Always. Build the muscle memory.
Bug: Test depends on a previous test having run.
Example: One test adds three items; the next test asserts the size is three.
Fix: Use @BeforeEach to reset state. Each test should be independent.
Bug: Catching an exception with try/catch inside a test instead of using assertThrows.
Example:
try { account.withdraw(-50); fail("should have thrown"); }
catch (IllegalArgumentException e) { /* expected */ }
Fix: assertThrows(IllegalArgumentException.class, () -> account.withdraw(-50));. The JUnit idiom is cleaner and produces better failure messages.
Bug: Test method named test1, test2, test3.
Fix: Name tests for the behavior being verified. depositIncreasesBalance, withdrawRejectsNegative, transferLeavesTotalUnchanged. The name is documentation.
Bug: Writing the test after the code, then “verifying” the code by running the test. Why this is a bug: You wrote a test that the code’s current behavior passes. You haven’t proven the code is correct; you’ve proven it does what it does. A test written after the code rarely catches the bugs that were there at the time of writing. Fix: TDD. Test first; watch it fail; write the code; watch it pass.
4.12 — Reps
Open the exercises. The reps this week are JUnit reps. Highlights:
- Rep 1 — Your first JUnit test.
- Rep 4 — Test
assertThrowsagainst your own custom exception from Chapter 3. - Rep 7 — Write a test that fails, then make it pass (the red-green cycle).
- Rep 10 — Build a tiny class TDD-style from a written spec.
AI stays off. The test muscle has to live in your hands before Phase 2’s AI work depends on it.
4.13 — This Week’s Project
You’re ready for Project 4 — The Test-First Calculator, in Project 4.
You will build a small expression calculator (+, -, *, /, parentheses, on integers). The Normal tier requires you to write at least 12 JUnit tests before writing the implementation; the grader will check file timestamps to confirm test-first. The Medium tier extends to floating-point with tolerance-based testing. The Hard tier adds unary minus and exponentiation, with a coverage requirement.
This is the first project where the test file is graded as substantively as the implementation. Treat the tests as first-class artifacts. They are.
4.14 — Coach’s Final Word for Week 4
Four weeks of Phase 1 down. You can read code, spec code, throw exceptions, and now verify behavior with tests. That is the senior engineer’s basic toolkit — the four moves used in every project for the rest of your career.
Four weeks to the midterm. Chapter 5 is debugging — what to do when the tests fail. Chapter 6 is files and data — where most real-world bugs live. Chapter 7 is recursion — the conceptual move you’ll need for the rest of programming. Chapter 8 is collections + the midterm.
The discipline you built this week is the one that makes Phase 2 possible. When you give an AI a spec and ask for an implementation, you are also handing it (implicitly) the standard of correctness. The tests are what make that standard visible. Without tests, “did the AI get it right?” is a guess. With tests, it’s a fact.
See you on Monday.
Up next: Read the exercises and complete every rep. Then open Project 4 and build the test-first calculator. After that, Chapter 5 — debugging discipline.