Testing & TDD

API Testing

40 min Lesson 15 of 35

Introduction to API Testing

API (Application Programming Interface) testing validates the functionality, reliability, performance, and security of APIs. Unlike UI testing, API testing focuses on the business logic layer and data responses, making it faster and more reliable for testing backend services.

Why API Testing?

  • Speed: Faster than UI tests, no browser overhead
  • Reliability: Less flaky than UI tests, no visual element dependencies
  • Early detection: Test backend logic before UI is ready
  • Coverage: Test edge cases and error scenarios easily
  • Integration testing: Verify service-to-service communication
  • Performance testing: Measure response times and throughput

Types of API Testing

  • Functional testing: Verify endpoints return correct data
  • Integration testing: Test interactions between multiple APIs
  • Load testing: Test performance under high traffic
  • Security testing: Validate authentication and authorization
  • Contract testing: Ensure API contracts are honored
  • Error handling: Test error responses and edge cases

Manual API Testing with Postman

Postman is a popular API testing tool with a user-friendly interface:

Basic Request

# Example: Test GET endpoint GET https://api.example.com/users # Headers Authorization: Bearer <token> Content-Type: application/json # Response (200 OK) { "users": [ { "id": 1, "name": "John Doe", "email": "john@example.com" } ], "total": 1 }

POST Request

# Create new user POST https://api.example.com/users # Body (JSON) { "name": "Jane Smith", "email": "jane@example.com", "password": "securepass123" } # Response (201 Created) { "id": 2, "name": "Jane Smith", "email": "jane@example.com", "created_at": "2026-02-14T10:30:00Z" }

Postman Tests

Add test scripts in Postman's Tests tab:

// Test status code pm.test("Status code is 200", function () { pm.response.to.have.status(200); }); // Test response time pm.test("Response time is less than 500ms", function () { pm.expect(pm.response.responseTime).to.be.below(500); }); // Test response body pm.test("Response has users array", function () { const jsonData = pm.response.json(); pm.expect(jsonData).to.have.property('users'); pm.expect(jsonData.users).to.be.an('array'); }); // Test specific data pm.test("First user has correct structure", function () { const jsonData = pm.response.json(); const firstUser = jsonData.users[0]; pm.expect(firstUser).to.have.property('id'); pm.expect(firstUser).to.have.property('name'); pm.expect(firstUser).to.have.property('email'); }); // Save data for next request pm.environment.set("userId", pm.response.json().users[0].id);
Tip: Use Postman Collections to organize related API requests and share them with your team. Collections can be exported and version-controlled.

Automated API Testing with Jest/Vitest

Automate API testing using JavaScript testing frameworks:

Setup

# Install dependencies npm install --save-dev jest node-fetch # Or use axios npm install --save-dev jest axios

Basic API Test

// api.test.js const axios = require('axios'); const BASE_URL = 'https://api.example.com'; describe('User API', () => { let authToken; beforeAll(async () => { // Login and get auth token const response = await axios.post(`${BASE_URL}/auth/login`, { email: 'test@example.com', password: 'testpass123' }); authToken = response.data.token; }); test('GET /users returns list of users', async () => { const response = await axios.get(`${BASE_URL}/users`, { headers: { Authorization: `Bearer ${authToken}` } }); expect(response.status).toBe(200); expect(response.data).toHaveProperty('users'); expect(Array.isArray(response.data.users)).toBe(true); }); test('GET /users/:id returns single user', async () => { const userId = 1; const response = await axios.get(`${BASE_URL}/users/${userId}`, { headers: { Authorization: `Bearer ${authToken}` } }); expect(response.status).toBe(200); expect(response.data).toHaveProperty('id', userId); expect(response.data).toHaveProperty('name'); expect(response.data).toHaveProperty('email'); }); test('POST /users creates new user', async () => { const newUser = { name: 'Test User', email: 'newuser@example.com', password: 'password123' }; const response = await axios.post(`${BASE_URL}/users`, newUser, { headers: { Authorization: `Bearer ${authToken}` } }); expect(response.status).toBe(201); expect(response.data).toHaveProperty('id'); expect(response.data.name).toBe(newUser.name); expect(response.data.email).toBe(newUser.email); expect(response.data).not.toHaveProperty('password'); // Password shouldn't be returned }); test('PUT /users/:id updates user', async () => { const userId = 2; const updates = { name: 'Updated Name' }; const response = await axios.put(`${BASE_URL}/users/${userId}`, updates, { headers: { Authorization: `Bearer ${authToken}` } }); expect(response.status).toBe(200); expect(response.data.id).toBe(userId); expect(response.data.name).toBe(updates.name); }); test('DELETE /users/:id deletes user', async () => { const userId = 2; const response = await axios.delete(`${BASE_URL}/users/${userId}`, { headers: { Authorization: `Bearer ${authToken}` } }); expect(response.status).toBe(204); // Verify user is deleted try { await axios.get(`${BASE_URL}/users/${userId}`, { headers: { Authorization: `Bearer ${authToken}` } }); fail('Expected 404 error'); } catch (error) { expect(error.response.status).toBe(404); } }); });

