Menu

Topic 2 of 8

Integration Tests For Endpoints

Learn Integration Tests For Endpoints for free with explanations, exercises, and a quick test (for API Engineer).

Published: January 21, 2026 | Updated: January 21, 2026

Why this matters

As an API Engineer, you ship endpoints that other systems and teams rely on. Integration tests prove that your routes, middleware, database, and external services work together in realistic scenarios. They catch issues unit tests miss: routing misconfigurations, serialization errors, auth/permissions gaps, schema mismatches, and transaction boundaries.

  • Real tasks you’ll face: verifying a new POST endpoint writes correct data, ensuring auth guards reject unauthenticated/unauthorized access, keeping backward compatibility across versions, and preventing regressions during refactors.
  • Outcome: confidence that your API behaves correctly in production-like conditions.

Who this is for

  • API Engineers and Backend Developers implementing REST/GraphQL endpoints.
  • QA/SET engineers adding backend coverage beyond unit tests.
  • Developers maintaining legacy services who need safe refactoring.

Prerequisites

  • Basic HTTP knowledge (methods, status codes, headers, JSON).
  • Familiarity with your backend framework (routing, middlewares).
  • Ability to run a local database or test double (e.g., in-memory, container, or temporary schema).
  • Comfort with a test runner (e.g., pytest, JUnit, Jest) and assertions.

Concept explained simply

An endpoint integration test starts the application (or a realistic slice), sends an HTTP request, and checks the full response plus side effects (database writes, emitted events). It’s broader than a unit test but narrower than a full end-to-end test through a browser or external client.

Mental model

Think of your API as an airport:

  • Unit tests: check each staff member’s skills (a single function).
  • Integration tests: check a passenger’s journey from entrance to gate (request through routing, auth, logic, DB).
  • E2E tests: check the entire trip including airline systems and baggage claims (external systems and UIs).

Test anatomy (repeatable structure)

  1. Arrange: boot the app (test mode), prepare data/fixtures, stub external calls if needed.
  2. Act: send an HTTP request to your endpoint with realistic headers/body.
  3. Assert: verify status code, headers, response body schema, and side effects in the database or message queue.
  4. Cleanup: rollback or reset state to keep tests isolated and fast.
Checklist: a good endpoint integration test
  • [ ] Starts the app or a representative slice
  • [ ] Uses a test database/schema or transaction
  • [ ] Sends realistic payloads and headers (auth, content-type)
  • [ ] Asserts status, headers (e.g., Location, Cache-Control), and JSON
  • [ ] Verifies database side effects and invariants
  • [ ] Cleans up data to avoid cross-test pollution

Worked examples

Example 1: Node.js (Express) + supertest — POST /users
// app.js (simplified)
const express = require('express');
const app = express();
app.use(express.json());
app.post('/users', async (req, res) => {
  const { email } = req.body;
  if (!email || !email.includes('@')) return res.status(400).json({ error: 'Invalid email' });
  const id = await db.users.insert({ email }); // assume test db client
  res.status(201).set('Location', `/users/${id}`).json({ id, email });
});
module.exports = app;

// users.int.test.js
const request = require('supertest');
const app = require('../app');
const db = require('../db');

