Comprehensive Guide to API Testing: Strategy, Tools, and Code Examples
Everything you need to know about API testing — why it matters, REST vs GraphQL testing strategies, tools comparison (Postman, REST Assured, Playwright), and practical code examples.
API testing is the highest-leverage investment in your QE strategy. While UI tests are slow, brittle, and expensive to maintain, API tests are fast, stable, and directly validate the business logic your product depends on. If your team is spending most of its automation budget on UI tests, this guide will help you rebalance toward the API layer — where the return on investment is significantly higher.
Why API Testing Matters
The traditional test automation pyramid puts unit tests at the base, integration/API tests in the middle, and UI tests at the top — with a warning to keep the top layer thin.
The reasoning is practical:
| Layer | Speed | Stability | Coverage | Cost |
|---|---|---|---|---|
| Unit tests | ~ms | High | Narrow | Low |
| API tests | ~seconds | High | Broad | Medium |
| UI tests | ~minutes | Low | End-to-end | High |
API tests give you broad coverage at high speed and stability. A well-designed API test suite can validate the behaviour of an entire backend in under two minutes — something a UI suite for the same functionality might take 30 minutes to do, with far more intermittent failures.
Beyond speed, API testing catches a specific category of defect that UI testing misses: contract violations. When a backend team changes a response schema without telling the frontend team, UI tests might not catch it until QA manually explores the feature. API tests catch it in seconds.
Understanding What to Test
Functional testing
Verify that each endpoint does what it's supposed to do:
- Returns the correct status code (200, 201, 400, 401, 404, 500)
- Returns the correct response body structure and data types
- Handles valid inputs correctly
- Returns meaningful errors for invalid inputs
- Enforces authentication and authorisation correctly
Contract testing
Ensure the API schema doesn't break unexpectedly. Tools like Pact (consumer-driven contract testing) and Dredd (OpenAPI/Swagger spec validation) automate this.
Performance testing
Validate that endpoints respond within acceptable time limits under expected load. A GET endpoint that takes 3 seconds is functionally correct but operationally broken. See our guide on performance testing for a deeper look at this area.
Security testing
Check that authentication is enforced, tokens expire correctly, rate limiting works, and sensitive data isn't leaked in responses.
Tools Comparison
Postman / Newman
Best for: Exploratory testing, team collaboration, contract documentation
Postman is the most widely used API testing tool. Its GUI makes it approachable for testers without deep coding experience. Collections can be exported and run in CI via Newman:
# Install Newman
npm install -g newman
# Run a collection
newman run MyAPI.postman_collection.json \
--environment staging.postman_environment.json \
--reporters cli,junit \
--reporter-junit-export results.xmlPostman's biggest limitation is that collection scripts (written in a restricted JavaScript sandbox) are harder to maintain than a proper codebase as tests grow.
REST Assured (Java)
Best for: Java shops, complex test logic, integration with JUnit/TestNG
REST Assured has a fluent DSL that makes API tests readable and expressive:
@Test
public void getUser_returnsCorrectName() {
given()
.header("Authorization", "Bearer " + authToken)
.pathParam("id", 123)
.when()
.get("/api/users/{id}")
.then()
.statusCode(200)
.body("name", equalTo("Alice"))
.body("email", containsString("@"))
.time(lessThan(1000L)); // Response under 1 second
}Playwright (TypeScript/JavaScript)
Best for: Teams already using Playwright for UI tests, full-stack test suites
Playwright has a built-in APIRequestContext that's ideal if you want UI and API tests in the same framework:
import { test, expect } from '@playwright/test';
test('GET /api/users returns user list', async ({ request }) => {
const response = await request.get('/api/users', {
headers: { Authorization: `Bearer ${process.env.API_TOKEN}` }
});
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.users).toBeInstanceOf(Array);
expect(body.users.length).toBeGreaterThan(0);
expect(body.users[0]).toMatchObject({
id: expect.any(Number),
name: expect.any(String),
email: expect.stringContaining('@'),
});
});Supertest (Node.js)
Best for: Node.js backends, testing the app layer directly without HTTP overhead
import request from 'supertest';
import app from '../app'; // Your Express/Fastify app
describe('POST /api/users', () => {
it('creates a user and returns 201', async () => {
const res = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'Alice', email: 'alice@example.com' });
expect(res.status).toBe(201);
expect(res.body.id).toBeDefined();
expect(res.body.name).toBe('Alice');
});
it('returns 400 for missing email', async () => {
const res = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'Alice' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/email/i);
});
});Building a Practical API Test Suite
Structure your tests by resource
Organise test files to mirror your API structure. This makes it easy to find tests when an endpoint changes:
tests/
api/
auth/
login.spec.ts
token-refresh.spec.ts
users/
get-user.spec.ts
create-user.spec.ts
update-user.spec.ts
delete-user.spec.ts
orders/
get-orders.spec.ts
create-order.spec.ts
Authentication patterns
Don't duplicate auth setup in every test. Use a shared fixture:
// fixtures/auth.ts
export async function getAuthToken(request: APIRequestContext): Promise<string> {
const response = await request.post('/api/auth/login', {
data: {
email: process.env.TEST_EMAIL,
password: process.env.TEST_PASSWORD,
}
});
const { token } = await response.json();
return token;
}
// playwright.config.ts — set token as a fixture available to all tests
// or pass it via environment variables in CITest data management
The hardest problem in API testing is test data. Tests that share a database state interfere with each other and produce inconsistent results.
The cleanest solution is to create and clean up test data within each test:
test('delete user removes them from the system', async ({ request }) => {
// Create test data as part of this test
const createResponse = await request.post('/api/users', {
headers: { Authorization: `Bearer ${token}` },
data: { name: 'Test User', email: `test-${Date.now()}@example.com` }
});
const { id } = await createResponse.json();
// Test the delete
const deleteResponse = await request.delete(`/api/users/${id}`, {
headers: { Authorization: `Bearer ${token}` }
});
expect(deleteResponse.status()).toBe(204);
// Verify deletion
const getResponse = await request.get(`/api/users/${id}`, {
headers: { Authorization: `Bearer ${token}` }
});
expect(getResponse.status()).toBe(404);
});Using Date.now() or a UUID in test data ensures each test run uses fresh, non-conflicting data.
GraphQL API Testing
GraphQL requires a slightly different approach — all requests go to a single endpoint, and the query structure determines what you get back.
test('fetch user with posts', async ({ request }) => {
const query = `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
posts {
title
publishedAt
}
}
}
`;
const response = await request.post('/graphql', {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
query,
variables: { id: '123' }
}
});
expect(response.status()).toBe(200);
const { data, errors } = await response.json();
expect(errors).toBeUndefined();
expect(data.user.name).toBe('Alice');
expect(data.user.posts).toBeInstanceOf(Array);
});For GraphQL specifically, always check the errors field even when the HTTP status is 200 — GraphQL returns errors in the response body, not as HTTP error codes.
Integrating API Tests into CI/CD
API tests should run on every pull request and be the first gate in your pipeline — they're fast enough to not block developers.
A minimal GitHub Actions setup:
name: API Tests
on: [push, pull_request]
jobs:
api-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports: ['5432:5432']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run db:migrate:test
env: { DATABASE_URL: postgres://test:test@localhost:5432/testdb }
- run: npx playwright test tests/api/
env:
BASE_URL: http://localhost:3000
TEST_EMAIL: ${{ secrets.TEST_EMAIL }}
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}Common Mistakes to Avoid
Testing implementation details. Assert on what the API returns, not on how the database stores it internally. Tests that reach into the DB to verify state are tightly coupled to implementation.
Ignoring error responses. Teams often test the happy path thoroughly and skip error cases. But error handling logic has bugs too — and those bugs surface in production.
Using shared test accounts. When multiple CI runs execute in parallel using the same test user, they interfere with each other. Use dynamic test data or isolated test users per run.
Not validating response schemas. Checking response.status === 200 is necessary but not sufficient. Validate the shape of the response body — a field changing from a number to a string is a breaking change that a status code check won't catch.
Next Steps
A solid API test suite is the backbone of a high-confidence CI/CD pipeline. With fast, stable API tests as your foundation, you can run UI tests selectively — on the flows where end-to-end validation genuinely adds value — rather than as the primary regression gate.
For the test sites to practice API testing on, see our API Testing Demo Sites guide. And once your API suite is running, explore how to combine it with Playwright's UI testing to build a complete test pyramid.