I was looking for a REST API to practice API testing on that wasn't yet another "todo list" or "fake store" clone, and I landed on funapi.dev — a free, no-signup playground with a few dozen mock APIs, everything from a Harry Potter API to a bank API with a deliberately broken 402. One of them caught my eye immediately: a full World Cup 2026 API, with 48 teams, live group standings, a Golden Boot leaderboard, ticket purchases that can actually sell out, and match simulation.
I figured it'd be a fun, low-stakes target for a Playwright test suite. It was — but not for the reason I expected. The interesting part of this post isn't really the football data. It's a gotcha I ran into before I wrote a single expect().
First move: curl it
Old habit — before opening an editor, I like to poke an API from the terminal.
curl https://funapi.dev/api/worldcup/v1/teams
Nothing. No JSON, no error, no response at all. I tried it in a plain fetch() from Node too. Same result. My first assumption was that the endpoint needed a header I was missing, or that I'd mistyped the base path. Neither was true.
Here's what's actually going on: funapi.dev doesn't run these APIs on a backend server at all. Every mock API on the site — World Cup 2026 included — is implemented inside a Service Worker that gets registered the moment you load a page on the domain. You can check this yourself in DevTools, or just ask the page:
const regs = await navigator.serviceWorker.getRegistrations();
console.log(regs.map(r => r.active?.scriptURL));
// → ["https://funapi.dev/sw.js"]
fetch() calls made inside a real browser tab on that origin get intercepted by sw.js and answered with realistic mock JSON, correct status codes, pagination, the works. fetch() calls made from curl, Postman, or a Node script never touch a browser, so there's no service worker to intercept them — they just fall through to the static site, which has nothing to say about /api/worldcup/v1/teams.
Once I understood that, the choice of Playwright stopped being "a tool I happened to like" and became the actually correct tool for the job. Postman and plain HTTP clients are structurally unable to talk to this API. You need something that drives a real browser engine.
The second gotcha: Playwright has two HTTP clients
This is the part that got me. Playwright ships an APIRequestContext (the request fixture) specifically for testing APIs without spinning up a browser page — it's fast, and it's the officially recommended way to hit REST endpoints in a Playwright test.
It also doesn't work here, for the exact same reason curl doesn't. request.get() fires from Playwright's own Node process, not from inside a page, so it bypasses the service worker just like curl did. I only caught this because my first attempt returned a 404 from the static host instead of the mock response I expected.
The fix is to keep the fetch inside the page, using page.evaluate():
import { test, expect, type Page } from '@playwright/test';
async function api<T = any>(
page: Page,
path: string,
init?: { method?: string; token?: string }
): Promise<{ status: number; body: T }> {
return page.evaluate(
async ({ path, init }) => {
const headers: Record<string, string> = {};
if (init?.token) headers['Authorization'] = `Bearer ${init.token}`;
const res = await fetch(path, { method: init?.method ?? 'GET', headers });
return { status: res.status, body: await res.json() };
},
{ path, init }
);
}
Every test navigates to a page on the funapi.dev origin first (I used the docs page for the World Cup API) and waits for the service worker to be ready, then routes all its requests through this helper instead of request.get().
Writing tests against actual endpoints
The API has 17 endpoints across teams, matches, venues, players, tickets, and a "fanzone" group with mascots and champion predictions. I didn't try to cover all of it — I picked a handful of tests that exercise real behavior instead of just happy-path smoke checks.
test.describe('World Cup 2026 API (funapi.dev)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://funapi.dev/docs/worldcup');
await page.evaluate(() => navigator.serviceWorker.ready);
});
test('lists all 48 qualified teams, hosts included', async ({ page }) => {
const { status, body } = await api(page, '/api/worldcup/v1/teams');
expect(status).toBe(200);
expect(body.total).toBe(48);
const hosts = body.data.filter((t: any) => t.host);
expect(hosts.map((t: any) => t.code).sort()).toEqual(['CAN', 'MEX', 'USA']);
});
test('rejects unknown query parameters instead of silently ignoring them', async ({ page }) => {
const { status, body } = await api(page, '/api/worldcup/v1/teams?limit=3');
expect(status).toBe(400);
expect(body.message).toMatch(/unknown query parameter/i);
});
test('blocks the goal timeline until the match has actually been played', async ({ page }) => {
const { status, body } = await api(page, '/api/worldcup/v1/matches/1/events');
expect(status).toBe(409);
expect(body.message).toMatch(/simulate/i);
});
test('requires a bearer token before it will simulate a match', async ({ page }) => {
const { status, body } = await api(page, '/api/worldcup/v1/matches/1/simulate', {
method: 'POST',
});
expect(status).toBe(401);
expect(body.message).toMatch(/bearer token/i);
});
});
A couple of things worth calling out here, because they're the reason I picked these particular tests over just asserting status === 200 everywhere:
The 400 on unknown query params is a real design choice worth testing. A lot of production APIs silently ignore parameters they don't recognize, which means a typo like ?limt=10 slips past code review and just quietly returns an unfiltered list — a bug that's genuinely annoying to track down later. This API uses strict query validation and fails loudly instead. That's exactly the kind of behavior I want a regression test pinned on, so nobody "fixes" it into permissiveness by accident.
The 409 on /matches/{id}/events models a real state machine. You can't get a goal timeline for a match that hasn't been simulated yet — the API tells you so explicitly, with a message pointing at the endpoint you need to call first (POST /matches/{id}/simulate). Testing the "wrong order of operations" path is usually more valuable than testing the happy path, and it's the kind of case that's easy to skip when you're just trying to get a suite green.
The 401 on the simulate endpoint checks auth is actually enforced, not just documented. The docs list this endpoint as requiring a Bearer token (qa-admin-token for admin-only actions, qa-viewer-token for read-level access, per the getting started guide) — but "the docs say so" and "the server actually rejects unauthenticated requests" are two different claims, and only one of them is worth trusting without a test.
I also threw in a couple of low-effort smoke tests for the Golden Boot leaderboard and the tournament mascots (Maple the moose, Zayu the jaguar, and Clutch the bald eagle, if you're curious), mostly to prove the pattern generalizes past the teams and matches resources.
Running it
Nothing unusual on the setup side, since the trick is entirely in how the requests are made, not in the Playwright config:
npm init playwright@latest
npx playwright test worldcup-api.spec.ts
One thing I'd flag if you're adapting this: don't run the suite in parallel against shared state you didn't reset. The mock backend keeps state (matches once simulated stay simulated, tickets once sold stay sold) for the lifetime of the service worker, so if you write tests that buy tickets or simulate matches, either isolate them or expect order to matter.
Takeaway
The football data was fun, but the actual lesson was about test design, not sports trivia: check what layer your API actually lives at before you reach for the "obvious" tool. I almost wrote this whole suite with request.get() out of habit, and it would have failed in a confusing way that had nothing to do with my assertions. A quick look at how the target actually serves responses — in this case, a Service Worker instead of a server — changed which Playwright feature was even the right one to reach for.
If you want to poke at it yourself, the full API reference (with a "Try it out" console, an OpenAPI spec, and a Postman collection) is at funapi.dev/docs/worldcup.
Top comments (1)
Nice catch. This is a great reminder that understanding the execution model matters as much as understanding the API itself. One assumption about where the request actually runs (browser vs. Node) completely changes which tool is appropriate. Sometimes the bug isn’t in the test—it’s in our mental model of the system.