A service can pass every normal-path test and still behave badly when a dependency times out, a write fails halfway through, or a connection disappears at an inconvenient moment. The problem is often not missing error handling. It is that the team has never observed whether the error handling produces the behavior they expect.

Fault injection is the deliberate introduction of a controlled failure into a system or test. Instead of waiting for a real dependency to fail, you make a specific failure happen and observe the consequence.

This article develops a practical mental model for fault injection, shows how to start with small deterministic tests, and explains how to choose faults that reveal useful information without turning testing into random disruption.

Test the failure path as behavior, not as a code branch

Suppose an order service charges a payment provider and then records the successful order:

function placeOrder(order):
    payment.charge(order.total)
    orders.save(order)
    return success

A normal test can prove that a successful charge is followed by a save. It says nothing about what happens when charge times out.

The implementation may catch the timeout and return an error. That sounds reasonable, but several questions remain:

  • Was the customer actually charged before the timeout occurred?
  • Does the caller retry automatically?
  • Could a retry charge the customer twice?
  • Was any local state changed before the failure?
  • Is the failure visible enough for an operator to diagnose?

These are behavioral questions. Merely executing an exception handler does not answer them.

A useful mental model is:

operation + controlled fault -> observable outcome

The fault is an input to the experiment. The outcome includes more than the returned error. It can include state changes, retries, emitted messages, cleanup, latency, and subsequent behavior.

Start with one deterministic fault

The smallest useful fault-injection test controls one dependency and makes one failure happen at a known point.

Imagine a report generator that stores a generated file and then records its location:

function publishReport(report):
    path = storage.put(report.bytes)
    reports.markPublished(report.id, path)

A focused test can replace storage with a test double that fails predictably:

storage.put = failWith(StorageUnavailable)

result = publishReport(report)

assert result == failure(StorageUnavailable)
assert reports.status(report.id) == "draft"

This example is intentionally simple. It demonstrates the key idea: force the failure you care about, then assert the externally meaningful consequence.

The important assertion is not that storage.put threw an exception. You arranged that yourself. The useful assertion is that the report did not become published when its file was never stored.

That distinction keeps fault-injection tests focused on guarantees rather than implementation details.

Choose faults from real boundaries

Good fault injection begins with boundaries where your code depends on something it does not fully control. Common examples include remote services, filesystems, message brokers, clocks, process boundaries, and resource pools.

For each important operation, ask two questions:

  1. What can this dependency fail to do?
  2. What must remain true if that happens?

A remote call, for example, can fail in meaningfully different ways. It can reject a request immediately, return an explicit server error, respond too slowly, or stop responding after receiving the request. Those failures may require different application behavior.

Consider a payment request. An explicit rejection such as “card declined” tells the caller that the operation did not succeed. A network timeout is more ambiguous: the client may not know whether the provider processed the request before the response was lost.

If a test represents both situations as a generic PaymentError, it hides an important engineering distinction. The injected fault should preserve the uncertainty that the real boundary creates.

Inject faults at the level where the assumption lives

A common mistake is to inject every failure at the lowest available technical layer. That can make tests realistic in one sense but difficult to interpret.

Suppose application logic depends on a repository operation with this contract:

save(invoice) -> success | unavailable | conflict

If you want to test how the application handles repository unavailability, injecting unavailable at that boundary is usually enough. You do not need to simulate a particular socket reset, driver exception, or operating-system error unless the repository itself is the component under test.

This leads to a practical rule:

Inject the fault at the boundary whose failure semantics the test is trying to verify.

For a repository test, a database connection failure may be appropriate. For an application-service test, a repository-level unavailable result is often clearer and less coupled to infrastructure details.

The goal is not maximum realism in every test. The goal is enough realism to test the assumption at the correct layer.

Verify what happens after the failure

Many resilience bugs appear after the first error has already been handled.

Imagine a worker that processes a message and calls an external service. The service fails, so the worker schedules a retry. A test that stops after checking retryScheduled == true can miss several problems:

  • the original message may have been acknowledged too early;
  • retry state may not contain enough information to repeat the work;
  • the retry may happen immediately and create a tight failure loop;
  • a partial side effect may make the retry unsafe;
  • resources acquired before the failure may remain open.

A stronger test follows the state transition far enough to verify the promised recovery behavior.

For example:

external.send = failOnce(Timeout)

worker.process(message)
assert queue.hasRetryFor(message.id)
assert message.isAcknowledged == false

external.send = succeed()
worker.processRetry(message.id)

