DEV Community

Andre Carbajal
Andre Carbajal

Posted on

Applying API Testing Frameworks: Real-World Microservices Examples

Most API testing tutorials test a single endpoint in isolation. Real systems are messier: services call each other over HTTP, share JWT tokens, and fail in ways that only show up when the whole thing runs together.

This article takes a different approach. We will build a small but realistic microservices system — user-service, product-service, and order-service — and then write a complete test suite for it using two different frameworks: Jest + Supertest on the Node.js side and Pytest + HTTPX on the Python side. Every code snippet in this article lives in a working repository at github.com/andre-carbajal/api-testing-microservices.


The system we are testing

Before writing a single test, it helps to understand what we are dealing with.

┌──────────────┐     JWT auth      ┌────────────────┐
│  user-service│ ◄───────────────► │  order-service │
│   :3001      │                   │   :3003        │
└──────────────┘                   └──────┬─────────┘
                                          │ stock check
                                          ▼
                                   ┌────────────────┐
                                   │product-service │
                                   │   :3002        │
                                   └────────────────┘
Enter fullscreen mode Exit fullscreen mode
Service Responsibility
user-service Registration, JWT login, user profiles
product-service Product catalog, stock management
order-service Place orders; calls product-service to validate stock and decrement it

The order-service authenticates callers using the same JWT secret as the user-service — a common real-world pattern where auth is decentralized via a shared secret (or public key in production).


Two frameworks, two strategies

We are going to apply two distinct testing strategies, each matching what its framework is best suited for:

Jest + Supertest Pytest + HTTPX
Layer Unit / in-process Integration / E2E
Services running? No — app imported directly Yes — subprocesses
HTTP mocking? Yes (order-service tests) No — real HTTP
Speed Fast (~1 s) Slower (~5–10 s)
What it catches Logic bugs, contract violations Cross-service wiring issues

Both layers are necessary. Unit tests are fast and precise; integration tests prove the system actually works end-to-end.


Part 1 — Jest + Supertest (Node.js)

Why Supertest?

Supertest wraps your Express app and lets you make real HTTP requests against it without starting a server. It binds to an ephemeral port under the hood, so your tests are fast and completely isolated from the network.

cd tests/jest && npm install
Enter fullscreen mode Exit fullscreen mode

Testing the user-service

The user-service handles registration, login, and profile retrieval. Let's look at the test for registration:

// tests/jest/user-service.test.js
const request = require("supertest");
const { app, users } = require("../../services/user-service/src/index");

// Helper to register a user
const registerUser = (overrides = {}) =>
  request(app)
    .post("/auth/register")
    .send({ name: "Alice", email: "alice@example.com", password: "Pass1234!", ...overrides });

beforeEach(() => {
  users.clear(); // reset in-memory state between tests
});

describe("POST /auth/register", () => {
  it("creates a new user and returns 201 with user data (no password)", async () => {
    const res = await registerUser();

    expect(res.status).toBe(201);
    expect(res.body).toMatchObject({ name: "Alice", email: "alice@example.com", role: "user" });
    expect(res.body).not.toHaveProperty("password"); // never expose the hash
  });

  it("returns 409 when email is already registered", async () => {
    await registerUser();
    const res = await registerUser(); // duplicate

    expect(res.status).toBe(409);
    expect(res.body.error).toMatch(/already registered/i);
  });
});
Enter fullscreen mode Exit fullscreen mode

A few things worth noting here:

  1. beforeEach(() => users.clear()) — the service exports its in-memory Map so tests can reset state without restarting the process. This is a deliberate design choice for testability.
  2. expect(res.body).not.toHaveProperty("password") — this assertion is easy to forget but critical. A regression here leaks password hashes to clients.
  3. toMatch(/already registered/i) — matching on a regex instead of an exact string makes the test resilient to minor copy changes.

Testing the login flow

