Unit 09 · lesson
Unit Tests: Turn a Robot Rule Into an Executable Check
You have written rules into Java classes throughout the course. A constructor rejects invalid state. A range source returns a reading. An obstacle guard decides whether a reading is inside a caution zone.
Reading the code can tell you what the program appears to do. A unit test gives you a repeatable way to execute a small part of that code with known inputs and check whether the result matches an expectation.
WPILib Java robot projects use JUnit 5 for unit testing. The official WPILib unit-testing documentation places Java test code under:
src/test/java/
rather than mixing tests into the robot's normal source files.
Start with one rule worth testing
Use the interface from Week 8:
interface RangeSource {
String getId();
double getRangeMeters();
}
and this fixed implementation:
class FixedRangeSource implements RangeSource {
private final String id;
private final double rangeMeters;
FixedRangeSource(String id, double rangeMeters) {
this.id = id;
this.rangeMeters = rangeMeters;
}
@Override
public String getId() {
return id;
}
@Override
public double getRangeMeters() {
return rangeMeters;
}
}
Now define the rule:
class ObstacleGuard {
private final double cautionDistanceMeters;
ObstacleGuard(double cautionDistanceMeters) {
this.cautionDistanceMeters =
cautionDistanceMeters;
}
boolean shouldStop(RangeSource source) {
return source.getRangeMeters()
< cautionDistanceMeters;
}
}
The contract is precise:
Return
truewhen the source reports a range strictly less than the caution distance.
The word strictly matters. 0.49 < 0.50 is true. 0.50 < 0.50 is false.
A useful test begins with that rule, not with random input values.
Your first JUnit test
A simple test class can look like this:
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class ObstacleGuardTest {
@Test
void stopsWhenRangeIsInsideCautionDistance() {
ObstacleGuard guard = new ObstacleGuard(0.50);
RangeSource source =
new FixedRangeSource("test-near", 0.25);
boolean actual = guard.shouldStop(source);
assertTrue(actual);
}
}
There are several important pieces here.
@Test marks a test case
@Test
void stopsWhenRangeIsInsideCautionDistance() {
The @Test annotation tells JUnit that the method is a test case.
The method name is not special syntax. It is a name chosen by the programmer. A descriptive test name is useful because a failed test report should tell you which behavior broke.
Compare:
void test1()
with:
void stopsWhenRangeIsInsideCautionDistance()
The second name communicates the behavior being checked.
The test creates controlled input
ObstacleGuard guard = new ObstacleGuard(0.50);
RangeSource source =
new FixedRangeSource("test-near", 0.25);
The test does not wait for a real robot sensor to report a convenient value. It deliberately constructs an input whose behavior is known.
That is one reason the RangeSource interface from Week 8 is useful. The decision logic can be tested with a fixed implementation.
The test executes the behavior
boolean actual = guard.shouldStop(source);
The variable actual stores what the method really returned for this test case.
Given a caution distance of 0.50 m and a range of 0.25 m, the expected result is:
true
because:
0.25 < 0.50
The assertion compares behavior with expectation
assertTrue(actual);
An assertion is a check inside the test.
If actual is true, this assertion passes.
If actual is false, the assertion fails and JUnit marks the test as failed.
The assertion is not a print statement. It changes the status of the test run.
Add the opposite case
A good rule normally needs more than one example.
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class ObstacleGuardTest {
@Test
void stopsWhenRangeIsInsideCautionDistance() {
ObstacleGuard guard = new ObstacleGuard(0.50);
RangeSource source =
new FixedRangeSource("test-near", 0.25);
assertTrue(guard.shouldStop(source));
}
@Test
void continuesWhenRangeIsOutsideCautionDistance() {
ObstacleGuard guard = new ObstacleGuard(0.50);
RangeSource source =
new FixedRangeSource("test-far", 1.20);
assertFalse(guard.shouldStop(source));
}
}
Now the test class checks both sides of the rule.
A result of true is not always "good" and false is not always "bad." The expected result depends on the input and the rule.
Think in expected and actual values
Every test should let you answer two questions:
- What should happen for this input?
- What did the code actually do?
For the two cases above:
| Test input | Expected | Actual if code is correct |
|---|---|---|
0.25 m | true | true |
1.20 m | false | false |
A passing test means the actual result matched the expectation for that case.
It does not prove the method is correct for every possible input.
That is why test selection matters.
Arrange, act, assert
Many programmers organize a test into three ideas:
- arrange the objects and inputs;
- act by calling the behavior under test;
- assert the expected result.
You do not need to put those words into comments in every test. The pattern is useful because it makes the test's logic easy to inspect.
In the first example:
ObstacleGuard guard = new ObstacleGuard(0.50);
RangeSource source =
new FixedRangeSource("test-near", 0.25);
is the setup.
boolean actual = guard.shouldStop(source);
executes the rule.
assertTrue(actual);
checks the outcome.
Tests belong beside the project, not inside the lesson page
In a WPILib Java project, your normal code belongs under a path such as:
src/main/java/frc/robot/
and Java tests belong under:
src/test/java/
A project might therefore contain:
src/main/java/frc/robot/logic/ObstacleGuard.java
src/test/java/frc/robot/logic/ObstacleGuardTest.java
The exact package folders should match the package declarations in your project.
The important design is that production code and test code have separate source sets.
Running the tests in WPILib
WPILib's current documentation provides Test Robot Code in the VS Code Command Palette for running robot-code tests.
The results appear in terminal output. JUnit also produces an HTML test report under:
build/reports/tests/test/index.html
A test runner gives the same test code a repeatable execution path. You do not need to manually re-enter every value each time the implementation changes.
The Robotnix browser terminal does not run JUnit. When this course shows a JUnit result in the reader, it must be labeled as an example or supplied evidence unless you are working in an actual local WPILib project.
A passing test is a narrow statement
If both tests pass, you can defend this:
For the supplied fixed inputs
0.25 mand1.20 m, the testedObstacleGuardimplementation returned the expected boolean results.
You cannot defend this:
The robot's obstacle protection system is safe.
The tests did not inspect a live sensor, scheduler timing, ROS communication, motor output, wiring, or physical motion.
Unit tests are powerful because they make small software rules repeatable. They become misleading when their evidence is promoted into a claim about layers they never observed.
Common mistake: testing the code instead of the requirement
Suppose the implementation says:
return source.getRangeMeters()
< cautionDistanceMeters;
A student may look at that line and write expectations that simply mirror it.
That can make the test repeat the implementation rather than challenge it.
Start from the intended rule in words:
Stop when the measured distance is strictly less than the caution distance.
Then select values that would expose a disagreement between the rule and the code.
The most useful values are often near the boundary. That is the focus of the next lesson.
Try it yourself
Write two JUnit test methods for a BatteryGuard rule:
boolean needsCharge(double percent) {
return percent < 20.0;
}
Test:
10.0 → true
80.0 → false
Use descriptive test names and assertTrue or assertFalse.
Then answer:
Why are those two tests not enough to tell you whether the exact value
20.0is handled correctly?
That unanswered case is where Week 9 goes next.