Should I test that?

Should I test exceptions?

Verdict

Yes

Write one unit test for each exception your code throws on purpose and for each catch block that recovers from a failure, asserting the exception type or the state the handler leaves.

Why

I test the exceptions my code throws on purpose and the catch blocks that recover from a failure. Detectability is eventually: an error path runs only when the failure happens, so a broken one shows days later. Blast radius is users: a wrong exception type turns a 400 response into a 500. Change frequency is regularly, since error branches change with the method around them, and Reversibility is with effort: records that passed a missing check need a repair script. Test cost is trivial, because assertThrows wraps one call.

When the decision changes
WhenDecisionWhy
The exception is an access check that stops users from reading other users' recordsTest mandatory: a record owned by someone else, a missing session, and a revoked roleBlast radius rises to safety-or-legal and Detectability to never: an access check that stops throwing exposes personal data without an error
The exception stops a money transfer when the balance is too lowTest mandatory: the exact balance, one cent over it, and no money moved after the exceptionBlast radius rises to money and Reversibility to costly: a wrong transfer ends in refunds
A batch job catches the exception of a failing record, logs it, and continuesTest mandatory: make one record fail and assert that the job reports itDetectability moves to never and Reversibility to costly: skipped records raise no error and are repaired by hand
The guard rejects null, and the compiler already rejects null from every callerDo not test the null guard; keep the compiler check onDetectability moves to immediately and Reversibility to trivial: a caller that passes null fails the build
The code is a personal script that runs on your own machineDo not test the script's exceptions; read the stack trace when one appearsBlast radius falls to none: nobody but you sees the failure
The catch block handles a failure that only fault injection reproducesTest the handler differently: alert on the error rate and retry count of the failing call in productionTest cost rises to heavy, so a production signal is cheaper than a fault-injection environment

What breaks if you don't test

A refactor replaces if (quantity <= 0) throw with a shared validator that accepts zero. CI stays green, because every test passes a valid quantity. Orders with zero items reach the warehouse as empty picking lists, and a warehouse lead reports them a week later. The code fix is one line; finding and cancelling the orders takes a day.

What you lose if you over-test

A test that asserts the full exception message breaks when someone fixes a typo. Tests for each throw inside private helpers break when a check moves one layer up, with no change in behaviour. A test that a method does not throw repeats the happy path test, which already fails on any exception. An assertThrows(Exception.class, ...) gives false confidence: it passes when a broken fixture throws before the code under test runs.

How to test

Test at the unit level, through the public method where the error leaves your code. The minimum set is one test per exception you throw on purpose: pass the triggering input, then assert the type and one stable field, such as an error code. For each catch block that recovers, make the dependency throw with a fake and assert what the handler leaves: a status, a retry count, or a record marked as failed. Use assertThrows in JUnit or pytest.raises in Python.

When the answer changes

  • Other teams catch your exception by type, so a rename breaks their handling while your tests pass.
  • A handler starts to catch and continue instead of failing the request, which hides the failure from alerts.
  • The compiler starts to reject the invalid input, for example with nullable reference types in C#, and the guard becomes unreachable.

Real incident + Code example

The sync job that skipped 2,300 addresses

On a logistics project I worked on, a nightly job copied customer addresses from the CRM into the shipping service. Each record ran inside a try block whose catch logged at debug level and moved on. The CRM team renamed a field, so every record with a second address line threw. The job reported success for 16 nights while 2,300 addresses stayed stale, and customers told us when parcels went to old addresses. No test had ever forced that catch to run. This is the test we added:

@Test
void failedRecordIsReportedAndOthersStillSync() {
    var crm = new FakeCrm(
        record("c-1", "12 High Street"),
        recordThatThrowsOnLookup("c-2"));
    var shipping = new InMemoryShipping();

    SyncResult result = new AddressSyncJob(crm, shipping).run();

    assertEquals("12 High Street", shipping.addressOf("c-1"));
    assertEquals(List.of("c-2"), result.failedIds());
    assertEquals(SyncStatus.PARTIAL_FAILURE, result.status());
}

The job now exits with a failed status when any record fails, and the scheduler alerts on it.

FAQ

Should I write tests for argument exceptions?

Write one test for each argument check that a caller can trigger, because nothing else in CI runs the check. Skip null checks that your compiler already enforces for every caller, such as Kotlin non-null parameters.

Should I test that methods don't throw exceptions?

Do not write a separate test that a method does not throw, because the test of its normal result already fails when it throws. Assert on what the method returns or changes instead.

Should I test exception messages?

Assert the exception type and a stable field, such as an error code, instead of the full message text. Test the message only when a person or another system reads it.

How do I test that a catch block works?

To test a catch block, make the dependency throw through a fake, call the public method, and assert what your code did next, such as the status it returned.