API Test Automation
API test automation turns API tests into code or runnable collections: a machine sends the requests, evaluates the assertions and reports every test as passed or failed, without a person repeating the steps. A test framework supplies the parts: a runner that finds and runs the tests, an HTTP client, assertions, hooks for setup and cleanup, and reporters that write the results.
Take the checklist for POST /api/v1/orders from the lesson on API response validation. Automated, it becomes a test that creates an order, asserts 201, the Location header, the status pending and the computed total, and then reads the order back. In a CI pipeline it runs on every pull request, and a failure stops the change from being merged until someone has looked at it.
Why it matters
APIs change often, and every change can break a client somewhere: a renamed field, a validation rule that stopped working, a 200 where a 201 was promised. Repeating dozens of request checks by hand after every merge is slow, and in practice it does not happen. An automated regression suite repeats them on every change, so a defect is found in the same pipeline run as the commit that introduced it, while the change is still fresh in the developer's mind.
API tests are also among the most useful automated tests. They skip the user interface, so they run faster and break less often than UI tests, and they reach business rules and error paths that no screen can trigger. For a tester, automation moves effort from re-running checks to designing them. That works only while the team trusts the suite: tests that fail for reasons unrelated to the product, such as shared data or a hard-coded URL, are soon ignored.
Frameworks: what they provide and how to choose
Every API automation framework, whatever its language, combines the same parts: a test runner that discovers tests, runs them, often in parallel, and reports results; an HTTP client that sends requests and exposes the status, headers and body; assertions; hooks or fixtures that prepare before tests and clean up after them; and reporters, including JUnit XML, the result format most CI systems can read. Frameworks differ mainly in how you write tests, and none of them designs tests for you: the techniques and expected results still come from the earlier lessons.
- Code-based: tests are files in a programming language. Examples are Playwright Test with its
requestfixture (TypeScript or JavaScript), REST Assured with JUnit or TestNG (Java) and pytest with therequestslibrary (Python). You get full control over data, reuse and structure, and tests are reviewed like any other code. - Collection-based: requests saved in an API client together with test scripts, such as a Postman collection. The same collection runs from the command line with Newman or the Postman CLI, which is how it reaches CI. Quick to start; sharing helpers and reviewing changes get harder as the collection grows.
- Choose what the developers on your team already use, so the tests live in the same repository and anyone can run and fix them. The concepts in this lesson carry over between frameworks.
A minimal code primer for API tests
You can read and write simple API tests without being a developer. You need the values, objects, loops and functions from the lesson on reading code for testers, plus a few features that every API test uses. The code at the end of this section is a complete test file in TypeScript for Playwright Test, still without layers: one positive test and two data-driven negative tests for POST /api/v1/orders, which accepts a qty from 1 to 99. The notes below read it part by part. It does not delete the orders it creates; the sections that follow add layers and cleanup. For more depth later, the lesson Programming Basics for Test Automation covers modules, classes and asynchronous code in full; it is further reading, not a requirement for this lesson.
- Test file structure: a test file is a module.
import { test, expect } from '@playwright/test'takes two functions from the framework package:testdeclares a test andexpectwrites an assertion. A file shares its own functions withexport, and other files take them withimport, as the layers below do. The runner looks in the test folder for files whose names end in.spec.tsor.test.tsand runs everytestin them. - A test:
test('creates a pending order', async ({ request }) => { … })gives the test a title, which the report shows, and a function that the runner calls.=>is a short way to write a function (an arrow function), and{ request }takes therequestfixture that the runner passes in: an HTTP client already set up with the base URL and headers from the configuration. - async and await: a request takes time, so
request.post(…)returns a promise, a value that arrives later.awaitwaits for it before the next line runs, and only a function markedasyncmay useawait. A missingawaitis the classic beginner bug: the next line works with an unfinished promise instead of the response, and the test fails with a confusing message or ends before the request has finished. - The request call:
request.post(path, { data })sends aPOSTto the path, resolved against the base URL. Playwright turns thedataobject into a JSON body and setsContent-Type: application/jsonunless you set it yourself.request.get,put,patchanddeletework the same way. - Assertions on status, headers and body:
expect(actual).toBe(expected)compares exactly.res.status()is the status code,res.headers()returns the headers with lower-case names, andawait res.json()parses the body, whose fields you then read like any object (order.status).toContainchecks part of a text,toHaveLengththe length of a list. The first failedexpectstops the test and reports the expected and the actual value. - Data-driven rows:
for (const qty of [0, 100])registers one test per value. The title is a template literal, text between back-quote characters in which${qty}inserts the value, so the report lists "rejects qty 0" and "rejects qty 100" as two tests. - Environment configuration: the file contains no host name and no key. The base URL and the token come from environment variables, named values set outside the code by your terminal or the CI job and read in code as
process.env.API_BASE_URL. The configuration file in the section on environments below reads them once, so the same test runs against local, test or staging and no secret is committed.
// tests/orders/first.spec.ts
import { test, expect } from '@playwright/test';
test('creates a pending order', async ({ request }) => {
const res = await request.post('/api/v1/orders', {
data: { customerId: 7, items: [{ sku: 'A-100', qty: 2 }] },
});
expect(res.status()).toBe(201);
expect(res.headers()['content-type']).toContain('application/json');
const order = await res.json();
expect(order.status).toBe('pending');
expect(order.items).toHaveLength(1);
});
for (const qty of [0, 100]) {
test(`rejects qty ${qty}`, async ({ request }) => {
const res = await request.post('/api/v1/orders', {
data: { customerId: 7, items: [{ sku: 'A-100', qty: qty }] },
});
expect(res.status()).toBe(422);
});
}Structure of a suite in layers
Five tests can live in one file. Five hundred cannot: if every test builds its own URLs, tokens and JSON, renaming one path means editing every test that calls it. A maintainable suite splits the work into layers, each with one reason to change, and tests reach the API only through the layers below them. Some teams call the client layer a service layer.
- Tests (
tests/): one behaviour per test, named after that behaviour and grouped by resource. A test prepares its input, calls the client and asserts the result. It contains no URLs, tokens or raw requests. - API client (
api/): one function per endpoint, such ascreate()orget(id)for orders. Paths, methods and default headers live only here, so a changed path is fixed once. The client returns the response and does not assert; the test decides what is correct. - Configuration (
config/): base URL, credentials, timeouts and logging for the environment the run targets, read from environment variables, with the framework's HTTP client set up once from them. - Test data (
data/): builders that return a valid default payload which a test changes only where it matters, plus setup and cleanup hooks for the records a test needs. - Shared checks (
schemas/): JSON Schemas and reusable assertions, so every test that receives an order checks its shape in the same way.
Calls go down the stack
- 1TestsOne behaviour per test: input, call, assertions; the test decides what is correct
Changes when a requirement changes
- 2API clientOne function per endpoint, such as
create()andget(id): paths, methods and default headers; no assertionsChanges when a path or parameter changes: one file, not every test
- 3HTTP client and configurationBase URL, token, timeouts and logging, read from environment variables
Changes per environment, never in test code
- 4The API under testIn one environment: local, test or staging; reached only through the layers above
From Tests to HTTP client and configuration:not allowed: a URL, token or raw request inside a test
Beside the call path: builders and hooks in data/ create and remove what each test needs, JSON Schemas in schemas/ are shared by the tests, and the runner writes a JUnit report to results/ for CI.
tests/
orders/
create-order.spec.ts one behaviour per test
order-validation.spec.ts data-driven invalid orders
customers/
api/
orders-client.ts create(), get(), remove(): paths live here
data/
order-builder.ts newOrder(): valid defaults, change what matters
schemas/
order.schema.json the shape of an order, shared by tests
config/
env.ts required(): stops when a variable is missing
playwright.config.ts runner, reporters, HTTP defaults
.github/workflows/api-tests.ymlAssertions, test data and environments
Three decisions make the difference between a suite the team trusts and one it ignores. Each applies what you know from manual API testing, with rules that matter once nobody watches the run:
- Assertions: assert the exact status first and attach the response body to the failure message, so the report explains itself. Then assert what the test is about: exact values for what you sent, type and format for generated values such as
idandcreatedAt, and the business rule. Check structure with a shared JSON Schema instead of repeating field checks in every test. - Avoid comparing a whole response with a stored copy: every harmless new field breaks the test, and the copy keeps whatever defect it contained when it was recorded.
- Data: each test creates what it needs, through the API or a seed script, with unique values such as a run id in names and codes, so tests can run alone, in any order and in parallel. Read-only reference data, such as a seeded test customer, may be shared; data that a test changes may not.
- Cleanup: delete what a test created in an after-hook that runs even when the test fails, or run against an environment that is rebuilt for each run.
- Data-driven tests: the invalid classes and boundary values from the lesson on negative API testing become rows of one table, each with its input and expected result, run by one test body.
- Environments: base URL, credentials and timeouts come from configuration, never from test code, so the same suite runs on a laptop, in CI and against staging. Secrets come from the CI secret store or a local file that is never committed. Tests that create or delete data never run against production. The code below shows the configuration layer: a helper that stops the run when a variable is missing, and the Playwright configuration that uses it.
// config/env.ts: stop at once when the environment is not configured
export function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing environment variable ${name}`);
return value;
}
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import { required } from './config/env';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
reporter: [['list'], ['junit', { outputFile: 'results/api-junit.xml' }]],
use: {
baseURL: required('API_BASE_URL'), // local, test or staging
extraHTTPHeaders: { Authorization: `Bearer ${required('API_TOKEN')}` },
},
});Try it: API Schema Validator Try it: API Test Data Generator
Worked example: automating the new-order checks
The contract of POST /api/v1/orders is the one from the lesson on API response validation: 201 Created, a Location header with the order's path, the status pending and total equal to the sum of qty × unitPrice. It adds validation rules: an order has at least one item, qty is an integer from 1 to 99 and every sku must exist; an invalid order returns 422 with an errors array whose field values name each invalid field, such as items[0].qty. Written in Playwright Test with the layers above, the code at the end of this section shows the orders client, the data builder and two test files with one positive test and five data-driven negative tests. The notes below explain what they do:
- Each test checks one behaviour, and its title says which, so the report already names the broken rule. The second argument in
expect(res.status(), await res.text())is the failure message, so a wrong status is reported together with the response body.toMatchObjectpasses when the order has the listed fields with these values and ignores its other fields. - Generated values are checked by relationship:
Locationmust equal the order path built from the newid, and the expected total is computed from the items in the response and compared as a number to two decimal places. - Reading the order back with
GETproves it was stored, not only echoed. - The negative rows share one test body; a new boundary value is one new line, and each row is reported as its own test. The rows
qty 0andqty 100sit just outside 1 to 99.errors.map(…)turns the list of error objects into a list of theirfieldvalues, andtoContainchecks that the expected field is among them. - Layers:
OrdersApiis a class, a template for objects.new OrdersApi(request)creates an orders client that keeps therequestfixture; itscreate(),get()andremove()hold the paths and return the response without asserting.newOrder()returns a valid order for customer 7, a seeded customer that no test changes, and...changescopies the fields a test passes in over these defaults. - Cleanup: each test records the ids of the orders it creates, and
test.afterEach, a hook that runs after every test in the file even when it fails, deletes them, so the tests can run alone, in any order and in parallel. A larger suite moves such hooks next to the builders indata/, so every test file shares them.
// api/orders-client.ts: the only file that knows the order paths
import type { APIRequestContext } from '@playwright/test';
export class OrdersApi {
constructor(private request: APIRequestContext) {}
create(order: object) { return this.request.post('/api/v1/orders', { data: order }); }
get(id: number) { return this.request.get(`/api/v1/orders/${id}`); }
remove(id: number) { return this.request.delete(`/api/v1/orders/${id}`); }
}
// data/order-builder.ts: a valid order; a test changes only what it is about
export function newOrder(changes: object = {}) {
return { customerId: 7, items: [{ sku: 'A-100', qty: 1 }], ...changes };
}
// tests/orders/create-order.spec.ts
import { test, expect } from '@playwright/test';
import { OrdersApi } from '../../api/orders-client';
import { newOrder } from '../../data/order-builder';
let created: number[] = []; // ids of the orders this test created
test.afterEach(async ({ request }) => { // runs after every test, even a failed one
for (const id of created) await new OrdersApi(request).remove(id);
created = [];
});
test('creates a pending order with the correct total', async ({ request }) => {
const orders = new OrdersApi(request);
const input = newOrder({
items: [{ sku: 'A-100', qty: 2 }, { sku: 'B-200', qty: 1 }],
});
const res = await orders.create(input);
expect(res.status(), await res.text()).toBe(201); // body shown on failure
const order = await res.json();
created.push(order.id); // for the after-hook
expect(res.headers()['location']).toBe(`/api/v1/orders/${order.id}`);
expect(order).toMatchObject({ customerId: input.customerId, status: 'pending' });
let sum = 0;
for (const item of order.items) sum = sum + item.qty * item.unitPrice;
expect(order.total).toBeCloseTo(sum, 2);
const stored = await orders.get(order.id); // stored, not only echoed
expect(stored.status()).toBe(200);
expect(await stored.json()).toMatchObject({ id: order.id, total: order.total });
});
// tests/orders/order-validation.spec.ts (same imports)
const invalid = [
{ name: 'qty 0', items: [{ sku: 'A-100', qty: 0 }], field: 'items[0].qty' },
{ name: 'qty 100', items: [{ sku: 'A-100', qty: 100 }], field: 'items[0].qty' },
{ name: 'qty as text', items: [{ sku: 'A-100', qty: '2' }], field: 'items[0].qty' },
{ name: 'unknown sku', items: [{ sku: 'Z-999', qty: 1 }], field: 'items[0].sku' },
{ name: 'no items', items: [], field: 'items' },
];
for (const row of invalid) {
test(`rejects an order with ${row.name}`, async ({ request }) => {
const res = await new OrdersApi(request).create(newOrder({ items: row.items }));
expect(res.status(), await res.text()).toBe(422);
const fields = (await res.json()).errors.map((e: { field: string }) => e.field);
expect(fields).toContain(row.field);
});
}Running the suite in CI
An automated suite pays off when it runs without anyone starting it. In a CI pipeline the API tests are one job: check out the code, install the dependencies, point the suite at an environment through variables and secrets, run it, publish the results and fail the pipeline when a test fails. The workflow below shows that job in GitHub Actions syntax; GitLab CI and Jenkins express the same steps in their own files. Most teams run API tests at several points:
- On every pull request: the fast core of the suite, against a test environment or a service started in the job, so the author sees a failure before merging.
- After a merge to the main branch: the full regression suite against the shared test environment.
- After each deployment: a short read-only smoke set, such as health, authentication and one read per key resource, against the deployed environment.
- Scheduled, for example nightly: slow tests and tests against third-party sandboxes, which should not block every merge.
- Every run uploads the JUnit report, even when tests fail (
if: always()), and the logs stay with the job, so a failure can be diagnosed without running it again. - A red run stops the merge or the deployment; on GitHub, that needs the job to be a required status check in a branch protection rule or ruleset. A flaky test gets an owner and is fixed or quarantined, not retried until it turns green.
# .github/workflows/api-tests.yml
name: api-tests
on:
pull_request:
push:
branches: [main]
jobs:
api-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- run: npx playwright test
env:
API_BASE_URL: ${{ vars.API_BASE_URL }} # configuration variable
API_TOKEN: ${{ secrets.API_TOKEN }} # from the secret store
- uses: actions/upload-artifact@v7
if: always() # failures need evidence
with:
name: api-test-results
path: results/Common mistakes
- Automating requests instead of test cases: a script that sends a request and checks only for
200adds run time, not coverage. - Tests that depend on each other, such as a delete test that works only after the create test has run. They fail when run alone, in another order or in parallel.
- Hard-coding base URLs, tokens and ids in tests, so the suite works on one machine only and secrets end up in the repository.
- Sharing data that tests change: two tests edit the same customer, and the result depends on which one runs first.
- Asserting exact values for generated fields or comparing whole responses, so tests fail after every harmless change and the team learns to ignore red runs.
- Re-running failed tests until they pass. A retry hides flaky tests and real intermittent defects alike; investigate, then fix or quarantine with an owner.
The QA perspective
On many teams, testers own what the API suite checks, even where developers write part of the code. In refinement, agree which checks of a story will be automated at the API level and which stay manual or exploratory. Build the API client and the data builders together with the developers, who know the endpoints and the seed data, and review test code in pull requests like production code: does each test have one clear behaviour, a real oracle and its own data?
Treat every red run as information and triage it: product defect, test defect, or environment and data problem. A product defect gets a bug report with the request, the response and the failed assertion taken from the CI report; a test defect is fixed in the suite; an environment problem goes to the environment's owner. Watch how long the suite takes and how often it fails without a product defect. When either grows, trust falls, and fixing it is part of the work.
Practice exercise
Plan the automated suite for a coupons API
A team adds discount coupons to the shop. The rules: percent is a whole number from 1 to 50; a coupon can be applied until the end of its validUntil date; each customer can use a code once. There are two environments: test, reset every night, and staging, shared with manual testers. Using the contract excerpt below, plan the automated tests for this API.
Pen and paper is enough. You can try requests in the REST API Tester and draft the invalid payloads with the API Test Data Generator.
POST /api/v1/coupons create a coupon (admin token)
{"code": "EID15", "percent": 15, "validUntil": "2027-03-31"}
201 coupon | 401 | 403 | 409 code exists | 422 errors[].field
GET /api/v1/coupons/{code} 200 coupon | 404
DELETE /api/v1/coupons/{code} 204 (admin token)
POST /api/v1/orders/{id}/coupon apply a coupon (customer token)
{"code": "EID15"}
200 order with discount | 404 unknown code | 422 expired or already used- List the tests, one behaviour each: positive, data-driven negative and business rules.
- Decide what data each test creates, how codes stay unique and how the data is cleaned up.
- Sketch the folders and the client functions.
- List the configuration values and secrets the suite needs, and when each part runs in CI.
Show the model answer
Positive: create a coupon → 201, then GET returns the same code, percent and validUntil. Boundaries: percent 1 and 50 → 201. Apply a valid 15% coupon to a new order → 200, and the discount equals 15% of the order total, computed in the test from the response.
Data-driven 422 rows, each asserting the field named in errors: percent 0, 51, 15.5 and "15"; code missing; validUntil 2027-02-30. Other negatives: the same code twice → 409; an unknown code → 404 on GET and on apply; create without a token → 401, with a customer token → 403.
Business rules: applying the same code twice for the same customer → 422. Expiry is a date boundary: a coupon whose validUntil is today still applies → 200, and one whose validUntil was yesterday → 422. If the create endpoint rejects past dates, ask the developers for a seed script or a controllable test clock. Raise that open question early, together with the time zone in which "the end of the date" is measured.
Data: every test creates its own coupon with a unique code, such as AT- followed by the run id and a counter, and deletes it with DELETE in an after-hook; the orders created for the apply tests are removed the same way, and the nightly reset of test clears whatever a crashed run left behind. The once-per-customer rule counts per customer and code, so with unique codes all tests can share the one seeded test customer behind CUSTOMER_TOKEN and still run in parallel without colliding.
Structure: tests/coupons/ (create, validation, apply), api/coupons-client.ts with create, get, apply and remove, data/coupon-builder.ts returning a valid coupon with a unique code, and schemas/coupon.schema.json. Configuration reads API_BASE_URL, ADMIN_TOKEN and CUSTOMER_TOKEN from environment variables; in CI the tokens come from the secret store.
CI: on every pull request, the coupon tests (create, validation and apply) against test, because they are few and fast; after a merge, the whole API regression suite, coupons included, against test; after a deployment to staging, only a read-only smoke check such as GET on a seeded coupon, because manual testers share staging. Every run uploads the JUnit report.
Frequently asked questions
Which framework should I learn first for API automation?
The one your team already uses, because you will read and extend its tests. If you can choose freely, follow the language of the product's developers: Playwright Test for JavaScript or TypeScript, REST Assured for Java, pytest with requests for Python. Runners, assertions, fixtures and reports work the same way in all of them, so the second framework is much quicker to learn.
Should automated API tests run against staging or a fresh environment?
Both, for different purposes. A fresh environment per run or per pull request gives isolated, repeatable results, because nobody else changes its data. A shared environment such as staging adds realism, such as real configuration and real dependencies, and is where post-deployment smoke checks run. Keep tests that create and delete data away from environments where people work by hand, unless their data is unique and cleaned up.
Do automated API tests replace manual API testing?
No. They repeat known checks quickly and reliably. Exploring a new endpoint, questioning an unclear contract, judging whether an error message helps a client developer and testing ideas nobody has written down yet still need a person. Automation gives that person more time for it.
How many API tests should run on every pull request?
Enough to cover the main behaviour and the key error cases of the changed endpoints, while the job stays fast enough that developers are happy to wait for its result. Move slow tests and tests against third-party sandboxes to the post-merge or scheduled runs.
References
- Certified Tester Advanced Level Test Automation Engineering (CTAL-TAE) v2.0ISTQB's advanced syllabus on designing, implementing and maintaining test automation solutions and their architecture.
- The Practical Test Pyramid (Ham Vocke)A widely cited explanation of how to balance unit, integration, contract and UI tests in an automated test portfolio.
- Playwright DocumentationOfficial documentation for Playwright, an end-to-end test framework that drives Chromium, WebKit and Firefox.
- GitHub Actions documentationHow workflows, jobs, runners and artifacts are defined in GitHub Actions — where automated tests run on each push or pull request.
Practise with Mutqan tools
Test REST endpoints with full control over method, headers and JSON body, then assert on status, headers and JSON paths.
Generate ready-to-run REST API request code in cURL, JavaScript fetch, axios, Python requests, Java, C#, Go and PHP.
Generate a structured test-case matrix (positive, negative, auth, boundary) from an OpenAPI / Swagger specification.
Generate request bodies and datasets for API testing from a schema or field list, including invalid variants.
Validate API responses against a JSON Schema or an OpenAPI response schema.
Format and validate YAML with consistent indentation and quoting.
Go deeper
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.