API testing is the middle layer of the testing pyramid – faster and more stable than UI tests, more representative of real behavior than unit tests. In a well-designed test suite, API tests provide the best coverage per testing hour. In a poorly designed suite, they provide false confidence: tests that pass even when the application is broken because they are testing the wrong things.
This post covers the practices I have found most valuable across nine-plus years of writing API tests for software teams of various sizes and stacks.
Test the Contract, Not Just the Happy Path
The most common API testing mistake is covering only the success cases. An endpoint that returns 200 for valid input is not fully tested until you have also verified what it returns for invalid input.
For every endpoint, the full test coverage includes: valid input (all required fields present, correct types) returns the expected response and status; missing required fields return 400 with a useful error message; incorrect field types return 400 with a useful error message; authentication failures return 401; authorization failures (authenticated but insufficient permissions) return 403; and resource not found returns 404.
The error cases are where APIs often behave inconsistently. Some endpoints return 400 for missing fields, others return 422, others return 200 with an error flag in the response body. Document and test the actual behavior – do not assume it matches the documentation.
The most common production bug we find through API testing: error responses that expose internal implementation details (stack traces, SQL errors, internal service names in error messages). These are security issues, not just bad UX. Testing error cases specifically catches them.
Schema Validation Should Be Automatic
Every API response should be validated against a schema. Not just that the status is 200 – that the response body has the expected fields, the fields have the expected types, and required fields are never null when they should not be.
In REST Assured:
given()
.auth().oauth2(token)
.when()
.get("/api/v1/users/{id}", userId)
.then()
.statusCode(200)
.body(matchesJsonSchemaInClasspath("schemas/user-response.json"));
In PyTest with jsonschema:
def test_user_response_schema(api_client, user_id, user_schema):
response = api_client.get(f"/api/v1/users/{user_id}")
assert response.status_code == 200
jsonschema.validate(response.json(), user_schema)
Schema validation catches a category of bugs that functional assertions miss: fields that were renamed, fields that disappeared, types that changed from string to integer. These bugs break frontend code silently – the endpoint returns 200, but the client fails to render the response. Schema tests catch them at the API layer, before frontend testing even starts.
Test Data Management Is Half the Battle
API tests that depend on a specific database state are fragile. The test passes in isolation, fails in CI because the test data was deleted, passes again after a database reset, and fails in production because production data is different from test data. This is the most common source of API test flakiness.
The solutions, in order of preference:
Create test data in the test setup: Each test creates the data it needs, and tears it down after. The test is self-contained and does not depend on pre-existing database state. The downside: test setup time increases.
Use API endpoints for test data setup: Instead of inserting directly into the database, use the application's own API to create test data. This tests the creation endpoint as a side effect and does not require database access from the test runner.
Use a dedicated test database reset between test runs: If tests need to run against a specific dataset, reset the database to a known state before each test run. This is more practical for integration test suites than for unit-level API tests.
Contract Testing for Microservices
If your architecture has multiple services communicating over HTTP, contract testing is the practice that prevents breaking changes from reaching production undetected. The standard tool is Pact.
Consumer-driven contract testing works like this: the consumer service defines what it expects from the provider service (the "contract"), and the provider service verifies that it can fulfill that contract. When the provider changes its API, the contract test fails if the change would break the consumer.
The value proposition: without contract tests, you discover that Service A broke Service B after both are deployed to staging. With contract tests, you discover it before Service A's PR is merged. This is the difference between a staging incident and a CI failure – orders of magnitude cheaper.
Authentication Testing Is Separate From Functional Testing
API authentication testing is its own concern. Most teams test authentication by running their functional tests while authenticated. This verifies that authenticated requests work, but it does not verify that unauthenticated requests fail correctly.
A separate authentication test suite should verify: endpoints that require authentication return 401 when called without credentials; endpoints that require specific permissions return 403 when called with insufficient permissions; JWT tokens are validated correctly (expired tokens, tampered tokens, tokens with incorrect audience claim); rate limiting applies to authentication attempts; and session tokens expire correctly.
Want API tests built into your CI/CD pipeline?
We scope API testing engagements based on your service architecture and current coverage. Tell us what you are working with.
Book a Free CallRunning API Tests in CI Without Breaking the Pipeline
API tests that depend on external services are the main cause of CI instability in API test suites. A test that calls a third-party payment API will fail when that API has an outage, when your sandbox credentials expire, or when the API rate limits your test runner.
The standard solution: mock external services in CI. Use Postman mock servers, WireMock, or similar tools to simulate third-party API responses in the CI environment. Keep a separate integration test suite that tests against real external services and run it on a schedule, not on every PR.
The tradeoff: mocked tests cannot catch bugs introduced by changes in the third-party API's actual behavior. The scheduled integration test suite handles this – it catches external API changes before they affect production users, just not in real time. For most teams, this is an acceptable tradeoff.