API Testing Guide: REST API Testing, Tools, Authentication, and Contract Testing
Every feature your users love is held together by API calls you never see. When one of those calls fails silently, your frontend shows stale data, your mobile app crashes, and your users think it's a bug in the UI. API testing catches the real failures — before your users do.
Key Takeaways
API testing is faster, more reliable, and cheaper than UI testing for backend logic. No browser rendering, no flaky selectors — just clean request/response assertions.
Test all status codes, not just happy paths. Expired tokens, malformed requests, rate limit errors — the 4xx and 5xx cases are where security vulnerabilities and data corruption hide.
Contract testing prevents microservice integration failures. When Service A changes its API response shape, contract tests catch the break before it reaches Service B in production.
Authentication testing must cover all token states. Valid, expired, invalid, and missing tokens — each one must behave correctly, or you have a security hole.
API testing is the process of verifying that application programming interfaces work correctly, securely, and reliably. As modern applications increasingly rely on APIs — REST, GraphQL, gRPC — for communication between services, API testing has become a critical discipline in software quality.
Unlike UI testing that interacts with an application through its visual interface, API testing communicates directly with the backend. This makes it faster, more stable, and able to test scenarios that are difficult or impossible to reach through the UI.
This guide covers every type of API test, how to use Postman and other tools, authentication testing, contract testing with Pact, and how to integrate API tests into your CI/CD pipeline.
What Is API Testing?
API testing sends requests to API endpoints and validates the responses. It verifies:
- Functionality: Does the endpoint return the correct data for valid inputs?
- Error handling: Does the endpoint return appropriate error codes and messages for invalid inputs?
- Security: Does the endpoint enforce authentication and authorization correctly?
- Performance: Does the endpoint respond within acceptable time thresholds?
- Reliability: Does the endpoint behave consistently across repeated calls?
- Contracts: Does the endpoint's request/response schema match what consumers expect?
Why API Testing Matters
APIs are the backbone of modern software. A typical mobile or web application makes dozens of API calls to render a single page. If the API is wrong, the whole application is wrong.
UI tests are fragile. UI tests break when CSS classes change, when layout changes, when text changes. The same test scenario written as an API test is far more stable.
APIs are shared contracts. When a backend team changes an API, every frontend, mobile, or partner application that consumes it can break. Testing APIs explicitly validates these contracts.
Direct access to business logic. Most business logic lives in the API layer. Unit tests cover individual functions; API tests verify the complete behavior of features including all the layers (routing, middleware, business logic, data access).
Types of API Tests
Functional Tests
Verify that the API does what it is supposed to do.
- Correct data returned for valid requests
- Correct errors returned for invalid requests
- Correct behavior for edge cases (empty inputs, boundary values, special characters)
- Correct side effects (records created in database, events triggered)
Validation Tests
Verify that the API enforces business rules.
- Required fields are validated and rejections contain meaningful error messages
- Data formats are enforced (valid email, valid date format, valid enum values)
- Business constraints are enforced (quantity must be positive, amount must not exceed limit)
UI-to-API Consistency Tests
Verify that what the UI sends matches what the API expects, and what the API returns is what the UI displays.
Security Tests
Verify that the API enforces access controls.
- Authentication is required where expected
- Authorization prevents access to other users' data
- Input validation prevents injection attacks
- Sensitive data is not exposed in responses
Performance Tests
Verify that the API responds within acceptable thresholds under load.
- Response time under normal conditions
- Response time under peak load
- Behavior under concurrent requests
- Degradation curves as load increases
Contract Tests
Verify that the API adheres to the schema that consumers depend on.
- Request schema matches what consumers send
- Response schema matches what consumers expect
- No breaking changes introduced to existing contracts
REST API Testing Fundamentals
HTTP Status Codes to Test
Do not just test the happy path (200 OK). Test every status code that your API should return:
| Status | Meaning | Test Scenario |
|---|---|---|
| 200 OK | Success | Valid request with existing resource |
| 201 Created | Resource created | POST with valid payload |
| 204 No Content | Success, no body | DELETE success |
| 400 Bad Request | Invalid input | Missing required field, invalid format |
| 401 Unauthorized | Not authenticated | Missing token, expired token |
| 403 Forbidden | Not authorized | Valid token but insufficient permissions |
| 404 Not Found | Resource not found | GET with non-existent ID |
| 409 Conflict | Duplicate resource | POST creating a duplicate |
| 422 Unprocessable Entity | Validation failure | Valid format but invalid business rule |
| 429 Too Many Requests | Rate limited | Exceed rate limit |
| 500 Internal Server Error | Server failure | Trigger an unhandled exception |
What to Assert on API Responses
Status code: Most critical. Always assert the expected status code.
Response body: Verify the structure and content of the response.
Response headers: Content-Type, CORS headers, caching headers.
Response time: Assert that responses return within acceptable thresholds.
Side effects: If the API creates, updates, or deletes resources, verify the state change.
// Example: Testing a POST /users endpoint
it('creates a new user and returns 201', async () => {
const payload = {
email: 'jane@example.com',
name: 'Jane Smith',
role: 'user'
};
const response = await api.post('/users', payload);
// Status code
expect(response.status).toBe(201);
// Response body
expect(response.body).toMatchObject({
id: expect.any(String),
email: 'jane@example.com',
name: 'Jane Smith',
role: 'user',
createdAt: expect.any(String)
});
// Response headers
expect(response.headers['content-type']).toContain('application/json');
// Side effect: verify user was actually created
const getResponse = await api.get(`/users/${response.body.id}`);
expect(getResponse.status).toBe(200);
expect(getResponse.body.email).toBe('jane@example.com');
});
Testing Error Responses
Error responses deserve as much attention as success responses. Users and client applications depend on clear error messages.
it('returns 400 when email is missing', async () => {
const response = await api.post('/users', { name: 'Jane' }); // No email
expect(response.status).toBe(400);
expect(response.body).toMatchObject({
error: 'VALIDATION_ERROR',
message: expect.stringContaining('email'),
fields: {
email: expect.stringContaining('required')
}
});
});
it('returns 409 when email already exists', async () => {
await createUser({ email: 'jane@example.com' });
const response = await api.post('/users', { email: 'jane@example.com', name: 'Jane' });
expect(response.status).toBe(409);
expect(response.body.error).toBe('EMAIL_ALREADY_EXISTS');
});
API Testing with Postman
Postman is the most widely used tool for API development and testing. It supports manual testing, automated test scripts, and integration with CI/CD pipelines.
Organizing Collections
Structure your Postman workspace to match your API:
Collection: User Service API
├── Auth
│ ├── POST /auth/login
│ ├── POST /auth/refresh
│ └── POST /auth/logout
├── Users
│ ├── GET /users (list)
│ ├── POST /users (create)
│ ├── GET /users/:id (get)
│ ├── PATCH /users/:id (update)
│ └── DELETE /users/:id (delete)
└── Negative Cases
├── Create user - missing email (400)
├── Get user - not found (404)
└── Update user - unauthorized (401)
Writing Postman Tests
Postman uses JavaScript in the "Tests" tab for assertions:
// Basic assertion
pm.test("Status is 200", function () {
pm.response.to.have.status(200);
});
// Response body assertions
pm.test("Response has user data", function () {
const body = pm.response.json();
pm.expect(body).to.have.property('id');
pm.expect(body.email).to.equal('jane@example.com');
pm.expect(body.role).to.equal('user');
});
// Response time
pm.test("Response time is under 500ms", function () {
pm.expect(pm.response.responseTime).to.be.below(500);
});
// Store value for chained requests
pm.test("Save user ID", function () {
const body = pm.response.json();
pm.environment.set("userId", body.id);
});
Postman Environments
Use environments to run the same collection against different base URLs:
Development: baseUrl = http://localhost:3000
Staging: baseUrl = https://api-staging.example.com
Production: baseUrl = https://api.example.com
Variables like {{baseUrl}}, {{authToken}}, and {{userId}} are resolved from the active environment.
Running Collections with Newman (CI/CD)
Newman is Postman's CLI runner for CI/CD integration:
# Install
npm install -g newman newman-reporter-htmlextra
<span class="hljs-comment"># Run collection
newman run MyCollection.postman_collection.json \
--environment staging.postman_environment.json \
--reporters cli,htmlextra \
--reporter-htmlextra-export test-results/report.html
<span class="hljs-comment"># GitHub Actions
- name: Run API Tests
run: <span class="hljs-pipe">|
newman run postman/collection.json \
--environment postman/staging.json \
--reporters cli,junit \
--reporter-junit-export test-results/newman-results.xml
API Test Automation
Framework-Based API Testing
For larger test suites, dedicated testing frameworks provide better organization, reporting, and CI/CD integration than Postman collections.
JavaScript/TypeScript with Jest + Supertest:
import request from 'supertest';
import app from '../src/app';
describe('POST /api/users', () => {
it('creates a user with valid payload', async () => {
const res = await request(app)
.post('/api/users')
.send({ email: 'test@example.com', name: 'Test User' })
.set('Authorization', `Bearer ${authToken}`);
expect(res.status).toBe(201);
expect(res.body.email).toBe('test@example.com');
});
it('returns 400 for invalid email format', async () => {
const res = await request(app)
.post('/api/users')
.send({ email: 'not-an-email', name: 'Test User' })
.set('Authorization', `Bearer ${authToken}`);
expect(res.status).toBe(400);
expect(res.body.fields.email).toBeDefined();
});
});
Python with pytest + httpx:
import pytest
import httpx
BASE_URL = "https://api-staging.example.com"
@pytest.fixture
def auth_headers():
response = httpx.post(f"{BASE_URL}/auth/login", json={
"email": "test@example.com",
"password": "testpass"
})
token = response.json()["token"]
return {"Authorization": f"Bearer {token}"}
def test_create_user_success(auth_headers):
response = httpx.post(
f"{BASE_URL}/users",
json={"email": "new@example.com", "name": "New User"},
headers=auth_headers
)
assert response.status_code == 201
data = response.json()
assert data["email"] == "new@example.com"
assert "id" in data
def test_create_user_duplicate_email(auth_headers):
# Create user first
httpx.post(f"{BASE_URL}/users",
json={"email": "dup@example.com", "name": "First"},
headers=auth_headers)
# Try to create again with same email
response = httpx.post(
f"{BASE_URL}/users",
json={"email": "dup@example.com", "name": "Second"},
headers=auth_headers
)
assert response.status_code == 409
assert response.json()["error"] == "EMAIL_ALREADY_EXISTS"
Data Management in API Tests
API tests that create data must clean up after themselves:
describe('Order API', () => {
let createdOrderId;
afterEach(async () => {
if (createdOrderId) {
await request(app).delete(`/api/orders/${createdOrderId}`)
.set('Authorization', `Bearer ${adminToken}`);
createdOrderId = null;
}
});
it('creates an order', async () => {
const res = await request(app)
.post('/api/orders')
.send({ items: [{ productId: 'prod_1', quantity: 2 }] })
.set('Authorization', `Bearer ${userToken}`);
createdOrderId = res.body.id; // Will be cleaned up in afterEach
expect(res.status).toBe(201);
});
});
Authentication Testing
Authentication is the most security-critical part of any API. Every authentication mechanism requires thorough testing.
Testing JWT Authentication
JWT (JSON Web Token) is the most common API authentication mechanism. Test all token scenarios:
describe('Authentication', () => {
it('accepts valid token', async () => {
const token = await getValidToken();
const res = await api.get('/protected').set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
});
it('rejects missing token', async () => {
const res = await api.get('/protected'); // No Authorization header
expect(res.status).toBe(401);
expect(res.body.error).toBe('UNAUTHORIZED');
});
it('rejects malformed token', async () => {
const res = await api.get('/protected')
.set('Authorization', 'Bearer not.a.valid.jwt');
expect(res.status).toBe(401);
});
it('rejects expired token', async () => {
const expiredToken = generateToken({ expiresIn: '-1s' }); // Already expired
const res = await api.get('/protected')
.set('Authorization', `Bearer ${expiredToken}`);
expect(res.status).toBe(401);
expect(res.body.error).toBe('TOKEN_EXPIRED');
});
it('rejects token with wrong signature', async () => {
const tamperedToken = validToken.slice(0, -10) + 'tampered12';
const res = await api.get('/protected')
.set('Authorization', `Bearer ${tamperedToken}`);
expect(res.status).toBe(401);
});
});
Testing Authorization (Access Control)
Authentication verifies identity; authorization verifies permissions. Both must be tested.
describe('Authorization', () => {
it('admin can access admin endpoint', async () => {
const res = await api.get('/admin/users')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
});
it('regular user cannot access admin endpoint', async () => {
const res = await api.get('/admin/users')
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(403);
});
it('user cannot access another user\'s data (IDOR)', async () => {
// User A creates a resource
const createRes = await api.post('/profile/notes')
.send({ content: 'Private note' })
.set('Authorization', `Bearer ${userAToken}`);
const noteId = createRes.body.id;
// User B attempts to access User A's resource
const accessRes = await api.get(`/profile/notes/${noteId}`)
.set('Authorization', `Bearer ${userBToken}`);
expect(accessRes.status).toBe(403); // Not 404! Returning 404 leaks information
});
});
Testing API Keys
describe('API Key Authentication', () => {
it('accepts valid API key in header', async () => {
const res = await api.get('/data')
.set('X-API-Key', validApiKey);
expect(res.status).toBe(200);
});
it('rejects revoked API key', async () => {
await revokeApiKey(validApiKey);
const res = await api.get('/data')
.set('X-API-Key', validApiKey);
expect(res.status).toBe(401);
});
it('API key cannot perform admin operations', async () => {
const res = await api.delete('/users/all')
.set('X-API-Key', readOnlyApiKey);
expect(res.status).toBe(403);
});
});
Contract Testing
Contract testing verifies that API providers and consumers agree on the interface. It is essential for microservices architectures where many services communicate through APIs.
The Problem Contract Testing Solves
In a microservices architecture:
- Service A (consumer) calls Service B (provider)
- Service B changes its response schema
- Service A breaks in production
Without contract testing, this break is discovered in production. With contract testing, it is caught before deployment.
Consumer-Driven Contract Testing with Pact
Pact is the most widely used contract testing framework. The workflow:
1. Consumer defines the contract:
// Consumer test (frontend or API client)
const { Pact } = require('@pact-foundation/pact');
describe('User Service Contract', () => {
const provider = new Pact({
consumer: 'OrderService',
provider: 'UserService',
});
it('returns user data for valid ID', async () => {
await provider.addInteraction({
state: 'user with ID 123 exists',
uponReceiving: 'a request for user 123',
withRequest: {
method: 'GET',
path: '/users/123',
headers: { Authorization: like('Bearer token') }
},
willRespondWith: {
status: 200,
body: {
id: '123',
email: like('user@example.com'),
name: like('Jane Smith'),
role: term({ generate: 'user', matcher: 'admin|user|moderator' })
}
}
});
// Consumer code that calls the provider
const user = await userServiceClient.getUser('123');
expect(user.id).toBe('123');
expect(user.email).toBeDefined();
});
});
2. Pact generates a contract file (pact JSON) from the consumer tests.
3. Provider verifies the contract:
// Provider verification (runs in the provider service's CI)
const { Verifier } = require('@pact-foundation/pact');
describe('User Service Provider Verification', () => {
it('validates contract with OrderService', () => {
return new Verifier({
provider: 'UserService',
providerBaseUrl: 'http://localhost:3000',
pactBrokerUrl: 'https://pact-broker.example.com',
providerVersion: process.env.GIT_COMMIT,
stateHandlers: {
'user with ID 123 exists': async () => {
await createTestUser({ id: '123', email: 'user@example.com', name: 'Jane Smith' });
}
}
}).verifyProvider();
});
});
4. If the provider changes in a breaking way, the verification fails before deployment.
Pact Broker
The Pact Broker is a shared repository for contracts:
- Consumers publish their contracts after successful tests
- Providers pull consumer contracts and verify against them
- Can-I-Deploy checks prevent deployments that would break consumers
# Can the UserService be deployed without breaking its consumers?
pact-broker can-i-deploy \
--pacticipant UserService \
--version <span class="hljs-variable">$GIT_COMMIT \
--to-environment production
Schema Validation
A lighter-weight alternative to full Pact: validate response schemas with JSON Schema.
const Ajv = require('ajv');
const ajv = new Ajv();
const userSchema = {
type: 'object',
required: ['id', 'email', 'name', 'role'],
properties: {
id: { type: 'string' },
email: { type: 'string', format: 'email' },
name: { type: 'string' },
role: { type: 'string', enum: ['admin', 'user', 'moderator'] },
createdAt: { type: 'string', format: 'date-time' }
}
};
it('user endpoint returns valid schema', async () => {
const res = await api.get('/users/123');
const validate = ajv.compile(userSchema);
const valid = validate(res.body);
expect(valid).toBe(true);
if (!valid) console.log(validate.errors);
});
GraphQL Testing
GraphQL APIs require a different testing approach than REST.
Testing GraphQL Queries
const { request, gql } = require('graphql-request');
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
email
name
orders {
id
total
status
}
}
}
`;
it('fetches user with orders', async () => {
const data = await request(
'https://api.example.com/graphql',
GET_USER,
{ id: '123' },
{ Authorization: `Bearer ${token}` }
);
expect(data.user.email).toBe('jane@example.com');
expect(data.user.orders).toBeInstanceOf(Array);
});
Testing GraphQL Mutations
const CREATE_ORDER = gql`
mutation CreateOrder($input: CreateOrderInput!) {
createOrder(input: $input) {
id
total
status
}
}
`;
it('creates an order via mutation', async () => {
const data = await request(
'https://api.example.com/graphql',
CREATE_ORDER,
{ input: { items: [{ productId: 'prod_1', quantity: 2 }] } },
{ Authorization: `Bearer ${token}` }
);
expect(data.createOrder.status).toBe('PENDING');
expect(data.createOrder.total).toBeGreaterThan(0);
});
Testing GraphQL Errors
GraphQL returns HTTP 200 even for errors, with an errors array in the response:
it('returns error for invalid product ID', async () => {
const { errors } = await rawRequest(
'https://api.example.com/graphql',
gql`query { product(id: "nonexistent") { id name } }`,
{},
{ Authorization: `Bearer ${token}` }
);
expect(errors).toBeDefined();
expect(errors[0].extensions.code).toBe('NOT_FOUND');
});
API Testing Best Practices
1. Test the Contract, Not the Implementation
Your tests should verify what the API promises (the contract), not how it is implemented internally. Tests that know too much about implementation break on refactoring.
2. Test All Error Codes
Most test suites cover the happy path (200 OK) but skip error scenarios. Error handling is where APIs most often break in production.
3. Use Data Factories
Create test data programmatically rather than relying on fixed test data in the database:
function createUser(overrides = {}) {
return {
email: `test-${Date.now()}@example.com`,
name: 'Test User',
role: 'user',
...overrides
};
}
4. Isolate Tests
Each test should be independent. Tests that share state (created records, authentication tokens) become fragile and order-dependent.
5. Assert on Response Time
API performance matters. Add response time assertions to catch performance regressions:
expect(response.time).toBeLessThan(500); // 500ms max
6. Test Rate Limiting
If your API has rate limits, test that they work:
it('returns 429 after rate limit exceeded', async () => {
const requests = Array(101).fill(null)
.map(() => api.get('/endpoint').set('Authorization', `Bearer ${token}`));
const responses = await Promise.all(requests);
const rateLimited = responses.filter(r => r.status === 429);
expect(rateLimited.length).toBeGreaterThan(0);
});
7. Use Environment Variables for Configuration
Never hardcode base URLs, credentials, or API keys in test files:
const BASE_URL = process.env.API_BASE_URL || 'http://localhost:3000';
const API_KEY = process.env.TEST_API_KEY;
API Testing Tools
Manual / Exploratory
| Tool | Best For | Cost |
|---|---|---|
| Postman | Full-featured API development + testing | Free (limited) / Paid |
| Insomnia | Clean UI, REST and GraphQL | Free / Paid |
| HTTPie | CLI-based, developer-friendly | Free |
| curl | Scriptable, universal | Free |
Automated / Code-Based
| Tool | Language | Best For |
|---|---|---|
| Supertest | JavaScript | Integration testing with Node.js apps |
| httpx + pytest | Python | Modern async Python API testing |
| REST Assured | Java | BDD-style REST testing |
| RestSharp | C# | .NET REST client and testing |
| Karate | Java/DSL | BDD API testing without code |
Contract Testing
| Tool | Type | Notes |
|---|---|---|
| Pact | Consumer-driven | Most widely used, multi-language |
| Spring Cloud Contract | Provider-driven | Java/Spring focused |
| Dredd | API Blueprint / OpenAPI | Tests against API documentation |
Performance / Load Testing
| Tool | Notes |
|---|---|
| k6 | JavaScript, CI/CD friendly |
| Apache JMeter | GUI-based, feature-rich |
| Artillery | YAML/JavaScript, cloud-native |
| Gatling | Scala DSL, high performance |
FAQ
What is the difference between API testing and integration testing?
Integration testing is a broad category that verifies components work correctly together. API testing specifically tests the API layer — the HTTP endpoints, request/response formats, authentication, and business logic. API testing is a form of integration testing, but "integration testing" can also include testing database integrations, message queue integrations, and service-to-service communication beyond just HTTP APIs.
Should I test my API with Postman or with code?
Both. Use Postman for exploratory testing during development — quickly send requests, inspect responses, and understand the API. Use code-based tests (Jest, pytest, etc.) for automated regression testing in CI/CD. Postman can also be automated via Newman, which is a good middle ground. For long-lived, maintained test suites, code-based tests with proper abstractions are easier to maintain at scale.
What is contract testing and when do I need it?
Contract testing verifies that an API provider and its consumers agree on the interface. You need it when multiple teams (or services) consume the same API and must not break each other. In a microservices architecture, without contract testing, one team can unknowingly break another team's service by changing the API schema. Contract testing (using Pact or similar) catches these breaking changes before deployment.
How do I test authentication in API tests?
Set up test user accounts with known credentials in your test environment. Obtain authentication tokens in test setup (beforeAll/beforeEach) and attach them to requests. Test all token scenarios: valid token, missing token, expired token, malformed token, and tokens with insufficient permissions. Never use production credentials in test environments.
What should I do if an API returns different data on different calls?
Test the structure (schema) rather than exact values when data varies. Use matchers like expect.any(String), expect.any(Number), or JSON Schema validation instead of asserting on specific values. For IDs, timestamps, and other auto-generated values, assert that they exist and are of the right type rather than asserting the exact value.
How many API tests should I write?
Write tests for every endpoint, covering: success cases, validation failures (each required field missing), authentication failures (missing, expired, invalid), authorization failures (wrong role), and any edge cases specific to the business logic. For CRUD endpoints, that typically means 5-10 tests per endpoint. A thorough API test suite for a medium-sized application might have 200-500 tests.
Should API tests hit a real database?
Yes, for integration tests — they should test the real behavior including the database layer. Spin up a test database (using Docker) and run migrations before the test suite. Each test should clean up the data it creates. Unit tests can use fakes/mocks for the database, but integration-level API tests should use real infrastructure to catch database-related bugs.