Verdict
Yes
Yes, test each kind of invalid input that your code promises to reject, with one value per kind and an assertion on the error type; do not test values that the parameter types already rule out.
Why
- Blast radiususers
- Change frequencyregularly
- Detectabilityeventually
- Reversibilitywith-effort
- Test costtrivial
Yes, test the invalid input your code promises to reject. The typical case is an application function that checks arguments from users or other systems, such as a booking slot that refuses an end time before its start. Blast radius is users, because a value that slips past the check lands in data customers see, and Change frequency is regularly, since any feature that touches the function can drop the check. Detectability is eventually: a missing rejection raises no error. Reversibility is with-effort, because stored records need a repair script, and Test cost is trivial, because assertThrows wraps one call, so rule R11 gives Test.
| When | Decision | Why |
|---|---|---|
| The check rejects a refund larger than the amount the customer paid, before the refund is paid out | Test mandatory: the paid amount plus 0.01 is rejected, and no payout follows the rejection | Blast radius rises to money and Reversibility to costly: an overpaid refund needs a manual claw-back |
| The input is a document ID from a request, and the check stops users from opening other customers' documents | Test mandatory: another customer's ID and a malformed ID, each denied | Blast radius rises to safety-or-legal and Detectability to never: a leaked document raises no error |
| The parameter type rules out the invalid value, such as an enum instead of a status string | Do not test the invalid value; keep the parameter type strict | Detectability moves to immediately and Reversibility to trivial: a caller that passes the value fails the build |
| A parser changed a few times a year throws on a malformed query parameter, and the error tracker reports the 500 the same day | Test minimally: one test with the malformed value | Change frequency falls to rarely, Detectability to same-day, and Reversibility to trivial because the failed request stores nothing |
| Invalid rows arrive once, in a one-time import of a partner's customer file | Test it differently: run the import on a copy of the data and review the rejected rows | Change frequency falls to once: a test in the suite would never run again |
What breaks if you don't test
A refactor moves the check into a shared validator that forgets one rule, and CI stays green because every test passes valid values. The first invalid value arrives weeks later from an import or a new client version. The value gets stored, and a customer finds it as wrong data or a double booking.
What you lose if you over-test
A test per invalid value grows without limit: -1, -1000 and Integer.MIN_VALUE all test one rule. A test that asserts the full error message breaks when someone rewords it. A test for a value where the contract promises nothing pins what the code happens to do today, so a harmless rewrite fails it.
How to test
Write unit tests against the function or constructor that owns the check. Split invalid input into kinds, one per rule: empty, too long, wrong order, out of range. The minimum set is one value per kind at the boundary, such as an end time equal to the start, plus one valid value next to it, so a check that rejects everything fails too. Assert the error type with assertThrows in JUnit or pytest.raises. For input from outside your system, the OWASP Input Validation Cheat Sheet lists the checks the server has to repeat.
When the answer changes
- The invalid value would move money or open another customer's data.
- The parameter type makes the invalid value impossible to write.
- The code runs once, in an import or a migration.
Real incident + Code example
The Sydney meetings that shared a room
On a meeting-room booking product I worked on, TimeSlot was a Java class whose constructor threw when the end came before the start. A refactor turned it into a record without the compact constructor that kept the check, and no test passed a reversed slot. Our calendar sync gave each end time the date of its start, and in a Sydney winter, midnight UTC falls at 10:00 local time. Every meeting across 10:00 was stored ending before it began, the overlap check never matched those slots, and the rooms showed as free. A customer reported two teams in one room 19 days after the release, and a repair script found 340 reversed bookings. The test we added:
record TimeSlot(Instant start, Instant end) {
TimeSlot {
if (!end.isAfter(start))
throw new IllegalArgumentException("end must be after start");
}
}
class TimeSlotTest {
static final Instant T = Instant.parse("2026-06-01T23:30:00Z");
@Test void rejectsEndBeforeStart() {
// 23:30 until 00:30 on the same date, as the sync stored it
assertThrows(IllegalArgumentException.class,
() -> new TimeSlot(T, T.minus(Duration.ofHours(23))));
}
@Test void rejectsZeroLength() {
assertThrows(IllegalArgumentException.class, () -> new TimeSlot(T, T));
}
@Test void acceptsOneMinute() {
new TimeSlot(T, T.plus(Duration.ofMinutes(1)));
}
}
The first test fails on the record without the compact constructor.
Related questions
FAQ
- Should input values outside of the contract be unit tested?
Test values outside the contract when the contract says what happens to them, such as an exception for an end time before the start time. When the contract leaves the result undefined, do not pin today's output in a test. Add a check and test it, or leave the precondition to the callers and test them with the values they pass.
- Should I unit test with data that should not be passed to a function?
Yes, when the function checks for data that should not be passed and rejects it, write one test per kind of bad value and assert the error type. If nothing checks the value and the type system allows it, decide what the function should do, then test that decision.
- How many invalid inputs should I test?
Test one invalid value per rule, at the boundary of the rule, plus one valid value next to it. For a name of at most 50 characters, an empty name and a name of 51 characters are the invalid cases, and a name of 50 characters is the valid one.