A small app: from zero to a green test
Lesson 6 of 12
6A small app: from zero to a green test
We build a tiny task API with zero dependencies, in an order that makes the assistant's work reviewable, and at the end we catch a “shorter and prettier” suggestion that silently deletes the wrong task.
What the picture shows: Write the spec, then a test that fails, then the code that makes it pass, so every change from the assistant stays reviewable.
Map of this part
The concepts in order, click any one to jump to it:
Two ways into the same idea
Building an app with an assistant is like building a house with a fast contractor: ask for one room, inspect it, then the next. Ask for the whole house at once and you will not know where the fault is. In the end you catch a "neat shortcut" it suggested that silently deletes the wrong thing.
You build a small task API with the Python standard library step by step: test first, then code, each step a small reviewable diff. A green test pins the behaviour, and reviewing the last suggestion uncovers a delete by the wrong index that existing tests missed.
Required fundamentals
Parts 3–5
Chat, agent mode and a rules file. This part uses all three together on one project.
A recent Node
We use the built-in http and the built-in fetch, no packages installed. Check with node -v.
The result of part four
That “it ran” is not “it is correct”. Every step here leans on that.
git initialised
git init && git commit before you start. You are about to run an agent on a real project; this is the undo button.
Core concepts
Contract, then test, then code, not the reverse. The assistant is excellent at writing code that satisfies a clear contract, and very poor at guessing a contract you never wrote.
store.js holds every decision; server.js holds transport only. This split is not architectural elegance: it is what makes the tests fast without a network, and what makes reviewing the assistant's work possible, because every decision is in one place.
All the value is at the edges: an empty string, a missing id, a wrong type, a double delete. The assistant writes the happy path beautifully; the edges are your job because they express your intent.
You own: the contract, the edges, the diff review, and the decision to merge. The assistant owns: fast typing, repetitive shapes, and explanation. Do not swap the roles.
Never say “done” without real pasted output. Every output line in this part was actually run, and that is the standard we ask of you and of your assistant.
How it works
Diagram 1, The build order: the test before the code, and the review at the end rather than the start
Diagram 2, The shape of the project: the dashed arrows are tests, one touches the logic directly, one goes through the network
store.spec.js tests the rules in a fraction of a second with no port and no network, so you run it after every edit. http.spec.js tests the wiring: status codes, routes, JSON shape. A failure in the first means your understanding of the problem is wrong; a failure in the second means the wiring is wrong, and telling those apart saves you hours.The whole project, with real output
The project: a tiny task API. GET /todos returns the list, POST /todos adds, PATCH /todos/:id flips the state, DELETE /todos/:id deletes. No packages, no database.
id, title and done. The title is a non-empty string after trimming, and the id is a number starting at 1 that never repeats.” This is the file you will attach with @.store.spec.js verifying the contract above, including the edges: an empty title, a wrong type, a missing id.” The test before the code forces the assistant to commit to your intent.store.js does not exist, so the test fails with “cannot find module”. That first red is your proof the test works.store.js that makes store.spec.js green, with no external packages.”server.js exposing the contract over HTTP, and put no decision in it, everything goes through store.js.”Here is the whole domain. Notice it knows nothing about HTTP:
// store.js - the whole domain, with no HTTP in it
let nextId = 1;
let items = [];
function reset() { nextId = 1; items = []; }
function list() { return items.slice(); }
function add(title) {
if (typeof title !== "string" || title.trim() === "") {
throw new TypeError("title must be a non-empty string");
}
const item = { id: nextId++, title: title.trim(), done: false };
items.push(item);
return item;
}
function toggle(id) {
const item = items.find((t) => t.id === id);
if (!item) return null;
item.done = !item.done;
return item;
}
function remove(id) {
const before = items.length;
items = items.filter((t) => t.id !== id);
return items.length < before;
}
module.exports = { reset, list, add, toggle, remove };And the test that was written before it (abridged):
// store.spec.js - no server, no network: the rules of the app
const assert = require("assert");
const store = require("./store");
check("a new store is empty", () => assert.deepStrictEqual(store.list(), []));
check("add returns the created item with id 1", () =>
assert.deepStrictEqual(store.add("buy milk"), { id: 1, title: "buy milk", done: false }));
check("titles are trimmed", () =>
assert.strictEqual(store.add(" spaced ").title, "spaced"));
check("an empty title is rejected", () => assert.throws(() => store.add(" "), TypeError));
check("toggle flips done, twice returns it", () => {
const t = store.add("x");
assert.strictEqual(store.toggle(t.id).done, true);
assert.strictEqual(store.toggle(t.id).done, false);
});
check("remove of a missing id returns false", () => assert.strictEqual(store.remove(999), false));
check("list returns a copy, not the live array", () => {
store.add("a"); const copy = store.list(); copy.push({ id: 99 });
assert.strictEqual(store.list().length, 1);
});$ node store.spec.js PASS a new store is empty PASS add returns the created item with id 1 PASS ids increase and do not repeat PASS titles are trimmed PASS an empty title is rejected PASS a non-string title is rejected PASS toggle flips done, twice returns it PASS toggle of a missing id returns null PASS remove deletes exactly one item PASS remove of a missing id returns false PASS list returns a copy, not the live array 11/11 checks passed
Then the transport layer, zero decisions, wiring only:
// server.js - HTTP only; every decision lives in store.js
const http = require("http");
const store = require("./store");
const server = http.createServer(async (req, res) => {
const { method } = req;
const path = req.url.split("?")[0];
const idMatch = /^\/todos\/(\d+)$/.exec(path);
if (method === "GET" && path === "/todos") return json(res, 200, store.list());
if (method === "POST" && path === "/todos") {
const body = await readBody(req);
try { return json(res, 201, store.add(body.title)); }
catch (e) { return json(res, 400, { error: e.message }); }
}
if (method === "PATCH" && idMatch) {
const item = store.toggle(Number(idMatch[1]));
return item ? json(res, 200, item) : json(res, 404, { error: "no such todo" });
}
if (method === "DELETE" && idMatch) {
return store.remove(Number(idMatch[1]))
? json(res, 204, null)
: json(res, 404, { error: "no such todo" });
}
return json(res, 404, { error: "unknown route" });
});$ node http.spec.js PASS GET /todos starts empty PASS POST /todos returns 201 and the item PASS POST with an empty title returns 400 PASS PATCH /todos/1 toggles done PASS PATCH a missing id returns 404 PASS DELETE /todos/1 returns 204 and empties the list PASS an unknown route returns 404 7/7 checks passed
list() returns a copy rather than the live array (otherwise anyone outside can mutate it), that toggle twice returns to the original state, and that deleting a missing id returns false rather than true. The third is about to save the project.And then came the pretty, dangerous suggestion
We asked it to “shorten remove”. The suggestion really was shorter, and far more common in the world's code:
// the suggestion: "shorter and more idiomatic"
function remove(id) {
items.splice(items.findIndex((t) => t.id === id), 1);
return true;
}It looks fine. And it is, in every case but one. findIndex returns -1 when nothing matches, and splice(-1, 1) means “start from the last element”:
$ node -e '...add two tasks, then remove a missing id...' before [ 'keep me', 'and me' ] remove(999) true after [ 'keep me' ] <-- "and me" is gone
It deleted the wrong task and returned true as though the operation had succeeded. Without a single line in the test, this reaches production:
$ node store.spec.js FAIL remove of a missing id returns false -> Expected values to be strictly equal: true !== false 10/11 checks passed $ echo $? 1
Myth vs. reality
| The myth | The reality |
|---|---|
| “Writing the test first slows me down.” | It is the fastest way to make the assistant write what you meant. Without it you explain your intent in prose, repeatedly. |
| “The happy path works, so we are done.” | Every bug in this part lived at the edges: a missing id, an empty title, a repeated delete. |
| “Shorter code is better.” | splice/findIndex is shorter than filter, and deletes the wrong task. Brevity is a property, not evidence. |
| “The assistant knows the edge cases.” | It knows the common ones. Your project's edges express your intent, and intent cannot be inferred from code. |
| “Separating logic from HTTP is architectural luxury.” | It is what let 11 checks run in a fraction of a second with no port, which is what makes you actually run them. |
Try it yourself
Why did remove(999) return true in the broken version?
Three problems in two lines: the result of findIndex was never checked, -1 means “from the end”, and the return value is a constant that says nothing about success. The test that asserted on the return value caught it.
Which order makes the assistant's work reviewable?
The contract fixes the intent, the test turns it into a measure, the code satisfies it, the run proves it, and the review protects you. The third is a common error: a green test proves only what you checked, not that you checked the right thing.
Quick review flashcards
store.js from server.js?findIndex returns what when nothing matches?-1, and splice(-1,1) deletes the last element.filter safer here than splice?Check your understanding
1. In one sentence: what is the difference between store.spec.js and http.spec.js, and why do we need both?
The first tests the rules with no port and no network, so it runs in a fraction of a second and you run it after every edit; the second tests the wiring: status codes, routes, JSON shape. The practical benefit is that where it fails tells you what kind of bug it is before you start looking.
2. Explain in detail how splice/findIndex deletes the wrong task.
findIndex does not find the id and returns -1. That value is passed straight into splice, and in JavaScript a negative index counts from the end of the array, so it now means “remove one element from the end”. The result: the last task is deleted, and the function returns its hard-coded true so everything looks fine. Three problems in one line: an unchecked value, an unexpected index semantics, and a lying return value.
3. (Spot the bug) Your tests are 11/11 green, and production shows tasks with empty titles. What is likely?
The transport layer is bypassing the domain: server.js is building the object itself instead of calling store.add. A green test proves the rules are right, not that every path goes through them. The cure: assert in http.spec.js that a malformed request returns 400, exactly what the HTTP test above does.
4. The agent added a package to generate ids. Your position?
Ask: what problem does it solve that a one-line counter does not? Every package adds attack surface, updates, a licence and weight. In a zero-dependency project, adding the first package is a decision rather than a detail, and the rule written in AGENTS.md (“no new dependencies without asking”) exists for precisely this moment.
5. (Decision) A colleague says: “the assistant wrote the app in ten minutes, why waste time on tests?” Your answer?
That the tests are what make those ten minutes believable. In this part a single assertion caught a bug that would have deleted a user's data in production, and the suggestion that caused it was shorter and prettier than our code, meaning it would have passed a visual review. A test is not a tax on speed; it is the only thing that makes speed safe. Without tests we did not save the time, we postponed it to a worse moment.
Resources for this part
Sorted by kind: start with the official docs when you want the precise reference, or with an article or video when you want the explanation first.