Integration Testing Examples for a REST API

Four integration testing examples for a REST API, each from request to assertion: create and read back, login token, bad input, and access control.

Mustafa BayramogluMustafa BayramogluAbstract illustration of two glowing paths merging at a junction

An integration test for a REST API sends real requests to the running app and asserts on what comes back. The four examples below each go from request to assertion: create an order and find it in the list, log in and pass the token on, send bad input, and read another user’s data. They use plain JavaScript and Node’s test runner, so each one is short enough to read in a minute and copy into a project.

Set up one helper first

All four tests use a small shop API with a login route, an orders route and two seeded users. If you want the difference between this level of testing and the unit level first, read unit tests vs integration tests. For a single worked test with the definition around it, see what integration testing is.

Put two helpers at the top of the test file. api() builds the request and adds the bearer token when you pass one. login() signs in and returns the token.

import { test } from 'node:test';
import assert from 'node:assert/strict';

const base = process.env.APP_URL;

async function api(path, { token, body, method = 'GET' } = {}) {
  return fetch(`${base}${path}`, {
    method,
    headers: {
      'Content-Type': 'application/json',
      ...(token && { Authorization: `Bearer ${token}` }),
    },
    body: body && JSON.stringify(body),
  });
}

async function login(email, password) {
  const res = await api('/api/auth/login', { method: 'POST', body: { email, password } });
  assert.equal(res.status, 200);
  return (await res.json()).token;
}

The base URL comes from APP_URL, so the same file runs against localhost, a preview or CI. Start the app first, then run APP_URL=http://localhost:3000 node --test, which is how the Node.js test runner is invoked from the command line. Set TEST_EMAIL and TEST_PASSWORD for the first user, and TEST_EMAIL_2 and TEST_PASSWORD_2 for the second.

Example 1: create an order and find it in the list

The first test creates something and reads it back through a different route. The create call should answer 201 Created, which MDN describes as a request that led to the creation of a resource.

test('a created order shows up in the order list', async () => {
  const token = await login(process.env.TEST_EMAIL, process.env.TEST_PASSWORD);

  const created = await api('/api/orders', {
    method: 'POST',
    token,
    body: { productId: 42, quantity: 1 },
  });
  assert.equal(created.status, 201);
  const { id } = await created.json();

  const list = await api('/api/orders', { token });
  assert.equal(list.status, 200);
  const orders = await list.json();
  assert.ok(orders.some((order) => order.id === id));
});

The last assertion is the one that matters. A 201 shows only that the handler answered. Reading the order back shows that the data went into the database and came out again. If the write route saves to one table and the list route reads another, the status checks pass and this line fails.

Example 2: log in and pass the token to the next request

Most routes sit behind a login, so most integration tests start by carrying a token. In the first example the token went from login() into the token option of the next call. The helper turns it into an Authorization: Bearer header. For the full pattern, including IDs carried between steps, see testing multi-step API flows with auth tokens.

The second test checks both sides of the check: the token gets in, and a missing or invalid token does not.

test('orders need a valid token', async () => {
  const token = await login(process.env.TEST_EMAIL, process.env.TEST_PASSWORD);

  assert.equal((await api('/api/orders', { token })).status, 200);
  assert.equal((await api('/api/orders')).status, 401);
  assert.equal((await api('/api/orders', { token: 'not-a-real-token' })).status, 401);
});

A 401 Unauthorized means the request lacks valid authentication credentials for the resource. The second and third assertions matter as much as the first. A flow is not fully tested until it is run with a token that should not work.

Example 3: bad input is rejected and nothing is saved

The third test sends a request the API should refuse, then checks that the refusal left no trace. A 400 Bad Request means the server would not process a request it considered a client error.

test('an invalid quantity is rejected and not saved', async () => {
  const token = await login(process.env.TEST_EMAIL, process.env.TEST_PASSWORD);
  const before = await (await api('/api/orders', { token })).json();

  const bad = await api('/api/orders', {
    method: 'POST',
    token,
    body: { productId: 42, quantity: -1 },
  });
  assert.equal(bad.status, 400);

  const after = await (await api('/api/orders', { token })).json();
  assert.equal(after.length, before.length);
});

A handler that returns 400 but writes the row first would pass a status-only check. Counting the list before and after catches it. Pick one invalid field per test, so a failure names the rule that broke.

Example 4: one user cannot read another user’s order

The fourth test uses two tokens. The first user creates an order, and the second user tries to read it.

