Skip to content
Production Ready — Part 10 of 30

Testing API Routes: Making Sure Your Backend Works

Written by claude-sonnet-4 · Edited by claude-sonnet-4
api-testingbackendsecurityjestpytestexpressfastapici-cdauthenticationauthorizationproduction

Production Ready — Part 10 of 30


The McDonald's Breach That Changed How I Talk About Testing

In early 2025, security researchers discovered that McDonald's hiring platform, McHire — built on a third-party service called Paradox.ai — had exposed the personal data of 64 million job applicants. Names, email addresses, phone numbers, chat histories, even personality test results.

Here's the part that should keep you up at night: the attack wasn't sophisticated. The admin account had default credentials (literally 123456 / 123456). And once inside, the attacker found that every API endpoint for fetching applicant data accepted a plain sequential integer as an ID — and never checked whether the authenticated user was authorized to see that particular record. You could cycle through IDs like turning pages in a book.

That's called Broken Object Level Authorization (BOLA), and according to APISecurity.io's 2025 year-end review, it was the second most frequent API vulnerability category of the entire year. Meanwhile, missing authentication entirely — endpoints that simply never check who you are — rose to the #1 spot in 2025, accounting for 17% of all reported API incidents.

Neither of these is a mysterious zero-day exploit. Both are things a basic test suite would catch in 30 seconds.

So let's build that test suite.


Why Vibe Coders Skip This (And Why They Regret It)

I've coached hundreds of developers over 25 years. The pattern I see with vibe coders — people who build fast and iterate even faster — is that API testing feels abstract and annoying until the moment it becomes catastrophically urgent. The AI wrote the route, it returned the right data in Postman, you shipped it. Done.

But "returned the right data in Postman" answers exactly one question: does it work when I use it correctly? Production doesn't work correctly. Production sends malformed inputs, expired tokens, someone else's user ID, and 500 concurrent requests at 2am.

Your routes need to handle all of that gracefully. Tests are how you prove they do, before users prove they don't.


The Four Things Every Route Needs to Prove

When I review a new backend, I apply a simple four-part checklist to every route:

  1. The happy path works — valid input, authenticated user, correct response
  2. Authentication is enforced — no token = 401, not 200 or 500
  3. Authorization is enforced — your token can't access someone else's data
  4. Bad input is handled gracefully — garbage in, clean error out (not a stack trace)

Everything else — performance, edge cases, contract validation — layers on top of these four. Get these right first.


Setting Up: A Simple Express API to Test

Let's use a minimal Node.js/Express API as our example. Imagine this route:

// routes/orders.js
router.get('/orders/:id', authenticate, async (req, res) => {
  const order = await db.orders.findById(req.params.id);
  if (!order) return res.status(404).json({ error: 'Order not found' });
  res.json(order);
});

Notice anything? The route checks if the order exists, but it doesn't check if the order belongs to the requesting user. This is textbook BOLA — exactly what brought down McDonald's hiring platform.

The fix is one line:

router.get('/orders/:id', authenticate, async (req, res) => {
  const order = await db.orders.findById(req.params.id);
  if (!order) return res.status(404).json({ error: 'Order not found' });

  // THE CRITICAL CHECK — don't skip this
  if (order.userId !== req.user.id) {
    return res.status(403).json({ error: 'Forbidden' });
  }

  res.json(order);
});

Now let's write tests that force this behavior to be verified on every deploy.


Writing Real Route Tests with Jest + Supertest

Supertest is the standard tool for testing Express routes without spinning up a live server. Install it:

npm install --save-dev jest supertest

Here's a complete test file that covers all four of our required checks:

// tests/orders.test.js
const request = require('supertest');
const app = require('../app');
const { createTestUser, generateToken, seedOrder } = require('./helpers');

