Testing Error Responses
Testing the unhappy paths
The previous lessons tested success cases. This lesson tests failures — validation errors, not found, conflicts. These are equally important: a wrong error status code or a missing error message confuses clients.
400 Bad Request (validation)
describe("POST /v2/books validation", () => {
test("returns 400 with field errors for missing title", async () => {
const response = await app.fetch(
jsonRequest("POST", "/v2/books", {
authorId: "a1",
genre: "fiction",
}),
);
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error.code).toBe("VALIDATION_ERROR");
expect(body.error.fields.title).toBeDefined();
});
test("returns 400 for empty body", async () => {
const response = await app.fetch(jsonRequest("POST", "/v2/books", {}));
expect(response.status).toBe(400);
});
test("returns 400 for invalid JSON", async () => {
const response = await app.fetch(
new Request("http://localhost/v2/books", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "not valid json",
}),
);
expect(response.status).toBe(400);
});
test("error response includes all invalid fields", async () => {
const response = await app.fetch(
jsonRequest("POST", "/v2/books", {
title: "",
authorId: "not-a-uuid",
genre: "invalid-genre",
}),
);
const body = await response.json();
expect(response.status).toBe(400);
// All three fields should have errors
expect(Object.keys(body.error.fields).length).toBeGreaterThanOrEqual(2);
});
}); [!NOTE] The Error Handling course’s
ValidationErrorand the Zod course’s.flatten()produce the error format being tested here. These tests verify the integration: Zod schema → error handler → formatted response.
404 Not Found
describe("GET /v2/books/:id not found", () => {
test("returns 404 for nonexistent book", async () => {
const response = await app.fetch(new Request("http://localhost/v2/books/nonexistent"));
expect(response.status).toBe(404);
const body = await response.json();
expect(body.error).toBeDefined();
});
test("returns 404 for invalid ID format", async () => {
const response = await app.fetch(new Request("http://localhost/v2/books/!!!"));
// Could be 400 (invalid format) or 404 (not found) depending on implementation
expect([400, 404]).toContain(response.status);
});
}); 409 Conflict
describe("POST /v2/books conflicts", () => {
test("returns 409 for duplicate ISBN", async () => {
// Create first book
await app.fetch(
jsonRequest("POST", "/v2/books", {
title: "Kindred",
authorId: "a1",
genre: "science-fiction",
isbn: "978-0807083697",
}),
);
// Create second book with same ISBN
const response = await app.fetch(
jsonRequest("POST", "/v2/books", {
title: "Different Book",
authorId: "a1",
genre: "fiction",
isbn: "978-0807083697",
}),
);
expect(response.status).toBe(409);
});
}); Testing error response shape consistency
All errors should have the same shape, regardless of the status code:
describe("error response shape", () => {
test("400 error has code and message", async () => {
const response = await app.fetch(jsonRequest("POST", "/v2/books", {}));
const body = await response.json();
expect(body.error.code).toBeDefined();
expect(body.error.message).toBeDefined();
});
test("404 error has code and message", async () => {
const response = await app.fetch(new Request("http://localhost/v2/books/nope"));
const body = await response.json();
expect(body.error).toBeDefined();
});
test("errors are JSON with correct Content-Type", async () => {
const response = await app.fetch(jsonRequest("POST", "/v2/books", {}));
expect(response.headers.get("content-type")).toContain("application/json");
});
}); Exercises
Exercise 1: Test every validation rule in your POST schema. Each rule should have a test for rejection.
Exercise 2: Test 404 for every endpoint that looks up a resource by ID.
Exercise 3: Test that all error responses have the same shape (code, message). No endpoint should return a bare string.
Why test error responses in addition to success responses?