Back to Blog

API Testing Best Practices: 10 Rules That Prevent Production Outages

Comprehensive guide to API testing covering REST, GraphQL, authentication, error handling, contract testing, and performance. Learn to build robust API test suites that catch bugs before they reach production.

Published

12 min read

Reading time

API Testing Best Practices: 10 Rules That Prevent Production Outages

While UI testing gets most of the attention, API testing is often more efficient and catches bugs earlier in the development cycle. A robust API test suite provides fast feedback, validates business logic directly, and serves as living documentation. This comprehensive guide covers everything from basic REST testing to advanced GraphQL and contract testing strategies.

Why API Testing Matters

graph LR
    A[Unit Tests<br/>10ms] --> B[API Tests<br/>100ms]
    B --> C[Integration Tests<br/>1s]
    C --> D[E2E Tests<br/>10s]

    style A fill:#6bcf7f
    style B fill:#95e1d3
    style C fill:#ffd93d
    style D fill:#ff9a76

The Testing Pyramid for Modern Apps:

Test Level Speed Coverage Maintenance Typical Count
Unit Tests Fastest Code logic Low 1000+
API Tests Fast Business logic Low 200-500
Integration Tests Medium System integration Medium 50-100
E2E Tests Slow User workflows High 20-50

Benefits of API Testing:

  • Faster execution than UI tests
  • More stable (no UI flakiness)
  • Tests business logic directly
  • Early bug detection
  • Serves as API documentation
  • Easier to maintain

Getting Started with Playwright API Testing

Playwright isn't just for browser testing—it has powerful built-in API testing capabilities.

Basic REST API Testing

// tests/api/users.spec.ts
import { test, expect } from '@playwright/test';

test.describe('User API', () => {
  let apiContext;

  test.beforeAll(async ({ playwright }) => {
    // Create API request context
    apiContext = await playwright.request.newContext({
      baseURL: 'https://api.example.com',
      extraHTTPHeaders: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
    });
  });

  test.afterAll(async () => {
    await apiContext.dispose();
  });

  test('GET /users returns user list', async () => {
    const response = await apiContext.get('/users');

    expect(response.ok()).toBeTruthy();
    expect(response.status()).toBe(200);

    const users = await response.json();
    expect(Array.isArray(users)).toBe(true);
    expect(users.length).toBeGreaterThan(0);

    // Validate user structure
    expect(users[0]).toHaveProperty('id');
    expect(users[0]).toHaveProperty('email');
    expect(users[0]).toHaveProperty('name');
  });

  test('POST /users creates a new user', async () => {
    const newUser = {
      email: `test-${Date.now()}@example.com`,
      name: 'Test User',
      password: 'SecurePass123!',
    };

    const response = await apiContext.post('/users', {
      data: newUser,
    });

    expect(response.status()).toBe(201);

    const createdUser = awaitresponse.json();
    expect(createdUser.id).toBeDefined();
    expect(createdUser.email).toBe(newUser.email);
    expect(createdUser.name).toBe(newUser.name);
    expect(createdUser.password).toBeUndefined(); // Should not return password
  });

  test('PUT /users/:id updates user', async () => {
    // First, create a user
    const createResponse = await apiContext.post('/users', {
      data: {
        email: `update-${Date.now()}@example.com`,
        name: 'Original Name',
        password: 'Pass123!',
      },
    });

    const user = await createResponse.json();

    // Update the user
    const updateResponse = await apiContext.put(`/users/${user.id}`, {
      data: {
        name: 'Updated Name',
      },
    });

    expect(updateResponse.status()).toBe(200);

    const updatedUser = await updateResponse.json();
    expect(updatedUser.name).toBe('Updated Name');
    expect(updatedUser.email).toBe(user.email); // Unchanged
  });

  test('DELETE /users/:id removes user', async () => {
    // Create a user to delete
    const createResponse = await apiContext.post('/users', {
      data: {
        email: `delete-${Date.now()}@example.com`,
        name: 'To Delete',
        password: 'Pass123!',
      },
    });

    const user = await createResponse.json();

    // Delete the user
    const deleteResponse = await apiContext.delete(`/users/${user.id}`);
    expect(deleteResponse.status()).toBe(204);

    // Verify deletion
    const getResponse = await apiContext.get(`/users/${user.id}`);
    expect(getResponse.status()).toBe(404);
  });
});

