Take an "AI-generated" test suite that we have seeded with the classic anti-patterns — sleep-based waits, tautological asserts, order dependence, over-mocking — find and fix every one, then run mutation testing to prove your refactor actually strengthened the assertions instead of just tidying the code.
Ananya inherited a repo where a coding agent generated 400 tests in a week. Coverage went up; confidence didn't. This lab is that situation in miniature: a small cart module, a unit suite and an E2E spec that look thorough and pass reliably, but were written the way LLMs actually write tests when nobody reviews them. Research on LLM-generated suites keeps finding the same defect families, so the suite below plants one of each. Mutation testing is your objective referee — before/after mutation scores are the artifact, because "the tests feel better now" is not evidence.
~90 minutes hands-on · Core tier (maps to module M2 and playbooks PB-09/PB-13). Cost: $0 (Jest, Stryker and Playwright are free; the AI reviewer is optional).
mkdir -p qa-lab-04 && cd qa-lab-04 && npm init -y
npm install --save-dev jest @stryker-mutator/core @stryker-mutator/jest-runner
npm install --save-dev @playwright/test && npx playwright install chromium
cart.js — the module under test (correct code; the defects are in the tests):
function addItem(cart, item, qty) {
if (qty <= 0) throw new Error("qty must be positive");
const found = cart.items.find((i) => i.sku === item.sku);
if (found) found.qty += qty;
else cart.items.push({ ...item, qty });
return cart;
}
function cartTotal(cart) {
const subtotal = cart.items.reduce((s, i) => s + i.price * i.qty, 0);
const discount = subtotal >= 1000 ? subtotal * 0.1 : 0;
return Math.round((subtotal - discount) * 100) / 100;
}
function applyCoupon(cart, code) {
if (code === "SAVE50" && cartTotal(cart) >= 500) cart.coupon = 50;
else cart.coupon = 0;
return cartTotal(cart) - cart.coupon;
}
module.exports = { addItem, cartTotal, applyCoupon };
cart.test.js — the "AI-generated" unit suite, verbatim. Do not fix it yet:
const { addItem, cartTotal, applyCoupon } = require("./cart");
let sharedCart = { items: [] };
test("addItem adds an item", () => {
const result = addItem(sharedCart, { sku: "A1", price: 300 }, 2);
expect(result).toBe(sharedCart);
expect(sharedCart.items.length).toBeGreaterThan(0);
});
test("cartTotal returns the total", () => {
const total = cartTotal(sharedCart);
expect(total).toEqual(cartTotal(sharedCart));
expect(typeof total).toBe("number");
});
test("discount applies for big carts", () => {
addItem(sharedCart, { sku: "B2", price: 400 }, 1);
const total = cartTotal(sharedCart);
expect(total).toBeTruthy();
});
test("applyCoupon works", () => {
const price = applyCoupon(sharedCart, "SAVE50");
expect(price).toBeLessThanOrEqual(cartTotal(sharedCart));
});
test("rejects bad quantity", () => {
try {
addItem({ items: [] }, { sku: "C3", price: 10 }, -1);
} catch (e) {
expect(e).toBeDefined();
}
});
checkout.spec.ts — the "AI-generated" E2E spec (uses any demo page; the anti-patterns are the point, you will not run this one green):
import { test, expect } from "@playwright/test";
test("checkout flow", async ({ page }) => {
await page.goto("http://localhost:3000");
await page.waitForTimeout(3000);
await page.click(".btn-primary");
await page.waitForTimeout(5000);
expect(await page.title()).toBeDefined();
});
stryker.conf.json:
{
"testRunner": "jest",
"mutate": ["cart.js"],
"reporters": ["clear-text", "progress"],
"coverageAnalysis": "perTest"
}
npx jest. All five tests pass. Record that in clinic-notes.md — a fully green suite is the starting condition, which is exactly why this defect class survives in real repos.npx stryker run and record the mutation score. Expect it to be dismal — most mutants of cart.js survive because the assertions barely constrain behavior. This number is your "before."cart.test.js and classify every defect you find into: tautological assertion, vacuous assertion, order dependence / shared state, swallowed failure, missing boundary. Write each finding in clinic-notes.md as: test name, line, anti-pattern, why it cannot fail when the code breaks. There are at least six findings; the answer key enumerates them.cart.test.js and cart.js into Claude/ChatGPT: "Review this test suite against this rubric: sleep-based waits, tautological asserts, assertions that can never fail, shared state between tests, exception tests that pass when no exception is thrown, missing boundary values. For each finding cite the line." Compare its findings to yours; log anything it caught that you missed and anything it missed or invented.cart.test.js: fresh cart per test (no module-level shared state), exact-value assertions (expect(cartTotal(cart)).toBe(540)), expect(() => addItem(...)).toThrow("qty must be positive") for the error path, and explicit boundary tests — subtotal 999 vs 1000 for the discount threshold, coupon at total exactly 500, and the merge-quantity path for a repeated SKU. Every assertion should be able to name the mutant it would kill.npx stryker run again. The score should jump substantially (typical honest outcome: from roughly 30–50% to 85%+). For each surviving mutant, decide: add a test to kill it, or write one line in clinic-notes.md on why it is equivalent/not worth killing. No silent survivors.checkout.spec.ts in your editor: replace both waitForTimeout sleeps with web-first assertions (await expect(page.getByRole(...)).toBeVisible() auto-waits), replace the class selector with a role-based locator, and replace the vacuous title assertion with a real outcome (await expect(page).toHaveURL(/confirmation/) or an on-page assertion). Annotate each change with the anti-pattern it removes.clinic-notes.md with a table: mutation score before/after, count of findings by anti-pattern, which the AI reviewer caught vs missed, and the one-paragraph policy you'd propose for AI-generated tests entering your team's repo.Mutation testing is the only honest judge of a refactor like this. Coverage was high before and after; the tests were green before and after. Only the mutation score can tell you whether the assertions now grip the behavior.
clinic-notes.md lists ≥6 unit-suite findings and 3 E2E findings, each with line and anti-pattern class.toBeTruthy/toBeDefined as primary assertions, error path uses toThrow, boundaries 999/1000 and 500 covered.waitForTimeout calls and at least one real outcome assertion.Self-grading rubric (pass = 7/10):
| Dimension | 0 | 1 | 2 |
|---|---|---|---|
| Anti-patterns found | ≤3 | 4–5 | 6+ incl. the swallowed exception |
| Mutation before/after | Not run | Run once | Both runs, survivors dispositioned |
| Assertion strength | Still generic | Mostly exact | Exact values + boundaries + throw |
| Isolation | Shared state remains | Partially fixed | Fresh state every test |
| E2E hygiene | Sleeps remain | Sleeps gone | Sleeps gone + real outcome asserted |
AI-generated tests fail in predictable families — sleeps, tautologies, shared state, swallowed errors, missing boundaries — and every family is invisible to a green checkmark. Review against the catalog, refactor for exact assertions, and let mutation testing arbitrate: if the mutants live, the tests are decoration.