Why a demo app instead of a real project
Most QA portfolios are claims: "experienced with Playwright," "strong in test automation," a list of tools on a CV. None of that is verifiable — a recruiter can't click into a claim. I wanted something they actually could: real test code, a real CI pipeline that runs on every push, and bugs with a visible paper trail from "caught" to "fixed."
A demo app, built specifically to have bugs, sounds like cheating. It isn't, as long as the bugs are the kind that actually happen — not contrived edge cases nobody would write. I picked an event registration system with a waitlist for exactly that reason: it has a natural concurrency problem (two people grabbing the last seat at once), a natural state machine (confirmed → waitlisted → promoted → cancelled), and natural places for sloppy validation. All real bug classes, just on a schedule I controlled.
The app, and the six bugs
Register for an event with limited seats. Once it's full, you join a waitlist; if someone cancels, the next person in line gets promoted automatically. Simple on purpose — the app isn't the point, the testing is.
The actual app. Nothing fancy — the test pyramid is the point.
The first commit shipped with six bugs, seeded on purpose, one per layer of the pyramid I planned to build:
- Race condition — registration count-then-insert wasn't atomic, so concurrent requests could both squeeze past a full event.
- Off-by-one — the capacity check used
>instead of>=, letting exactly one extra "confirmed" registration slip past the waitlist. - Validation gap — any non-empty string passed as a valid email.
- Waitlist promotion race — cancelling a seat and promoting the next person wasn't atomic either.
- Double-submit — no guard against a fast double-click, no idempotency key. One impatient click, two registrations.
- Accessibility — a low-contrast "sold out" badge, form inputs with no associated labels.
What the test pyramid actually caught
Four layers, each aimed at a different failure mode: Vitest for business-logic unit tests, Playwright's request fixture for API/contract tests (status codes, error shapes — a layer I skipped at first and had to go back and add), Playwright + axe-core for browser E2E and automated accessibility scans, and k6 for load testing. 18 automated tests in total, all running in CI on every push.
The unit tests caught the off-by-one and the validation gap immediately — real failing-test output, not a retelling:
FAIL registerForEvent — capacity boundary (off-by-one)
Expected: "waitlisted"
Received: "confirmed"
FAIL registerForEvent — email validation
Expected: "error"
Received: "confirmed"
The actual failing output, from the actual buggy first commit — not a reenactment.
The load test against the race condition is where it gets interesting. 50 virtual users, 500 requests, a 50-seat event. Before the fix: 51 confirmed — overbooked by exactly one. After: exactly 50. Clean, consistent, reproducible. But it's a smaller effect than I expected going in, and the honest reason is worth saying out loud: Node.js is single-threaded and the local database driver (better-sqlite3) is synchronous, which narrows how badly two requests can actually race each other. A multi-process stack would very likely show a much worse number under the exact same bug.
The axe-core scan caught the planned accessibility bugs, then kept
going and found two more I hadn't planned: a <p>
rendered as a direct child of a <ul> (invalid list
structure), and a second insufficient-contrast color I'd missed
because I was only thinking about the one badge I'd deliberately
broken. That's the actual value of running a real scanner instead of
just fixing the bug you set out to seed — it finds things outside the
plan.
The bug I claimed and then had to retract
I wrote in my own bug log that the waitlist-promotion race was "caught by a k6 load test" — before I'd actually built that scenario. When I went back and built it properly (two confirmed seats, cancelled concurrently, checking whether both waitlisted people get promoted), I ran it four times against the deliberately buggy code and never once reproduced the bug. Same root cause as the race condition above: this runtime's concurrency model doesn't let the two operations interleave at the JavaScript level, transaction or not.
I corrected the claim rather than leave it. The fix (a proper database transaction around the cancel-and-promote step) is still worth keeping — it's real protection against a different deployment shape — but "caught by a test" and "theoretically possible, never actually reproduced" are different claims, and only one of them was true.
Taking it to production found three more bugs
The original plan stopped at a local demo. I pushed further: a real Postgres database (Neon) and a real deployment (Vercel), partly to get a live link people could actually click, and partly to test a hypothesis — would the race condition that never reproduced locally show up once requests hit genuinely separate server instances?
Getting there surfaced three real failures, none of them guessable from reading the code:
- Build failure — a peer-dependency conflict I'd been silently working around locally with a flag I forgot Vercel's build wouldn't use.
- A wrong first guess — I assumed a missing WebSocket configuration was causing 500 errors in production. Reasonable, well-documented gotcha for the database driver I was using — just not what was actually happening.
- The real cause — one URL parameter in the database connection string (
channel_binding=require) that failed authentication specifically through that driver, on that runtime, invisible locally and even when testing the same credentials directly. Found it by temporarily making the API leak its own error message, reading it, then reverting that before shipping anything.
CI green on the commit that documents all three deployment failures — the fixes shipped with the honesty, not instead of it.
And the hypothesis itself: did the race condition show up on real infrastructure? No — zero overbooking, exactly 50 confirmed out of 50 seats, even under genuine multi-instance concurrent load. The fix holds. What I didn't expect was the actual finding: requests against the deployed version averaged 10 seconds, against 750 milliseconds locally — roughly 13x slower, from serverless cold-start and cross-region database connection overhead. Not the bug I went looking for, but a real, honestly more useful lesson: architecture choices trade one risk for another, and "it works" isn't the same question as "it works fast."
Try it / read the code
Live demo: event-rsvp-red.vercel.app. Full source, every commit from "intentionally broken" to "fixed with receipts," the complete bug log, and every piece of evidence referenced above: github.com/jhonsalya11/event-rsvp.
PS: the live demo resets occasionally when I re-run the load test against it for fun. If you show up and it's suspiciously close to full, that's why — not a bug, just me testing my own test.