Unit 09 · lesson
Build a Test Matrix Before You Trust the Component
One or two tests can prove that a method works for one or two cases. A test matrix helps you choose a set of cases that challenges the important rules instead of collecting random inputs.
A good matrix is not a spreadsheet made for paperwork. It is a design tool that answers:
Which behaviors could break, and which test would expose each one?
This lesson combines the week into one testing strategy.
Start from the component contract
Use the validated range component from Week 8:
class ValidatedRangeSensor implements RangeSource {
private final String id;
private final double minRangeMeters;
private final double maxRangeMeters;
private double rangeMeters;
ValidatedRangeSensor(
String id,
double minRangeMeters,
double maxRangeMeters,
double initialRangeMeters
) {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException(
"ID cannot be blank"
);
}
if (minRangeMeters < 0) {
throw new IllegalArgumentException(
"Minimum range cannot be negative"
);
}
if (maxRangeMeters <= minRangeMeters) {
throw new IllegalArgumentException(
"Maximum range must exceed minimum"
);
}
this.id = id;
this.minRangeMeters = minRangeMeters;
this.maxRangeMeters = maxRangeMeters;
updateRange(initialRangeMeters);
}
void updateRange(double newRangeMeters) {
if (newRangeMeters < minRangeMeters
|| newRangeMeters > maxRangeMeters) {
throw new IllegalArgumentException(
"Range outside declared interval"
);
}
rangeMeters = newRangeMeters;
}
@Override
public String getId() {
return id;
}
@Override
public double getRangeMeters() {
return rangeMeters;
}
}
This class has several promises worth testing.
It should:
- accept a valid ID;
- reject a blank ID;
- reject an invalid interval;
- accept readings inside the interval;
- accept the exact minimum and maximum because the interval is inclusive;
- reject values below the minimum;
- reject values above the maximum;
- preserve the last valid reading after a rejected update.
That list is already the beginning of a test matrix.
Turn promises into cases
For a sensor configured with:
minimum = 0.10 m
maximum = 2.00 m
build this plan:
| Behavior | Input | Expected result | Why it matters |
|---|---|---|---|
| ordinary valid reading | 0.42 | accepted | normal path |
| exact minimum | 0.10 | accepted | lower boundary |
| just below minimum | 0.09 | exception | lower invalid neighbor |
| exact maximum | 2.00 | accepted | upper boundary |
| just above maximum | 2.01 | exception | upper invalid neighbor |
| blank ID | "" | exception | constructor contract |
| reversed interval | min 2.00, max 0.10 | exception | configuration contract |
| failed update after valid state | 0.42 then 3.70 | exception, state remains 0.42 | state preservation |
Each row exists for a reason.
If you cannot explain what defect a test could expose, reconsider whether the test belongs in the suite.
Write normal and boundary tests
A valid middle value:
@Test
void acceptsOrdinaryReading() {
ValidatedRangeSensor sensor =
new ValidatedRangeSensor(
"range-front",
0.10,
2.00,
0.42
);
assertEquals(
0.42,
sensor.getRangeMeters(),
0.001
);
}
Lower boundary:
@Test
void acceptsExactMinimum() {
ValidatedRangeSensor sensor =
new ValidatedRangeSensor(
"range-front",
0.10,
2.00,
0.10
);
assertEquals(
0.10,
sensor.getRangeMeters(),
0.001
);
}
Upper boundary:
@Test
void acceptsExactMaximum() {
ValidatedRangeSensor sensor =
new ValidatedRangeSensor(
"range-front",
0.10,
2.00,
2.00
);
assertEquals(
2.00,
sensor.getRangeMeters(),
0.001
);
}
These tests protect the inclusive interval requirement.
Test invalid inputs explicitly
Just below the minimum:
@Test
void rejectsReadingBelowMinimum() {
ValidatedRangeSensor sensor =
new ValidatedRangeSensor(
"range-front",
0.10,
2.00,
0.42
);
assertThrows(
IllegalArgumentException.class,
() -> sensor.updateRange(0.09)
);
}
Just above the maximum:
@Test
void rejectsReadingAboveMaximum() {
ValidatedRangeSensor sensor =
new ValidatedRangeSensor(
"range-front",
0.10,
2.00,
0.42
);
assertThrows(
IllegalArgumentException.class,
() -> sensor.updateRange(2.01)
);
}
Now a future edit that accidentally changes the interval rules has a better chance of being caught.
Test what happens after failure
The exception alone is not the entire contract.
You also care whether the object remains valid.
@Test
void rejectedUpdatePreservesLastValidReading() {
ValidatedRangeSensor sensor =
new ValidatedRangeSensor(
"range-front",
0.10,
2.00,
0.42
);
assertThrows(
IllegalArgumentException.class,
() -> sensor.updateRange(3.70)
);
assertEquals(
0.42,
sensor.getRangeMeters(),
0.001
);
}
This test catches the validation-order bug from Week 8.
A method that assigns the invalid value before throwing will fail the second assertion.
Tests can reveal missing requirements
Suppose you try to write a test for:
NaN
or:
positive infinity
as a sensor reading.
The current implementation only checks numeric bounds:
newRangeMeters < minRangeMeters
|| newRangeMeters > maxRangeMeters
Java floating-point NaN has unusual comparison behavior. Neither comparison is true for NaN, so the method may accept it unless you explicitly check for non-finite values.
Now you have discovered a requirement question:
Should this component reject non-finite readings?
Testing can expose gaps in the specification, not just bugs in the code.
Do not silently invent a production rule. Record the question and decide the requirement before changing the implementation.
Keep tests independent
This is weak:
ValidatedRangeSensor sensor =
new ValidatedRangeSensor(...);
@Test
void firstTest() {
sensor.updateRange(0.20);
}
@Test
void secondTest() {
assertEquals(0.42, sensor.getRangeMeters());
}
The result can depend on whether another test changed the shared object first.
A clearer test creates the required state inside each method or uses a fresh setup before every test.
A test should explain its own starting conditions.
Do not create one giant test method
Another weak pattern is:
@Test
void everything() {
// 50 different operations and assertions
}
If assertion 3 fails, later assertions may not run. The test report also tells you only that everything failed.
Prefer focused tests with names that identify one behavior or closely related rule.
A test suite can contain many small tests without making the production code more complicated.
Add the robot-system boundary to the matrix
Your software test matrix should also state what it does not test.
Add a final column:
| Case | Software behavior checked | Not established |
|---|---|---|
0.10 accepted | lower Java boundary | physical sensor accuracy |
2.01 rejected | Java validation | ROS message delivery |
failed update preserves 0.42 | object state invariant | motor response |
| WPILib subsystem test passes | desktop simulated component behavior | wiring/mechanics |
This keeps the test suite from becoming a magic certificate for the whole robot.
ROS 2 requires different observations
A JUnit test cannot tell you whether a ROS 2 node is visible.
If your next question is:
Is
/sensor_bridgepresent in this controlled ROS graph?
then a ROS graph inspection such as:
ros2 node list
is a more relevant source of evidence.
If the question is:
Did my Java
ValidatedRangeSensorreject2.01 m?
then JUnit is a better tool.
Choose evidence based on the claim.
Build your lab test plan
Before entering the lab, prepare at least six cases for ValidatedRangeSensor.
Your matrix must include:
- one ordinary valid input;
- exact minimum;
- exact maximum;
- below minimum;
- above maximum;
- one state-preservation case after a rejected update.
For every row, write:
- test name;
- starting state;
- action;
- expected result;
- assertion type;
- defect the test could expose;
- one system fact it cannot establish.
The lab will turn that plan into a real JUnit-style test suite, include one deliberate defect, and compare the software test evidence with a separate ROS graph observation.