A test that expects an error can still miss the most damaging part of a failure.
An order operation may return payment declined correctly while also marking the order as paid. A file import may report that parsing failed after writing half of its records. A retry may succeed but send the same notification twice.
In each case, the visible error is correct. The failure semantics are not.
Failure semantics describe what the system promises about state, side effects, and subsequent operations when something goes wrong. Testing them means asking more than “did this fail?” This article shows how to identify those promises and turn them into focused tests.
An error is only one observable outcome
Consider a simplified transfer operation:
transfer(from, to, amount)A basic negative-path test might say:
when destination does not exist:
expect transfer(...) to return DestinationNotFoundThat assertion is useful, but incomplete. The caller also cares about the source balance.
If the implementation debits the source before discovering that the destination is missing, the operation can return the expected error while leaving the system in the wrong state.
A stronger test describes both outcomes:
before:
source.balance = 100
transfer(source, missingDestination, 30)
-> DestinationNotFound
after:
source.balance = 100The important shift is small:
Test the contract that remains true after failure, not only the signal that reports failure.
That contract may include unchanged state, bounded partial progress, compensation, emitted events, resource cleanup, or retry behavior.
Start with the failure boundary
Before writing assertions, identify where the operation can stop unsuccessfully.
Suppose an order workflow performs these steps:
1. validate order
2. reserve inventory
3. charge payment
4. mark order confirmed
5. publish confirmationSeveral failures are possible. Validation can reject the request. Inventory can be unavailable. Payment can be declined or time out. Publishing can fail after the order is already confirmed.
These failures do not necessarily have the same semantics.
A validation failure happens before durable work begins, so “nothing changed” may be the right guarantee. A publication failure happens after confirmation, so rolling back the whole order may be impossible or undesirable. The system might instead keep the order confirmed and retry publication.
This is why a generic test such as expect checkout to fail is too weak. First decide which boundary failed and what the system promises at that boundary.
Write the post-failure invariant explicitly
An invariant is a property that must remain true for the state to be valid. Failure tests become clearer when they name the relevant invariant directly.
For the transfer example, one invariant might be:
if a transfer is rejected before funds move,
neither account balance changesFor an inventory reservation:
if reservation creation fails,
available quantity is not reducedFor a configuration update:
if validation fails,
the previous valid configuration remains activeThese statements are more useful than broad goals such as “handle errors correctly.” They tell the test exactly what to observe.
A focused test can then follow three steps:
1. arrange known state
2. trigger one specific failure
3. assert the error and the post-failure invariantThe test is not trying to inspect every implementation detail. It checks the behavior that callers or operators rely on.
Include side effects outside the main state
Database state is only part of a system’s behavior. Failures can leak through external side effects even when stored state looks correct.
Imagine this workflow:
create invoice
send invoice email
record delivery statusIf sending succeeds but recording the status fails, a retry may send a second email. A test that checks only the delivery-status row will miss the duplicate external effect.
For failure-sensitive operations, inventory the effects that cross meaningful boundaries:
- messages published;
- emails or notifications requested;
- files written;
- external API calls made;
- locks or leases acquired;
- counters or quotas consumed.
Do not assert every internal method call. That makes tests depend on implementation structure. Assert effects whose duplication, omission, or persistence changes externally meaningful behavior.
For example:
when recording delivery fails after send:
retry operation
expect:
invoice remains valid
email is not sent twiceWhether the implementation achieves that with an idempotency key, an outbox, deduplication, or another mechanism is a separate design decision. The test protects the required behavior.
Test partial progress deliberately
Some operations cannot provide all-or-nothing behavior.
A batch importer may process records independently. If record 51 is invalid, discarding the first 50 successful records might be unnecessary. In that design, partial progress is intentional.
The failure contract should say so:
input: 100 records
record 51: invalid
result:
99 accepted
1 rejected with reasonA different importer may promise atomicity:
if any record is invalid:
accept 0 recordsNeither policy is universally correct. What matters is that the test matches the intended guarantee.
Avoid writing a failure test that assumes rollback merely because rollback sounds safer. If the real contract allows partial completion, test which progress is retained and how callers can discover it.
Treat timeouts as uncertain outcomes
Timeouts deserve special care because they do not always mean that the underlying operation failed.
Suppose a service sends a payment request and waits two seconds. The caller times out, but the payment service may have received and completed the charge just before the connection was lost.
From the caller’s perspective:
request timed outFrom the payment system’s perspective:
charge succeededA test that models timeout as definite failure can encode a dangerous assumption.
Instead, the contract may need an unknown outcome state:
payment status = pending verificationThe system can then reconcile the result before deciding whether to retry.
When testing timeout paths, ask whether the failure proves that no effect occurred. If it does not, verify the behavior for uncertainty rather than treating the timeout like an ordinary rejection.
Verify retry behavior separately
A good first-attempt failure test does not automatically prove that retrying is safe.
Consider an operation that creates a shipment and then loses the response. The caller retries the same request. If the system creates another shipment, each individual attempt may look valid while the combined behavior is wrong.
A retry test needs a sequence:
attempt 1:
operation reaches external side effect
response is lost
attempt 2:
same logical request is retried
expect:
one logical shipment existsThis is especially important for operations that may be retried automatically by clients, workers, queues, or infrastructure.
Do not claim idempotency merely because a method uses the same arguments twice. Idempotency is an observable property: repeating the same logical operation must not create additional effects beyond the contract. The test should observe those effects.
Inject failures at meaningful points
Failure tests need a controlled way to make a dependency fail. The useful question is where to inject the failure.
Suppose a service writes a record and then publishes an event. A test that makes the entire service throw before it starts proves little about the difficult case. The interesting boundary is between the durable write and the publication.
A test double can model that dependency failure:
repository.save(order) -> succeeds
publisher.publish(event) -> failsThe test can then verify the intended post-failure state.
Use the narrowest controllable boundary that represents the real failure mode. Avoid elaborate mocks that reproduce the implementation line by line. The goal is to create the condition, not to duplicate the algorithm inside the test.
For infrastructure-specific behavior such as transaction rollback, process termination, or real network failure, a higher-level integration test may be necessary. Unit tests remain useful for application-level contracts, but they cannot prove guarantees provided by infrastructure they do not exercise.
Distinguish expected rejection from system failure
Not every unsuccessful operation is the same kind of failure.
A request rejected because a quantity is negative is an expected domain outcome. A request that fails because storage is unavailable is an operational failure. They often need different guarantees and different tests.
For expected rejection, the contract may be simple:
invalid command -> no state change + specific rejectionFor an operational failure, the system may need to preserve enough information for recovery:
storage unavailable -> no false success + safe retry pathKeeping these cases separate makes tests easier to read and helps prevent accidental policies such as retrying permanent validation errors.
Avoid assertions that hide the real guarantee
Failure tests commonly become either too weak or too coupled.
A weak test checks only that some exception occurred:
expect any errorIt can pass when the system fails for the wrong reason.
An over-coupled test verifies every internal interaction:
validator called once
repository called zero times
helper A called before helper BThat may freeze the current implementation without proving the behavior users care about.
Prefer assertions at stable boundaries:
expect InvalidQuantity
expect order unchanged
expect no reservation createdInteraction assertions are appropriate when the interaction itself is the contract, such as proving that no external charge is requested after validation fails. Use them selectively.
Keep the failure matrix small and purposeful
A system can fail at many points. Testing every theoretical combination quickly becomes unmanageable.
Choose cases based on distinct semantics rather than raw branch count. Two dependency failures that produce the same post-failure guarantee may not both need exhaustive tests at every layer. A failure that changes the recovery strategy deserves separate coverage.
A compact matrix might look like this:
failure required guarantee
------------------------------------------------------------
validation rejected no durable work begins
inventory unavailable no payment requested
payment declined reservation released
payment outcome unknown do not charge again blindly
publication unavailable confirmed order remains recoverableThis table is useful because each row represents a different engineering promise.
When this approach matters most
Detailed failure-semantics tests are most valuable when operations mutate durable state, cross process boundaries, trigger external effects, or run under automatic retry. They are also useful around workflows where a failure halfway through can leave expensive or confusing intermediate state.
A pure calculation that either returns a value or raises before changing anything may need only a simple error assertion. Adding elaborate post-failure tests there provides little value.
Match the depth of testing to the consequences of partial execution.
Conclusion
An error message tells you that an operation did not complete normally. It does not tell you what happened before the error, what state remains, which side effects escaped, or whether repeating the operation is safe.
Test failure semantics by identifying a specific failure boundary, stating the post-failure invariant, and observing externally meaningful state and effects. Treat partial progress and uncertain outcomes as explicit design choices, and test retries as sequences rather than isolated calls.
A practical question makes these tests easier to design: after this exact failure, what must still be true? The answer is the contract your failure test should protect.