describe('GET /orders/:id', () => {
  let userA, userB, tokenA, tokenB, orderA;

  beforeEach(async () => {
    userA = await createTestUser({ email: 'alice@test.com' });
    userB = await createTestUser({ email: 'bob@test.com' });
    tokenA = generateToken(userA);
    tokenB = generateToken(userB);
    orderA = await seedOrder({ userId: userA.id, total: 59.99 });
  });

  // 1. Happy path
  it('returns the order for the owner', async () => {
    const res = await request(app)
      .get(`/orders/${orderA.id}`)
      .set('Authorization', `Bearer ${tokenA}`);

    expect(res.status).toBe(200);
    expect(res.body.id).toBe(orderA.id);
    expect(res.body.total).toBe(59.99);
  });

  // 2. Authentication enforced
  it('returns 401 when no token is provided', async () => {
    const res = await request(app).get(`/orders/${orderA.id}`);
    expect(res.status).toBe(401);
  });

  // 3. Authorization enforced (BOLA check)
  it('returns 403 when user B tries to access user A order', async () => {
    const res = await request(app)
      .get(`/orders/${orderA.id}`)
      .set('Authorization', `Bearer ${tokenB}`);

    expect(res.status).toBe(403);
    // Critical: the body should NOT contain any order data
    expect(res.body.total).toBeUndefined();
  });

  // 4. Bad input handled gracefully
  it('returns 404 for a non-existent order ID', async () => {
    const res = await request(app)
      .get('/orders/nonexistent-id-99999')
      .set('Authorization', `Bearer ${tokenA}`);

    expect(res.status).toBe(404);
    // Make sure we're not leaking a stack trace
    expect(res.body.stack).toBeUndefined();
  });
});

That third test — userB trying to access userA's order — is the one that would have caught the McDonald's vulnerability during development. It takes 10 seconds to write. It could have protected 64 million people.


The Python Equivalent (FastAPI + pytest)

If you're on a Python stack, the same philosophy applies with FastAPI and pytest:

# tests/test_orders.py
import pytest
from httpx import AsyncClient
from app.main import app
from tests.factories import create_user, create_order, make_token

@pytest.mark.asyncio
async def test_owner_can_access_order():
    user = await create_user()
    order = await create_order(user_id=user.id)
    token = make_token(user)

    async with AsyncClient(app=app, base_url="http://test") as client:
        response = await client.get(
            f"/orders/{order.id}",
            headers={"Authorization": f"Bearer {token}"}
        )
    assert response.status_code == 200
    assert response.json()["id"] == str(order.id)

@pytest.mark.asyncio
async def test_other_user_cannot_access_order():
    owner = await create_user()
    attacker = await create_user()
    order = await create_order(user_id=owner.id)
    attacker_token = make_token(attacker)

    async with AsyncClient(app=app, base_url="http://test") as client:
        response = await client.get(
            f"/orders/{order.id}",
            headers={"Authorization": f"Bearer {attacker_token}"}
        )
    assert response.status_code == 403

@pytest.mark.asyncio
async def test_unauthenticated_request_is_rejected():
    async with AsyncClient(app=app, base_url="http://test") as client:
        response = await client.get("/orders/any-id")
    assert response.status_code == 401

Running Tests in Your CI Pipeline

Tests that only run locally are decorative. The whole point is to block bad code from reaching production automatically.

Here's a minimal GitHub Actions workflow that runs your test suite on every push:

# .github/workflows/test.yml
name: API Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - name: Use Node.js 20
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run API tests
        run: npm test
        env:
          DATABASE_URL: postgres://postgres:testpass@localhost:5432/testdb
          JWT_SECRET: test-secret-not-for-production

Now any pull request that breaks authentication or authorization gets blocked before it merges. The Volkswagen breach of 2025 — where their connected car app's OTP endpoint had no rate limiting, letting attackers brute-force login in seconds — would have been blocked by a test that simply verifies the 429 response kicks in after N failed attempts. Security researchers documented the full attack chain: a multithreaded Python script cycled through all 10,000 four-digit combos in under a minute. One rate-limit test. One blocked PR. Millions of vehicle owners protected.


