Negative Cases, Boundary Coverage, and a bugs.md That Actually Helps
category: "E2E Testing"
Negative Cases, Boundary Coverage, and a bugs.md That Actually Helps
The Failure Path Deserves Its Own Structure
Most teams treat negative coverage like cleanup work.
They add a couple of bad requests to the bottom of a happy-path spec and call it done.
That is how important bugs stay invisible.
Negative coverage should be explicit.
Split Positive and Negative Work
For a domain like posts, keep the files separate.
PostTest.spec.tsandPost.jsonfor accepted behaviorPostNegativeTest.spec.tsandPostNegative.jsonfor rejected behaviorbugs.mdfor what the system actually does wrong today
That separation matters because the intent is different.
- Positive tests prove supported behavior.
- Negative tests prove validation, auth, and error handling.
Negative Scenarios Belong in Data Too
PostNegative.json
{
"tc_postNegativeTest": [
{
"useCaseId": "tc_postNegativeTest.01",
"description": "title is empty",
"request": {
"title": "",
"description": "Still has a description",
"body": "Still has a body"
},
"expectedStatus": 400
},
{
"useCaseId": "tc_postNegativeTest.02",
"description": "title exceeds max length",
"request": {
"title": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"description": "Overflow",
"body": "Overflow body"
},
"expectedStatus": 400
},
{
"useCaseId": "tc_postNegativeTest.03",
"description": "missing auth token",
"request": {
"title": "Unauthorized",
"description": "No auth",
"body": "Should fail"
},
"expectedStatus": 401
}
]
}The Spec Should Read Like a Rejection Suite
const negativeCases = baseSetup.getTestCases(
JSON_FILE,
'tc_postNegativeTest'
);
for (const tc of negativeCases) {
test(`[${tc.useCaseId}] ${tc.description}`, async () => {
const softAssert = new SoftAssertExt();
const response = await postUtils.createPostRaw(tc.request, tc.headers);
BaseSetupAPI.validateActualVsExpected(softAssert, 'Post: ', [
['response status', response.apiResponse?.status(), tc.expectedStatus],
]);
softAssert.assertAll();
});
}The key detail there is createPostRaw. Sometimes the normal utility method is too helpful. For negative testing, you often want the raw request path so you can omit auth, omit required fields, or send malformed data without a convenience layer smoothing it over.
Boundary Coverage Should Stay Boring
That is a compliment.
Boundary coverage should look like simple rows:
- single character
- typical length
- max length
- max + 1
- null
- empty string
- disallowed characters
If your boundary coverage requires custom code for every case, it is not really data-driven yet.
Keep a Real bugs.md
If a negative test finds a real issue, document it next to the suite.
bugs.md
# Post API Bugs Found During Testing
## BUG-1: title over max length returns 500 instead of 400
- Test case: tc_postNegativeTest.02
- Severity: Medium
- Expected: 400 Bad Request
- Actual: 500 Internal Server Error
- Root cause: database constraint is hit before validation
## BUG-2: empty title is accepted
- Test case: tc_postNegativeTest.01
- Severity: High
- Expected: 400 Bad Request
- Actual: 201 Created
- Root cause: no server-side not-blank validationThat file does three useful things:
- it preserves the reason a negative test exists
- it makes current bugs visible without reading test code
- it gives the next person a faster path to fixing the backend
Why This Beats Inline Comments
Inline comments inside a spec age badly.
bugs.md is easier to scan, easier to update, and easier to hand to an engineer or PM who does not want to read test code.
It also changes the emotional feel of negative testing. The suite is not "flaky." It is surfacing real defects and documenting them cleanly.
What Comes Next
Once lower-level API behavior is covered directly, we can stop using shared hooks to create setup data for every higher-level test.
That is where builders finally show up.
Not as a replacement for direct API tests.
As a seeding mechanism.
Previous: Stop Hand-Writing Giant API Specs Next: Builders as Seeding: Replace beforeEach Sprawl ->