Microservices & Event-Driven Testing

API Integration Testing: Strategies, Example and CI Setup

Sushant Joshi12 min read

Quick answer

API integration testing checks that an API works correctly with the things it depends on: database, broker, cache and downstream services. API testing is integration testing when the test calls a running service wired to real or realistic dependencies. Use containers for what you own, virtualized services for what you don't, and gate every pull request.

Reviewed by Parveen Kumari

Share:
API integration testing diagram: a pytest suite calls the orders API, which talks to a real Postgres container and a WireMock stub of the payments service, all started by docker compose in CI

Part of the Microservices & Event-Driven Testing hub. Start with the pillar guide: Microservices testing: strategy, types and tools.

Is API testing integration testing? Sometimes. API testing is how you test: through the interface. Integration testing is what you test: whether components work together. An API test becomes an API integration test when it calls a running service wired to its real database, queue or downstream services and checks that the whole slice behaves correctly.

That distinction matters because it decides what your tests can catch. A suite that mocks every dependency will pass while the SQL migration is broken. A suite that needs twelve deployed services will be too slow and flaky to gate a pull request. This guide sits in our microservices testing hub and covers the middle ground: what to test at the integration level, how to handle each kind of dependency, a runnable docker-compose + pytest example, and how to wire it into CI.

What is API integration testing?

API integration testing verifies that an API and the components it depends on work together correctly when exercised through the API's public interface. A typical test sends a real HTTP request to a running instance of the service, lets it talk to a real (usually containerized) database and to a stubbed or real downstream service, and then asserts on three things:

  1. The response: status, body and headers.
  2. The side effects: the row in the database, the message on the queue, the call to the downstream service.
  3. The failure handling: what the API does when a dependency is slow, down or returns garbage.

The second and third points are what separate an integration test from a plain functional API test. A functional test asks "did POST /orders return 201?" An integration test also asks "did the order land in Postgres with the right total, did we call the payments service exactly once, and what happens when payments returns 503?"

API integration testing vs unit, contract and end-to-end testing

Each level answers a different question. Mixing them up is how teams end up with slow suites that still miss bugs.

LevelWhat runs for realWhat is fakedCatchesTypical speed
UnitOne function or classEverything elseLogic errorsMilliseconds
ComponentOne service in-processAll external dependenciesRouting, validation, serializationTens of ms
API integrationOne service + its database, broker, cacheDownstream services you don't ownSQL, migrations, transactions, timeouts, retries100 ms–2 s per test
ContractConsumer or provider, one side at a timeThe other sideInterface drift between teamsMilliseconds–seconds
End-to-endMany deployed services, often the UIVery littleJourney-level breakageSeconds–minutes

Two pairs cause most of the confusion:

  • API testing vs integration testing. They overlap. Most API test suites contain integration tests, and most integration tests at the service boundary are API tests. The "unit vs integration" question is about how many real components are involved, not about whether HTTP is used. See unit vs integration vs system testing for the wider pyramid.
  • Integration vs contract testing. When you stub a downstream service, your integration test proves your service works with the stub. A contract test proves the stub still matches the real provider. You need both; neither replaces the other.

What to cover in API integration tests

Integration tests are expensive compared with unit tests, so aim them at the boundaries where bugs hide:

  • Persistence. Writes are committed, reads see them, unique constraints return 409 rather than 500, migrations run on an empty database.
  • Transactions. A failure halfway through a multi-step write leaves nothing behind.
  • Downstream calls. The right request goes out (method, path, headers, idempotency key), exactly once, with the right timeout.
  • Dependency failure. Downstream 5xx, timeouts and malformed bodies map to a documented response such as 502, 503 or 202 with a retry, never a hang or a stack trace.
  • Messaging. A write publishes the expected event; a consumed event changes state. See testing event-driven microservices.
  • Auth wiring. The service validates real tokens from your identity provider or a realistic stand-in, not a hard-coded bypass flag.
  • Configuration. Connection strings, feature flags and secrets resolve the way they will in production.

Leave pure input validation and business rules to unit and component tests, and leave cross-service user journeys to a handful of end-to-end tests.

Strategies for dependencies: containers, virtualization and mocks

The core design decision in API integration testing is what to run for real. A useful rule: run what you own, virtualize what you don't.

StrategyUse it forStrengthsWatch out for
TestcontainersYour database, broker, cacheReal engine, created per test run, code-controlled lifecycleNeeds Docker on the runner; image pulls slow down cold starts
docker composeThe service plus its dependencies as one stackSame file runs locally and in CI; language-agnosticShared state between tests unless you reset it
Service virtualization (WireMock, Mountebank, Hoverfly)Third-party and other teams' APIsDeterministic responses, fault injection, request verificationStubs drift from reality without contract tests
Hosted or spec-generated mocksEarly development, parallel teamsNo infrastructure to run; built from the specUsually stateless; limited fault simulation
In-process mocks (unittest.mock, Mockito, nock)Component testsFastestNot an integration test any more
Shared stagingA final smoke checkClosest to productionContention, flaky data, slow feedback