What to Test Beyond the Happy Path

Once the four core checks pass, here's the priority order for expanding coverage:

Priority Test Type What It Catches
🔴 Critical Auth/authz on every protected route BOLA, missing auth (top 2 vulnerabilities in 2025)
🔴 Critical Input validation on write routes Injection attacks, data corruption
🟡 High Error response format Stack traces leaking internals
🟡 High Pagination / limit enforcement Unbounded data dumps
🟢 Medium Rate limiting behavior Brute force, DDoS amplification
🟢 Medium Response schema validation Schema drift breaking consumers
🔵 Nice-to-have Performance under load Latency regressions

Start with the red row. Get 100% coverage there before writing a single performance test. Organizations test only 38% of their APIs for vulnerabilities on average — being in the tested 100% puts you in rare company.


A Quick Note on Test Data

The biggest friction point I see in new test suites is test data setup. Two rules that will save you weeks of pain:

Rule 1: Always use a separate test database. Set NODE_ENV=test and point it at a different DB than development. Never run tests against real data.

Rule 2: Reset between tests. Each test should start clean. Use beforeEach to seed only what that test needs. Shared state between tests is how you get tests that pass in isolation and fail in CI.

// In your test setup file
beforeEach(async () => {
  await db.orders.deleteMany({});
  await db.users.deleteMany({});
});

afterAll(async () => {
  await db.$disconnect();
});

Your Action Checklist

  • Install supertest + jest (Node) or httpx + pytest (Python) in your project
  • Write a test for every protected route that checks: happy path, 401 without token, 403 for wrong user, 404 for missing resource
  • Add the authorization check (resource.userId !== req.user.id) to every route that returns user-specific data — then write the test that would fail without it
  • Add your test command to a GitHub Actions workflow that runs on every PR to main
  • Audit your existing routes: open each one and ask "can a valid user access another user's data through this endpoint?"
  • Set up a separate test database and confirm NODE_ENV=test routes to it
  • Add at minimum one negative test (bad input, expired token) for every route you add going forward

Ask The Guild

Community prompt: What's the most embarrassing API bug you've shipped to production — and what test would have caught it? Drop it in the thread below. Bonus points if you include the one-line test that would have blocked it. We learn faster from war stories than from documentation, and I promise: whatever you share, someone in this guild has done something worse. 🙂

Copy A Prompt Next

Review and debug

If this article changed how you think about the problem, copy a prompt that turns that judgment into one safe, reviewable next step.

Matching public prompts

23

Keep the task scoped, copy the prompt, then inspect one reviewable diff before the agent continues.

Need the safest first move instead? Open the curated sample prompts before you browse the broader library.

Working With AI ToolsWorking With AI Tools

v0 by Vercel — UI Components From a Text Prompt

Generate production-ready UI components with v0 and integrate them into your projects.

Preview
"I want v0 to generate a React component for this screen:
[describe the UI, data fields, visual style, empty state, loading state, and mobile behavior]
The component must:
1. work in a Next.js + Tailwind project
2. be easy to wire to real data later
Production Ready

Use this production insight inside a full build sequence

Production articles show you what breaks in the real world. The right path turns that lesson into a sequence you can ship with instead of just nodding at.

Best Next Path

DevOps and Deployment

Guild Member · $29/mo

Connect the code to production: CI/CD, hosting, observability, DNS, and the runtime habits that keep launches boring.

25 lessonsIncluded with the full Guild Member library

Need the free route first?

Start with Start Here — Build Safely With AI if you want the workflow and vocabulary before you dive into the deeper path above.

T

About Tom Hundley

Tom Hundley writes for builders who need stronger technical judgment around AI-assisted software work. The Guild turns production experience into public articles, copy-paste prompts, and structured learning paths that help non-software developers supervise AI agents more safely.

Do this next

Leave this article with one concrete move. Copy the matching prompt, or start with the path that teaches the safest next skill in sequence.