assert external.successCount == 1
assert message.isAcknowledged == true

This is still a teaching example rather than a universal production design. Real queue acknowledgment and retry semantics vary. What matters is the testing pattern: verify the failure, the intermediate state, and the eventual recovery that your design claims to provide.

Distinguish expected failures from ambiguous failures

Not every failure should trigger the same response.

An expected business rejection is usually information, not an infrastructure incident. Retrying an invalid request is unlikely to help. A transient dependency outage may justify a bounded retry. An ambiguous outcome may require an idempotency key, reconciliation process, or explicit “unknown” state rather than an immediate repeat.

Fault injection is useful because it forces these categories to become concrete.

If the code cannot represent the difference between “definitely rejected” and “outcome unknown,” a test may reveal a design problem before it reveals a coding bug. The application cannot respond differently to situations it has collapsed into the same error.

That is valuable feedback. Resilience often depends on preserving enough failure information for the next layer to make a sound decision.

Keep the experiment bounded

Fault injection becomes risky when the blast radius is larger than the question being tested.

In unit and component tests, containment is straightforward because dependencies can usually be replaced or controlled. In shared or production-like environments, the same principle requires more care. A fault should have a clear target, duration, and stop condition.

For example, “make every storage request fail for ten minutes” is a broad experiment. “Return an unavailable response for requests from one test workload for thirty seconds” answers a similar question with less unrelated disruption.

Before running a broader experiment, define:

  • the specific hypothesis being tested;
  • which requests or components may be affected;
  • how long the fault may remain active;
  • what observation indicates success or failure;
  • how the fault will be removed if the experiment behaves unexpectedly.

Without those boundaries, disruption can produce noise without producing knowledge.

Avoid random faults before deterministic tests are strong

Random failure injection can explore combinations that engineers did not predict, but randomness makes reproduction harder. If a test fails only because an unspecified call happened to receive a fault, diagnosis can take longer than the test saves.

Start with deterministic cases for known guarantees:

first call -> timeout
second call -> success

is easier to reason about than:

5% of calls -> timeout

Once deterministic tests cover important failure semantics, controlled randomness can be useful for broader exploration. Record enough information to reproduce the sequence that caused a failure, such as the random seed or generated fault schedule.

Randomness is therefore an extension of a fault-injection strategy, not a substitute for precise failure tests.

Do not confuse injected failures with resilience

A system is not resilient merely because it survives a fault-injection test without crashing.

Suppose a dependency is unavailable and the application catches every exception, logs it, and returns success. The process remains alive, but the behavior may be incorrect because work was silently lost.

Resilience has to be defined in terms of system guarantees. Depending on the operation, a useful guarantee might be:

  • no successful response is returned unless required state is durable;
  • an uncertain external operation is not repeated blindly;
  • temporary failure does not create unbounded retries;
  • partial work is either recoverable or explicitly visible;
  • resources are released even when an operation fails;
  • independent work can continue when one dependency is unavailable.

Fault injection tests those guarantees by creating the conditions under which they matter.

Know what fault injection cannot prove

A passing injected-failure test proves only what the experiment covered.

A test that simulates a timeout does not prove correct behavior during process termination. A test that returns a storage error does not reproduce disk corruption. A test double that throws before an operation starts does not model a failure after a remote system has committed a side effect.

This is why the failure point matters. Ask whether the injected fault preserves the property that makes the real failure difficult: uncertainty, partial progress, delay, concurrency, or resource loss.

If it does not, either improve the experiment or narrow the claim you make from its result.

Use the simplest level that answers the question

Not every resilience question needs a large environment or a sophisticated fault framework.

Use a small deterministic test when you want to verify application decisions such as retry classification, state transitions, cleanup, or error translation. Use component-level injection when behavior depends on a real protocol or adapter. Use broader system experiments when the property emerges from multiple running components, such as whether traffic shifts correctly after an instance disappears.

The larger the experiment, the more realistic interactions it can include, but the more expensive it becomes to run and diagnose. Move outward only when a smaller test cannot answer the engineering question.

Conclusion

Normal-path tests show what software does when its dependencies cooperate. Fault injection makes failure assumptions executable.

Start with one important boundary and one deterministic fault. State the guarantee that should still hold, inject the failure at the layer where that guarantee is defined, and verify the resulting state rather than merely checking that an error occurred. Then follow recovery far enough to catch unsafe retries, partial state, and missing cleanup.

The most useful fault-injection tests are not the most dramatic. They are controlled experiments that turn “this should handle failure” into behavior the team can observe and verify.