Automating tests with code
Turning manual checks into tests with JavaScript or Python.
By now you have sent a lot of requests by hand and checked the responses by eye. That works for exploring an API. It doesn't work for the tenth time you check the same thing after a release, because by then you are skimming, and skimming is how regressions get through. An automated test sends the same request and checks the same things every time, in a few hundred milliseconds, without getting bored.
This lesson turns the manual checks from Testing a resource end to end into code. The main example is JavaScript, using only what ships with Node.js 20 or later. There is a Python version after it. Pick whichever language your team uses. The ideas are the same.
What an automated API test is
Strip away the frameworks and an API test is three steps: send a request, read the response, and compare parts of it with what you expected. If every comparison holds, the test passes. If one fails, the test stops and reports which one.
The comparisons are called assertions. You already know what to assert, because it's what you checked by hand: the status code, a few headers, and the fields in the body.
A first test file
Node.js has a test runner built in (node:test), an assertion module (node:assert) and fetch. So you need no packages at all. Make a folder, and save this as products.test.mjs:
// products.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
const BASE_URL = process.env.BASE_URL ?? "https://api.testingapis.com";
test("GET /api/Products/1 returns the product", async () => {
const res = await fetch(`${BASE_URL}/api/Products/1`);
assert.equal(res.status, 200);
assert.match(res.headers.get("content-type"), /^application\/json/);
const product = await res.json();
assert.equal(product.id, 1);
assert.equal(typeof product.title, "string");
assert.equal(typeof product.price, "number");
assert.ok(product.price > 0, `price should be positive, got ${product.price}`);
});
test("GET /api/Products/9999 returns 404 with an error body", async () => {
const res = await fetch(`${BASE_URL}/api/Products/9999`);
assert.equal(res.status, 404);
const body = await res.json();
assert.equal(body.status, 404);
assert.match(body.message, /9999/);
});A few things about this file are deliberate.
The base URL comes from an environment variable, with the public API as a default. The same tests can then run against your laptop, a staging server or production by changing one variable: BASE_URL=https://staging.example.com node --test. Hard-coding the host is the first thing people regret.
The assertions go in the order status, headers, body. If the status is wrong, the body is probably an error message, and checking product.title on an error body gives a confusing failure about a missing title. Checking the status first gives you the real reason.
The test checks types and ranges, not every value. It says the title is a string and the price is a positive number. It doesn't say the title is "Wireless Noise-Cancelling Headphones", because product data changes, and a test that fails whenever someone edits a product description will be ignored within a month. Assert exact values only where the value is the point of the test: the id you asked for, the status code, an error message that must mention the missing ID.
Running it
Add the create-read-delete test from the next section as books.test.mjs in the same folder, then run node --test. Without arguments it finds files ending in .test.mjs (and a few other patterns) and runs them:
$ node --test
✔ a created book can be read and then deleted (2241.8021ms)
✔ GET /api/Products/1 returns the product (446.0678ms)
✔ GET /api/Products/9999 returns 404 with an error body (320.9066ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2343.9797To see what a failure looks like, change the price check to assert.equal(product.price, 249.0, "price of product 1") and run it again:
✖ failing tests:
test at fail.mjs:4:1
✖ GET /api/Products/1 returns the product (454.4497ms)
AssertionError [ERR_ASSERTION]: price of product 1
249.99 !== 249
at TestContext.<anonymous> (file:///.../fail.mjs:8:10)
...The message you passed as the third argument appears first, then the actual and expected values. Without that message you'd get only 249.99 !== 249, which in a suite of 200 tests tells you very little.
A create, read, delete flow
Tests that change data need more care. Here is the book lifecycle as one test:
// books.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
const BASE_URL = process.env.BASE_URL ?? "https://api.testingapis.com";
const newBook = {
title: "Automation test book",
author: "QA Team",
isbn: "9780000000001",
genre: "testing",
publishedYear: 2026,
pages: 120,
price: 9.99,
};
test("a created book can be read and then deleted", async (t) => {
// Create
const created = await fetch(`${BASE_URL}/api/Books`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newBook),
});
assert.equal(created.status, 201);
const { data } = await created.json();
const id = data.id;
// Delete the book even if a later assertion fails.
t.after(() => fetch(`${BASE_URL}/api/Books/${id}`, { method: "DELETE" }));
// Read it back
const read = await fetch(`${BASE_URL}/api/Books/${id}`);
assert.equal(read.status, 200);
const book = await read.json();
assert.equal(book.title, newBook.title);
assert.equal(book.author, newBook.author);
assert.equal(book.price, newBook.price);
// Delete it
const deleted = await fetch(`${BASE_URL}/api/Books/${id}`, { method: "DELETE" });
assert.equal(deleted.status, 200);
// It is gone
const gone = await fetch(`${BASE_URL}/api/Books/${id}`);
assert.equal(gone.status, 404);
});The ID is taken from the create response, never hard-coded. The API assigns IDs, and the next ID depends on what everyone else has created since the server restarted. A test that expects the new book to be number 27 passes once.
The t.afterline registers the cleanup straight after the create. If the read assertion fails, the test stops there, but the cleanup still runs, so a failing test doesn't leave junk behind. When the test passes, the book is already deleted by the time the cleanup runs, so that second DELETE returns 404, which is harmless.
One test or four
The flow above is one test with four stages. The alternative is four tests: create, read, update, delete, each depending on the one before. Don't do that. Test runners may run tests in any order or in parallel, and when the create test fails, the other three fail too with messages that point you at the wrong place.
Each test should set up what it needs and pass or fail on its own. When the setup is shared, like "there must be a book to read", put it in a helper or a fixture that every test calls, rather than in another test. The Python version below shows that pattern.
Failure messages you can act on
A failed test is read by someone in a hurry, often in a CI log, often not the person who wrote it. The most useful thing you can add is the response body when a status check fails. A small helper does it:
async function expectStatus(res, expected) {
if (res.status !== expected) {
const body = await res.text();
assert.fail(`${res.url} returned ${res.status}, expected ${expected}. Body: ${body}`);
}
}
test("GET /api/Books/9999 returns the book", async () => {
const res = await fetch("https://api.testingapis.com/api/Books/9999");
await expectStatus(res, 200);
});✖ GET /api/Books/9999 returns the book (454.4073ms)
AssertionError [ERR_ASSERTION]: https://api.testingapis.com/api/Books/9999 returned 404, expected 200. Body: {"status":404,"error":"Not Found","message":"Book with ID 9999 does not exist."}Compare that with 404 !== 200. The URL and the server's own message tell you it's a data problem, not a routing problem, before you open any file.
The same tests in Python
In Python, the usual pair is pytest for running tests and requests for HTTP. Install both with pip. This file covers the same cases, with the book setup moved into a fixture:
# test_api.py
import os
import pytest
import requests
BASE_URL = os.environ.get("BASE_URL", "https://api.testingapis.com")
def test_get_product():
res = requests.get(f"{BASE_URL}/api/Products/1", timeout=10)
assert res.status_code == 200
assert res.headers["Content-Type"].startswith("application/json")
product = res.json()
assert product["id"] == 1
assert isinstance(product["title"], str)
assert product["price"] > 0
def test_missing_product_returns_404():
res = requests.get(f"{BASE_URL}/api/Products/9999", timeout=10)
assert res.status_code == 404
assert "9999" in res.json()["message"]
@pytest.fixture
def book():
res = requests.post(
f"{BASE_URL}/api/Books",
json={"title": "Automation test book", "author": "QA Team", "price": 9.99},
timeout=10,
)
assert res.status_code == 201
created = res.json()["data"]
yield created
requests.delete(f"{BASE_URL}/api/Books/{created['id']}", timeout=10)
def test_created_book_can_be_read(book):
res = requests.get(f"{BASE_URL}/api/Books/{book['id']}", timeout=10)
assert res.status_code == 200
assert res.json()["title"] == "Automation test book"
def test_deleted_book_is_gone(book):
res = requests.delete(f"{BASE_URL}/api/Books/{book['id']}", timeout=10)
assert res.status_code == 200
res = requests.get(f"{BASE_URL}/api/Books/{book['id']}", timeout=10)
assert res.status_code == 404$ pip install pytest requests
$ pytest -v test_api.py
test_api.py::test_get_product PASSED [ 25%]
test_api.py::test_missing_product_returns_404 PASSED [ 50%]
test_api.py::test_created_book_can_be_read PASSED [ 75%]
test_api.py::test_deleted_book_is_gone PASSED [100%]The fixture creates a book, hands it to the test with yield, and deletes it afterwards, whether the test passed or not. Each test that takes book as an argument gets its own fresh one. That is the independence rule from earlier, handled by the framework. Also note timeout=10 on every call: requests waits forever by default, and a test suite that hangs is worse than one that fails.
Rules that keep a suite useful
Suites rarely die because the tests were wrong on day one. They die because they get slow and flaky and people stop trusting them. Most of that is avoidable.
- Each test runs on its own, in any order. No test relies on data another test created.
- Check status, then headers, then body. A failure should point at the first thing that went wrong.
- One behaviour per test. "Missing product returns 404" and "product has a positive price" are two tests. When one fails, the name alone tells you what broke.
- Don't assert on values that change between runs: generated IDs, timestamps, random data, the total count of a shared list. Assert on their type or format instead, or compare them with something from the same run.
- Clean up what you create, and name it so leftovers are easy to spot.
- Configuration (base URL, credentials) comes from environment variables. Credentials never go in the test file or the repository.
- Set timeouts. A hung request should fail the test, not the whole pipeline.
Other tools
You don't have to write tests in a general-purpose language. Some teams prefer other tools, and you'll meet these on real projects:
- Postman collections with test scripts, run from the command line with Newman. Popular where testers already use Postman by hand.
- REST Assured, a Java library with a fluent given/when/then style. Common in Java shops.
- Playwright's
requestAPI, useful when a team already uses Playwright for browser tests and wants API tests in the same suite. - Karate, which writes API tests in a Gherkin-like text format with built-in JSON matching.
They all do the three steps from the start of this lesson. Choose the one that fits the language and tools your team already has, so that developers can read and fix the tests too. A suite only the tester can run tends to be abandoned when that tester moves on. Running tests in CI covers running a suite like this on every change.
Exercise
Extend the JavaScript suite (or the Python one) with these tests. Each should pass or fail on its own.
GET /api/Products?limit=5returns an array of exactly five items and anX-Total-Countheader that is a number greater than or equal to five.GET /api/Countries/code/INsign in to see returns a country whosecodeisINand whosepopulationis a positive integer.- Creating a movie with
POST /api/Moviessign in to see, updating its title withPATCH /api/Movies/{id}sign in to see, and reading it back shows the new title. If the create fails, the error body says what is missing. Clean up afterwards.
Then run the suite with BASE_URL set to a wrong host and read the failure output. Would a colleague understand from that output alone what went wrong? If not, improve the messages.