describe("POST /auth/login", () => {
  beforeEach(async () => {
    await registerUser(); // ensure user exists
  });

  it("returns a JWT token on valid credentials", async () => {
    const res = await request(app)
      .post("/auth/login")
      .send({ email: "alice@example.com", password: "Pass1234!" });

    expect(res.status).toBe(200);
    expect(res.body).toHaveProperty("token");
    expect(typeof res.body.token).toBe("string");
  });

  it("returns 401 on wrong password", async () => {
    const res = await request(app)
      .post("/auth/login")
      .send({ email: "alice@example.com", password: "wrongpassword" });

    expect(res.status).toBe(401);
  });
});
Enter fullscreen mode Exit fullscreen mode

Mocking inter-service HTTP in the order-service

This is where things get interesting. The order-service calls the product-service over HTTP to validate stock and decrement it after a successful order. In unit tests, we do not want a real product-service running. We want to control exactly what it returns.

The service is designed with a seam for exactly this purpose:

// services/order-service/src/index.js (simplified)
let httpClient = null;

const getHttpClient = () => {
  if (httpClient) return httpClient; // injected mock
  return require("node-fetch");      // real in production
};

module.exports = { app, orders, setHttpClient };
Enter fullscreen mode Exit fullscreen mode

In tests, we inject a jest.fn() that mimics the product-service responses:

// tests/jest/order-service.test.js
const { app, orders, setHttpClient } = require("../../services/order-service/src/index");

const mockFetch = (productOverrides = {}) => {
  const defaultProduct = {
    id: 1, name: "Mechanical Keyboard", price: 129.99, stock: 50, ...productOverrides,
  };

  return jest.fn().mockImplementation((url, options = {}) => {
    const method = options.method || "GET";

    if (method === "GET" && url.includes("/products/")) {
      return Promise.resolve({
        ok: true,
        json: () => Promise.resolve(defaultProduct),
      });
    }

    if (method === "PATCH" && url.includes("/stock")) {
      return Promise.resolve({
        ok: true,
        json: () => Promise.resolve({ ...defaultProduct, stock: defaultProduct.stock - 2 }),
      });
    }
  });
};

afterEach(() => {
  setHttpClient(null); // restore real fetch
});
Enter fullscreen mode Exit fullscreen mode

Now we can test the "insufficient stock" path without touching any real service:

it("returns 422 when stock is insufficient", async () => {
  setHttpClient(mockFetch({ stock: 1 })); // only 1 unit in stock
  const token = makeToken();

  const res = await request(app)
    .post("/orders")
    .set("Authorization", `Bearer ${token}`)
    .send({ items: [{ productId: 1, quantity: 5 }] }); // requesting 5

  expect(res.status).toBe(422);
  expect(res.body.error).toMatch(/insufficient stock/i);
});
Enter fullscreen mode Exit fullscreen mode

And the happy path, verifying the total is calculated correctly:

it("creates an order and returns 201 with confirmed status", async () => {
  setHttpClient(mockFetch()); // price: 129.99
  const token = makeToken();

  const res = await request(app)
    .post("/orders")
    .set("Authorization", `Bearer ${token}`)
    .send({ items: [{ productId: 1, quantity: 2 }] });

  expect(res.status).toBe(201);
  expect(res.body).toMatchObject({
    status: "confirmed",
    userId: 1,
    total: 259.98, // 129.99 * 2
  });
});
Enter fullscreen mode Exit fullscreen mode

Run the Jest suite

cd tests/jest
npm test
Enter fullscreen mode Exit fullscreen mode

You should see something like:

PASS  user-service.test.js
PASS  product-service.test.js
PASS  order-service.test.js

Test Suites: 3 passed, 3 total
Tests:       22 passed, 22 total
Time:        1.4 s
Enter fullscreen mode Exit fullscreen mode

Part 2 — Pytest + HTTPX (Python)

Why HTTPX?

HTTPX is a modern HTTP client for Python with a clean async API. Paired with pytest-asyncio, it lets you write async test functions that feel natural:

async def test_something(client):
    res = await client.get("/health")
    assert res.status_code == 200
Enter fullscreen mode Exit fullscreen mode

