Skip to main content
/tayyab/portfolio — zsh
tayyab
TA
// dispatch.read --classified=false --access-level: public

REST API Testing Best Practices Every QA Engineer Must Know

March 4, 2026 EST. READ: 11 MIN #API Testing

REST API Testing Best Practices Every QA Engineer Must Know

REST API testing looks simple on the surface: send a request, check the response. In reality, most teams get it wrong.

I've audited 40+ API test suites across fintech, healthcare, and SaaS. The problems are always the same:

  • Tests that pass locally but fail in CI (environment differences)
  • Fragile tests that break when a developer renames a field
  • Zero coverage of edge cases (null, empty arrays, 10MB payloads)
  • No test isolation (test A leaves data that breaks test B)
  • Assertions on implementation details instead of contracts

This guide covers the fundamentals that separate good API test suites from mediocre ones.

Table of Contents

  1. Status Code Mastery
  2. Headers and Content Negotiation
  3. Authentication Testing
  4. Error Handling and Edge Cases
  5. Test Organization and Isolation
  6. Anti-Patterns to Avoid
  7. Real Project Example
  8. FAQ

Status Code Mastery

Status codes are the API's way of telling you what happened. Most teams test for 200 and call it done.

Common Status Codes

The fundamentals: 2xx = success, 3xx = redirect, 4xx = client error, 5xx = server error.

Test all status codes for critical endpoints:

// Successful request (201 Created)
test('Create user: 201', async ({ request }) => {
  const res = await request.post('https://api.example.com/users', {
    data: { email: 'user@example.com', name: 'John' }
  });
  expect(res.status()).toBe(201);
});

// Invalid input (400 Bad Request)
test('Create user: 400 Missing email', async ({ request }) => {
  const res = await request.post('https://api.example.com/users', {
    data: { name: 'John' }
  });
  expect(res.status()).toBe(400);
});

// Not authenticated (401 Unauthorized)
test('Get protected: 401 No token', async ({ request }) => {
  const res = await request.get('https://api.example.com/protected');
  expect(res.status()).toBe(401);
});

// Not found (404)
test('Get user: 404', async ({ request }) => {
  const res = await request.get('https://api.example.com/users/99999');
  expect(res.status()).toBe(404);
});

Headers and Content Negotiation

Headers control request/response format and authentication.

const headers = {
  'Content-Type': 'application/json',
  'Accept': 'application/json',
  'Authorization': 'Bearer TOKEN',
  'User-Agent': 'MyApp/1.0',
};

Authentication Testing

Test all authentication scenarios:

test.describe('Authentication', () => {
  // No token
  test('401 when no token provided', async ({ request }) => {
    const res = await request.get('https://api.example.com/protected');
    expect(res.status()).toBe(401);
  });

  // Invalid token
  test('401 when token is invalid', async ({ request }) => {
    const res = await request.get('https://api.example.com/protected', {
      headers: { 'Authorization': 'Bearer invalid' }
    });
    expect(res.status()).toBe(401);
  });

  // Valid token
  test('200 with valid token', async ({ request }) => {
    const res = await request.get('https://api.example.com/protected', {
      headers: { 'Authorization': 'Bearer valid-token' }
    });
    expect(res.status()).toBe(200);
  });
});

Error Handling and Edge Cases

Test edge cases thoroughly:

test.describe('Edge Cases', () => {
  // Null values
  test('400 when required field is null', async ({ request }) => {
    const res = await request.post('https://api.example.com/users', {
      data: { email: null, name: 'John' }
    });
    expect(res.status()).toBe(400);
  });

  // Empty string
  test('400 when required field is empty', async ({ request }) => {
    const res = await request.post('https://api.example.com/users', {
      data: { email: '', name: 'John' }
    });
    expect(res.status()).toBe(400);
  });

  // Boundary: max length
  test('400 when input exceeds max length', async ({ request }) => {
    const res = await request.post('https://api.example.com/users', {
      data: { email: 'test@example.com', name: 'A'.repeat(256) }
    });
    expect(res.status()).toBe(400);
  });

  // Special characters
  test('201 with special characters', async ({ request }) => {
    const res = await request.post('https://api.example.com/users', {
      data: { email: 'test@example.com', name: 'José García' }
    });
    expect(res.status()).toBe(201);
    const user = await res.json();
    expect(user.name).toBe('José García');
  });
});

Test Organization and Isolation

Good test organization prevents cascading failures.

Setup and Teardown

test.beforeEach(async ({ request }) => {
  // Create test data before each test
  const response = await request.post('https://api.example.com/users', {
    data: { email: 'test@example.com', name: 'Test User' }
  });
  testUserId = (await response.json()).id;
});

test.afterEach(async ({ request }) => {
  // Clean up after each test
  await request.delete(`https://api.example.com/users/${testUserId}`);
});

test('GET user returns correct data', async ({ request }) => {
  const res = await request.get(`https://api.example.com/users/${testUserId}`);
  expect(res.status()).toBe(200);
  const user = await res.json();
  expect(user.email).toBe('test@example.com');
});

Independent Tests

Good: Each test is independent

test('Create and retrieve user', async ({ request }) => {
  const createRes = await request.post('https://api.example.com/users', {
    data: { email: 'new@example.com', name: 'New User' }
  });
  const userId = (await createRes.json()).id;

  const getRes = await request.get(`https://api.example.com/users/${userId}`);
  expect(getRes.status()).toBe(200);
});

Anti-Patterns to Avoid

Hard-Coded IDs

Bad: ID might not exist

test('Get user', async ({ request }) => {
  const res = await request.get('https://api.example.com/users/12345');
  expect(res.status()).toBe(200);
});

Good: Create resource first

test('Create and get user', async ({ request }) => {
  const createRes = await request.post('https://api.example.com/users', {
    data: { email: 'test@example.com', name: 'Test' }
  });
  const userId = (await createRes.json()).id;
  
  const getRes = await request.get(`https://api.example.com/users/${userId}`);
  expect(getRes.status()).toBe(200);
});

Testing Implementation Details

Bad: Brittle

test('User object has exactly 8 properties', async ({ request }) => {
  const res = await request.get('https://api.example.com/users/1');
  const user = await res.json();
  expect(Object.keys(user).length).toBe(8);
});

Good: Robust

test('User has required fields', async ({ request }) => {
  const res = await request.get('https://api.example.com/users/1');
  const user = await res.json();
  expect(user.id).toBeDefined();
  expect(user.email).toBeDefined();
});

Real Project Example

On my Wells Fargo automation project, I tested a payment API with 150 tests covering:

  • All status codes (200, 201, 400, 401, 403, 404, 409, 500)
  • Authentication scenarios (no token, invalid, expired, valid)
  • Edge cases (null, empty, boundary values, special characters)
  • Error handling

Result: 3 validation bugs caught, 2 auth bugs fixed, zero production issues.


FAQ

Q: How many tests per endpoint?
A: Minimum 1-2 per status code. For critical endpoints: 10-20 total.

Q: Should API tests run in CI/CD?
A: Yes, every push to main/develop branches.

Q: Test contract or implementation?
A: Contract only. Test status codes, required fields, data types not internal logic.

Q: How to handle rate limits in tests?
A: Add delays between requests or use test data that doesn't trigger limits.

Tayyab Akmal
// author

Tayyab Akmal

AI & QA Automation Engineer

I've caught critical bugs in fintech, e-commerce, and SaaS platforms — then built the automation that prevents them from shipping again. 6+ years scaling test automation and AI-driven QA.

// feedback_channel

FOUND THIS USEFUL?

Share your thoughts or let's discuss automation testing strategies.

→ Start Conversation
Available for hire