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:

BehaviorInputExpected resultWhy it matters
ordinary valid reading0.42acceptednormal path
exact minimum0.10acceptedlower boundary
just below minimum0.09exceptionlower invalid neighbor
exact maximum2.00acceptedupper boundary
just above maximum2.01exceptionupper invalid neighbor
blank ID""exceptionconstructor contract
reversed intervalmin 2.00, max 0.10exceptionconfiguration contract
failed update after valid state0.42 then 3.70exception, state remains 0.42state 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:

CaseSoftware behavior checkedNot established
0.10 acceptedlower Java boundaryphysical sensor accuracy
2.01 rejectedJava validationROS message delivery
failed update preserves 0.42object state invariantmotor response
WPILib subsystem test passesdesktop simulated component behaviorwiring/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_bridge present 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 ValidatedRangeSensor reject 2.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.