Unit 09 · lesson

A Failing Test Is Evidence, Not Something to Hide

A green test result is comfortable. A red result is often more useful.

When a test fails, JUnit is telling you that the actual program behavior did not match the expectation written into the test. That disagreement is evidence. Your next job is to decide which side is wrong: the implementation, the test, or the requirement behind both.

Do not "fix" a test simply by changing the expected result until the failure disappears.

Create a deliberate defect

The intended rule is:

Stop only when the range is strictly less than 0.50 m.

Correct implementation:

boolean shouldStop(RangeSource source) {
  return source.getRangeMeters()
      < cautionDistanceMeters;
}

Introduce a bug:

boolean shouldStop(RangeSource source) {
  return source.getRangeMeters()
      <= cautionDistanceMeters;
}

The difference is one character.

Now run this test:

@Test
void continuesExactlyAtBoundary() {
  ObstacleGuard guard = new ObstacleGuard(0.50);
  RangeSource source =
      new FixedRangeSource("at", 0.50);

  assertFalse(guard.shouldStop(source));
}

The test expects false because the written rule says exactly 0.50 m is not inside the caution zone.

The buggy implementation returns true.

A JUnit failure report will identify the failed test and the mismatch. The exact formatting depends on the runner, but the important information is conceptually:

continuesExactlyAtBoundary FAILED
expected: false
actual:   true

That record points directly to a disagreement worth investigating.

Do not change the test first

A bad response is:

assertTrue(guard.shouldStop(source));

Now the test passes, but the requirement has silently changed.

You did not repair the program. You edited the evidence so it would agree with the bug.

The correct first question is:

What does the specification say should happen at exactly 0.50 m?

If the requirement still says strict <, repair the implementation.

If the requirement changed to "at or below," then both implementation and test expectations may need deliberate updates.

The important part is that the decision comes from the requirement, not from a desire to make the test runner green.

A failure has several possible causes

A failed assertion can come from different sources.

The implementation is wrong

Example:

<=

was written where the rule requires:

<

The expected result is wrong

Maybe the requirement actually says "0.50 m or closer" and the test incorrectly expects false at 0.50.

The test setup is wrong

Perhaps the test accidentally constructed:

new FixedRangeSource("at", 0.05)

instead of 0.50.

The test is exercising the wrong object

A variable might point to an old instance or different implementation.

Shared state leaked between tests

If one test modifies an object and another test reuses it, the second case may begin in an unexpected state.

A failure tells you there is a mismatch. It does not automatically identify the root cause.

Preserve enough information to diagnose the case

A good test name already helps:

continuesExactlyAtBoundary

A test can also include a useful assertion message:

assertFalse(
    guard.shouldStop(source),
    "0.50 m should remain outside a strict < 0.50 caution rule"
);

If the assertion fails, the message records why the expectation exists.

Use messages to clarify the requirement, not to write essays inside the test suite.

Repair the implementation and rerun the same test

Change:

<=

back to:

<

Then rerun the tests.

The important sequence is:

  1. preserve the failing case;
  2. identify the requirement;
  3. repair the implementation;
  4. rerun the same test;
  5. keep the test so the defect is less likely to return unnoticed.

That last point turns the case into a regression test.

A regression is a defect that reappears after code changes. A regression test protects behavior that was previously broken or is important enough to preserve explicitly.

The test should stay after the bug is fixed

It may be tempting to delete:

continuesExactlyAtBoundary()

after the implementation is repaired.

Do not.

The test now documents an important edge case and will fail again if a future edit accidentally changes < back to <=.

A useful test suite becomes a record of behavior the project intends to preserve.

A failing test can expose a design problem too

Not every failure is a one-line bug.

Suppose ValidatedRangeSensor should preserve the last valid reading after an invalid update.

The intended behavior is:

start at 0.42 m
attempt 3.70 m
reject request
remain at 0.42 m

Write a test:

@Test
void rejectedUpdatePreservesLastValidRange() {
  ValidatedRangeSensor sensor =
      new ValidatedRangeSensor(
          "range-front",
          0.10,
          2.00,
          0.42
      );

  try {
    sensor.updateRange(3.70);
  } catch (IllegalArgumentException error) {
    // expected rejection for this exercise
  }

  assertEquals(
      0.42,
      sensor.getRangeMeters(),
      0.001
  );
}

If the implementation assigns 3.70 before validating it, the test fails.

That failure exposes a state-management problem, not just an incorrect boolean operator.

Test the exception itself

A stronger test can verify that the invalid operation actually throws the expected exception.

JUnit provides assertThrows(...) for this purpose:

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

@Test
void rejectsRangeAboveDeclaredMaximum() {
  ValidatedRangeSensor sensor =
      new ValidatedRangeSensor(
          "range-front",
          0.10,
          2.00,
          0.42
      );

  IllegalArgumentException error =
      assertThrows(
          IllegalArgumentException.class,
          () -> sensor.updateRange(3.70)
      );

  assertEquals(
      0.42,
      sensor.getRangeMeters(),
      0.001
  );
}

This test checks two behaviors:

  • the invalid request throws IllegalArgumentException;
  • the object's last valid state remains unchanged.

Those are separate claims, both worth checking.

Why an uncaught exception is different

If a test simply does this:

@Test
void invalidRangeTest() {
  sensor.updateRange(3.70);
}

and the method throws, the test fails because an unexpected exception escaped the test method.

That is useful when no exception is expected.

When the intended behavior is to throw, assertThrows(...) makes that expectation explicit.

The test now distinguishes:

an expected rejection occurred

from:

the test crashed unexpectedly.

A test can be wrong even when it passes

Consider:

@Test
void boundaryTest() {
  ObstacleGuard guard = new ObstacleGuard(0.50);
  RangeSource source =
      new FixedRangeSource("at", 0.50);

  assertTrue(guard.shouldStop(source));
}

If the buggy <= implementation is present, this test passes.

But the test itself contradicts the stated strict < rule.

A green result does not guarantee a correct test suite.

Tests are code. They need review too.

Read the failure before editing anything

When a test fails, capture:

  • test name;
  • expected result;
  • actual result;
  • input used;
  • rule being checked;
  • implementation line or state involved;
  • repair made;
  • result after rerun.

This creates a small failure record another student can follow.

For the boundary bug:

FieldRecord
testcontinuesExactlyAtBoundary
input0.50 m
expectedfalse
actualtrue
rulestop only for range < 0.50 m
defectimplementation used <=
repairchanged <= to <
reruntest passes

That is far more useful than writing "fixed test."

Practice: decide what to repair

A battery rule says:

needsCharge returns true only below 20 percent.

Implementation:

boolean needsCharge(double percent) {
  return percent <= 20.0;
}

Test:

@Test
void noChargeExactlyAtTwenty() {
  assertFalse(needsCharge(20.0));
}

The test fails.

Write a failure record containing:

  • expected;
  • actual;
  • requirement;
  • implementation defect;
  • repair;
  • reason the test should remain after the repair.

Then answer one final question:

If a product owner changes the requirement to "20 percent or lower," which artifact should change first: the requirement, the test, or the implementation?

The next lesson moves from pure Java rules into WPILib robot-code testing, where JUnit can also exercise subsystem behavior in desktop simulation.