I've been meaning to write some API tests outside of work for a while now, mostly to keep my Cypress skills from going rusty between projects. The problem is always the same: I don't have a spare API lying around to poke at, and spinning up my own Express server just to practice cy.request() felt like more setup than the practice was worth.
A few days ago I stumbled on funapi.dev while looking for exactly that — a free, no-signup mock API I could throw requests at without owning the backend. It's a whole playground of fandom-themed mock APIs (Harry Potter, Star Wars, The Office, you name it), but the one that caught my eye was the Sherlock Holmes API. Five cases, a cast of suspects, some clues, Bearer auth on the write endpoints, and — this is the part that sold me — an endpoint that returns a 409 if you try to update something to a status it's already in. That's a real pattern I've had to test at work before, so it felt worth writing up.
This post is basically me working through that API in Cypress, endpoint by endpoint, including the part where I tripped over the 409 myself before I meant to.
What you're working with
Base URL:
https://funapi.dev/api/holmes/v1
Reading data needs nothing — no key, no signup. Writing data (POST, PUT, PATCH, DELETE) needs a Bearer token, and funapi.dev conveniently ships two of them for testing: qa-admin-token, which can actually write, and qa-viewer-token, which gets politely rejected if you try to write with it. Every caller also gets its own sandboxed copy of the data behind the scenes, so if you and I both run this suite at the same time, we're not stepping on each other's test data. That's a nice touch if you ever wire this into CI.
Setup
Nothing fancy here:
npm install cypress --save-dev
npx cypress open
I set the API as the baseUrl in cypress.config.js so I could write relative paths instead of the full URL every time:
const { defineConfig } = require("cypress");
module.exports = defineConfig({
e2e: {
baseUrl: "https://funapi.dev/api/holmes/v1",
},
});
Test file: cypress/e2e/sherlock-holmes-api.cy.js.
Starting simple: GET /cases
First thing I did was just hit /cases and look at what came back:
{
"total": 5,
"data": [
{ "id": 1, "title": "The Vanishing Violin", "status": "solved", "year": 1889 },
{ "id": 2, "title": "The Crimson Cipher", "status": "solved", "year": 1891 },
{ "id": 3, "title": "The Baker Street Burglary", "status": "open", "year": 1894 },
{ "id": 4, "title": "The Midnight Telegram", "status": "solved", "year": 1896 },
{ "id": 5, "title": "The Fogbound Heir", "status": "open", "year": 1899 }
],
"requestId": "…"
}
Five cases, two still open. Easy enough to write a first test around:
describe("Sherlock Holmes API (funapi.dev)", () => {
it("GET /cases returns the full case list", () => {
cy.request("GET", "/cases").then((response) => {
expect(response.status).to.eq(200);
expect(response.body).to.have.property("total", 5);
expect(response.body.data).to.have.length(5);
expect(response.body.data[0]).to.include.keys(
"id",
"title",
"status",
"year"
);
});
});
});
Filtering, and making the API say no on purpose
/cases takes a status query param — open or solved. That's worth testing both ways: once for the filter actually working, and once for what happens when you feed it nonsense.
it("GET /cases?status=open only returns open cases", () => {
cy.request("GET", "/cases?status=open").then((response) => {
expect(response.status).to.eq(200);
response.body.data.forEach((c) => {
expect(c.status).to.eq("open");
});
});
});
it("GET /cases with a garbage status value returns 400", () => {
cy.request({
method: "GET",
url: "/cases?status=bribed",
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(400);
});
});
Quick note on failOnStatusCode: false in case you haven't run into it: Cypress fails the test the moment cy.request() gets anything outside 2xx, unless you tell it not to. Forget that flag on a test that's expecting a 400 and you'll spend ten confused minutes wondering why your assertion never even runs.
Nested resource: suspects per case
/cases/{id}/suspects was the one that made me trust this API a bit more — it's a proper nested resource, not just a flat list with a filter bolted on:
{
"case": "The Vanishing Violin",
"suspects": [
{ "id": 1, "name": "Ambrose Reed", "alibi": "at the opera", "caseId": 1, "guilty": false, "_v": 1 },
{ "id": 2, "name": "Clara Whitfield", "alibi": "none given", "caseId": 1, "guilty": true, "_v": 1 }
]
}
it("GET /cases/:id/suspects returns suspects for that case", () => {
cy.request("GET", "/cases/1/suspects").then((response) => {
expect(response.status).to.eq(200);
expect(response.body.case).to.eq("The Vanishing Violin");
expect(response.body.suspects.length).to.be.greaterThan(0);
expect(response.body.suspects[0]).to.include.keys(
"id",
"name",
"alibi",
"caseId",
"guilty",
"_v"
);
});
});
(Clara Whitfield's alibi is literally "none given," which, if you ask me, is not a great look for her.)
Auth: proving the door is actually locked
Anything that writes — POST /suspects, PUT/DELETE /suspects/{id}, PATCH /cases/{id} — wants a Bearer token. Before testing that the token works, I like to test that its absence actually gets rejected. It sounds obvious, but I've seen "protected" endpoints in the wild that quietly accept unauthenticated requests because someone forgot a middleware somewhere. Cheap test, real value.
it("PATCH /cases/:id without a token returns 401", () => {
cy.request({
method: "PATCH",
url: "/cases/3",
body: { status: "solved" },
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(401);
});
});
Then the version with a real token:
const ADMIN_TOKEN = "qa-admin-token";
it("PATCH /cases/:id updates an open case to solved", () => {
cy.request({
method: "PATCH",
url: "/cases/3", // "The Baker Street Burglary" — open in the seed data
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
body: { status: "solved" },
}).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.status).to.eq("solved");
});
});
The 409 I found by accident
Here's the part I mentioned earlier. While poking around in the docs' "try it out" panel before writing any actual test code, I patched case 1 ("The Vanishing Violin," already marked solved in the seed data) to solved again, expecting a boring 200. Instead I got a 409. Turns out the API treats "update a resource to the status it's already in" as a conflict rather than a no-op, which is a genuinely useful thing to test for — it's the same shape of bug as an order-status endpoint letting you "cancel" an already-cancelled order.
it("PATCH /cases/:id to its current status returns 409", () => {
cy.request({
method: "PATCH",
url: "/cases/1",
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
body: { status: "solved" },
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(409);
});
});
POST with an Idempotency-Key
POST /suspects adds a suspect to a case, and it accepts an Idempotency-Key header — send the same key twice and you get the original response back instead of a duplicate row. That's the kind of thing that's easy to forget to test until a flaky network retry duplicates a real order in production, so I added it here too:
it("POST /suspects creates a new suspect", () => {
cy.request({
method: "POST",
url: "/suspects",
headers: {
Authorization: `Bearer ${ADMIN_TOKEN}`,
"Idempotency-Key": `cy-${Date.now()}`,
},
body: {
name: "Mr. Hartley",
alibi: "claims he was asleep",
caseId: 3,
},
}).then((response) => {
expect(response.status).to.eq(201);
expect(response.body).to.include({
name: "Mr. Hartley",
caseId: 3,
});
});
});
One for the schema-checkers: /clues/random
/clues/random, as the name says, hands back a different clue every time, so there's nothing fixed to assert on except shape. Still worth having — it's a cheap contract test that'll catch a field getting renamed or dropped.
it("GET /clues/random always returns a well-formed clue", () => {
cy.request("GET", "/clues/random").then((response) => {
expect(response.status).to.eq(200);
expect(response.body).to.include.keys(
"id",
"description",
"caseId",
"type"
);
});
});
The whole file
Here's everything above stitched into one spec, cypress/e2e/sherlock-holmes-api.cy.js:
const ADMIN_TOKEN = "qa-admin-token";
describe("Sherlock Holmes API (funapi.dev)", () => {
it("GET /cases returns the full case list", () => {
cy.request("GET", "/cases").then((response) => {
expect(response.status).to.eq(200);
expect(response.body).to.have.property("total", 5);
expect(response.body.data).to.have.length(5);
expect(response.body.data[0]).to.include.keys(
"id",
"title",
"status",
"year"
);
});
});
it("GET /cases?status=open only returns open cases", () => {
cy.request("GET", "/cases?status=open").then((response) => {
expect(response.status).to.eq(200);
response.body.data.forEach((c) => {
expect(c.status).to.eq("open");
});
});
});
it("GET /cases with a garbage status value returns 400", () => {
cy.request({
method: "GET",
url: "/cases?status=bribed",
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(400);
});
});
it("GET /cases/:id/suspects returns suspects for that case", () => {
cy.request("GET", "/cases/1/suspects").then((response) => {
expect(response.status).to.eq(200);
expect(response.body.case).to.eq("The Vanishing Violin");
expect(response.body.suspects.length).to.be.greaterThan(0);
expect(response.body.suspects[0]).to.include.keys(
"id",
"name",
"alibi",
"caseId",
"guilty",
"_v"
);
});
});
it("PATCH /cases/:id without a token returns 401", () => {
cy.request({
method: "PATCH",
url: "/cases/3",
body: { status: "solved" },
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(401);
});
});
it("PATCH /cases/:id updates an open case to solved", () => {
cy.request({
method: "PATCH",
url: "/cases/3",
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
body: { status: "solved" },
}).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.status).to.eq("solved");
});
});
it("PATCH /cases/:id to its current status returns 409", () => {
cy.request({
method: "PATCH",
url: "/cases/1",
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
body: { status: "solved" },
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(409);
});
});
it("POST /suspects creates a new suspect", () => {
cy.request({
method: "POST",
url: "/suspects",
headers: {
Authorization: `Bearer ${ADMIN_TOKEN}`,
"Idempotency-Key": `cy-${Date.now()}`,
},
body: {
name: "Mr. Hartley",
alibi: "claims he was asleep",
caseId: 3,
},
}).then((response) => {
expect(response.status).to.eq(201);
expect(response.body).to.include({
name: "Mr. Hartley",
caseId: 3,
});
});
});
it("GET /clues/random always returns a well-formed clue", () => {
cy.request("GET", "/clues/random").then((response) => {
expect(response.status).to.eq(200);
expect(response.body).to.include.keys(
"id",
"description",
"caseId",
"type"
);
});
});
});
Run it:
npx cypress run --spec "cypress/e2e/sherlock-holmes-api.cy.js"
All eight pass for me, in about a second and a half, which is roughly seven seconds faster than the equivalent UI test would've taken just to load a login page.
Why I'd actually recommend this over a toy backend
None of this is really about Sherlock Holmes, obviously. Strip the theme away and what's left is a pretty normal shape for an API test suite: a happy-path GET, a filtered GET, a deliberately broken GET, an unauthenticated write that should fail, an authenticated write that should succeed, a conflict case, an idempotent POST, and a loose schema check on something unpredictable. That's most of what I'd write against a real API at work, minus the fictional detective agency.
The advantage of doing this against funapi.dev instead of mocking everything with cy.intercept() is that you're hitting a real server and getting real HTTP behavior back — actual status codes, actual timing, actual auth rejection. It's a better rep for the muscle memory than a fixture file ever is. And if you want to go further, funapi.dev has an OpenAPI spec and Postman collection for this API (and the 24 others) that you can pull into whatever contract-testing setup you're already using.
If you try this out, I'd extend it with PUT/DELETE on /suspects/{id} next, plus a test confirming qa-viewer-token gets rejected on writes — I ran out of case file to look through before I got to those, but they're on my list.
If you've found other mock APIs worth practicing on, drop them in the comments — always looking for new ones to break.
Top comments (0)