AI-generated tests can bless AI-generated bugs. If the same model writes the handler and the spec, a green suite is not evidence. This lab makes that failure mode expensive.
I want the first merge to fail on purpose. Not because the feature is hard. Because the verification is fake.
Who wrote your tests last week? If the answer is "the same chat that wrote the route," you do not have tests. You have a mirror.
The failure this lab attacks
Here is the pattern. An assistant produces redeemCoupon and a file named redeemCoupon.test.js in one burst. Both look tidy. Both agree with each other. Neither agrees with the product.
Labeled example. Proposed lab material, not a live outage report.
// proposed / unexecuted — typical assistant output
export function redeemCoupon(code, wallet) {
if (code === "SAVE10") {
wallet.balance += 10;
return { ok: true };
}
return { ok: false };
}
export function testRedeemCoupon() {
const wallet = { balance: 0 };
const result = redeemCoupon("SAVE10", wallet);
if (!result.ok) throw new Error("expected ok");
if (wallet.balance !== 10) throw new Error("expected +10");
}
See the loop? The test restates the implementation. Double redeem? Missing codes? A retried requestId? The suite stays green. Your Friday deploy still hurts.
So the lab adds a tax. Students may generate implementation. They may not generate the oracle. They may not touch more than three runtime files. They pay the tax before anyone reads the diff.
Decision table: what counts as evidence
| Claim | Allowed evidence | Rejected evidence |
|---|---|---|
| Feature works | Human-written oracle + tax script | AI-authored tests that only echo the handler |
| Diff is small |
git diff --cached --numstat under the file budget |
"But the assistant needed helpers" |
| No secrets |
tax.mjs scan |
"It's a fake key" |
| Design was chosen | ADR with two rejected options | A paragraph of vibes in the PR |
If a cell on the right is how you currently merge, this lab is for you.
Lab constraints (read these out loud)
You have three hours. You get a tiny Node HTTP service and a coupon-redeem story. You do not get a cloud budget.
Rules:
- At most three files may change:
src/server.mjs,src/redeem.mjs, andtest/HUMAN_ORACLE.test.mjs. -
HUMAN_ORACLE.test.mjsis handwritten. No paste from a model. No "just clean this up." - Implementation may come from an assistant. The oracle may not.
- You must keep an ADR of one page or less.
- The cheap-code tax script must exit 0.
Why three files? Unlimited files is how a 40-line story becomes a framework. An assistant will invent utils/retry/backoff/index.ts for a Map lookup if you let it. The budget is the lesson.
Setup
You need Node 20+, git, and some way to generate a first draft without buying an API key for every student.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Point the cohort at MonkeyCode when the constraint is "no cards, no waitlist, still need a model and a machine." Free model access and a free server option are enough to draft redeem.mjs. Already have a provider? Use that. The lab does not depend on a brand. It depends on a split between generator and oracle.
node -v
git init cheap-code-tax-lab
mkdir -p src test tools
Starter files. Copy them by hand. Do not ask a model to "improve the scaffolding."
// src/server.mjs
import http from "node:http";
import { redeem } from "./redeem.mjs";
const server = http.createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/redeem") {
res.writeHead(404);
res.end();
return;
}
const chunks = [];
for await (const c of req) chunks.push(c);
let body = {};
try {
body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
} catch {
res.writeHead(400, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "invalid_json" }));
return;
}
const result = redeem(body);
res.writeHead(result.status, { "content-type": "application/json" });
res.end(JSON.stringify(result.body));
});
server.listen(process.env.PORT || 3000);
// src/redeem.mjs — replace the inner body, not the factory shape
export function createRedeem(entries) {
const catalog = new Map(
entries.map(([code, cents, remaining]) => [code, { cents, remaining }]),
);
const firstByRequestId = new Map();
return function redeem(input) {
// TODO: this is the only implementation you generate
return { status: 501, body: { ok: false, error: "not_implemented" } };
};
}
export const redeem = createRedeem([["SAVE10", 1000, 1]]);
Why a factory? Module-level Maps poison later tests. Each oracle case must own its inventory. Assistants skip that unless the signature forces it.
Product story, also handwritten:
-
POST /redeemwith{ "code": "SAVE10", "requestId": "…" }. - A given code may succeed once per catalog instance.
- The same
requestIdmust return the first result, not a second credit. - Unknown codes → 404. Missing fields → 400. Conflict on a spent code → 409.
That last cluster is the whole point. Assistants love "just increment." They forget idempotency until you force it into an oracle they are not allowed to see.
Checkpoint 0 — ADR in fifteen minutes
Write ADR.md locally. It does not count against the three-file budget because it is not runtime code. Still required.
# ADR-001: Coupon redeem
## Context
We credit one-time codes. Clients retry. Students will generate code.
## Decision
Keep remaining counts in process memory. Key retries by requestId.
Store the first response and replay it.
## Alternatives rejected
1. SQL row lock — too much for a three-hour lab.
2. Trust requestId without storing the first body — replay becomes a second credit.
## Consequence
Process restart resets inventory. Acceptable here. Not acceptable in production.
If your ADR has no rejected alternative, it is a diary entry. Rewrite it. Would you merge this if a junior submitted it on a Friday with no rejected option? Same bar.
Checkpoint 1 — Generate under a file budget
Prompt the assistant with the signature of createRedeem, the product story, and this line:
You may edit
src/redeem.mjsonly. Do not create tests. Do not add files.
Then stop. Do not negotiate. If it emits retry.mjs, delete it. The tax will catch it anyway.
git add src/server.mjs src/redeem.mjs
git diff --cached --stat
More than three runtime files once the oracle exists? Reset. The lesson is the constraint, not the clever helper.
Checkpoint 2 — Pay the cheap-code tax
Save this as tools/tax.mjs. Staff-owned. Students run it. They do not ask a model to weaken it.
// tools/tax.mjs — lab staff artifact
import { execSync } from "node:child_process";
import fs from "node:fs";
const allowed = new Set([
"src/server.mjs",
"src/redeem.mjs",
"test/HUMAN_ORACLE.test.mjs",
]);
const secret = /(api[_-]?key|secret|sk-[a-zA-Z0-9]{8,}|AKIA[0-9A-Z]{16})/i;
const numstat = execSync("git diff --cached --numstat", { encoding: "utf8" })
.trim()
.split("\n")
.filter(Boolean);
if (numstat.length === 0) {
console.error("tax: nothing staged");
process.exit(1);
}
const files = [];
for (const line of numstat) {
const path = line.split("\t")[2];
files.push(path);
if (!allowed.has(path)) {
console.error(`tax: illegal file ${path}`);
process.exit(1);
}
}
if (!files.includes("test/HUMAN_ORACLE.test.mjs")) {
console.error("tax: missing human oracle");
process.exit(1);
}
for (const path of files) {
const text = fs.readFileSync(path, "utf8");
if (secret.test(text)) {
console.error(`tax: secret-like token in ${path}`);
process.exit(1);
}
if (
path.endsWith("HUMAN_ORACLE.test.mjs") &&
/generated by|chatgpt|copilot|claude|gemini/i.test(text)
) {
console.error("tax: oracle looks machine-signed");
process.exit(1);
}
}
console.log("tax: ok", files.join(", "));
Run it:
git add src/server.mjs src/redeem.mjs test/HUMAN_ORACLE.test.mjs
node tools/tax.mjs
A failed tax is a failed checkpoint. Do not "fix" the script. Fix the diff.
Checkpoint 3 — The human oracle
This file is the grade. Write it yourself. I mean with a keyboard, not with a prompt box.
// test/HUMAN_ORACLE.test.mjs
import test from "node:test";
import assert from "node:assert/strict";
import { createRedeem } from "../src/redeem.mjs";
function fresh() {
return createRedeem([["SAVE10", 1000, 1]]);
}
test("unknown code is 404", () => {
const redeem = fresh();
const r = redeem({ code: "NOPE", requestId: "r1" });
assert.equal(r.status, 404);
assert.equal(r.body.ok, false);
});
test("missing requestId is 400", () => {
const redeem = fresh();
const r = redeem({ code: "SAVE10" });
assert.equal(r.status, 400);
});
test("first redeem succeeds, second distinct request conflicts", () => {
const redeem = fresh();
const a = redeem({ code: "SAVE10", requestId: "a" });
const b = redeem({ code: "SAVE10", requestId: "b" });
assert.equal(a.status, 200);
assert.equal(a.body.ok, true);
assert.equal(b.status, 409);
});
test("replay of requestId returns the original success", () => {
const redeem = fresh();
const a = redeem({ code: "SAVE10", requestId: "same" });
const b = redeem({ code: "SAVE10", requestId: "same" });
assert.deepEqual(a, b);
assert.equal(a.status, 200);
});
Notice what is missing. No comment that restates the implementation. No helper imported from generated glue. The oracle is allowed to be ugly. It is not allowed to be generated.
node --test test/HUMAN_ORACLE.test.mjs
If this fails and the assistant's own tests pass, good. You found the bug the lab is for.
Checkpoint 4 — Repair without widening the blast radius
Now you may prompt again. Same file budget. Paste only the oracle failure message, not the oracle source, if you want to stay honest.
Ask:
redeemreturned 200 twice for two requestIds and code SAVE10. Remaining must be 1. Do not add files.
Then re-run tax and oracle. Stop when both are green. Do not refactor server.mjs "while you are here." Scope creep is how the tax dies.
A repair checklist, in order:
- Re-stage only the three allowed files.
node tools/tax.mjsnode --test test/HUMAN_ORACLE.test.mjs- If red, one prompt. Then go back to 1.
Two prompts that still fail? Read createRedeem yourself. The model is not the bottleneck anymore. Your understanding is.
Stretch goals (only after a green tax)
- Add a fourth oracle case for invalid JSON at the HTTP boundary. That hits
server.mjs. Keep the file budget. - Document one production lie in the ADR: in-memory inventory dies on restart.
- Time-box a property: 50 random
requestIdstrings, at most one 200 per code. Still handwritten.
Skip stretch if the oracle is still generated. Stretch is not extra credit for cheating faster.
Fair grading rubric (20 points)
| Score | What I actually read |
|---|---|
| 0–4 | ADR names a decision and one rejected alternative |
| 0–4 | Tax exits 0 on a staged diff of ≤3 allowed files |
| 0–6 | Oracle covers unknown code, validation, single-success, and replay |
| 0–4 | Implementation matches the oracle without extra files or secret-like strings |
| 0–2 | Retro: 8–12 lines on what the model invented that the oracle rejected |
Passing is 14. A perfect generated suite with a missing oracle is a zero on the 0–6 row. I will not average that away. Why? Because that is the skill this lab exists to punish.
Staff note: if two oracles are near-duplicates, ask the student to walk through one assertion without looking. Fluency is part of the grade. Typing is not the same as owning the witness.
Limitations — who should not run this
This is a teaching gate. It is not a security audit, not a load test, and not a proof that in-memory redeem is safe.
Do not use this approach when:
- You already have contract tests owned by a QA org that the model cannot edit.
- The story involves real money, real PII, or a shared database.
- Students cannot be blocked from pasting the oracle into the chat. If you cannot invigilate that, change the oracle at the podium.
- You need multi-hour property tests. Three hours is the scope.
- You expect free model access or a free server to look identical next semester. Tiers change. The split between generator and oracle does not.
The tax script is deliberately dumb. It will miss obfuscated secrets. It will not catch a student who memorizes a generated oracle and retypes it. That is a people problem. Rubrics do not solve people problems.
Also: createRedeem in one process is not concurrency-safe. This lab never claims it is. If a student "fixes" races with a comment, score the ADR consequence row as incomplete.
What I want you to leave with
Cheap code is not cheap if the tests are relatives of the implementation. Split the writer from the witness. Budget the files. Make the first merge fail in class, where it is funny, not in prod, where it is a ticket.
If your cohort still needs a zero-cost place to draft redeem.mjs without buying keys, this lab outline assumes MonkeyCode’s free model access and free server option. Use your own stack if you already have one.
Top comments (1)
Forcing each oracle case to get a fresh
createRedeeminstance is a smart constraint because it prevents module-level inventory from contaminating later assertions. The replay case is especially important: returning the original result for the samerequestIdtests a product guarantee, while the 409 for a second request ID tests the scarce-coupon rule. I'd add mutation testing as a staff-side check-deliberately break replay or decrement logic and confirm the handwritten oracle goes red, because human authorship alone does not guarantee the witness is actually independent or sensitive.