Unit 09 · lesson

Boundary Tests: Check the Exact Place Where Behavior Changes

A method can pass an obvious "near" test and an obvious "far" test while still being wrong at the exact boundary.

That is why good tests do not choose only comfortable values. They deliberately test the point where the rule changes from one result to another.

Use the Week 9 rule:

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

For a caution distance of 0.50 m, the intended behavior is:

  • below 0.50 m → stop;
  • exactly 0.50 m → do not stop;
  • above 0.50 m → do not stop.

The operator < makes the boundary exclusive.

Test immediately below, at, and above the boundary

Three values expose the rule clearly:

0.49 m
0.50 m
0.51 m

The expected results are:

RangeComparisonExpected result
0.490.49 < 0.50true
0.500.50 < 0.50false
0.510.51 < 0.50false

Now turn those expectations into JUnit tests.

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;

class ObstacleGuardBoundaryTest {

  private final ObstacleGuard guard =
      new ObstacleGuard(0.50);

  @Test
  void stopsImmediatelyBelowBoundary() {
    RangeSource source =
        new FixedRangeSource("below", 0.49);

    assertTrue(guard.shouldStop(source));
  }

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

    assertFalse(guard.shouldStop(source));
  }

  @Test
  void continuesImmediatelyAboveBoundary() {
    RangeSource source =
        new FixedRangeSource("above", 0.51);

    assertFalse(guard.shouldStop(source));
  }
}

These tests do more than add coverage. Together they describe the boundary rule in executable form.

Why the middle test matters most

Suppose the implementation accidentally changes to:

return source.getRangeMeters()
    <= cautionDistanceMeters;

The 0.49 test still passes.

The 0.51 test still passes.

Only the exact-boundary test exposes the change:

expected false
actual   true

Without the 0.50 test, the bug could survive while the test suite remains green.

This is a classic boundary-value problem.

A boundary value is an input at or immediately around the point where program behavior changes.

Boundaries appear everywhere in robot code

Robot software contains many thresholds:

  • maximum motor output;
  • minimum battery warning level;
  • encoder limits;
  • allowed temperature range;
  • acceptable sensor interval;
  • command timeout;
  • angle tolerance;
  • deadband around joystick input.

Whenever the implementation uses operators such as:

<
<=
>
>=

ask where the boundary is and which side owns the exact value.

For example:

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

has a different contract from:

boolean batteryLow(double percent) {
  return percent < 20.0;
}

A single symbol changes the result at exactly 20.0.

The specification decides the expectation

Tests should not guess which operator is "safer."

The requirement must decide.

Imagine the operating rule says:

A warning appears below 20 percent. At exactly 20 percent, no warning is shown.

Then the correct implementation is:

percent < 20.0

If a test expects true at 20.0, the test is wrong even if someone personally prefers a more conservative warning threshold.

Testing does not replace requirements. It makes requirements executable.

A table can become a test plan

Before writing test code, write the rule as cases:

CaseInputExpectedWhy this case exists
clearly inside0.25trueordinary stop case
just below boundary0.49truelower boundary neighbor
exact boundary0.50falsedistinguishes < from <=
just above boundary0.51falseupper boundary neighbor
clearly outside1.20falseordinary continue case

The table is not the test itself. It is the reasoning that tells you which tests should exist.

Then each important case becomes executable JUnit code.

Test the constructor boundary too

The same technique applies to validation.

Suppose a constructor requires:

if (cautionDistanceMeters <= 0) {
  throw new IllegalArgumentException(
      "Caution distance must be greater than zero"
  );
}

Now the boundary is 0.0.

Useful cases include:

-0.01  rejected
 0.00  rejected
 0.01  accepted

The exact boundary test again tells you whether the implementation matches the written rule.

Do not confuse precision with unrealistic numbers

A boundary test may use a value such as 0.49 because it is easy to read. In other systems, the meaningful neighbor may be much smaller.

For floating-point calculations, exact equality can also be tricky when the value was produced by arithmetic rather than written directly as a constant.

WPILib's JUnit examples use an acceptable error value, often called a delta, when comparing floating-point results with assertEquals.

For a method that returns a boolean comparison like shouldStop(...), assertTrue and assertFalse avoid that issue because the method has already made the comparison.

For a calculated numeric result, a test may look like:

assertEquals(
    0.41,
    calculatedAverage,
    0.001
);

That means the actual value may differ from 0.41 by up to the stated tolerance and still satisfy the test.

Do not add a tolerance without understanding what precision the calculation and requirement actually need.

Worked defect: the comfortable tests all pass

Suppose the requirement is strict < 0.50, but the code contains:

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

The current test suite has only:

0.25 → true
1.20 → false

Both pass.

A developer concludes the method is correct.

Add:

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

  assertFalse(guard.shouldStop(source));
}

Now the test fails.

Nothing about the production code changed. The new test simply asked a better question.

This is why test quality depends on case selection, not just the number of tests.

Build boundary tests from operators

When reviewing code, use the comparison operator as a clue.

For:

value < limit

check:

just below
exactly equal
just above

For:

value >= minimum && value <= maximum

there are two boundaries. Test both ends:

below minimum
at minimum
inside interval
at maximum
above maximum

That pattern is especially useful for the ValidatedRangeSensor from Week 8.

Your turn

The allowed interval is:

0.10 m through 2.00 m, inclusive

Design five JUnit cases for an isValidRange(double value) method:

0.09
0.10
1.00
2.00
2.01

For each value, write the expected boolean result and identify which boundary rule it checks.

Then write at least three of the cases as JUnit methods.

Finish by answering:

Which single test would expose an accidental change from value <= 2.00 to value < 2.00?

In the next lesson, you will intentionally create that kind of mismatch and learn how to read a failing test as evidence instead of treating failure as something to erase.