Fetching latest headlinesโ€ฆ
Applying API Testing Frameworks: Real-World Microservices Examples
NORTH AMERICA
๐Ÿ‡บ๐Ÿ‡ธ United Statesโ€ขJune 30, 2026

Applying API Testing Frameworks: Real-World Microservices Examples

6 views0 likes0 comments
Originally published byDev.to

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        โ”‚
                                   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
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

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: "[email protected]", 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: "[email protected]", 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);
  });
});

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: "[email protected]", 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: "[email protected]", password: "wrongpassword" });

    expect(res.status).toBe(401);
  });
});

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 };

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
});

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);
});

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
  });
});

Run the Jest suite

cd tests/jest
npm test

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

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

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

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"]

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": "[email protected]", "password": "PyTest1234!"},
        )
        assert res.status_code == 201
        body = res.json()
        assert body["email"] == "[email protected]"
        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

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": "[email protected]", "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": "[email protected]", "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": "[email protected]", "password": "E2ETest99!"},
        )
        login2 = await client.post(
            f"{user_url}/auth/login",
            json={"email": "[email protected]", "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

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

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

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 };

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)
        โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

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 };

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)
        โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

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.

Comments (0)

Sign in to join the discussion

Be the first to comment!