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: "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);
});
});
A few things worth noting here:
-
beforeEach(() => users.clear())— the service exports its in-memoryMapso tests can reset state without restarting the process. This is a deliberate design choice for testability. -
expect(res.body).not.toHaveProperty("password")— this assertion is easy to forget but critical. A regression here leaks password hashes to clients. -
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);
});
});
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": "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
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
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.pythat 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.pythat 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)