A non-2xx response does not identify the fault; classify the test driver, request contract, application, provider, and environment before you patch the API.
A failing API test tells you where a request stopped. It does not tell you why.
During a recent verification pass on a private NestJS and Mongoose API, I found five different failure types under one set of red responses. One came from the test driver. One was an application contract bug. Two came from external providers. Another came from development email configuration. A payment success was valid only inside a test path.
Those failures need different fixes. I now classify the boundary first and change code only when the evidence points to the application.
A response code is only a symptom
An API request can cross several boundaries before the client sees a response:
- The test driver creates the request and supplies authentication.
- The API validates the request body and maps it to its internal contract.
- The application writes data or calls an external service.
- A provider accepts or rejects the downstream request.
- The environment supplies credentials, addresses, and test-mode behavior.
When a test fails, start with the boundary that produced the observed result. A 500 from a database cast is different from a 400 that wraps a provider's 422. A successful development payment simulation is different from a verified gateway callback.
Fix the request contract at the API boundary
The clearest application defect came from a profile update. The database schema stored account_info.account_type as a number because the value referred to an existing master-data record. The UI sent a display label, CURRENT. Mongoose tried to cast that label to a number and the endpoint returned 500.
The database schema was not the part that needed to accept more vocabulary. The API boundary needed to translate the vocabulary that clients already used.
I added a small normalizer before the profile update reaches Mongoose. It accepts the existing numeric IDs and the supported labels, such as CURRENT, Current Account, SAVING, and Savings Account. It returns the corresponding master-data ID. It throws BadRequestException for an empty, unknown, or out-of-range value.
The normalizer does not turn arbitrary text into a number. It accepts only the two known IDs and their known aliases. That keeps the stored representation stable while making the input contract explicit.
The focused test covers numeric values, numeric strings, the UI labels, and invalid input. The suite passed 14 assertions. The follow-up profile request stored the mapped ID, and invalid values now stop at the API boundary with 400 instead of reaching Mongoose as a cast failure.
This pattern works when a client and a database use different representations of the same concept:
const ACCOUNT_TYPE_IDS = {
CURRENT: 1,
SAVING: 2,
} as const;
export function normalizeAccountType(value: unknown): number {
// Accept only known IDs and aliases, then reject everything else.
}
Keep the mapping near the boundary. Do not spread UI labels through services and persistence code.
Check the test driver before blaming authentication
The first authenticated pass produced misleading results. The verification driver used a JSON parsing option that Windows PowerShell 5.1 did not support. The driver then sent requests without the tokens that the test setup expected.
The API was not rejecting valid credentials. The test driver had not sent them.
After correcting the driver, I repeated the authenticated calls and recorded the endpoint result separately from the driver result. This distinction matters because a broken test harness can lead to an unnecessary authorization change, a disabled guard, or a false production incident.
For every authenticated test, inspect the request that left the driver. Confirm the method, URL, body, and authorization header before you inspect the controller.
Separate provider responses from application defects
The same shipment data produced different results across courier integrations. One configured path completed booking and cancellation. Two other paths reached their providers and received upstream 422 and 400 responses. The API surfaced those failures as 400 responses.
That result does not prove that the application contract is wrong. It proves that the request reached a downstream boundary and that the downstream system rejected it. The next checks belong to the provider integration: endpoint, account permission, credential, service code, or payload mapping.
The logs and response mapping must preserve enough information to make this distinction. An application should return a safe client message, but the verification record should retain the upstream status and the provider operation that failed.
Changing a development flag is not a diagnosis. Check whether the provider client has a mode gate. If it does not, a failed request in development still needs provider-level investigation.
Treat environment and test mode as separate evidence
The forgot-password flow exposed another boundary. The API created the reset record, then development Gmail delivery failed. The route returned 500 because the mail operation failed after the local work had started.
The fix was small: normalize the configured mailbox and app-password formatting, and use the configured mailbox as the sender. A later development request returned 201, and the message appeared in the development inbox.
That proves the development SMTP path. It does not prove that production has the right mailbox, credentials, sender policy, or network access. Keep those claims separate in the test report.
The payment flow needed the same care. A development-only settlement path credited the wallet and allowed later shipment checks to run. That was useful test setup, but it was not evidence of a real gateway payment or webhook signature. A test can establish local behavior without proving external payment confirmation.
Use a fault-domain record
I use a short record for each failed assertion:
| observation | likely boundary | next evidence |
|---|---|---|
| token is missing from the outgoing request | test driver | capture the serialized request |
| a label reaches a numeric field | API contract | inspect the boundary mapper and invalid-input response |
a provider returns 422 | external integration | retain the upstream response and request mapping |
| a local record exists but email delivery fails | environment or provider | verify SMTP configuration and delivery |
| a wallet changes through a test shortcut | test mode | mark the result as synthetic |
The record stops me from treating every 500 as a controller bug or every 400 as bad application validation. It also makes the next test obvious.
Verify the claim that the test actually supports
An endpoint test supports a narrow claim. A focused unit test can prove a normalizer maps aliases. A live request can prove that a configured development route accepts a payload. An upstream response can prove that a provider rejected a call. None of those claims automatically proves the production flow.
When a test fails, find the boundary that produced the evidence. Then fix that boundary, rerun the smallest useful check, and state what remains unverified.