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:
| Range | Comparison | Expected result |
|---|---|---|
0.49 | 0.49 < 0.50 | true |
0.50 | 0.50 < 0.50 | false |
0.51 | 0.51 < 0.50 | false |
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:
| Case | Input | Expected | Why this case exists |
|---|---|---|---|
| clearly inside | 0.25 | true | ordinary stop case |
| just below boundary | 0.49 | true | lower boundary neighbor |
| exact boundary | 0.50 | false | distinguishes < from <= |
| just above boundary | 0.51 | false | upper boundary neighbor |
| clearly outside | 1.20 | false | ordinary 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.00tovalue < 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.