CRUD is exactly the kind of code that benefits from tests: clear inputs, clear outputs, lots of edge cases.
/\ E2E (few): real browser → API → DB
/ \
/----\ Integration (some): handler → real test DB
/------\
/--------\ Unit (many): pure logic, validation, policies
Validation and authorization are pure functions — test them in isolation:
1test("rejects empty title", () => {
2 const r = CreateTask.safeParse({ title: "" });
3 expect(r.success).toBe(false);
4});
5
6test("non-owner cannot edit", () => {
7 expect(canEdit(user, otherUsersTask)).toBe(false);
8});Hit the real handler against a real (test) database and assert status + body:
1test("POST /tasks creates a task", async () => {
2 const res = await app.inject({
3 method: "POST",
4 url: "/api/v1/tasks",
5 headers: auth(user),
6 payload: { title: "Test" },
7 });
8 expect(res.statusCode).toBe(201);
9 expect(res.json().data.title).toBe("Test");
10});For each resource, test the happy path and the failures:
| Operation | Don't forget |
|---|---|
| Create | invalid body → 400, unauthenticated → 401 |
| Read | missing id → 404, other user's row → 404/403 |
| Update | partial update, not-owner → 403, stale version → 409 |
| Delete | already deleted → 404, cascade behavior |
Each test should set up and tear down its own data (transaction rollback per test, or a truncate between runs). Tests that depend on each other's leftover rows are flaky tests.