All articles
169 articles · updated weekly See our Tools
All articles
Tips

How to Test APIs in an Automated Way Without a Useless Suite

Test the real edge of your API, not internal details: endpoint, contract, and production smoke tests, what to assert, fixtures, mock servers, and CI.

How to Test APIs in an Automated Way Without a Useless Suite
COVER · Tips

Every API that survives its first deploy ends up with a confused test suite: dozens of unit tests that mock the entire controller, assert a method was called, and catch no real bug. Meanwhile, the client consuming the API gets a 500 because someone renamed a field in the JSON and not a single test complained. The problem is almost always the same: we test internal details instead of the edge the client actually sees. Testing an API in an automated way is not about covering every line of code — it is about proving the external contract still holds.

This article covers the test levels that matter (endpoint/integration, contract, and production smoke), what is actually worth asserting, how to handle test data and external dependencies, and how to run all of it in CI without it becoming the team's bottleneck.

The three levels that matter

The endpoint test, or integration test, is the one that pays the bills. It boots the real application — or at least the full HTTP layer — fires a real request at the endpoint, and validates what came back: status, body, headers, and side effects in the database. It is the test closest to what the client experiences, which is why it catches bugs a controller unit test never reaches. A test that boots the app and hits the endpoint finds more real bugs than ten unit tests that mock the request and the response.

import request from 'supertest';
import { app } from '../app.js';

test('POST /users creates a user and returns 201', async () => {
  const res = await request(app)
    .post('/users')
    .send({ name: 'Ana', email: 'ana@example.com' });

  expect(res.status).toBe(201);
  expect(res.body).toMatchObject({ id: expect.any(String), name: 'Ana' });
  expect(res.headers['content-type']).toMatch(/application\/json/);

  const row = await db.query('SELECT * FROM users WHERE email = $1', ['ana@example.com']);
  expect(row.rowCount).toBe(1);
});

Notice that the assertion does not stop at the status. It checks the shape of the JSON, the content-type header, and the side effect in the database. This is the level where most of your confidence should live.

The contract test solves a different problem: making sure the request and response match the schema agreed upon between two services. When a producer and a consumer evolve in separate repositories, the contract is the only thing keeping them compatible. Tools like Pact or schema validation (OpenAPI, JSON Schema) verify that the producer did not remove a field the consumer expects, nor change a type. It is cheap, runs fast, and prevents the most expensive class of breakage: the integration that only fails in production.

The production smoke test is the last line. After deploy, a handful of requests against the critical endpoints confirm that the application booted, authenticates, connects to the database, and responds. It is deliberately not exhaustive — it is a sign of life. If the health check and login pass, the deploy is up; if they fail, the rollback fires before the user notices.

What to assert (and it is not just the happy path)

The most common trap is testing only success. A 200 with the right body is half the job. The other half — where the expensive bugs hide — is the errors and the edge cases.

At a minimum, it is worth asserting: the correct status for each scenario (201 on creation, 404 for a missing resource, 422 on an invalid payload, 401 without authentication); the shape of the JSON, not just the presence of fields but their types and structure; the relevant headers (Content-Type, Cache-Control, pagination headers); and the body of error responses, which needs to be as consistent as the success body. A client that gets {"error": "..."} in one case and an HTML stack trace in another will break.

Edge cases deserve explicit tests: an empty list, pagination on the last item, missing optional fields, a payload at the size limit, special characters. These are the ones the happy path never exercises and that invariably show up in production.

Test data and fixtures

API tests need predictable data. Fixtures provide it: a known set of records you seed before the test and clean up afterward. The golden rule is isolation — each test should set up its own state and never depend on execution order or leftovers from another test. Transactions that roll back at the end, or a database recreated per suite, keep everything deterministic.

For the responses that come from outside — a payment gateway, a third-party API — you need realistic sample payloads without calling the real service. Generating a structured JSON mock gives you fixtures and fake responses for the tests without depending on an external service, with the shape and the types your code expects to consume.

Isolate external dependencies with a mock server

Here lives the most important opinion and the rule most often violated: do not mock the very thing you are trying to test. If the goal is to test your endpoint, your endpoint runs for real — you do not replace the controller with a stub. What you isolate are the external dependencies that are outside the test's scope and that you do not control: the third-party API, the payment gateway, the email service.

A mock server intercepts those external calls and returns deterministic responses. That way you can exercise the scenario where the gateway returns 402, or takes too long, without depending on the real service's availability or spending real money. The distinction between mock, stub, and fake matters more than it seems when deciding what to substitute; it is worth understanding the differences between mocks, stubs, and fakes before filling the suite with doubles that hide bugs instead of revealing them.

The dividing line is simple: mock the edge that is outside your control, never the edge you are testing.

Run it in CI

An API test only protects the team if it runs automatically on every push. In CI, boot the services the suite depends on — database and mock server — as containers, wait for the health check, run the suite, and tear everything down. Postgres and Redis as service containers, or a dedicated test docker-compose, handle the environment without each dev having to configure their machine.

Keep the tests fast and parallelizable: databases isolated per worker, no fixed sleep waiting for something to boot. A pipeline that takes ten minutes is a pipeline the team will learn to ignore.

On tooling, the menu is wide and none is mandatory. Postman with newman runs collections in CI and is great for teams already documenting the API there. supertest shines in Node projects by booting the app in memory. pytest with requests is the pragmatic standard in Python. Pick the one that talks to your stack and resist the temptation to adopt three at once — the suite's consistency is worth more than the trendy tool.

Frequently asked questions

What is the difference between an integration test and an API contract test?

The integration test boots your application and validates the endpoint's real behavior — status, body, side effects in the database. The contract test only verifies that the request and response format match the schema agreed upon between two services, without necessarily running the full logic. Integration proves your API works; contract proves it stays compatible with whoever consumes it.

Do I need to mock the database in API tests?

Usually not. If the goal is to test the endpoint end to end, the database is part of what you want to validate — including the side effects. Use a real test database, isolated per transaction or recreated per suite. Mock the database only in unit tests focused on logic that does not touch persistence.

How often should I run smoke tests in production?

On every deploy, at a minimum, as the final gate before releasing traffic. Many teams also run them at short intervals as continuous monitoring, confirming the critical endpoints are still up between deploys. The point is that they stay fast and cover only the essentials.

How many endpoint tests are enough?

There is no magic number. Cover each endpoint across the scenarios that matter: the happy path, the expected errors, and the edge cases your domain exposes. One solid test per real scenario beats twenty that repeat variations of the same success.

Where to put your confidence

Test the real edge of your API — the contract the client sees — not the internal details that will change in the next refactor. Prioritize the endpoint tests that boot the app and hit real HTTP, complement them with contract tests between services, and protect the deploy with production smoke. Assert errors and edges with the same rigor as the happy path, isolate only the dependencies outside your control, and keep everything running in CI. The suite that remains is small, fast, and — what matters — catches real bugs.

RD
Author
Rafael Duarte
Backend developer with a background in fintech and B2B SaaS — worked on teams that scaled APIs from zero to millions of requests. Carries enough production scars to hold strong opinions on tools, patterns and architecture decisions. Not an academic: he read the UUID RFC when he had to choose between v4 and v7 for a write-heavy table.
View profile