Ready to shift left with your API testing?

Try our no-code API test automation platform free. Generate tests from OpenAPI, run in CI/CD, and scale quality.

In-process frameworks help here too. Spring Boot's @SpringBootTest with Testcontainers @ServiceConnection, and ASP.NET Core's WebApplicationFactory<Program>, start your service in the test process against a real containerized database. That covers most "Spring Boot", ".NET Core" and "C# web API integration testing" setups without a separate compose file. For mocking options in depth, see API mocking for parallel development.

Worked example: docker compose + pytest

The system under test is an orders API. It writes to Postgres and calls a payments service before marking an order paid. We run the API and Postgres for real and stand in for payments with WireMock, so we can script approvals, declines and outages.

1. The stack (docker-compose.test.yml):

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: orders
      POSTGRES_PASSWORD: orders
      POSTGRES_DB: orders
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U orders"]
      interval: 2s
      retries: 15
    ports: ["5432:5432"]

  payments:
    image: wiremock/wiremock:3.9.1
    ports: ["8081:8080"]
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/__admin/health"]
      interval: 2s
      retries: 15

  api:
    build: .
    environment:
      DATABASE_URL: postgresql://orders:orders@db:5432/orders
      PAYMENTS_URL: http://payments:8080
      PAYMENTS_TIMEOUT_MS: "800"
    depends_on:
      db: { condition: service_healthy }
      payments: { condition: service_healthy }
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 2s
      retries: 30
    ports: ["8000:8000"]

2. Fixtures (tests/conftest.py). Each test resets WireMock and truncates the tables, so tests are independent and can run in any order.

import os, psycopg, pytest, requests

API = os.getenv("API_URL", "http://localhost:8000")
WIREMOCK = os.getenv("WIREMOCK_URL", "http://localhost:8081")
DB_DSN = os.getenv("DB_DSN", "postgresql://orders:orders@localhost:5432/orders")

@pytest.fixture(autouse=True)
def clean_state():
    requests.post(f"{WIREMOCK}/__admin/reset", timeout=5)
    with psycopg.connect(DB_DSN, autocommit=True) as conn:
        conn.execute("TRUNCATE orders, order_items RESTART IDENTITY CASCADE")
    yield

@pytest.fixture
def db():
    with psycopg.connect(DB_DSN) as conn:
        yield conn

def stub_payments(status, body=None, delay_ms=0):
    requests.post(f"{WIREMOCK}/__admin/mappings", json={
        "request": {"method": "POST", "url": "/v1/charges"},
        "response": {"status": status, "jsonBody": body or {},
                     "fixedDelayMilliseconds": delay_ms},
    }, timeout=5).raise_for_status()

def payment_calls():
    r = requests.post(f"{WIREMOCK}/__admin/requests/count",
                      json={"method": "POST", "url": "/v1/charges"}, timeout=5)
    return r.json()["count"]

3. The tests (tests/test_orders_integration.py):

import requests
from conftest import API, stub_payments, payment_calls

ORDER = {"customerId": "c_1", "items": [{"sku": "BOOK-1", "quantity": 2, "unitPrice": 12.5}]}

def test_paid_order_is_persisted(db):
    stub_payments(201, {"chargeId": "ch_1", "status": "approved"})

    r = requests.post(f"{API}/orders", json=ORDER, timeout=5)

    assert r.status_code == 201
    assert r.json()["status"] == "PAID"
    row = db.execute("SELECT status, total FROM orders WHERE id = %s",
                     (r.json()["id"],)).fetchone()
    assert row == ("PAID", 25.0)
    assert payment_calls() == 1          # charged exactly once

def test_declined_payment_rolls_back(db):
    stub_payments(402, {"status": "declined"})

    r = requests.post(f"{API}/orders", json=ORDER, timeout=5)

    assert r.status_code == 402
    assert db.execute("SELECT count(*) FROM orders").fetchone()[0] == 0
    assert db.execute("SELECT count(*) FROM order_items").fetchone()[0] == 0

def test_payments_timeout_returns_503_not_hang():
    stub_payments(201, {"status": "approved"}, delay_ms=3000)   # slower than the 800 ms budget

    r = requests.post(f"{API}/orders", json=ORDER, timeout=5)

    assert r.status_code == 503
    assert r.elapsed.total_seconds() < 2
    assert "Retry-After" in r.headers

4. Run it locally:

docker compose -f docker-compose.test.yml up -d --build --wait
pytest tests -q
docker compose -f docker-compose.test.yml down -v

--wait blocks until every health check passes, which removes the most common source of flaky first tests.