Authentication and Authorization Testing

// tests/api/auth.spec.ts

test.describe('Authentication', () => {
  let apiContext;
  let authToken: string;

  test.beforeAll(async ({ playwright }) => {
    apiContext = await playwright.request.newContext({
      baseURL: process.env.API_BASE_URL!,
    });

    // Obtain auth token
    const loginResponse = await apiContext.post('/auth/login', {
      data: {
        email: 'test@example.com',
        password: 'TestPassword123!',
      },
    });

    const loginData = await loginResponse.json();
    authToken = loginData.token;
  });

  test('protected endpoint requires authentication', async () => {
    // Without auth token
    const response = await apiContext.get('/api/protected');
    expect(response.status()).toBe(401);

    const error = await response.json();
    expect(error.message).toContain('authentication');
  });

  test('protected endpoint accessible with valid token', async () => {
    const response = await apiContext.get('/api/protected', {
      headers: {
        Authorization: `Bearer ${authToken}`,
      },
    });

    expect(response.ok()).toBeTruthy();
  });

  test('expired token returns 401', async () => {
    const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...expired';

    const response = await apiContext.get('/api/protected', {
      headers: {
        Authorization: `Bearer ${expiredToken}`,
      },
    });

    expect(response.status()).toBe(401);
  });

  test('admin endpoint requires admin role', async () => {
    // Regular user token
    const response = await apiContext.get('/api/admin/users', {
      headers: {
        Authorization: `Bearer ${authToken}`,
      },
    });

    expect(response.status()).toBe(403); // Forbidden
  });

  test('admin endpoint accessible to admin', async () => {
    // Get admin token
    const adminLogin = await apiContext.post('/auth/login', {
      data: {
        email: 'admin@example.com',
        password: 'AdminPass123!',
      },
    });

    const adminData = await adminLogin.json();

    const response = await apiContext.get('/api/admin/users', {
      headers: {
        Authorization: `Bearer ${adminData.token}`,
      },
    });

    expect(response.ok()).toBeTruthy();
  });
});

Error Handling and Edge Cases

// tests/api/error-handling.spec.ts

test.describe('Error Handling', () => {
  test('invalid request body returns 400', async ({ request }) => {
    const response = await request.post('/api/users', {
      data: {
        email: 'not-an-email', // Invalid email
        name: '', // Empty name
      },
    });

    expect(response.status()).toBe(400);

    const error = await response.json();
    expect(error.errors).toBeDefined();
    expect(error.errors).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          field: 'email',
          message: expect.stringContaining('valid email'),
        }),
        expect.objectContaining({
          field: 'name',
          message: expect.stringContaining('required'),
        }),
      ]),
    );
  });

  test('resource not found returns 404', async ({ request }) => {
    const response = await request.get('/api/users/non-existent-id');

    expect(response.status()).toBe(404);

    const error = await response.json();
    expect(error.message).toContain('not found');
  });

  test('duplicate resource returns 409', async ({ request }) => {
    const email = `duplicate-${Date.now()}@example.com`;

    // Create user
    await request.post('/api/users', {
      data: { email, name: 'User 1', password: 'Pass123!' },
    });

    // Attempt to create duplicate
    const response = await request.post('/api/users', {
      data: { email, name: 'User 2', password: 'Pass123!' },
    });

    expect(response.status()).toBe(409);

    const error = await response.json();
    expect(error.message).toContain('already exists');
  });

  test('malformed JSON returns 400', async ({ request }) => {
    const response = await request.post('/api/users', {
      data: 'not valid json',
    });

    expect(response.status()).toBe(400);
  });

  test('rate limiting returns 429', async ({ request }) => {
    // Make many requests quickly
    const requests = Array.from({ length: 100 }, () => request.get('/api/public/status'));

    const responses = await Promise.all(requests);

    // At least one should be rate limited
    const rateLimited = responses.filter((r) => r.status() === 429);
    expect(rateLimited.length).toBeGreaterThan(0);

    // Check rate limit headers
    const limitedResponse = rateLimited[0];
    expect(limitedResponse.headers()['retry-after']).toBeDefined();
  });
});