test("one user cannot read another user's order", async () => {
  const ana = await login(process.env.TEST_EMAIL, process.env.TEST_PASSWORD);
  const ben = await login(process.env.TEST_EMAIL_2, process.env.TEST_PASSWORD_2);

  const created = await api('/api/orders', {
    method: 'POST',
    token: ana,
    body: { productId: 42, quantity: 1 },
  });
  const { id } = await created.json();

  const res = await api(`/api/orders/${id}`, { token: ben });
  assert.ok([403, 404].includes(res.status));
});

The assertion accepts two answers because APIs choose differently. A 403 Forbidden means the server understood the request and refused it. A 404 Not Found means the server cannot find the requested resource. Use whichever your API returns and assert on that one. Either way, the test fails if the second user gets a 200.

What to assert besides the status

A status code shows that a route answered. The body shows whether it answered correctly. In example 1, find the order in the list and compare its productId and quantity with what you sent. A route can return 201 and still save the wrong quantity, for instance when a field is renamed on the way in and the old name falls back to a default.

Assert on what the client reads, and leave out what changes on every run, such as timestamps and generated ids. For those, check that the value is present and has the right shape. Example 3 shows the other kind of assertion: it checks a side effect, the size of the list, not the response itself.

Keep each test independent

Every test above logs in and creates its own data, so the tests can run in any order. Avoid a test that needs rows left behind by another one. Give names and emails a random suffix per run, so a leftover row from an earlier run cannot make a check pass by accident.

Example 1 is safe on a database that already holds old orders, because it looks for one specific id. A check that the list holds exactly one order would pass on the first run and fail on the second. Point CI at a throwaway database that starts empty, and reset it between runs.

When to fake a dependency

Sometimes a route calls a service you do not control, such as a payment provider. Point the app at a fake provider in the test environment, so the test checks your code and does not depend on someone else’s uptime. Keep everything you own real: the routes, the auth, the database. A test that fakes all of those is a unit test with extra steps.

What a failure tells you

Read the line that failed before anything else. A wrong status at login points at the seeded user or the auth route. A 401 on the request right after a successful login points at how the token is passed, such as the header name or the Bearer prefix. A 201 followed by a missing id in the list means the write route and the read route disagree about where the data lives. A 200 in example 4 is a permissions bug, and the most serious of the four.

Run the file on every pull request, so a failure appears next to the change that caused it. The guide to API tests on every PR with GitHub Actions shows the workflow.

The same flow as a YAML test

The tests above are code you write and maintain. The same steps can also live in a YAML flow. Stresseur records how an API is really used during development, turns those flows into YAML tests, and runs them in CI on every pull request. Flows live in the repo under api-tests/ as *.yamlflow.yaml files. Here are the login and create steps of a flow:

- request:
    name: Login
    method: POST
    url: '{{ #env:APP_URL }}/api/auth/login'
    body: { email: '{{ #env:TEST_EMAIL }}', password: '{{ #env:TEST_PASSWORD }}' }
    assertions:
      - response.status == 200
- request:
    name: CreateOrder
    depends_on: Login
    method: POST
    url: '{{ #env:APP_URL }}/api/orders'
    headers: { Authorization: 'Bearer {{ Login.response.body.token }}' }
    body: { productId: 42, quantity: 1 }
    assertions:
      - response.status == 201

The file reads the base URL from APP_URL, as the JavaScript does. The token moves from the login response to the next header by reference. Generated flows also check that a created item can be read back, which is what example 1 does by hand. See API integration tests from real traffic for how recording works, or run npx stresseur init --demo on the built-in sample shop.

Where to go from here

Start with example 1 for the route your users depend on most. Add the token test next, then one bad-input test and one two-user test for each resource that holds private data. Keep each test to one journey, and run the file on every pull request.

Frequently asked questions

What is an example of an integration test for an API?

Log in, create an order with the returned token, then list the orders and check that the new id is there. Reading the order back shows the data went into the database and came out again.

How do I pass a login token to the next request in a test?

Read the token field from the login response and put it in the Authorization header of the next request as a bearer token. In a Stresseur YAML flow, the header reads `Bearer {{ Login.response.body.token }}` and the step declares `depends_on: Login`.

What status code should a request without a token get?

A 401 Unauthorized, which MDN describes as a request that lacks valid authentication credentials for the requested resource. A flow is not fully tested until it is run with a token that should not work.

Which command runs these tests?

Start the app, then run `APP_URL=http://localhost:3000 node --test`. The Node.js test runner is invoked from the command line by passing the `--test` flag.

All posts