hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Testing APIs with @hectoday/http

Why Test

  • What Testing Gives You
  • Types of Tests
  • Project Setup

Unit Testing

  • Testing Pure Functions
  • Testing Zod Schemas
  • Testing Business Logic

Integration Testing

  • Testing Route Handlers
  • Testing GET Endpoints
  • Testing POST Endpoints
  • Testing Error Responses
  • Testing Authentication

Test Helpers

  • Factories and Fixtures
  • Test Database Isolation
  • Request Helpers

Advanced Testing

  • Mocking External Services
  • Testing Background Jobs
  • Testing Edge Cases

Putting It All Together

  • Test Organization
  • Checklist and Capstone

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 ValidationError and 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?

← Testing POST Endpoints Testing Authentication →

© 2026 hectoday. All rights reserved.