GraphQL API Testing

GraphQL requires a slightly different approach than REST.

Basic GraphQL Queries

// tests/api/graphql.spec.ts

test.describe('GraphQL API', () => {
  async function graphqlRequest(query: string, variables?: Record<string, any>) {
    const response = await apiContext.post('/graphql', {
      data: {
        query,
        variables,
      },
    });

    const json = await response.json();

    // Check for GraphQL errors
    if (json.errors) {
      throw new Error(`GraphQL Error: ${JSON.stringify(json.errors)}`);
    }

    return json.data;
  }

  test('query users with fields', async () => {
    const data = await graphqlRequest(`
      query GetUsers {
        users {
          id
          email
          name
          createdAt
        }
      }
    `);

    expect(data.users).toBeDefined();
    expect(Array.isArray(data.users)).toBe(true);
    expect(data.users.length).toBeGreaterThan(0);

    const user = data.users[0];
    expect(user.id).toBeDefined();
    expect(user.email).toBeDefined();
    expect(user.name).toBeDefined();
    expect(user.createdAt).toBeDefined();
  });

  test('query user by ID', async () => {
    const data = await graphqlRequest(
      `
      query GetUser($id: ID!) {
        user(id: $id) {
          id
          email
          name
        }
      }
    `,
      {
        id: 'user-123',
      },
    );

    expect(data.user).toBeDefined();
    expect(data.user.id).toBe('user-123');
  });

  test('mutation creates user', async () => {
    const data = await graphqlRequest(
      `
      mutation CreateUser($input: CreateUserInput!) {
        createUser(input: $input) {
          id
          email
          name
        }
      }
    `,
      {
        input: {
          email: `test-${Date.now()}@example.com`,
          name: 'Test User',
          password: 'SecurePass123!',
        },
      },
    );

    expect(data.createUser.id).toBeDefined();
    expect(data.createUser.email).toBeDefined();
  });

  test('nested queries work correctly', async () => {
    const data = await graphqlRequest(
      `
      query GetUserWithProjects($userId: ID!) {
        user(id: $userId) {
          id
          name
          projects {
            id
            name
            scans {
              id
              status
            }
          }
        }
      }
    `,
      {
        userId: 'user-123',
      },
    );

    expect(data.user.projects).toBeDefined();
    expect(Array.isArray(data.user.projects)).toBe(true);

    if (data.user.projects.length > 0) {
      expect(data.user.projects[0].scans).toBeDefined();
    }
  });

  test('GraphQL validation errors', async () => {
    try {
      await graphqlRequest(
        `
        mutation CreateUser($input: CreateUserInput!) {
          createUser(input: $input) {
            id
            email
          }
        }
      `,
        {
          input: {
            email: 'invalid-email',
            name: '',
          },
        },
      );

      fail('Should have thrown validation error');
    } catch (error) {
      expect(error.message).toContain('GraphQL Error');
    }
  });
});

Schema Validation and Contract Testing

JSON Schema Validation

// tests/api/schema-validation.spec.ts
import Ajv from 'ajv';
import addFormats from 'ajv-formats';

const ajv = new Ajv();
addFormats(ajv);

