Effective API testing means testing both what your API does when everything works and what it does when things go wrong. Most developers test the happy path thoroughly and skip the error paths — which means production is the first place a missing authorization check or malformed error response gets discovered. Here is how to test APIs properly.
Unit Tests vs Integration Tests for APIs
Unit tests for APIs mock the database and test business logic in isolation. Integration tests hit a real (test) database and test the full request-response cycle including database operations.
Both are necessary. Unit tests are faster and more precise — they tell you exactly which function has a bug. Integration tests are slower but more realistic — they catch issues that only appear when the database, authentication, and business logic interact.
A reasonable ratio for API projects: more unit tests than integration tests. Write unit tests for complex business logic (pricing calculations, permission checks, data transformations). Write integration tests for the endpoints that your application actually uses.
Test Database Strategies
In-memory database: Some databases have in-memory variants (SQLite with :memory:, MongoDB with mongodb-memory-server). Fast, no external dependencies, tests are isolated. The downside: in-memory databases may behave differently from production databases for edge cases, and MongoDB's in-memory server is bulky to set up.
Test database instance: A real database instance with a separate database name (e.g., myapp_test) used only for tests. More realistic than in-memory, but requires the database to be running during tests and proper cleanup between test runs.
Transaction rollback per test: Start a database transaction before each test, run the test, then rollback the transaction. The database is always in a clean state for the next test. This approach works best with PostgreSQL and requires your application code to use the same database transaction that the test opened — which requires injection.
For most Node.js projects, the test database instance is the pragmatic choice. The setup is straightforward and the behavior is realistic.
Seeding Test Data: Factories vs Fixtures
Fixtures are static JSON files with predefined test data. They are easy to understand but brittle — change your schema and you update every fixture file.
Factories are functions that create test data programmatically with sensible defaults that can be overridden:
function createUser(overrides: Partial<User> = {}): User {
return {
id: new ObjectId().toString(),
name: "Test User",
email: `user-${Date.now()}@test.com`,
organizationId: new ObjectId().toString(),
role: "member",
createdAt: new Date(),
...overrides,
};
}
Factories are more maintainable. When the schema changes, you update the factory, and all tests using it automatically get valid data. Use factories for most test data, fixtures only for complex static data that is painful to generate programmatically.
Testing Authentication Without Mocking the Full Auth Flow
The approach that avoids both "mock everything" and "run full auth flow in every test":
Create a test helper that generates a valid JWT token directly:
import jwt from "jsonwebtoken";
export function createTestToken(userId: string, organizationId: string): string {
return jwt.sign(
{ userId, organizationId, role: "member" },
process.env.JWT_SECRET!,
{ expiresIn: "1h" }
);
}
In tests, call this function to get a token, then include it in requests:
const token = createTestToken(testUser.id, testOrg.id);
const response = await request(app)
.get("/api/projects")
.set("Authorization", `Bearer ${token}`);
This tests the real authentication middleware (it validates the JWT), without running the actual login flow. The token is valid and the middleware accepts it. You are testing authentication behavior, not the login endpoint, which is tested separately.
Testing Error Paths: The Cases Developers Skip
Every endpoint should have tests for:
400 Bad Request: missing required fields, invalid field types, values outside allowed ranges.
it("returns 400 when name is missing", async () => {
const response = await request(app)
.post("/api/projects")
.set("Authorization", `Bearer ${token}`)
.send({ description: "A project without a name" });
expect(response.status).toBe(400);
expect(response.body.error).toMatch(/name/i);
});
401 Unauthorized: requests without a token, requests with an expired token, requests with a tampered token.
it("returns 401 with no token", async () => {
const response = await request(app).get("/api/projects");
expect(response.status).toBe(401);
});
403 Forbidden: authenticated users accessing resources they are not allowed to access. A member trying to delete an organization. A user from one organization accessing another organization's data.
it("returns 403 when accessing another organization's project", async () => {
const otherOrgToken = createTestToken(otherUser.id, otherOrg.id);
const response = await request(app)
.get(`/api/projects/${testProject.id}`)
.set("Authorization", `Bearer ${otherOrgToken}`);
expect(response.status).toBe(403);
});
404 Not Found: requesting a resource that does not exist, requesting a resource that exists but belongs to another organization (return 403 or 404 depending on your security model — returning 404 leaks less information).
500 Internal Server Error: this is harder to test intentionally. The best approach is to inject a mock that throws an error. Make sure your 500 responses do not leak stack traces or internal error messages in production.
Testing Rate Limiting
Rate limiting should be off in tests. The cleanest approach is an environment variable:
// In your rate limiting middleware
if (process.env.NODE_ENV === "test") {
return next(); // skip rate limiting in tests
}
If you want to test the rate limiting behavior specifically, write a dedicated test file that temporarily enables rate limiting with a very low limit.
Tools: Supertest and Vitest
Supertest wraps your Express (or any Node.js HTTP) server and lets you make HTTP requests without actually starting the server:
import request from "supertest";
import { app } from "../app";
describe("GET /api/projects", () => {
it("returns projects for the authenticated user", async () => {
const response = await request(app)
.get("/api/projects")
.set("Authorization", `Bearer ${token}`);
expect(response.status).toBe(200);
expect(response.body.projects).toBeInstanceOf(Array);
});
});
Vitest is faster than Jest, has native TypeScript support without configuration, and has a compatible API. For new projects, Vitest over Jest. For existing Jest projects, the migration is straightforward but not urgent.
The Test That Gets Skipped Most Often
The test for organization isolation: user A cannot see, modify, or delete user B's data even if user A is authenticated. This is the most critical security test for multi-tenant applications and the one most likely to be skipped because it requires creating multiple organizations and multiple test users.
Write this test. Make it part of the CI pipeline. A missing organization filter in one MongoDB query can expose all customers' data, and the only way to catch it before production is to test it.
Keep Reading
- Postman vs Insomnia vs HTTPie Guide — tools for manual API testing that complement automated tests
- Debugging Techniques Guide — how to diagnose test failures efficiently
- GitHub Actions Guide for Developers — running your API test suite in CI on every push
Pristren builds AI-powered software for teams. Zlyqor is our all-in-one workspace — chat, projects, time tracking, AI meeting summaries, and invoicing — in one tool. Try it free.