The Importance of API Testing
APIs are the connective tissue of modern applications, making their reliability critical. Think of APIs as the nervous system of your application ecosystem – if they fail, the entire organism suffers.
Why API Testing Deserves Special Attention
- Contract Validation: Ensures your API follows its promised contract
- Integration Point: APIs connect multiple systems and components
- Security Gateway: Often serves as the entry point to your application
- Performance Bottleneck: API inefficiencies affect the entire system
- Versioning Challenges: APIs must maintain backward compatibility
Real-World API Failure Impact
In 2020, a leading financial services company experienced an API outage that affected millions of users for over 12 hours. The root cause was a simple schema change that wasn't properly tested. This led to inconsistent data validation behaviors between their development and production environments. The incident cost them millions in lost transactions and damaged their reputation with both partners and customers.
Comprehensive API testing would have caught this issue before it reached production.
API Testing Levels
Like a building needs inspection at different stages of construction, APIs require testing at multiple levels.
Unit Testing
Tests individual API endpoint handlers, controllers, or resolver functions in isolation.
- Tests business logic and validation within a single endpoint
- Uses mocks for database, services, and other dependencies
- Fast and focused
Integration Testing
Tests how API endpoints interact with each other and with dependencies.
- May include real database connections
- Verifies that components work together
- Often runs against a test version of the full application
Functional Testing
Tests complete API workflows from the perspective of API consumers.
- Validates expected behaviors for specific use cases
- Tests multiple API calls in sequence
- Focuses on business requirements
Contract Testing
Ensures that the API adheres to its defined contract or specification.
- Validates response schemas against documentation (e.g., OpenAPI/Swagger)
- Verifies that changes don't break existing consumers
- Can be automated as part of CI/CD pipelines
Load/Performance Testing
Tests API behavior under various load conditions.
- Validates response times and throughput
- Identifies bottlenecks
- Determines scaling requirements
Security Testing
Identifies vulnerabilities in API endpoints and authentication mechanisms.
- Tests for common security issues (OWASP API Security Top 10)
- Validates authorization controls
- Checks for sensitive data exposure
When Each Level is Important
Consider an e-commerce payment API:
- Unit Testing: Test that the payment processor correctly calculates tax and shipping
- Integration Testing: Verify that a successful payment updates inventory and creates an order
- Functional Testing: Test the complete checkout flow from cart to order confirmation
- Contract Testing: Ensure the payment response includes all required fields in the documented format
- Load Testing: Verify the API can handle Black Friday sales volume
- Security Testing: Check that payment information is encrypted and card details aren't stored improperly
Setting Up Your API Testing Environment
Express API Testing Setup
Let's create a testable structure for an Express API:
// src/app.js - Separate Express app from server
const express = require('express');
const bodyParser = require('body-parser');
const routes = require('./routes');
const errorMiddleware = require('./middleware/error');
const app = express();
app.use(bodyParser.json());
app.use('/api', routes);
app.use(errorMiddleware);
module.exports = app;
// src/server.js - Server startup
const app = require('./app');
const config = require('./config');
const db = require('./db');
async function startServer() {
try {
await db.connect();
const server = app.listen(config.port, () => {
console.log(`Server running on port ${config.port}`);
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(async () => {
await db.disconnect();
console.log('Process terminated');
});
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
}
if (require.main === module) {
startServer();
}
module.exports = { startServer };
This separation makes it easier to import the app for testing without starting the server.
Testing Configuration
// src/config.js
module.exports = {
port: process.env.PORT || 3000,
environment: process.env.NODE_ENV || 'development',
db: {
url: process.env.NODE_ENV === 'test'
? process.env.TEST_DB_URL || 'mongodb://localhost:27017/test-db'
: process.env.DB_URL || 'mongodb://localhost:27017/app-db',
options: {
useNewUrlParser: true,
useUnifiedTopology: true
}
},
jwt: {
secret: process.env.JWT_SECRET || 'dev-secret',
expiresIn: process.env.JWT_EXPIRES_IN || '1d'
}
};
// test/setup.js
process.env.NODE_ENV = 'test';
process.env.TEST_DB_URL = 'mongodb://localhost:27017/test-db';
process.env.JWT_SECRET = 'test-secret';
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
let mongoServer;
// Setup before tests run
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
process.env.TEST_DB_URL = mongoUri;
});
// Clean up after tests complete
afterAll(async () => {
if (mongoose.connection.readyState !== 0) {
await mongoose.disconnect();
}
if (mongoServer) {
await mongoServer.stop();
}
});
Test Database Utilities
// test/utils/db.js
const mongoose = require('mongoose');
const { User, Product, Order } = require('../../src/models');
async function connectDB() {
if (mongoose.connection.readyState === 0) {
await mongoose.connect(process.env.TEST_DB_URL, {
useNewUrlParser: true,
useUnifiedTopology: true
});
}
}
async function clearDatabase() {
if (mongoose.connection.readyState !== 0) {
const collections = mongoose.connection.collections;
for (const key in collections) {
const collection = collections[key];
await collection.deleteMany({});
}
}
}
async function disconnectDB() {
if (mongoose.connection.readyState !== 0) {
await mongoose.disconnect();
}
}
async function createTestUser(data = {}) {
return await User.create({
name: 'Test User',
email: `test-${Math.random().toString(36).substring(2)}@example.com`,
password: 'password123',
...data
});
}
// Add more helper functions for other collections
module.exports = {
connectDB,
clearDatabase,
disconnectDB,
createTestUser
};
Testing Tools for API Endpoints
The right tools make API testing more efficient, just as a master chef relies on quality knives.
Supertest
A popular HTTP assertion library that allows testing Express.js applications.
// Example Supertest usage
const request = require('supertest');
const app = require('../src/app');
test('GET /api/users returns list of users', async () => {
const response = await request(app)
.get('/api/users')
.set('Authorization', `Bearer ${token}`)
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toBeInstanceOf(Array);
});
Jest
Testing framework that works well with Supertest for assertions and test organization.
// Example Jest test structure
describe('User API', () => {
let token;
beforeEach(async () => {
// Set up test data
const user = await createTestUser();
token = generateToken(user);
});
describe('GET /api/users', () => {
test('returns 401 without token', async () => {
await request(app)
.get('/api/users')
.expect(401);
});
test('returns user list with valid token', async () => {
const response = await request(app)
.get('/api/users')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body.length).toBeGreaterThan(0);
});
});
});
Postman/Newman
Postman provides a GUI for API testing and Newman allows running these tests from the command line.
// Example of running Postman tests via Newman
const newman = require('newman');
newman.run({
collection: require('./postman/collection.json'),
environment: require('./postman/environment.json'),
reporters: ['cli', 'junit'],
reporter: {
junit: {
export: './test-results/postman-results.xml'
}
}
}, function (err) {
if (err) { throw err; }
console.log('Collection run complete!');
});
REST Client (VS Code Extension)
A lightweight alternative that allows creating HTTP requests in a simple text file.
// Example .http file for REST Client
### Get all users
GET http://localhost:3000/api/users
Authorization: Bearer {{$token}}
### Create new user
POST http://localhost:3000/api/users
Content-Type: application/json
{
"name": "New User",
"email": "newuser@example.com",
"password": "password123"
}
Pactum.js
A newer API testing library that offers a more readable syntax.
const { spec } = require('pactum');
it('should get user by id', async () => {
await spec()
.get('/api/users/{id}')
.withPathParams('id', 1)
.withHeaders('Authorization', 'Bearer ' + token)
.expectStatus(200)
.expectJsonSchema({
type: 'object',
properties: {
id: { type: 'number' },
name: { type: 'string' }
}
});
});
Choosing the Right Tool
Your choice depends on your specific needs:
- Supertest + Jest: Great for developers who want tests alongside code
- Postman/Newman: Excellent for teams with non-developers who need to test APIs
- REST Client: Perfect for quick ad-hoc testing during development
- Pactum.js: Good for scenarios requiring complex assertions and validations
Writing Comprehensive API Tests
Basic CRUD Endpoint Testing
Let's test a set of API endpoints for a product resource:
// test/api/products.test.js
const request = require('supertest');
const app = require('../../src/app');
const db = require('../utils/db');
const { createTestUser } = require('../utils/auth');
describe('Products API', () => {
let adminToken, userToken;
let testProduct;
beforeAll(async () => {
await db.connectDB();
});
afterAll(async () => {
await db.disconnectDB();
});
beforeEach(async () => {
await db.clearDatabase();
// Create test users
const admin = await createTestUser({ role: 'admin' });
const user = await createTestUser({ role: 'user' });
adminToken = admin.generateToken();
userToken = user.generateToken();
// Create a test product
testProduct = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send({
name: 'Test Product',
description: 'A product for testing',
price: 99.99,
sku: 'TEST-1234',
category: 'electronics'
});
});
describe('GET /api/products', () => {
test('returns list of products', async () => {
const response = await request(app)
.get('/api/products')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toBeInstanceOf(Array);
expect(response.body.length).toBeGreaterThan(0);
expect(response.body[0]).toHaveProperty('name');
expect(response.body[0]).toHaveProperty('price');
});
test('supports filtering by category', async () => {
// Create a product in different category
await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send({
name: 'Another Product',
description: 'In a different category',
price: 49.99,
sku: 'TEST-5678',
category: 'books'
});
const response = await request(app)
.get('/api/products?category=electronics')
.expect(200);
expect(response.body.length).toBe(1);
expect(response.body[0].category).toBe('electronics');
});
test('supports pagination', async () => {
// Create 10 more products
for (let i = 0; i < 10; i++) {
await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send({
name: `Product ${i}`,
description: `Description ${i}`,
price: 10 + i,
sku: `SKU-${i}`,
category: 'test'
});
}
const response = await request(app)
.get('/api/products?page=1&limit=5')
.expect(200);
expect(response.body.length).toBe(5);
expect(response.header).toHaveProperty('x-total-count');
expect(parseInt(response.header['x-total-count'])).toBeGreaterThan(5);
});
});
describe('GET /api/products/:id', () => {
test('returns product by id', async () => {
const response = await request(app)
.get(`/api/products/${testProduct.body.id}`)
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.id).toBe(testProduct.body.id);
expect(response.body.name).toBe('Test Product');
expect(response.body.price).toBe(99.99);
});
test('returns 404 for non-existent product', async () => {
await request(app)
.get('/api/products/nonexistentid')
.expect(404);
});
});
describe('POST /api/products', () => {
test('admin can create a product', async () => {
const newProduct = {
name: 'New Product',
description: 'Brand new product',
price: 129.99,
sku: 'NEW-1234',
category: 'electronics'
};
const response = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send(newProduct)
.expect('Content-Type', /json/)
.expect(201);
expect(response.body.id).toBeDefined();
expect(response.body.name).toBe(newProduct.name);
expect(response.body.price).toBe(newProduct.price);
// Verify product was actually created in the database
const getResponse = await request(app)
.get(`/api/products/${response.body.id}`)
.expect(200);
expect(getResponse.body.name).toBe(newProduct.name);
});
test('regular user cannot create a product', async () => {
const newProduct = {
name: 'Unauthorized Product',
description: 'Should not be created',
price: 9.99,
sku: 'UNAUTH-1234',
category: 'electronics'
};
await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${userToken}`)
.send(newProduct)
.expect(403);
});
test('returns 400 for invalid product data', async () => {
// Missing required fields
const invalidProduct = {
name: 'Invalid Product'
// Missing price, sku, etc.
};
const response = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send(invalidProduct)
.expect('Content-Type', /json/)
.expect(400);
expect(response.body).toHaveProperty('error');
});
});
describe('PUT /api/products/:id', () => {
test('admin can update a product', async () => {
const updates = {
name: 'Updated Product Name',
price: 149.99
};
const response = await request(app)
.put(`/api/products/${testProduct.body.id}`)
.set('Authorization', `Bearer ${adminToken}`)
.send(updates)
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.name).toBe(updates.name);
expect(response.body.price).toBe(updates.price);
expect(response.body.description).toBe(testProduct.body.description); // Unchanged
// Verify the update persisted
const getResponse = await request(app)
.get(`/api/products/${testProduct.body.id}`)
.expect(200);
expect(getResponse.body.name).toBe(updates.name);
});
test('regular user cannot update a product', async () => {
await request(app)
.put(`/api/products/${testProduct.body.id}`)
.set('Authorization', `Bearer ${userToken}`)
.send({ price: 0.99 })
.expect(403);
// Verify product was not changed
const getResponse = await request(app)
.get(`/api/products/${testProduct.body.id}`)
.expect(200);
expect(getResponse.body.price).toBe(testProduct.body.price);
});
test('returns 404 for non-existent product', async () => {
await request(app)
.put('/api/products/nonexistentid')
.set('Authorization', `Bearer ${adminToken}`)
.send({ name: 'New Name' })
.expect(404);
});
});
describe('DELETE /api/products/:id', () => {
test('admin can delete a product', async () => {
await request(app)
.delete(`/api/products/${testProduct.body.id}`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(204);
// Verify product was deleted
await request(app)
.get(`/api/products/${testProduct.body.id}`)
.expect(404);
});
test('regular user cannot delete a product', async () => {
await request(app)
.delete(`/api/products/${testProduct.body.id}`)
.set('Authorization', `Bearer ${userToken}`)
.expect(403);
// Verify product still exists
await request(app)
.get(`/api/products/${testProduct.body.id}`)
.expect(200);
});
test('returns 404 for non-existent product', async () => {
await request(app)
.delete('/api/products/nonexistentid')
.set('Authorization', `Bearer ${adminToken}`)
.expect(404);
});
});
});
Testing Authentication and Authorization
// test/api/auth.test.js
const request = require('supertest');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const app = require('../../src/app');
const db = require('../utils/db');
const config = require('../../src/config');
describe('Authentication API', () => {
beforeAll(async () => {
await db.connectDB();
});
afterAll(async () => {
await db.disconnectDB();
});
beforeEach(async () => {
await db.clearDatabase();
});
describe('POST /api/auth/register', () => {
test('registers a new user', async () => {
const userData = {
name: 'Test User',
email: 'test@example.com',
password: 'Password123!'
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect('Content-Type', /json/)
.expect(201);
expect(response.body.user).toBeDefined();
expect(response.body.user.name).toBe(userData.name);
expect(response.body.user.email).toBe(userData.email);
expect(response.body.token).toBeDefined();
// Verify token is valid
const decoded = jwt.verify(response.body.token, config.jwt.secret);
expect(decoded.id).toBe(response.body.user.id);
expect(decoded.email).toBe(userData.email);
// Password should not be returned
expect(response.body.user.password).toBeUndefined();
// Verify user was saved to database
const user = await db.findUserByEmail(userData.email);
expect(user).toBeTruthy();
// Password should be hashed
const passwordMatch = await bcrypt.compare(userData.password, user.password);
expect(passwordMatch).toBe(true);
});
test('prevents duplicate email registration', async () => {
// Create a user first
const userData = {
name: 'Original User',
email: 'duplicate@example.com',
password: 'Password123!'
};
await request(app)
.post('/api/auth/register')
.send(userData);
// Try to register with the same email
const duplicateResponse = await request(app)
.post('/api/auth/register')
.send({
name: 'Duplicate User',
email: 'duplicate@example.com',
password: 'DifferentPass123!'
})
.expect('Content-Type', /json/)
.expect(400);
expect(duplicateResponse.body.error).toBeDefined();
expect(duplicateResponse.body.error).toContain('email already in use');
});
test('validates password requirements', async () => {
const response = await request(app)
.post('/api/auth/register')
.send({
name: 'Test User',
email: 'test@example.com',
password: 'weak'
})
.expect(400);
expect(response.body.error).toBeDefined();
});
});
describe('POST /api/auth/login', () => {
beforeEach(async () => {
// Create a test user
const hashedPassword = await bcrypt.hash('Password123!', 10);
await db.createUser({
name: 'Test User',
email: 'test@example.com',
password: hashedPassword
});
});
test('logs in with valid credentials', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
email: 'test@example.com',
password: 'Password123!'
})
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.user).toBeDefined();
expect(response.body.user.email).toBe('test@example.com');
expect(response.body.token).toBeDefined();
});
test('rejects invalid password', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
email: 'test@example.com',
password: 'WrongPassword123!'
})
.expect(401);
expect(response.body.error).toBeDefined();
expect(response.body.token).toBeUndefined();
});
test('rejects non-existent user', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
email: 'nonexistent@example.com',
password: 'Password123!'
})
.expect(401);
expect(response.body.error).toBeDefined();
});
});
describe('GET /api/auth/me', () => {
let token;
let userId;
beforeEach(async () => {
// Create a test user
const hashedPassword = await bcrypt.hash('Password123!', 10);
const user = await db.createUser({
name: 'Test User',
email: 'test@example.com',
password: hashedPassword
});
userId = user.id;
// Generate a token
token = jwt.sign(
{ id: user.id, email: user.email },
config.jwt.secret,
{ expiresIn: config.jwt.expiresIn }
);
});
test('returns user profile with valid token', async () => {
const response = await request(app)
.get('/api/auth/me')
.set('Authorization', `Bearer ${token}`)
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.id).toBe(userId);
expect(response.body.name).toBe('Test User');
expect(response.body.email).toBe('test@example.com');
expect(response.body.password).toBeUndefined(); // Password should not be returned
});
test('rejects request without token', async () => {
await request(app)
.get('/api/auth/me')
.expect(401);
});
test('rejects request with invalid token', async () => {
await request(app)
.get('/api/auth/me')
.set('Authorization', 'Bearer invalidtoken')
.expect(401);
});
test('rejects request with expired token', async () => {
// Create an expired token
const expiredToken = jwt.sign(
{ id: userId, email: 'test@example.com' },
config.jwt.secret,
{ expiresIn: '0s' }
);
await request(app)
.get('/api/auth/me')
.set('Authorization', `Bearer ${expiredToken}`)
.expect(401);
});
});
});
Testing Complex Workflows
// test/api/order-workflow.test.js
const request = require('supertest');
const app = require('../../src/app');
const db = require('../utils/db');
const { createTestUser } = require('../utils/auth');
describe('Order Workflow', () => {
let userToken;
let userId;
let products = [];
beforeAll(async () => {
await db.connectDB();
});
afterAll(async () => {
await db.disconnectDB();
});
beforeEach(async () => {
await db.clearDatabase();
// Create a test user
const user = await createTestUser();
userId = user.id;
userToken = user.generateToken();
// Create test products
const productData = [
{ name: 'Product 1', price: 10.99, sku: 'SKU001', inventory: 100 },
{ name: 'Product 2', price: 24.99, sku: 'SKU002', inventory: 50 },
{ name: 'Product 3', price: 5.99, sku: 'SKU003', inventory: 0 } // Out of stock
];
const adminToken = (await createTestUser({ role: 'admin' })).generateToken();
products = [];
for (const data of productData) {
const response = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send(data);
products.push(response.body);
}
});
test('complete order flow - add to cart, checkout, verify inventory, view order', async () => {
// Step 1: Add items to cart
const cart = await request(app)
.post('/api/cart')
.set('Authorization', `Bearer ${userToken}`)
.send({
productId: products[0].id,
quantity: 2
})
.expect(200);
expect(cart.body.items).toHaveLength(1);
expect(cart.body.items[0].productId).toBe(products[0].id);
expect(cart.body.items[0].quantity).toBe(2);
// Add another item
await request(app)
.post('/api/cart')
.set('Authorization', `Bearer ${userToken}`)
.send({
productId: products[1].id,
quantity: 1
})
.expect(200);
// Step 2: View cart
const cartResponse = await request(app)
.get('/api/cart')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(cartResponse.body.items).toHaveLength(2);
expect(cartResponse.body.total).toBe((products[0].price * 2) + products[1].price);
// Step 3: Try to add out-of-stock item
const outOfStockResponse = await request(app)
.post('/api/cart')
.set('Authorization', `Bearer ${userToken}`)
.send({
productId: products[2].id, // This is out of stock
quantity: 1
})
.expect(400);
expect(outOfStockResponse.body.error).toContain('out of stock');
// Step 4: Create order from cart
const orderResponse = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${userToken}`)
.send({
shippingAddress: {
street: '123 Test St',
city: 'Test City',
state: 'TS',
zipCode: '12345',
country: 'Test Country'
},
paymentMethod: 'credit_card',
paymentDetails: {
cardNumber: '4111111111111111',
expiryMonth: 12,
expiryYear: 2030,
cvv: '123'
}
})
.expect(201);
expect(orderResponse.body.id).toBeDefined();
expect(orderResponse.body.status).toBe('created');
expect(orderResponse.body.items).toHaveLength(2);
expect(orderResponse.body.total).toBe(cartResponse.body.total);
// Step 5: Verify cart is now empty
const emptyCartResponse = await request(app)
.get('/api/cart')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(emptyCartResponse.body.items).toHaveLength(0);
// Step 6: Verify inventory was updated
const product1Response = await request(app)
.get(`/api/products/${products[0].id}`)
.expect(200);
expect(product1Response.body.inventory).toBe(products[0].inventory - 2);
// Step 7: View order details
const orderId = orderResponse.body.id;
const orderDetailsResponse = await request(app)
.get(`/api/orders/${orderId}`)
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(orderDetailsResponse.body.id).toBe(orderId);
expect(orderDetailsResponse.body.user.id).toBe(userId);
expect(orderDetailsResponse.body.status).toBe('created');
// Step 8: List all user orders
const ordersListResponse = await request(app)
.get('/api/orders')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(ordersListResponse.body).toBeInstanceOf(Array);
expect(ordersListResponse.body.length).toBeGreaterThan(0);
expect(ordersListResponse.body[0].id).toBe(orderId);
});
test('handles payment failure gracefully', async () => {
// Step 1: Add item to cart
await request(app)
.post('/api/cart')
.set('Authorization', `Bearer ${userToken}`)
.send({
productId: products[0].id,
quantity: 1
})
.expect(200);
// Step 2: Create order with invalid payment details
const orderResponse = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${userToken}`)
.send({
shippingAddress: {
street: '123 Test St',
city: 'Test City',
state: 'TS',
zipCode: '12345',
country: 'Test Country'
},
paymentMethod: 'credit_card',
paymentDetails: {
cardNumber: '4111111111111111',
expiryMonth: 1,
expiryYear: 2020, // Expired card
cvv: '123'
}
})
.expect(400);
expect(orderResponse.body.error).toBeDefined();
expect(orderResponse.body.error).toContain('payment');
// Step 3: Verify inventory was not affected
const productResponse = await request(app)
.get(`/api/products/${products[0].id}`)
.expect(200);
expect(productResponse.body.inventory).toBe(products[0].inventory);
// Step 4: Verify cart still contains items
const cartResponse = await request(app)
.get('/api/cart')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(cartResponse.body.items).toHaveLength(1);
});
});
Testing API Response Schemas
Schema validation ensures your API response structure remains consistent and follows your documentation.
Using JSON Schema Validation
// test/schemas/product.schema.js
const productSchema = {
type: 'object',
required: ['id', 'name', 'price', 'sku', 'inventory'],
properties: {
id: { type: 'string' },
name: { type: 'string' },
description: { type: ['string', 'null'] },
price: { type: 'number', minimum: 0 },
sku: { type: 'string' },
category: { type: 'string' },
inventory: { type: 'number', minimum: 0 },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' }
},
additionalProperties: false
};
const productListSchema = {
type: 'array',
items: productSchema
};
module.exports = {
productSchema,
productListSchema
};
// test/api/product-schema.test.js
const request = require('supertest');
const Ajv = require('ajv');
const addFormats = require('ajv-formats');
const app = require('../../src/app');
const db = require('../utils/db');
const { createTestUser } = require('../utils/auth');
const { productSchema, productListSchema } = require('../schemas/product.schema');
describe('Product API Schema Validation', () => {
let adminToken;
let testProduct;
// Set up Ajv (JSON Schema validator)
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
const validateProduct = ajv.compile(productSchema);
const validateProductList = ajv.compile(productListSchema);
beforeAll(async () => {
await db.connectDB();
});
afterAll(async () => {
await db.disconnectDB();
});
beforeEach(async () => {
await db.clearDatabase();
// Create admin user
const admin = await createTestUser({ role: 'admin' });
adminToken = admin.generateToken();
// Create test product
const response = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send({
name: 'Test Product',
description: 'Product for schema testing',
price: 99.99,
sku: 'SCHEMA-TEST',
category: 'test',
inventory: 100
});
testProduct = response.body;
});
test('GET /api/products returns data matching product list schema', async () => {
const response = await request(app)
.get('/api/products')
.expect(200);
const valid = validateProductList(response.body);
if (!valid) {
console.error('Schema validation errors:', ajv.errorsText(validateProductList.errors));
}
expect(valid).toBe(true);
});
test('GET /api/products/:id returns data matching product schema', async () => {
const response = await request(app)
.get(`/api/products/${testProduct.id}`)
.expect(200);
const valid = validateProduct(response.body);
if (!valid) {
console.error('Schema validation errors:', ajv.errorsText(validateProduct.errors));
}
expect(valid).toBe(true);
});
test('POST /api/products returns data matching product schema', async () => {
const newProduct = {
name: 'New Schema Product',
description: 'Testing schema for new products',
price: 49.99,
sku: 'SCHEMA-NEW',
category: 'test',
inventory: 50
};
const response = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send(newProduct)
.expect(201);
const valid = validateProduct(response.body);
if (!valid) {
console.error('Schema validation errors:', ajv.errorsText(validateProduct.errors));
}
expect(valid).toBe(true);
// Verify all sent fields are in the response
expect(response.body.name).toBe(newProduct.name);
expect(response.body.price).toBe(newProduct.price);
expect(response.body.sku).toBe(newProduct.sku);
});
test('PUT /api/products/:id returns data matching product schema', async () => {
const updates = {
name: 'Updated Schema Product',
price: 129.99
};
const response = await request(app)
.put(`/api/products/${testProduct.id}`)
.set('Authorization', `Bearer ${adminToken}`)
.send(updates)
.expect(200);
const valid = validateProduct(response.body);
if (!valid) {
console.error('Schema validation errors:', ajv.errorsText(validateProduct.errors));
}
expect(valid).toBe(true);
// Verify updates were applied
expect(response.body.name).toBe(updates.name);
expect(response.body.price).toBe(updates.price);
// Other fields should remain unchanged
expect(response.body.description).toBe(testProduct.description);
expect(response.body.sku).toBe(testProduct.sku);
});
});
Using OpenAPI/Swagger for Schema Validation
If your API is documented with OpenAPI/Swagger, you can use those definitions for validation:
// test/utils/openapi-validator.js
const OpenAPIResponseValidator = require('openapi-response-validator').default;
const swaggerDocument = require('../../api-docs/swagger.json');
function createResponseValidator(path, method) {
const apiPath = swaggerDocument.paths[path];
if (!apiPath) {
throw new Error(`Path ${path} not found in Swagger document`);
}
const apiMethod = apiPath[method.toLowerCase()];
if (!apiMethod) {
throw new Error(`Method ${method} not found for path ${path} in Swagger document`);
}
return new OpenAPIResponseValidator({
responses: apiMethod.responses,
components: swaggerDocument.components
});
}
function validateResponse(validator, statusCode, response) {
statusCode = statusCode.toString();
const validationError = validator.validateResponse(statusCode, response);
return {
valid: !validationError,
error: validationError
};
}
module.exports = {
createResponseValidator,
validateResponse
};
// test/api/openapi-validation.test.js
const request = require('supertest');
const app = require('../../src/app');
const { createResponseValidator, validateResponse } = require('../utils/openapi-validator');
const db = require('../utils/db');
const { createTestUser } = require('../utils/auth');
describe('OpenAPI Schema Validation', () => {
let productValidator;
let userValidator;
let adminToken;
let testProduct;
beforeAll(async () => {
await db.connectDB();
// Create validators based on OpenAPI spec
productValidator = {
getAll: createResponseValidator('/api/products', 'GET'),
getById: createResponseValidator('/api/products/{id}', 'GET'),
create: createResponseValidator('/api/products', 'POST'),
update: createResponseValidator('/api/products/{id}', 'PUT')
};
userValidator = {
register: createResponseValidator('/api/auth/register', 'POST'),
login: createResponseValidator('/api/auth/login', 'POST'),
getProfile: createResponseValidator('/api/auth/me', 'GET')
};
});
afterAll(async () => {
await db.disconnectDB();
});
beforeEach(async () => {
await db.clearDatabase();
// Create admin user
const admin = await createTestUser({ role: 'admin' });
adminToken = admin.generateToken();
// Create test product
const response = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send({
name: 'OpenAPI Test Product',
description: 'Product for OpenAPI schema testing',
price: 88.99,
sku: 'OPENAPI-TEST',
category: 'test',
inventory: 75
});
testProduct = response.body;
});
describe('Product API OpenAPI Validation', () => {
test('GET /api/products response matches OpenAPI schema', async () => {
const response = await request(app)
.get('/api/products')
.expect(200);
const validation = validateResponse(productValidator.getAll, 200, response.body);
if (!validation.valid) {
console.error('Validation error:', validation.error);
}
expect(validation.valid).toBe(true);
});
test('GET /api/products/:id response matches OpenAPI schema', async () => {
const response = await request(app)
.get(`/api/products/${testProduct.id}`)
.expect(200);
const validation = validateResponse(productValidator.getById, 200, response.body);
expect(validation.valid).toBe(true);
});
test('POST /api/products response matches OpenAPI schema', async () => {
const newProduct = {
name: 'New OpenAPI Product',
description: 'Testing OpenAPI schema for new products',
price: 39.99,
sku: 'OPENAPI-NEW',
category: 'test',
inventory: 25
};
const response = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send(newProduct)
.expect(201);
const validation = validateResponse(productValidator.create, 201, response.body);
expect(validation.valid).toBe(true);
});
test('PUT /api/products/:id response matches OpenAPI schema', async () => {
const updates = {
name: 'Updated OpenAPI Product',
price: 149.99
};
const response = await request(app)
.put(`/api/products/${testProduct.id}`)
.set('Authorization', `Bearer ${adminToken}`)
.send(updates)
.expect(200);
const validation = validateResponse(productValidator.update, 200, response.body);
expect(validation.valid).toBe(true);
});
});
describe('Auth API OpenAPI Validation', () => {
test('POST /api/auth/register response matches OpenAPI schema', async () => {
const userData = {
name: 'OpenAPI Test User',
email: 'openapi-test@example.com',
password: 'Password123!'
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(201);
const validation = validateResponse(userValidator.register, 201, response.body);
expect(validation.valid).toBe(true);
});
test('POST /api/auth/login response matches OpenAPI schema', async () => {
// Create a user first
const userData = {
name: 'Login Test User',
email: 'login-test@example.com',
password: 'Password123!'
};
await request(app)
.post('/api/auth/register')
.send(userData);
// Test login
const response = await request(app)
.post('/api/auth/login')
.send({
email: userData.email,
password: userData.password
})
.expect(200);
const validation = validateResponse(userValidator.login, 200, response.body);
expect(validation.valid).toBe(true);
});
test('GET /api/auth/me response matches OpenAPI schema', async () => {
// Create a user
const user = await createTestUser();
const token = user.generateToken();
const response = await request(app)
.get('/api/auth/me')
.set('Authorization', `Bearer ${token}`)
.expect(200);
const validation = validateResponse(userValidator.getProfile, 200, response.body);
expect(validation.valid).toBe(true);
});
});
});
Testing Error Handling
Robust APIs must handle errors gracefully and return appropriate error responses.
Common Error Scenarios to Test
- Validation Errors: Invalid input data
- Authentication Errors: Missing or invalid tokens
- Authorization Errors: Insufficient permissions
- Resource Not Found: Non-existent records
- Conflict Errors: Duplicate unique fields
- Dependency Errors: External service failures
- Rate Limiting: Too many requests
// test/api/error-handling.test.js
const request = require('supertest');
const nock = require('nock');
const app = require('../../src/app');
const db = require('../utils/db');
const { createTestUser } = require('../utils/auth');
describe('API Error Handling', () => {
let userToken, adminToken;
beforeAll(async () => {
await db.connectDB();
});
afterAll(async () => {
await db.disconnectDB();
nock.cleanAll();
});
beforeEach(async () => {
await db.clearDatabase();
// Create test users
const user = await createTestUser({ role: 'user' });
userToken = user.generateToken();
const admin = await createTestUser({ role: 'admin' });
adminToken = admin.generateToken();
});
describe('Validation Errors', () => {
test('returns 400 for missing required fields', async () => {
const response = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send({
name: 'Incomplete Product'
// Missing required fields
})
.expect(400);
expect(response.body.error).toBeDefined();
expect(response.body.error).toContain('required');
// Should specify which fields are missing
expect(response.body.details).toBeDefined();
});
test('returns 400 for invalid data types', async () => {
const response = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send({
name: 'Invalid Product',
description: 'Invalid data types test',
price: 'not-a-number', // Should be a number
sku: 123, // Should be a string
inventory: 50
})
.expect(400);
expect(response.body.error).toBeDefined();
expect(response.body.details).toBeDefined();
expect(response.body.details).toContain('price');
});
test('returns 400 for values outside allowed ranges', async () => {
const response = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send({
name: 'Invalid Range Product',
description: 'Testing range validation',
price: -10, // Price should be positive
sku: 'RANGE-TEST',
inventory: -5 // Inventory should be positive
})
.expect(400);
expect(response.body.error).toBeDefined();
expect(response.body.details).toBeDefined();
expect(response.body.details).toContain('positive');
});
});
describe('Authentication Errors', () => {
test('returns 401 for missing token', async () => {
await request(app)
.get('/api/auth/me')
.expect(401);
});
test('returns 401 for invalid token format', async () => {
await request(app)
.get('/api/auth/me')
.set('Authorization', 'not-a-valid-token-format')
.expect(401);
});
test('returns 401 for expired token', async () => {
// Create an expired token (would need to mock JWT verification)
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE2MjM5MDIyfQ.signature';
await request(app)
.get('/api/auth/me')
.set('Authorization', `Bearer ${expiredToken}`)
.expect(401);
});
});
describe('Authorization Errors', () => {
test('returns 403 when non-admin tries to create product', async () => {
await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${userToken}`) // Regular user token
.send({
name: 'Unauthorized Product',
description: 'Should not be created',
price: 19.99,
sku: 'UNAUTH-TEST',
inventory: 10
})
.expect(403);
});
test('returns 403 when user tries to access another user\'s data', async () => {
// Create a second user
const anotherUser = await createTestUser();
// Create an order for the second user
const orderResponse = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${anotherUser.generateToken()}`)
.send({
items: [{ productId: 'someProductId', quantity: 1 }],
shippingAddress: { street: '123 Test St', city: 'Test City' }
});
// Try to access it with first user's token
await request(app)
.get(`/api/orders/${orderResponse.body.id}`)
.set('Authorization', `Bearer ${userToken}`)
.expect(403);
});
});
describe('Resource Not Found Errors', () => {
test('returns 404 for non-existent product', async () => {
await request(app)
.get('/api/products/nonexistentid')
.expect(404);
});
test('returns 404 for non-existent user', async () => {
await request(app)
.get('/api/users/nonexistentid')
.set('Authorization', `Bearer ${adminToken}`)
.expect(404);
});
test('returns 404 for non-existent order', async () => {
await request(app)
.get('/api/orders/nonexistentid')
.set('Authorization', `Bearer ${userToken}`)
.expect(404);
});
});
describe('Conflict Errors', () => {
test('returns 409 for duplicate email during registration', async () => {
// Create a user
const userData = {
name: 'Original User',
email: 'conflict-test@example.com',
password: 'Password123!'
};
await request(app)
.post('/api/auth/register')
.send(userData);
// Try to create another user with the same email
const response = await request(app)
.post('/api/auth/register')
.send({
name: 'Duplicate User',
email: 'conflict-test@example.com', // Same email
password: 'AnotherPassword123!'
})
.expect(409);
expect(response.body.error).toBeDefined();
expect(response.body.error).toContain('already exists');
});
test('returns 409 for duplicate SKU when creating product', async () => {
// Create a product
const productData = {
name: 'Original Product',
description: 'Testing conflict errors',
price: 29.99,
sku: 'CONFLICT-TEST',
inventory: 20
};
await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send(productData);
// Try to create another product with the same SKU
const response = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send({
name: 'Duplicate SKU Product',
description: 'Should conflict',
price: 39.99,
sku: 'CONFLICT-TEST', // Same SKU
inventory: 30
})
.expect(409);
expect(response.body.error).toBeDefined();
expect(response.body.error).toContain('SKU already exists');
});
});
describe('Dependency Errors', () => {
test('handles payment service errors gracefully', async () => {
// Mock the payment service to fail
nock('https://api.payment-provider.com')
.post('/v1/charges')
.reply(500, { error: 'Payment service unavailable' });
// Add product to cart
await request(app)
.post('/api/cart')
.set('Authorization', `Bearer ${userToken}`)
.send({
productId: 'someProductId',
quantity: 1
});
// Try to checkout
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${userToken}`)
.send({
shippingAddress: {
street: '123 Test St',
city: 'Test City',
state: 'TS',
zipCode: '12345',
country: 'Test Country'
},
paymentMethod: 'credit_card',
paymentDetails: {
cardNumber: '4111111111111111',
expiryMonth: 12,
expiryYear: 2030,
cvv: '123'
}
})
.expect(500);
expect(response.body.error).toBeDefined();
// Should provide a user-friendly error, not expose internal details
expect(response.body.error).not.toContain('Payment service unavailable');
expect(response.body.error).toContain('payment processing');
});
test('handles database errors gracefully', async () => {
// Simulate a database connection error
const originalFindById = db.findProductById;
db.findProductById = jest.fn().mockRejectedValue(new Error('Database connection lost'));
const response = await request(app)
.get('/api/products/someid')
.expect(500);
expect(response.body.error).toBeDefined();
expect(response.body.error).toContain('server error');
// Should not expose internal error details
expect(response.body.error).not.toContain('Database connection lost');
// Restore the original function
db.findProductById = originalFindById;
});
});
describe('Rate Limiting', () => {
test('returns 429 when rate limit is exceeded', async () => {
// Make many requests in quick succession
const requests = [];
for (let i = 0; i < 20; i++) {
requests.push(
request(app)
.get('/api/products')
.set('X-Forwarded-For', '192.168.1.1') // Same IP address
);
}
const responses = await Promise.all(requests);
// At least one response should be rate limited
const rateLimited = responses.some(response => response.status === 429);
expect(rateLimited).toBe(true);
// Check error response format
const limitedResponse = responses.find(response => response.status === 429);
expect(limitedResponse.body.error).toBeDefined();
expect(limitedResponse.body.error).toContain('too many requests');
expect(limitedResponse.headers['retry-after']).toBeDefined();
});
});
});
Testing Error Response Format Consistency
// test/api/error-format.test.js
const request = require('supertest');
const app = require('../../src/app');
const db = require('../utils/db');
const { createTestUser } = require('../utils/auth');
describe('Error Response Format', () => {
let adminToken;
beforeAll(async () => {
await db.connectDB();
});
afterAll(async () => {
await db.disconnectDB();
});
beforeEach(async () => {
await db.clearDatabase();
// Create admin user
const admin = await createTestUser({ role: 'admin' });
adminToken = admin.generateToken();
});
test('validation errors use consistent format', async () => {
const response = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send({
// Missing required fields
})
.expect(400);
// Check response structure
expect(response.body).toHaveProperty('error');
expect(response.body).toHaveProperty('details');
expect(response.body).toHaveProperty('status', 400);
expect(response.body).toHaveProperty('timestamp');
expect(typeof response.body.error).toBe('string');
expect(Array.isArray(response.body.details)).toBe(true);
});
test('authentication errors use consistent format', async () => {
const response = await request(app)
.get('/api/auth/me')
.expect(401);
// Check response structure
expect(response.body).toHaveProperty('error');
expect(response.body).toHaveProperty('status', 401);
expect(response.body).toHaveProperty('timestamp');
expect(typeof response.body.error).toBe('string');
});
test('not found errors use consistent format', async () => {
const response = await request(app)
.get('/api/products/nonexistentid')
.expect(404);
// Check response structure
expect(response.body).toHaveProperty('error');
expect(response.body).toHaveProperty('status', 404);
expect(response.body).toHaveProperty('timestamp');
expect(typeof response.body.error).toBe('string');
});
test('server errors use consistent format', async () => {
// Simulate server error by making the database throw an error
const originalFindById = db.findProductById;
db.findProductById = jest.fn().mockRejectedValue(new Error('Simulated server error'));
const response = await request(app)
.get('/api/products/someid')
.expect(500);
// Check response structure
expect(response.body).toHaveProperty('error');
expect(response.body).toHaveProperty('status', 500);
expect(response.body).toHaveProperty('timestamp');
expect(typeof response.body.error).toBe('string');
// Restore the original function
db.findProductById = originalFindById;
});
});
Testing Performance and Concurrency
Beyond functional correctness, it's important to test how your API performs under load and handles multiple concurrent requests.
Basic Load Testing with Artillery
// load-tests/artillery-config.yml
config:
target: "http://localhost:3000"
phases:
- duration: 60
arrivalRate: 5
rampTo: 50
name: "Warm up phase"
- duration: 120
arrivalRate: 50
name: "Sustained load phase"
defaults:
headers:
Content-Type: "application/json"
Accept: "application/json"
scenarios:
- name: "Browse products and view details"
flow:
- get:
url: "/api/products"
capture:
- json: "$[0].id"
as: "productId"
- get:
url: "/api/products/{{ productId }}"
- think: 3
- get:
url: "/api/products?category=electronics"
- think: 2
- name: "User authentication flow"
weight: 2
flow:
- post:
url: "/api/auth/register"
json:
name: "Load User {{ $randomString(10) }}"
email: "load-test-{{ $randomString(8) }}@example.com"
password: "Password123!"
capture:
- json: "token"
as: "authToken"
- think: 1
- get:
url: "/api/auth/me"
headers:
Authorization: "Bearer {{ authToken }}"
- think: 2
Running this with Artillery provides insights into your API's performance under load.
Testing Race Conditions
// test/api/concurrency.test.js
const request = require('supertest');
const app = require('../../src/app');
const db = require('../utils/db');
const { createTestUser } = require('../utils/auth');
describe('Concurrency Tests', () => {
let userToken, productId;
beforeAll(async () => {
await db.connectDB();
});
afterAll(async () => {
await db.disconnectDB();
});
beforeEach(async () => {
await db.clearDatabase();
// Create test user
const user = await createTestUser();
userToken = user.generateToken();
// Create test product with limited inventory
const adminToken = (await createTestUser({ role: 'admin' })).generateToken();
const productResponse = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send({
name: 'Limited Product',
description: 'Only 5 available',
price: 99.99,
sku: 'LIMITED-SKU',
inventory: 5
});
productId = productResponse.body.id;
});
test('handles concurrent inventory updates correctly', async () => {
// Create multiple concurrent requests to add product to cart
const requests = [];
for (let i = 0; i < 10; i++) {
requests.push(
request(app)
.post('/api/cart')
.set('Authorization', `Bearer ${userToken}`)
.send({
productId,
quantity: 1
})
);
}
// Execute all requests concurrently
const responses = await Promise.all(requests);
// Check responses
const successResponses = responses.filter(r => r.status === 200);
const errorResponses = responses.filter(r => r.status === 400);
// Should have 5 successful requests (inventory = 5) and 5 failures
expect(successResponses.length).toBe(5);
expect(errorResponses.length).toBe(5);
// All error responses should mention inventory
for (const response of errorResponses) {
expect(response.body.error).toContain('inventory');
}
// Verify final inventory is 0
const productResponse = await request(app)
.get(`/api/products/${productId}`)
.expect(200);
expect(productResponse.body.inventory).toBe(0);
});
test('handles concurrent order creation correctly', async () => {
// Add product to cart
await request(app)
.post('/api/cart')
.set('Authorization', `Bearer ${userToken}`)
.send({
productId,
quantity: 3
});
// Create multiple concurrent requests to create orders from the same cart
const orderRequests = [];
for (let i = 0; i < 3; i++) {
orderRequests.push(
request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${userToken}`)
.send({
shippingAddress: {
street: '123 Test St',
city: 'Test City',
state: 'TS',
zipCode: '12345',
country: 'Test Country'
},
paymentMethod: 'credit_card',
paymentDetails: {
cardNumber: '4111111111111111',
expiryMonth: 12,
expiryYear: 2030,
cvv: '123'
}
})
);
}
// Execute all requests concurrently
const responses = await Promise.all(orderRequests);
// Should have only one successful order
const successResponses = responses.filter(r => r.status === 201);
expect(successResponses.length).toBe(1);
// Verify cart is now empty
const cartResponse = await request(app)
.get('/api/cart')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(cartResponse.body.items).toHaveLength(0);
// Verify only one order was created
const ordersResponse = await request(app)
.get('/api/orders')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(ordersResponse.body).toHaveLength(1);
});
});
Functional Testing for API Workflows
Functional tests go beyond testing individual endpoints to verify that complete business workflows work correctly.
// test/functional/checkout-workflow.test.js
const request = require('supertest');
const app = require('../../src/app');
const db = require('../utils/db');
const { createTestUser } = require('../utils/auth');
describe('Checkout Workflow', () => {
let userToken, productIds = [];
beforeAll(async () => {
await db.connectDB();
});
afterAll(async () => {
await db.disconnectDB();
});
beforeEach(async () => {
await db.clearDatabase();
// Create test user
const user = await createTestUser();
userToken = user.generateToken();
// Create test products
const adminToken = (await createTestUser({ role: 'admin' })).generateToken();
const products = [
{
name: 'Product 1',
description: 'First test product',
price: 29.99,
sku: 'FUNC-TEST-1',
inventory: 100
},
{
name: 'Product 2',
description: 'Second test product',
price: 49.99,
sku: 'FUNC-TEST-2',
inventory: 50
}
];
productIds = [];
for (const product of products) {
const response = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send(product);
productIds.push(response.body.id);
}
});
test('complete checkout workflow from cart to order confirmation', async () => {
// Step 1: Add products to cart
await request(app)
.post('/api/cart')
.set('Authorization', `Bearer ${userToken}`)
.send({
productId: productIds[0],
quantity: 2
})
.expect(200);
await request(app)
.post('/api/cart')
.set('Authorization', `Bearer ${userToken}`)
.send({
productId: productIds[1],
quantity: 1
})
.expect(200);
// Step 2: View cart and calculate expected total
const cartResponse = await request(app)
.get('/api/cart')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(cartResponse.body.items).toHaveLength(2);
const expectedTotal = (29.99 * 2) + 49.99;
expect(cartResponse.body.total).toBeCloseTo(expectedTotal);
// Step 3: Create a shipping address
const addressResponse = await request(app)
.post('/api/shipping-addresses')
.set('Authorization', `Bearer ${userToken}`)
.send({
fullName: 'Test User',
street: '123 Test Street',
city: 'Test City',
state: 'TS',
zipCode: '12345',
country: 'Test Country',
isDefault: true
})
.expect(201);
const addressId = addressResponse.body.id;
// Step 4: Get shipping methods
const shippingMethodsResponse = await request(app)
.get('/api/shipping-methods')
.expect(200);
expect(shippingMethodsResponse.body).toBeInstanceOf(Array);
expect(shippingMethodsResponse.body.length).toBeGreaterThan(0);
const shippingMethod = shippingMethodsResponse.body[0];
// Step 5: Create order
const orderResponse = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${userToken}`)
.send({
shippingAddressId: addressId,
shippingMethodId: shippingMethod.id,
paymentMethod: 'credit_card',
paymentDetails: {
cardNumber: '4111111111111111',
expiryMonth: 12,
expiryYear: 2030,
cvv: '123'
}
})
.expect(201);
const orderId = orderResponse.body.id;
expect(orderResponse.body.status).toBe('created');
// Step 6: Verify cart is empty after order creation
const emptyCartResponse = await request(app)
.get('/api/cart')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(emptyCartResponse.body.items).toHaveLength(0);
// Step 7: Verify order details
const orderDetailsResponse = await request(app)
.get(`/api/orders/${orderId}`)
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(orderDetailsResponse.body.items).toHaveLength(2);
expect(orderDetailsResponse.body.subtotal).toBeCloseTo(expectedTotal);
expect(orderDetailsResponse.body.shippingAddress.id).toBe(addressId);
expect(orderDetailsResponse.body.shippingMethod.id).toBe(shippingMethod.id);
// Step 8: Simulate order payment confirmation (webhook callback)
await request(app)
.post('/api/webhooks/payment')
.send({
orderId,
status: 'success',
transactionId: 'test-transaction-123',
amount: orderDetailsResponse.body.total
})
.expect(200);
// Step 9: Verify order status is updated
const updatedOrderResponse = await request(app)
.get(`/api/orders/${orderId}`)
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(updatedOrderResponse.body.status).toBe('paid');
expect(updatedOrderResponse.body.paymentDetails.transactionId).toBe('test-transaction-123');
// Step 10: Verify product inventory was updated
const product1Response = await request(app)
.get(`/api/products/${productIds[0]}`)
.expect(200);
expect(product1Response.body.inventory).toBe(98); // 100 - 2
const product2Response = await request(app)
.get(`/api/products/${productIds[1]}`)
.expect(200);
expect(product2Response.body.inventory).toBe(49); // 50 - 1
});
test('handles cart abandonment and restoration', async () => {
// Step 1: Add products to cart
await request(app)
.post('/api/cart')
.set('Authorization', `Bearer ${userToken}`)
.send({
productId: productIds[0],
quantity: 3
})
.expect(200);
// Step 2: Get cart contents
const cartResponse = await request(app)
.get('/api/cart')
.set('Authorization', `Bearer ${userToken}`)
.expect(200);
expect(cartResponse.body.items).toHaveLength(1);
// Step 3: Simulate user logging out and back in (new token)
const user = await db.findUserByToken(userToken);
const newToken = user.generateToken();
// Step 4: Verify cart is persisted across sessions
const persistedCartResponse = await request(app)
.get('/api/cart')
.set('Authorization', `Bearer ${newToken}`)
.expect(200);
expect(persistedCartResponse.body.items).toHaveLength(1);
expect(persistedCartResponse.body.items[0].productId).toBe(productIds[0]);
expect(persistedCartResponse.body.items[0].quantity).toBe(3);
// Step 5: Update cart quantity
await request(app)
.put('/api/cart/items')
.set('Authorization', `Bearer ${newToken}`)
.send({
productId: productIds[0],
quantity: 5
})
.expect(200);
// Step 6: Verify quantity was updated
const updatedCartResponse = await request(app)
.get('/api/cart')
.set('Authorization', `Bearer ${newToken}`)
.expect(200);
expect(updatedCartResponse.body.items[0].quantity).toBe(5);
// Step 7: Remove item from cart
await request(app)
.delete(`/api/cart/items/${productIds[0]}`)
.set('Authorization', `Bearer ${newToken}`)
.expect(200);
// Step 8: Verify cart is empty
const emptyCartResponse = await request(app)
.get('/api/cart')
.set('Authorization', `Bearer ${newToken}`)
.expect(200);
expect(emptyCartResponse.body.items).toHaveLength(0);
});
});
API Testing CI/CD Integration
GitHub Actions Configuration for API Tests
// .github/workflows/api-tests.yml
name: API Tests
on:
push:
branches: [ main, develop ]
paths:
- 'src/**'
- 'test/**'
- 'package.json'
- 'package-lock.json'
pull_request:
branches: [ main, develop ]
paths:
- 'src/**'
- 'test/**'
- 'package.json'
- 'package-lock.json'
jobs:
test:
runs-on: ubuntu-latest
services:
mongodb:
image: mongo:4.4
ports:
- 27017:27017
strategy:
matrix:
node-version: [14.x, 16.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
- name: Run unit tests
run: npm run test:unit
- name: Run API integration tests
run: npm run test:api
env:
TEST_DB_URL: mongodb://localhost:27017/test-db
JWT_SECRET: test-secret-key
- name: Generate test report
if: always()
run: npm run test:report
- name: Upload test results
if: always()
uses: actions/upload-artifact@v2
with:
name: test-results
path: |
test-results/
coverage/
junit.xml
functional-tests:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop')
services:
mongodb:
image: mongo:4.4
ports:
- 27017:27017
steps:
- uses: actions/checkout@v2
- name: Use Node.js 16.x
uses: actions/setup-node@v2
with:
node-version: 16.x
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Start API server
run: npm run start:test &
env:
PORT: 3000
NODE_ENV: test
TEST_DB_URL: mongodb://localhost:27017/test-db
JWT_SECRET: test-secret-key
- name: Wait for server to start
run: |
echo "Waiting for server to start..."
timeout 60 bash -c 'until curl -s http://localhost:3000/api/health; do sleep 1; done'
echo "Server started!"
- name: Run functional tests
run: npm run test:functional
env:
TEST_DB_URL: mongodb://localhost:27017/test-db
JWT_SECRET: test-secret-key
- name: Generate functional test report
if: always()
run: npm run test:functional:report
- name: Upload functional test results
if: always()
uses: actions/upload-artifact@v2
with:
name: functional-test-results
path: functional-test-results/
performance-tests:
needs: functional-tests
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
services:
mongodb:
image: mongo:4.4
ports:
- 27017:27017
steps:
- uses: actions/checkout@v2
- name: Use Node.js 16.x
uses: actions/setup-node@v2
with:
node-version: 16.x
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Start API server
run: npm run start:prod &
env:
PORT: 3000
NODE_ENV: production
DB_URL: mongodb://localhost:27017/test-db
JWT_SECRET: test-secret-key
- name: Wait for server to start
run: |
echo "Waiting for server to start..."
timeout 60 bash -c 'until curl -s http://localhost:3000/api/health; do sleep 1; done'
echo "Server started!"
- name: Install Artillery
run: npm install -g artillery
- name: Generate test data
run: node ./scripts/generate-test-data.js
- name: Run performance tests
run: artillery run ./load-tests/artillery-config.yml -o load-test-results.json
- name: Generate performance test report
run: artillery report load-test-results.json -o load-test-report.html
- name: Upload performance test results
uses: actions/upload-artifact@v2
with:
name: performance-test-results
path: |
load-test-results.json
load-test-report.html
Best Practices for API Testing
Best Practices] --> B[Isolate Tests] A --> C[Test at Multiple Levels] A --> D[Use Realistic Data] A --> E[Verify Side Effects] A --> F[Test Error Cases] A --> G[Check Response Headers] A --> H[Test Relationships] style A fill:#f5f5f5,stroke:#333333 style B fill:#d1e7dd,stroke:#0f5132 style C fill:#d1e7dd,stroke:#0f5132 style D fill:#d1e7dd,stroke:#0f5132 style E fill:#d1e7dd,stroke:#0f5132 style F fill:#d1e7dd,stroke:#0f5132 style G fill:#d1e7dd,stroke:#0f5132 style H fill:#d1e7dd,stroke:#0f5132
Isolate Tests
- Each test should be independent and not rely on the state from other tests
- Reset the database or use transactions before each test
- Create fresh test data within each test
Test at Multiple Levels
- Unit test the logic in route handlers and controllers
- Integration test API endpoints with dependencies
- Functional test complete workflows
- Performance test under load
Use Realistic Data
- Test with data that resembles real-world usage
- Include edge cases and boundary conditions
- Test with different quantities (empty, single, multiple)
Verify Side Effects
- Check that database changes were made correctly
- Verify that external services were called as expected
- Ensure events or messages were emitted
Test Error Cases
- Test invalid inputs and bad requests
- Test unauthorized and forbidden access
- Test what happens when dependencies fail
Check Response Headers
- Verify content types are correct
- Check for caching headers when appropriate
- Test pagination headers for list endpoints
Test Relationships
- Verify that parent-child relationships work
- Test that related resources can be created and retrieved
- Check that cascading operations work (e.g., deletions)
Practical Test Organization
A well-organized test suite might look like:
test/ ├── unit/ │ ├── controllers/ │ ├── services/ │ └── utils/ ├── integration/ │ ├── api/ │ └── db/ ├── functional/ │ ├── workflows/ │ └── scenarios/ ├── schemas/ ├── fixtures/ └── utils/
Practice Activities
Activity 1: Test a CRUD API
Create a simple API for managing blog posts with the following endpoints:
- GET /api/posts - Get all posts
- GET /api/posts/:id - Get a post by ID
- POST /api/posts - Create a new post
- PUT /api/posts/:id - Update a post
- DELETE /api/posts/:id - Delete a post
Then write comprehensive tests for all endpoints, including:
- Happy path tests for each endpoint
- Error case tests (validation errors, not found, etc.)
- Schema validation tests
- Authentication tests (if implemented)
Activity 2: Test a Multi-step API Workflow
Create and test a multi-step API workflow such as:
- User registration and profile management
- Shopping cart and checkout process
- Task creation and assignment workflow
Your tests should:
- Follow users through the entire workflow
- Verify state changes at each step
- Test error scenarios and recovery
- Test concurrent operations (if applicable)
Activity 3: API Performance Testing
Set up performance tests for an existing API:
- Use Artillery to create a load test configuration
- Design realistic user scenarios and flows
- Run tests with different concurrency levels
- Analyze results and identify bottlenecks
- Implement at least one optimization based on test results
Compare performance before and after your optimization.