// Define schemas
const userSchema = {
  type: 'object',
  required: ['id', 'email', 'name', 'createdAt'],
  properties: {
    id: { type: 'string' },
    email: { type: 'string', format: 'email' },
    name: { type: 'string', minLength: 1 },
    createdAt: { type: 'string', format: 'date-time' },
  },
  additionalProperties: true,
};

const userListSchema = {
  type: 'array',
  items: userSchema,
};

test.describe('Schema Validation', () => {
  test('GET /users returns valid schema', async ({ request }) => {
    const response = await request.get('/api/users');
    const users = await response.json();

    const validate = ajv.compile(userListSchema);
    const valid = validate(users);

    if (!valid) {
      console.error('Validation errors:', validate.errors);
    }

    expect(valid).toBe(true);
  });

  test('POST /users returns valid user schema', async ({ request }) => {
    const response = await request.post('/api/users', {
      data: {
        email: `test-${Date.now()}@example.com`,
        name: 'Test User',
        password: 'Pass123!',
      },
    });

    const user = await response.json();

    const validate = ajv.compile(userSchema);
    const valid = validate(user);

    expect(valid).toBe(true);
  });
});

OpenAPI Contract Testing

// tests/api/openapi-contract.spec.ts
import fs from 'fs';
import yaml from 'js-yaml';
import { OpenAPIValidator } from 'express-openapi-validator';

// Load OpenAPI spec
const openapiSpec = yaml.load(fs.readFileSync('./openapi.yaml', 'utf8')) as any;

test.describe('OpenAPI Contract', () => {
  test('API responses match OpenAPI spec', async ({ request }) => {
    // Test each endpoint defined in OpenAPI spec
    const paths = Object.keys(openapiSpec.paths);

    for (const path of paths) {
      const methods = Object.keys(openapiSpec.paths[path]);

      for (const method of methods) {
        if (method === 'get') {
          const response = await request.get(path);

          // Validate response against spec
          const expectedStatus = Object.keys(openapiSpec.paths[path][method].responses)[0];

          expect(response.status()).toBe(parseInt(expectedStatus));

          const data = await response.json();
          const schema = openapiSpec.paths[path][method].responses[expectedStatus].content['application/json'].schema;

          // Validate schema
          const validate = ajv.compile(schema);
          expect(validate(data)).toBe(true);
        }
      }
    }
  });
});

Performance Testing

// tests/api/performance.spec.ts

test.describe('API Performance', () => {
  test('GET /users responds within 200ms', async ({ request }) => {
    const start = Date.now();
    const response = await request.get('/api/users');
    const duration = Date.now() - start;

    expect(response.ok()).toBeTruthy();
    expect(duration).toBeLessThan(200);
  });

  test('handles concurrent requests', async ({ request }) => {
    const requests = Array.from({ length: 50 }, () => request.get('/api/users'));

    const start = Date.now();
    const responses = await Promise.all(requests);
    const duration = Date.now() - start;

    // All should succeed
    expect(responses.every((r) => r.ok())).toBe(true);

    // Should handle 50 requests in reasonable time
    expect(duration).toBeLessThan(5000); // 5 seconds
  });

  test('pagination performs efficiently', async ({ request }) => {
    const pageSize = 100;
    const pages = 10;

    const timings: number[] = [];

    for (let page = 1; page <= pages; page++) {
      const start = Date.now();
      const response = await request.get(`/api/users?page=${page}&limit=${pageSize}`);
      timings.push(Date.now() - start);

      expect(response.ok()).toBeTruthy();
    }

    // Later pages shouldn't be significantly slower
    const avgFirst3 = timings.slice(0, 3).reduce((a, b) => a + b) / 3;
    const avgLast3 = timings.slice(-3).reduce((a, b) => a + b) / 3;

    expect(avgLast3).toBeLessThan(avgFirst3 * 2); // Max 2x slower
  });
});

Advanced Patterns

Test Data Management

// tests/helpers/api-test-helper.ts

export class APITestHelper {
  private createdResources: Map<string, string[]> = new Map();

