API 測試涵蓋成功路徑,卻從未驗證錯誤路徑
The Happy Path Is Fully Tested; the Error Path Is Never Asserted
這篇 API 測試指南指出,只驗證成功回應而不斷言失敗情況,會令覆蓋率看似完整,卻漏掉 4xx、5xx 錯誤分支。指南建議為預期失敗明確測試狀態碼、回應格式及 Retry-After 等標頭,並以 stub 測試上游錯誤。另可設定分支覆蓋率門檻,並以 OpenAPI 合約測試核對已列明的錯誤回應。
Quick answer: A suite that covers every successful response but never asserts a failure gives you a false sense of safety: the happy path is the easy part, and the error path is where real systems break, retries, and surface data to users. Leaving 4xx and 5xx responses untested means a malformed error body, a missing field, or a wrong status code reaches production even though coverage reports look complete. Treat error responses as first-class behavior with explicit assertions, not as exceptions you hope never happen.
Coverage numbers hide this gap neatly. A test that calls the endpoint and asserts a 200 touches the success branch, and a tool reports the line as covered even though no test ever exercises the branch that returns a 422. Line coverage is not branch coverage. The failures that dominate incident reviews — a validation error with the wrong shape, an upstream timeout surfaced as a 500 with no detail, a rate-limit the client does not know how to handle — live in precisely those untested branches.
What "only happy path" looks like
The common pattern is a single test that sends valid input and asserts the success response. Invalid input, missing permissions, and upstream failures have no corresponding case. When the error-handling code later changes — or is generated by an agent that simplifies it — nothing fails.
// This is the only test. It proves success and nothing else. it("returns the order", async () => { const res = await getOrder("ord_123"); expect(res.status).toBe(200); expect(res.body.id).toBe("ord_123"); }); // None of these are asserted anywhere: // 404 when the order does not exist // 403 when the caller does not own it // 429 when rate limited // 502 when the upstream service is down
Assert the failure as behavior
Each error response is a contract: it has a status code, a documented body shape, and often headers the client acts on. Write a test per meaningful failure and pin those elements. For errors the handler triggers, send the input that causes it. For upstream failures, inject a stub that returns an error rather than relying on a real dependency.
These assertions do more than catch regressions; they document what a client should do when things fail. The retryable flag and the Retry-After header are the difference between a client that backs off and one that hammers a failing endpoint.
The error cases worth a test
| Failure class | What to assert | Why it matters |
|---|---|---|
| Validation (400/422) | Status and per-field error shape | Clients render field-level messages |
| Not found (404) | Problem details body | Distinguish missing from forbidden |
| Permission (403) | Status, no leaked resource data | Authorization must not reveal data |
| Rate limit (429) | Retry-After header | Drives client backoff |
| Upstream error (502/504) | Mapped status, retryable flag | Avoid surfacing raw internals |
| Conflict (409) | Current-state pointer | Clients reconcile optimistic writes |
Branch coverage and contract-driven checks
Configure coverage to report branch coverage and set a threshold so an untested error branch fails the build rather than quietly passing. Where responses are documented in OpenAPI, a contract test can verify each declared error status returns a body matching the schema, closing the gap even for cases you forget to hand-write. The spec lists every response the client must handle; generating tests from it means the documented failures cannot drift from the actual ones. This is especially valuable when an agent generates code, because the build enforces the error contract instead of trusting the model to remember it.
Frequently asked questions
My coverage is 90%. Aren't the error paths negligible?
Line coverage can be high while most error branches are never asserted, because a single success call may count the surrounding lines as covered. Check branch coverage and ask, for each declared error status, which test triggers it. The answer tells you more than the percentage.
Do I need a test for every single status code?
Cover every failure a client is expected to act on — validation, not-found, permission, rate-limit, and upstream mapping. Trivial duplicates can share a parameterized test, but each distinct behavior the client handles should have an assertion.
How do I test upstream failures without a flaky real dependency?
Inject a stub or mock at the boundary that returns the specific error, then assert how your handler maps it. Deterministic injected failures are faster and more reliable than waiting for a real service to time out.
What about error bodies that leak internal details?
Add an assertion that error responses do not include stack traces, internal hostnames, or query details. Testing the 500 path gives you a place to enforce that only safe, problem-shaped bodies reach clients.
This is part of the API Testing Guide — start there for a complete approach to tests that assert behavior rather than just green status codes.
來源:DEV Community · dev.to