Mehdi Akiki
Published on

A test should fail for the reason you meant

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

Article · Measurement

The test inserts a negative stock quantity and expects an error. It passes.

Later, someone discovers that the test database was unreachable. The constraint could have been missing for months. Any connection error satisfied the assertion.

Negative tests are easy to write too loosely. “Something failed” is weaker than “the system rejected this specific invalid operation and kept its state intact.”

Establish that the normal path works

First connect to the intended database and perform a valid operation. Check the expected stored value. This prevents a broken fixture from masquerading as successful protection.

Then attempt the invalid write. For a Rust SQLx constraint test, inspect the database error rather than accepting every error:

fn intended_constraint(error: &sqlx::Error) -> bool {
    error
        .as_database_error()
        .and_then(|error| error.code())
        .as_deref()
        == Some("23514")
}

This is an excerpt. The fixture supplies the operation and the engine-specific code. The MySQL and MariaDB compatibility example records the distinct check-constraint errors for those engines. One driver does not make every server diagnostic identical.

SQLx documents its database error types. Prefer stable structured fields, such as the documented error code or constraint identity, over matching an entire human-readable message.

Inspect the result, not only the rejection

Reload the affected row through a separate query. Confirm that the invalid value was not stored and that unrelated fields did not change.

If the operation modifies several rows, verify their shared invariant. A transfer that returns an error after committing the debit is still broken. The response is evidence about the handler; stored state is evidence about the effect.

Use the same application role and relevant session settings as production. A setup account with different privileges or a different constraint configuration can exercise another behaviour.

Keep transaction failure semantics in mind. Some engines abort a transaction after a statement error. The state check may need a rollback and a separate connection. Do not accidentally check uncommitted local state and call it a durable result.

Make the test reject an unrelated failure

A useful review question is whether the test would still pass with the database port wrong. Another is whether it would pass if the invalid operation unexpectedly succeeded.

Exercise those distinctions in the fixture or review its assertions directly. The intended constraint error should satisfy the test. An unavailable service and a successful invalid write should not.

The same habit applies outside databases. A permission test should check denial, not a missing route. A parser test should reject the intended malformed input, not panic because a fixture file is absent. A tool-call test should inspect the absence of effects, not only an error string.

Keep the contract at the right level

Do not assert every incidental detail of a vendor message. That creates brittle failures after harmless updates. Assert the category the application depends on and the resulting state it promises.

A good negative test has a narrow reason to pass. When it turns green, a reviewer should be able to say which protection was exercised. If the explanation is only “an exception happened,” the test probably needs one more assertion.

Watch the database state as well as the error

The PostgreSQL reproduction creates a protected quantity with the condition quantity >= 0. It first inserts a valid value of seven. Only then does it try the invalid update. That order gives us a useful control: the connection, schema, and ordinary write path already worked.

The rejected update must carry SQLSTATE 23514, PostgreSQL's check-constraint violation. The classifier shown earlier accepts that database result. A separate assertion feeds it PoolClosed and requires false. Losing the connection pool must never count as proof that the server enforced the quantity rule.

A fresh query reads the protected row after the rejection. Its quantity is still seven. That closes another gap in the evidence: a correctly named error is useful, but the requirement also concerns what was stored. If a trigger or a surrounding operation changed other state, the test would need to inspect that state too.

The negative control creates a second table without the condition. Inserting minus one there succeeds. The fixture expects that success because it demonstrates what the rejection contract must catch when protection is missing. It does not treat a successful invalid write as a passing security test.

This pattern helps with permission checks as well. Establish a valid read through the intended low-privilege connection, try the forbidden operation, classify the documented denial, and inspect protected state through an independent observer. A missing server or a typo in a table name should fail the experiment.

The Rust reproduction runs against PostgreSQL in CI. Its narrow classifier checks one condition; applications with several check constraints should also examine the reported constraint name before treating a failure as the intended one.