  constructor(private apiContext: APIRequestContext) {}

  async createUser(overrides?: Partial<User>): Promise<User> {
    const response = await this.apiContext.post('/api/users', {
      data: {
        email: `test-${Date.now()}@example.com`,
        name: 'Test User',
        password: 'SecurePass123!',
        ...overrides,
      },
    });

    const user = await response.json();

    // Track for cleanup
    if (!this.createdResources.has('users')) {
      this.createdResources.set('users', []);
    }
    this.createdResources.get('users')!.push(user.id);

    return user;
  }

  async cleanupTestData(): Promise<void> {
    // Clean up in reverse order
    for (const [resource, ids] of this.createdResources) {
      for (const id of ids) {
        await this.apiContext.delete(`/api/${resource}/${id}`);
      }
    }

    this.createdResources.clear();
  }
}

// Usage
test.describe('User Projects', () => {
  let helper: APITestHelper;

  test.beforeEach(async ({ request }) => {
    helper = new APITestHelper(request);
  });

  test.afterEach(async () => {
    await helper.cleanupTestData();
  });

  test('user can create project', async () => {
    const user = await helper.createUser();
    // Test logic...
  });
});

Retry and Resilience Testing

// tests/api/resilience.spec.ts

test.describe('API Resilience', () => {
  test('retries on transient failures', async ({ request }) => {
    let attempts = 0;
    let response;

    // Retry up to 3 times
    for (let i = 0; i < 3; i++) {
      attempts++;
      response = await request.get('/api/users');

      if (response.ok()) {
        break;
      }

      // Exponential backoff
      await new Promise((resolve) => setTimeout(resolve, 100 * Math.pow(2, i)));
    }

    expect(response!.ok()).toBeTruthy();
    console.log(`Succeeded after ${attempts} attempts`);
  });

  test('handles timeouts gracefully', async ({ request }) => {
    const response = await request.get('/api/slow-endpoint', {
      timeout: 5000, // 5 second timeout
    });

    // Should either succeed or timeout, not hang forever
    expect([200, 408, 504]).toContain(response.status());
  });
});

CI/CD Integration

# .github/workflows/api-tests.yml
name: API Tests

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  api-tests:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'

      - run: npm ci
      - run: npx playwright install

      # Run API tests
      - name: Run API tests
        env:
          API_BASE_URL: ${{ secrets.API_BASE_URL }}
          API_KEY: ${{ secrets.API_KEY }}
        run: npm run test:api

      # Generate report
      - name: Generate API test report
        if: always()
        run: npm run test:api:report

      # Upload results
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: api-test-results
          path: api-test-results/

Best Practices Summary

Practice Description Benefit
Test Happy & Unhappy Paths Test both success and failure scenarios Comprehensive coverage
Validate Response Schemas Use JSON Schema or OpenAPI Catch contract violations
Test Authentication Verify auth required, roles work Security
Test Rate Limiting Ensure rate limits work Prevent abuse
Clean Up Test Data Delete created resources after tests No pollution
Use Factories Generate test data dynamically Avoid conflicts
Test Performance Measure response times Catch regressions
Contract Testing Verify API matches spec Documentation accuracy

Conclusion: API Testing for Confidence

Comprehensive API testing provides:

  1. Fast Feedback: Tests run in milliseconds
  2. Stability: No UI flakiness
  3. Coverage: Business logic validation
  4. Documentation: Tests serve as examples
  5. Confidence: Deploy knowing APIs work

Build a robust API test suite and catch bugs before they reach the UI.

Supercharge Your API Testing with ScanlyApp

ScanlyApp provides advanced API testing features including automated contract validation, performance monitoring, and comprehensive reporting.

Start Your Free Trial and elevate your API testing today.

Related articles: Also see applying these API testing best practices to GraphQL schemas, contract testing as the complementary API testing strategy, and layering security testing on top of your functional API test suite.

Related Posts