Unlike the Jest tests that import the app directly, Pytest tests run against real running services. This means we need to start them first.

pip install -r tests/pytest/requirements.txt
Enter fullscreen mode Exit fullscreen mode

conftest.py — shared fixtures

conftest.py is where Pytest fixtures live. Ours does three things: start the Node services as subprocesses, wait for them to be healthy, and expose async helpers for auth.

# tests/pytest/conftest.py
import subprocess, time, os
import httpx, pytest

USER_URL = os.getenv("USER_SERVICE_URL", "http://localhost:3001")
PRODUCT_URL = os.getenv("PRODUCT_SERVICE_URL", "http://localhost:3002")
ORDER_URL = os.getenv("ORDER_SERVICE_URL", "http://localhost:3003")


def _wait_for_service(url: str, retries: int = 20, delay: float = 0.5) -> None:
    for _ in range(retries):
        try:
            r = httpx.get(f"{url}/health", timeout=2)
            if r.status_code == 200:
                return
        except httpx.TransportError:
            pass
        time.sleep(delay)
    raise RuntimeError(f"Service at {url} did not become healthy in time")


@pytest.fixture(scope="session")
def services():
    """Start all three Node services once per test session."""
    procs = []
    root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))

    for rel_dir, port in [
        ("services/user-service", 3001),
        ("services/product-service", 3002),
        ("services/order-service", 3003),
    ]:
        p = subprocess.Popen(
            ["node", "src/index.js"],
            cwd=os.path.join(root, rel_dir),
            env={**os.environ, "PORT": str(port)},
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
        procs.append(p)

    for url in [USER_URL, PRODUCT_URL, ORDER_URL]:
        _wait_for_service(url)

    yield {"user": USER_URL, "product": PRODUCT_URL, "order": ORDER_URL}

    for p in procs:
        p.terminate(); p.wait()


@pytest.fixture
async def client():
    async with httpx.AsyncClient(timeout=10) as c:
        yield c


@pytest.fixture
async def auth_token(client, services, registered_user):
    res = await client.post(
        f"{services['user']}/auth/login",
        json={"email": registered_user["email"], "password": registered_user["password"]},
    )
    assert res.status_code == 200
    return res.json()["token"]
Enter fullscreen mode Exit fullscreen mode

The scope="session" on the services fixture means the services start once and stay up for the entire test run — much faster than restarting them for every test.

Integration tests for the user-service

# tests/pytest/test_user_service.py
import pytest

pytestmark = pytest.mark.asyncio


class TestRegister:
    async def test_register_returns_201_with_user_data(self, client, services):
        res = await client.post(
            f"{services['user']}/auth/register",
            json={"name": "Bob", "email": "bob@pytest.com", "password": "PyTest1234!"},
        )
        assert res.status_code == 201
        body = res.json()
        assert body["email"] == "bob@pytest.com"
        assert body["role"] == "user"
        assert "password" not in body  # never expose the hash

    async def test_register_returns_409_on_duplicate_email(self, client, services, registered_user):
        res = await client.post(
            f"{services['user']}/auth/register",
            json=registered_user,  # same email as the fixture
        )
        assert res.status_code == 409
        assert "already registered" in res.json()["error"].lower()


class TestLogin:
    async def test_login_returns_jwt_with_three_segments(self, client, services, registered_user):
        res = await client.post(
            f"{services['user']}/auth/login",
            json={"email": registered_user["email"], "password": registered_user["password"]},
        )
        assert res.status_code == 200
        token = res.json().get("token")
        assert token is not None
        assert len(token.split(".")) == 3  # JWTs always have three base64 segments
Enter fullscreen mode Exit fullscreen mode

The crown jewel: end-to-end order flow

This test exercises the entire system in a single scenario — register, login, browse, order, verify stock, verify history:

# tests/pytest/test_order_flow.py
import pytest

pytestmark = pytest.mark.asyncio


class TestFullOrderFlow:
    async def test_end_to_end_order_flow(self, client, services):
        user_url, product_url, order_url = (
            services["user"], services["product"], services["order"]
        )

        # Step 1: Register
        reg_res = await client.post(
            f"{user_url}/auth/register",
            json={"name": "Charlie", "email": "charlie@e2e.com", "password": "E2ETest99!"},
        )
        assert reg_res.status_code in (201, 409)

        # Step 2: Login
        login_res = await client.post(
            f"{user_url}/auth/login",
            json={"email": "charlie@e2e.com", "password": "E2ETest99!"},
        )
        assert login_res.status_code == 200
        token = login_res.json()["token"]
        headers = {"Authorization": f"Bearer {token}"}

        # Step 3: Browse products
        products_res = await client.get(f"{product_url}/products")
        assert products_res.status_code == 200
        products = products_res.json()
        item_a, item_b = products[0], products[1]
        stock_before = item_a["stock"]

        # Step 4: Place an order
        order_res = await client.post(
            f"{order_url}/orders",
            headers=headers,
            json={"items": [
                {"productId": item_a["id"], "quantity": 2},
                {"productId": item_b["id"], "quantity": 1},
            ]},
        )
        assert order_res.status_code == 201
        order = order_res.json()
        assert order["status"] == "confirmed"
        assert len(order["items"]) == 2
        assert order["total"] > 0

        # Step 5: Verify stock was decremented
        stock_res = await client.get(f"{product_url}/products/{item_a['id']}")
        assert stock_res.json()["stock"] == stock_before - 2

        # Step 6: Verify order isolation — another user cannot access this order
        reg2 = await client.post(
            f"{user_url}/auth/register",
            json={"name": "Dave", "email": "dave@e2e.com", "password": "E2ETest99!"},
        )
        login2 = await client.post(
            f"{user_url}/auth/login",
            json={"email": "dave@e2e.com", "password": "E2ETest99!"},
        )
        headers2 = {"Authorization": f"Bearer {login2.json()['token']}"}
        forbidden = await client.get(f"{order_url}/orders/{order['id']}", headers=headers2)
        assert forbidden.status_code == 403
Enter fullscreen mode Exit fullscreen mode

This single test would have caught the order-service stock-decrement bug, the JWT forwarding wiring, and the order isolation rule — all at once. That is the power of an end-to-end test.

Run the Pytest suite

cd tests/pytest
pytest -v
Enter fullscreen mode Exit fullscreen mode

Output:

tests/pytest/test_user_service.py::TestRegister::test_register_returns_201_with_user_data PASSED
tests/pytest/test_user_service.py::TestLogin::test_login_returns_jwt_with_three_segments PASSED
tests/pytest/test_order_flow.py::TestFullOrderFlow::test_end_to_end_order_flow PASSED
tests/pytest/test_order_flow.py::TestFullOrderFlow::test_order_isolation_between_users PASSED

5 passed in 6.4s
Enter fullscreen mode Exit fullscreen mode

Key patterns to take away

1. Design services for testability

Export internal state (like the in-memory users Map) so tests can reset it. Add a dependency injection seam for HTTP clients so you can mock outbound calls. These are not hacks — they are good design.

// Export state for test teardown
module.exports = { app, users };

// Export a setter so tests can inject a mock HTTP client
module.exports = { app, orders, setHttpClient };
Enter fullscreen mode Exit fullscreen mode

2. Use beforeEach to reset state, not test ordering

Never rely on tests running in a specific order. Each test should set up everything it needs and clean up after itself. The beforeEach(() => users.clear()) pattern enforces this.

3. Match the right tool to the right layer

  • Supertest: fast, in-process, ideal for unit-testing Express route logic and middleware.
  • HTTPX async: clean async API for integration and E2E tests against real running services.
  • Do not try to use one tool for everything.

4. Test the contract, not the implementation

A test like expect(res.body).not.toHaveProperty("password") documents a security contract. A test that checks the exact SQL query being run is brittle and tests the wrong thing.

5. Layer your test pyramid

          ┌─────────┐
          │   E2E   │ ← Pytest + HTTPX (few, slow, high confidence)
         ┌┴─────────┴─┐
         │Integration │ ← Pytest + HTTPX (services + real HTTP)
        ┌┴────────────┴┐
        │    Unit      │ ← Jest + Supertest (many, fast, isolated)
        └──────────────┘
Enter fullscreen mode Exit fullscreen mode

Most of your tests should be at the bottom. A handful of E2E tests at the top catch wiring issues that unit tests cannot.


Repository

All the code from this article is available at:

👉 github.com/andre-carbajal/api-testing-microservices

The repository includes:

  • Three fully working Express microservices
  • 22 Jest + Supertest unit tests
  • Pytest integration and E2E tests with async fixtures
  • A conftest.py that manages service lifecycle automatically

Clone it, run the tests, break something, and see which test catches it first.


Wrapping up

Testing APIs in a microservices architecture is not just about making requests and checking status codes. It is about choosing the right layer for each concern:

  • Use Jest + Supertest to test your route logic fast, in isolation, with full control over dependencies.
  • Use Pytest + HTTPX to prove your services wire together correctly in a real running environment.
  • Design your services with testability in mind — exportable state, injectable HTTP clients, and clean health endpoints.

The patterns here scale well: whether you have three services or thirty, the same layered approach — many fast unit tests, fewer slower integration tests, a handful of E2E smoke tests — gives you confidence without making your test suite painful to run.


Found this useful? The repo is open source — PRs and issues welcome.

1. Design services for testability

Export internal state (like the in-memory users Map) so tests can reset it. Add a dependency injection seam for HTTP clients so you can mock outbound calls. These are not hacks — they are good design.

// Export state for test teardown
module.exports = { app, users };

// Export a setter so tests can inject a mock HTTP client
module.exports = { app, orders, setHttpClient };
Enter fullscreen mode Exit fullscreen mode

2. Use beforeEach to reset state, not test ordering

Never rely on tests running in a specific order. Each test should set up everything it needs and clean up after itself. The beforeEach(() => users.clear()) pattern enforces this.

3. Match the right tool to the right layer

  • Supertest: fast, in-process, ideal for unit-testing Express route logic and middleware.
  • HTTPX async: clean async API for integration and E2E tests against real running services.
  • Do not try to use one tool for everything.

4. Test the contract, not the implementation

A test like expect(res.body).not.toHaveProperty("password") documents a security contract. A test that checks the exact SQL query being run is brittle and tests the wrong thing.

5. Layer your test pyramid

          ┌─────┐
          │ E2E │  ← Pytest + HTTPX (few, slow, high confidence)
         ┌┴─────┴┐
         │Integration│ ← Pytest + HTTPX (services + real HTTP)
        ┌┴──────────┴┐
        │    Unit    │ ← Jest + Supertest (many, fast, isolated)
        └────────────┘
Enter fullscreen mode Exit fullscreen mode

Most of your tests should be at the bottom. A handful of E2E tests at the top catch wiring issues that unit tests cannot.


Repository

All the code from this article is available at:

👉 github.com/andre-carbajal/api-testing-microservices

The repository includes:

  • Three fully working Express microservices
  • 22 Jest + Supertest unit tests
  • Pytest integration and E2E tests with async fixtures
  • A conftest.py that manages service lifecycle automatically

Clone it, run the tests, break something, and see which test catches it first.


Wrapping up

Testing APIs in a microservices architecture is not just about making requests and checking status codes. It is about choosing the right layer for each concern:

  • Use Jest + Supertest to test your route logic fast, in isolation, with full control over dependencies.
  • Use Pytest + HTTPX to prove your services wire together correctly in a real running environment.
  • Design your services with testability in mind — exportable state, injectable HTTP clients, and clean health endpoints.

The patterns here scale well: whether you have three services or thirty, the same layered approach — many fast unit tests, fewer slower integration tests, a handful of E2E smoke tests — gives you confidence without making your test suite painful to run.


Found this useful? The repo is open source — PRs and issues welcome.

Top comments (0)