describe('POST /users', () => {
  beforeAll(async () => { await db.migrate.latest(); });
  afterEach(async () => { await db.truncateAll(); });
  afterAll(async () => { await db.destroy(); });

  it('creates a user and returns 201 + Location', async () => {
    const res = await request(app)
      .post('/users')
      .send({ email: 'a@b.com' })
      .set('Content-Type', 'application/json');

    expect(res.status).toBe(201);
    expect(res.headers.location).toMatch(/\/users\//);
    expect(res.body.email).toBe('a@b.com');

    const row = await db.users.findById(res.body.id);
    expect(row).toBeTruthy();
  });

  it('rejects invalid email with 400', async () => {
    const res = await request(app).post('/users').send({ email: 'bad' });
    expect(res.status).toBe(400);
    expect(res.body.error).toBeDefined();
  });
});
Example 2: Python (FastAPI) + pytest + httpx — GET /orders/{id}
# app.py (simplified)
from fastapi import FastAPI, HTTPException
app = FastAPI()

@app.get('/orders/{oid}')
async def get_order(oid: int):
    row = await db.get_order(oid)
    if not row:
        raise HTTPException(status_code=404, detail='Not found')
    return row

# test_orders_int.py
import pytest
from httpx import AsyncClient
from app import app

@pytest.mark.asyncio
async def test_get_order_found(test_db):
    oid = await test_db.create_order({"total": 29.99})
    async with AsyncClient(app=app, base_url='http://test') as ac:
        r = await ac.get(f'/orders/{oid}')
    assert r.status_code == 200
    body = r.json()
    assert body["id"] == oid
    assert body["total"] == 29.99

@pytest.mark.asyncio
async def test_get_order_not_found(test_db):
    async with AsyncClient(app=app, base_url='http://test') as ac:
        r = await ac.get('/orders/99999')
    assert r.status_code == 404
    assert r.json()["detail"] == 'Not found'
Example 3: Java (Spring Boot) + @SpringBootTest + TestRestTemplate — PUT /profiles
// ProfileController.java (simplified)
@RestController
public class ProfileController {
  @PutMapping("/profiles/{id}")
  public ResponseEntity<Profile> update(@PathVariable Long id, @RequestBody Profile p) {
    if (!repo.existsById(id)) return ResponseEntity.status(404).build();
    p.setId(id);
    Profile saved = repo.save(p);
    return ResponseEntity.ok(saved);
  }
}

// ProfileIntTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProfileIntTest {
  @LocalServerPort int port;
  @Autowired TestRestTemplate rest;
  @Autowired ProfileRepository repo;

  @BeforeEach void setup() { repo.deleteAll(); }

  @Test void updatesExistingProfile() {
    Profile p = repo.save(new Profile(null, "Ann"));
    var url = "http://localhost:"+port+"/profiles/"+p.getId();
    var body = Map.of("name","Ann Marie");
    ResponseEntity<Profile> res = rest.exchange(url, HttpMethod.PUT, new HttpEntity<>(body), Profile.class);
    assertThat(res.getStatusCode()).isEqualTo(HttpStatus.OK);
    assertThat(res.getBody().getName()).isEqualTo("Ann Marie");
  }

  @Test void returns404ForMissing() {
    var url = "http://localhost:"+port+"/profiles/999";
    var res = rest.exchange(url, HttpMethod.PUT, new HttpEntity<>(Map.of("name","X")), Profile.class);
    assertThat(res.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
  }
}

Environment and data setup

  • Use a dedicated test database/schema. Reset with transactions, truncation, or ephemeral containers. Keep seed data minimal.
  • Run migrations on test start. Prefer fast, in-memory options where accurate (e.g., SQLite in memory only if behavior matches prod).
  • Stub only true externals (email, payment gateway). Keep your service logic and database real to maintain fidelity.
Tip: Transactional tests

Wrap each test in a DB transaction and roll it back in teardown. It isolates tests and speeds cleanup.

Flakiness prevention and performance

  • Avoid sleeps. Wait on explicit signals (HTTP responses, DB rows exist).
  • Make IDs deterministic when helpful; avoid relying on wall-clock time.
  • Parallelize tests only if your setup is isolation-safe (separate schemas/containers).
  • Keep fixtures small and focused. Create only the rows needed for each test.
Debugging flaky tests
  • Log request/response when a test fails.
  • Record DB state diffs before/after.
  • Run the single test repeatedly (10–50 times) to reproduce flake patterns.

What to test (and what not)

  • Test: correctness of status codes, headers, response schema, auth/permission behavior, DB side effects, idempotency, and error paths.
  • Skip: detailed logic already covered by unit tests, or external systems you don’t control (mock them at integration boundary).

Hands-on exercises

Do these locally with your stack. The platform’s quick test is available to everyone; saving progress requires login.

  1. Exercise 1 (mirrors ex1): Create an integration test for POST /users that returns 201 with Location header and persists a row. Also test a 400 invalid payload case.
  2. Exercise 2 (mirrors ex2): Add tests for a protected PUT /orders/{id} that requires a bearer token. Cover: 401 without token, 403 with wrong role, 200 success, and ensure an idempotent re-send returns the same representation.
Step-by-step (Exercise 1)
  1. Boot app in test mode; run migrations.
  2. Send POST with valid JSON; assert 201, Location, response schema.
  3. Query DB by returned id; assert row exists and matches fields.
  4. Send POST with invalid body; assert 400 and error message.
  5. Cleanup: rollback or truncate.
Step-by-step (Exercise 2)
  1. Seed a user with role=editor and issue a token.
  2. Call PUT with no token; expect 401.
  3. Call PUT with viewer role; expect 403.
  4. Call PUT with editor; expect 200 and persisted change.
  5. Repeat the same PUT; expect 200 with unchanged updatedAt (idempotency).

Common mistakes and self-check

  • Using the prod database by accident. Self-check: print connection string in test logs; ensure it’s a test DB.
  • Not verifying side effects. Self-check: assert DB state after responses.
  • Over-mocking. Self-check: only stub externals you don’t own; keep DB real.
  • Ignoring headers/cache/auth. Self-check: assert critical headers and permission outcomes.
  • Tests depend on each other. Self-check: run tests in random order; they should still pass.

Practical projects

  • Order Service: CRUD endpoints with integration tests for pagination, filtering, and optimistic locking.
  • Auth Gateway: Login, refresh tokens, role-based routes; tests for 401/403 flows and token expiry.
  • Billing Webhooks: Verify signature validation, idempotency keys, and duplicate-event handling with integration tests.

Learning path

  1. Write one happy-path test per critical endpoint.
  2. Add validation/error-path tests (400/404/409).
  3. Layer in auth/permissions scenarios (401/403).
  4. Cover idempotency and concurrency controls.
  5. Introduce parallel runs and speed optimizations.

Mini challenge

Your PATCH /profiles/{id} returns 200 but occasionally leaves the database unchanged. Add an integration test that reproduces the issue by sending a payload missing optional fields. Assert that only provided fields change and others remain intact. Then fix the handler to merge fields correctly.

Next steps

  • Adopt a consistent test data strategy (transactions or truncation).
  • Add integration coverage to the top 5 endpoints by traffic.
  • Automate tests in CI with test containers or ephemeral DBs.

Quick Test

Take the quick test below to check your understanding. Anyone can take it for free; sign in to save your progress.

Practice Exercises

2 exercises to complete

Instructions

Create an integration test for POST /users that:

  • Sends a valid JSON body with an email and expects 201 Created.
  • Asserts a Location header pointing to /users/{id}.
  • Asserts the response body contains id and email.
  • Queries the test database to ensure the user row exists with the same email.
  • Sends an invalid payload and expects 400 with an error field.

Use your stack (Node, Python, Java, Go, etc.). Prefer a test database or transactional tests.

Expected Output
A passing test suite where the valid request returns 201 with Location and persists the user, and the invalid request returns 400 with an error.

Integration Tests For Endpoints — Quick Test

Test your knowledge with 8 questions. Pass with 70% or higher.

8 questions70% to pass

Have questions about Integration Tests For Endpoints?

AI Assistant

Ask questions about this tool