Prefer Testcontainers? The same Postgres can be started from the test code instead of a compose file. This suits teams that run tests from the IDE:

import pytest
from testcontainers.postgres import PostgresContainer

@pytest.fixture(scope="session")
def pg_url():
    with PostgresContainer("postgres:16-alpine") as pg:
        yield pg.get_connection_url()   # pass to the app under test as DATABASE_URL

Want this pattern applied across a whole service estate, with a decision table for which level owns which risk? The Microservices Testing Strategy Blueprint lays it out service by service.

Free Visual blueprint + guide

Microservices Testing Strategy Blueprint

A visual testing strategy for microservices architectures. Covers testing layers, contract testing, service mesh validation, and tool selection.

Download Free

Running API integration tests in CI

The same compose file runs in CI. On GitHub Actions, the Ubuntu runners ship with Docker and Compose v2:

name: api-integration
on: [pull_request]
jobs:
  integration:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install pytest requests "psycopg[binary]"
      - run: docker compose -f docker-compose.test.yml up -d --build --wait
      - run: pytest tests --junitxml=integration.xml
      - if: failure()
        run: docker compose -f docker-compose.test.yml logs api
      - if: always()
        run: docker compose -f docker-compose.test.yml down -v

A few rules keep the job useful:

  • Gate the pull request, and keep the job under about ten minutes. Split by service if it grows past that.
  • Dump service logs on failure. An integration failure without the API's log is a guessing game.
  • Pin image tags. postgres:latest changing under you is not a bug in your code.
  • Publish JUnit XML so failures show up per test in the PR, not as one red job.

Some teams build these multi-step checks without writing the harness. In Shift-Left API, integration test workflows chain calls on a visual canvas, pass values between steps through extractions, and route failures through dedicated failure connectors. Its mock server can stand in for a dependency from the imported spec, as long as the environment's base URL points at the mock URL. Workflows run from packs in CI through the GitHub Actions, Jenkins and Azure DevOps integrations, or through the REST API for other CI systems.

API integration testing best practices and common mistakes

  • Mocking your own database. If the repository layer is mocked, you are not testing the integration. Use a real engine of the same major version as production.
  • Sharing state between tests. Truncate or use unique ids per test. Order-dependent suites fail randomly as soon as you parallelize.
  • Only testing the happy path. Dependency failure handling (timeouts, 5xx, partial writes) is the main reason integration tests exist.
  • Stubs with no contract check. A WireMock stub written two years ago proves nothing about today's payments API. Back each stub with contract testing or regenerate it from the provider's spec.
  • Asserting only status codes. Check the side effect too: the row, the message, the outbound call count.
  • Testing through staging only. Shared environments are for a final smoke check, not for the main integration suite.
  • Fixed sleeps. Wait on health checks or poll for a condition with a timeout instead of sleep(10).

For the journey level above this, see end-to-end testing strategies for microservices.

Related reading: Testing Kafka-Based Microservices: Complete Guide

API integration testing FAQ

Is API testing integration testing?

Sometimes. API testing describes how you test (through the API's interface); integration testing describes what you test (whether components work together). An API test that calls a running service connected to a real database or downstream service is an integration test. An API test that hits a handler with every dependency mocked is closer to a unit or component test.

What is the difference between API testing and integration testing?

API testing is a technique: send HTTP, GraphQL or other protocol requests and assert on the responses. Integration testing is a level in the test pyramid: verify that two or more components cooperate correctly. They overlap heavily, which is why most API test suites contain integration tests, but you can also integration-test code paths without going through an API.

Should API integration tests use mocks?

Mock or virtualize what you don't own and can't run cheaply, such as a third-party payment provider, but run the dependencies you own for real, usually in containers. Mocking your own database removes the integration you were trying to test. Pair the mocks with contract tests so a stub can't drift away from the real service.

What tools are used for API integration testing?

Common choices are a test runner (pytest, JUnit, xUnit, Jest), an HTTP client or DSL (requests, REST Assured, Supertest), Testcontainers or docker compose for real dependencies, and WireMock, Mockoon or a hosted mock server for downstream services. Spring Boot's @SpringBootTest and ASP.NET Core's WebApplicationFactory start the service in-process.

How do you run API integration tests in CI?

Start the service and its dependencies in the CI job (docker compose up --wait or Testcontainers), wait for health checks, seed data per test, run the suite, publish JUnit XML and tear everything down. Keep the job under about ten minutes so it can block pull requests.

How is API integration testing different from end-to-end testing?

An integration test checks one service against its direct dependencies in an isolated environment. An end-to-end test drives a whole user journey across many deployed services, often through the UI. Integration tests are faster, more numerous and easier to debug; end-to-end tests are few and cover critical journeys.

Sources and further reading

Ready to shift left with your API testing?

Import your OpenAPI spec and get a runnable test suite, in our cloud or on your own infrastructure.