Testing Error Responses

Comprehensive error handling tests:

describe('Error Handling', () => { test('returns 400 for invalid data', async () => { try { await axios.post(`${BASE_URL}/users`, { name: '', // Invalid: empty name email: 'invalid-email' // Invalid format }, { headers: { Authorization: `Bearer ${authToken}` } }); fail('Expected validation error'); } catch (error) { expect(error.response.status).toBe(400); expect(error.response.data).toHaveProperty('errors'); expect(error.response.data.errors).toContainEqual( expect.objectContaining({ field: 'name' }) ); expect(error.response.data.errors).toContainEqual( expect.objectContaining({ field: 'email' }) ); } }); test('returns 401 for unauthorized requests', async () => { try { await axios.get(`${BASE_URL}/users`); // No auth token fail('Expected unauthorized error'); } catch (error) { expect(error.response.status).toBe(401); expect(error.response.data).toHaveProperty('message'); } }); test('returns 404 for non-existent resource', async () => { try { await axios.get(`${BASE_URL}/users/99999`, { headers: { Authorization: `Bearer ${authToken}` } }); fail('Expected not found error'); } catch (error) { expect(error.response.status).toBe(404); } }); test('returns 429 for rate limiting', async () => { // Make many rapid requests const requests = Array.from({ length: 100 }, (_, i) => axios.get(`${BASE_URL}/users`, { headers: { Authorization: `Bearer ${authToken}` } }).catch(err => err.response) ); const responses = await Promise.all(requests); // At least one should be rate limited const rateLimited = responses.some(r => r.status === 429); expect(rateLimited).toBe(true); }); });

Testing with SuperTest (Express Apps)

SuperTest is ideal for testing Node.js/Express APIs:

// Install npm install --save-dev supertest // api.test.js const request = require('supertest'); const app = require('../app'); // Your Express app describe('User API', () => { test('GET /users returns 200', async () => { const response = await request(app) .get('/api/users') .set('Authorization', 'Bearer token') .expect(200) .expect('Content-Type', /json/); expect(response.body).toHaveProperty('users'); expect(Array.isArray(response.body.users)).toBe(true); }); test('POST /users creates user', async () => { const newUser = { name: 'Test User', email: 'test@example.com' }; const response = await request(app) .post('/api/users') .send(newUser) .set('Authorization', 'Bearer token') .expect(201) .expect('Content-Type', /json/); expect(response.body).toMatchObject(newUser); expect(response.body).toHaveProperty('id'); }); test('POST /users validates required fields', async () => { await request(app) .post('/api/users') .send({ name: 'Test' }) // Missing email .set('Authorization', 'Bearer token') .expect(400); }); });

Mocking External APIs

Use mocking libraries to test without real API calls:

// Install npm install --save-dev nock // Using Nock to mock HTTP requests const nock = require('nock'); describe('External API Integration', () => { afterEach(() => { nock.cleanAll(); // Clear all mocks }); test('fetches data from external API', async () => { // Mock external API nock('https://external-api.com') .get('/data') .reply(200, { result: 'success', data: [1, 2, 3] }); const response = await axios.get('https://external-api.com/data'); expect(response.status).toBe(200); expect(response.data.result).toBe('success'); }); test('handles external API errors', async () => { // Mock error response nock('https://external-api.com') .get('/data') .reply(500, { error: 'Internal Server Error' }); try { await axios.get('https://external-api.com/data'); fail('Expected error'); } catch (error) { expect(error.response.status).toBe(500); } }); test('handles network timeout', async () => { nock('https://external-api.com') .get('/data') .delayConnection(10000) // 10 second delay .reply(200); try { await axios.get('https://external-api.com/data', { timeout: 1000 // 1 second timeout }); fail('Expected timeout error'); } catch (error) { expect(error.code).toBe('ECONNABORTED'); } }); });
Note: Mocking external APIs makes tests faster, more reliable, and prevents hitting rate limits during testing.

Contract Testing with Pact

Contract testing ensures API providers and consumers agree on the interface:

// Install Pact npm install --save-dev @pact-foundation/pact // Consumer test (frontend) const { Pact } = require('@pact-foundation/pact'); const path = require('path'); const provider = new Pact({ consumer: 'FrontendApp', provider: 'UserAPI', port: 8080, log: path.resolve(process.cwd(), 'logs', 'pact.log'), dir: path.resolve(process.cwd(), 'pacts') }); describe('User API Contract', () => { beforeAll(() => provider.setup()); afterAll(() => provider.finalize()); test('get user by ID', async () => { // Define expected interaction await provider.addInteraction({ state: 'user with ID 1 exists', uponReceiving: 'a request for user 1', withRequest: { method: 'GET', path: '/users/1', headers: { Accept: 'application/json' } }, willRespondWith: { status: 200, headers: { 'Content-Type': 'application/json' }, body: { id: 1, name: 'John Doe', email: 'john@example.com' } } }); // Make actual request const response = await axios.get('http://localhost:8080/users/1', { headers: { Accept: 'application/json' } }); // Verify response matches contract expect(response.data).toMatchObject({ id: 1, name: 'John Doe', email: 'john@example.com' }); await provider.verify(); }); });

Performance Testing

Measure API performance and identify bottlenecks:

describe('Performance Tests', () => { test('response time is under 200ms', async () => { const start = Date.now(); await axios.get(`${BASE_URL}/users`, { headers: { Authorization: `Bearer ${authToken}` } }); const duration = Date.now() - start; expect(duration).toBeLessThan(200); }); test('handles concurrent requests', async () => { const requests = Array.from({ length: 50 }, () => axios.get(`${BASE_URL}/users`, { headers: { Authorization: `Bearer ${authToken}` } }) ); const start = Date.now(); const responses = await Promise.all(requests); const duration = Date.now() - start; // All requests should succeed responses.forEach(response => { expect(response.status).toBe(200); }); // Average response time should be reasonable const avgTime = duration / requests.length; expect(avgTime).toBeLessThan(500); }); });

Security Testing

Test authentication, authorization, and input validation:

describe('Security Tests', () => { test('requires authentication', async () => { try { await axios.get(`${BASE_URL}/users`); fail('Expected authentication error'); } catch (error) { expect(error.response.status).toBe(401); } }); test('validates JWT token', async () => { try { await axios.get(`${BASE_URL}/users`, { headers: { Authorization: 'Bearer invalid-token' } }); fail('Expected invalid token error'); } catch (error) { expect(error.response.status).toBe(401); } }); test('prevents SQL injection', async () => { const maliciousInput = "1' OR '1'='1"; try { const response = await axios.get(`${BASE_URL}/users/${maliciousInput}`, { headers: { Authorization: `Bearer ${authToken}` } }); // Should return 404, not all users expect(response.status).toBe(404); } catch (error) { expect(error.response.status).toBe(404); } }); test('sanitizes user input', async () => { const xssPayload = '<script>alert("XSS")</script>'; const response = await axios.post(`${BASE_URL}/users`, { name: xssPayload, email: 'test@example.com', password: 'password123' }, { headers: { Authorization: `Bearer ${authToken}` } }); // XSS payload should be sanitized/escaped expect(response.data.name).not.toContain('<script>'); }); test('enforces rate limiting', async () => { const requests = Array.from({ length: 100 }, () => axios.get(`${BASE_URL}/users`, { headers: { Authorization: `Bearer ${authToken}` } }).catch(err => err.response) ); const responses = await Promise.all(requests); const rateLimited = responses.filter(r => r.status === 429); expect(rateLimited.length).toBeGreaterThan(0); }); });
Exercise:

Create a comprehensive API test suite for a blog REST API:

  1. Test authentication endpoints (login, logout, refresh token)
  2. Test blog post CRUD operations (create, read, update, delete)
  3. Test comments API (create, read, update, delete comments)
  4. Test pagination and filtering (query parameters)
  5. Test authorization (users can only edit their own posts)
  6. Test validation (required fields, email format, etc.)
  7. Test error responses (400, 401, 403, 404, 500)
  8. Test file upload (blog post images)
  9. Measure response times for all endpoints
  10. Mock external services (image processing API)

Use proper test structure with setup/teardown, create test data fixtures, and organize tests by resource.

Best Practices

  • Test happy paths and edge cases: Cover success scenarios and error conditions
  • Use proper HTTP status codes: Verify correct status codes are returned
  • Test authentication/authorization: Verify security controls work correctly
  • Mock external dependencies: Don't rely on external services in tests
  • Test data validation: Ensure input validation works properly
  • Clean up test data: Delete created resources after tests
  • Use environment variables: Configure API URLs and credentials externally
  • Organize tests by resource: Group related endpoint tests together
Common Mistakes:
  • Testing only happy paths, ignoring error scenarios
  • Not cleaning up test data (database pollution)
  • Hardcoding API URLs and credentials in tests
  • Not testing edge cases (empty arrays, null values, etc.)
  • Skipping authentication/authorization tests
  • Not validating response structure and data types
  • Creating dependent tests that must run in specific order

Summary

API testing is crucial for ensuring reliable backend services. Use manual tools like Postman for exploration and automated tests with Jest/SuperTest for continuous integration. Combine functional testing with security, performance, and contract testing for comprehensive coverage.

This concludes our Testing & TDD tutorial series. You now have the knowledge to write unit tests, integration tests, component tests, E2E tests, and API tests for modern web applications.