← Article2026-07-07

I said I knew nothing about testing. My repo had 342 of them.

A comment thread went by the other day, and the consensus was brutal: AI writes garbage code, so anything built with AI is stuck at the MVP stage — it can’t ship for real, can’t carry a real business.

I’m exactly the person those comments are pointing at. No engineering background, a pure vibe coder — the AI writes every line and I can’t read any of it. My honest reaction to the pile-on was: as long as the thing actually works when it ships, what’s the problem? Clean code, messy code — why is that my concern?

Then two things happened. First, I sat down and had a long conversation about what “spaghetti code” actually means, until the word finally clicked. Second, I went back and opened the repo for the project I’d just finished — and found 342 automated tests sitting inside it. This was days after I had said, out loud, “testing is something I really have no concept of.”

This piece is me working through both. If you also ship with AI and can’t read code, this might be the shortest path between you and that word everyone keeps throwing around.

1. In plain words: what “spaghetti code” actually is

Spaghetti code — legacy rot, technical debt, whatever you want to call it — is code that has piled up over years until nobody dares touch it. The key word is piled. It’s never written badly in one shot; it accumulates:

The first person is racing a deadline and writes something that works but is rough. The second person needs to add a feature, can’t follow the first person’s logic, is afraid to change it, and smears a new layer on top to route around it. The third person fixes a bug, discovers that touching A breaks B, and slaps a patch over the symptom instead of the cause. A few years and a few dozen people later, no single person understands the whole mountain.

The counterintuitive part: this stuff usually runs fine. Some of the most critical systems on earth — banks, airline booking — run on exactly this kind of code. Precisely because it still runs and nobody understands it, “don’t touch it” becomes the most rational choice available.

So the cost of bad code was never in running it. It’s in changing it. A messy product can ship today and work great today — no problem at all. The problem shows up three months later when you want to add one feature: changing A breaks B, fixing B takes down C, and each edit costs more than the last, until rewriting is cheaper than repairing. Bad code isn’t a quality problem, it’s debt. You don’t feel it while you’re carrying it. The day you have to move, you pay principal plus interest, all at once.

Once that lands, you can see exactly where my “it works, so it’s fine” went wrong: I answered a charge about whether you can change it with a claim about whether it runs. Wrong question.

But my instinct wasn’t entirely off, either. The AI era added a new variable: the cost of a rewrite has collapsed. It used to take person-months to rebuild a system, so you had to maintain the mess. Now regenerating it might take an afternoon. For a one-person-plus-AI project, code starts to look a bit like a disposable fork — use it, toss it, make a new one when you need one. Fussing over whether it’s “clean” misses the point. From that angle, holding a solo AI project to a big-company team-collaboration standard is fighting the last war.

There is one exception that breaks the comfort, though. Once a project grows too big for the AI to hold in its “head” at once, the AI starts flailing at its own mess too — ask it to fix one bug and it introduces three. At that moment you can’t read the code to fix it yourself, and the AI can’t untangle it either. That’s when the project actually dies. That is the real threshold for a vibe coder — not “can it ship.”

2. I’d already been bitten once. I just didn’t know it.

The project I just finished is an automation tool for discovering influencers and running campaign delivery. Two months, a hundred-odd commits, grown from a little scraping script into a full pipeline that ended up live on the public internet.

Along the way there was one data incident. A batch of influencer IDs got corrupted — the data was wrong, but the program kept running fine and kept producing output. I figured it was trivial: I’d already located the ID in the page structure, so surely the rest was just a matter of extracting it?

That “just extract it” turned into a war of attrition. The AI I was using kept writing me diagnostic tools, swapping in different logic each time, one after another — there are still four such tools sitting in that repo, fossils of the fight. At one point I was so fed up I nearly told it to wipe the whole data store and re-scrape everything from scratch, just to be done. In the end I only found the root cause by switching to a different AI. And reading the docs afterward, I found the scarier detail: one of those repair tools had a bug of its own that nearly overwrote the correct data with wrong values. The firefighter almost started a fire.

At the time I was just annoyed. Looking back, the whole episode was a miniature mess forming in fast-forward:

Each new diagnostic tool was one more layer smeared on top of the previous layer of confusion. The AI got trapped in its own sunk cost, digging deeper along a wrong path, unable to climb out. And the fact that “just switch AIs” solved it is telling in itself — a fresh AI walked in with a clean head, no baggage, and saw the root cause immediately. This is exactly how human teams work: sometimes the person who untangles the mess isn’t the smartest one, it’s the one with no history to defend.

There’s a bigger lesson buried in there that I also missed at the time: what actually ground me down wasn’t the bug. It was that every round of diagnosis required me to make the call — a person who can’t read code, forced to keep ruling on details I didn’t understand. Remember that feeling. It comes back later.

3. And then I found the 342 tests

A few days after the spaghetti conversation, I pulled that project’s repo back open. Forty-five test files. Three hundred and forty-two automated tests. “Full test suite passing” written all over the docs.

Days after I’d said testing was something I had “no concept of.”

And not just tests. The docs held some strikingly seasoned security decisions. The intake table had three internal-only columns with a note beside them: never expose these to the public form, or public users could self-trigger internal workflows. The remote-control table parsed commands through a strict allowlist and would never run an arbitrary instruction. Every bit of this defensive thinking — none of it was mine. The AI did all of it, unprompted.

Someone asked me: did you know the AI was writing all this at the time? My honest answer: I was aware of it, but I had no felt sense of it. I knew it was writing things called “tests.” I had no idea what those things were guarding against, or what they’d already caught.

Now I know. My project ran for two months without collapsing and shipped in the end, and I’d always assumed that was down to my process — spec out every feature, then a plan, then build. That’s part of it. But the other part is that the AI had been quietly paying a tax I didn’t know existed. Those 342 tests are the smoke alarm against rot: after every change, they re-verify all the old features, and the instant “changing A breaks B” happens, a test goes red. I never once saw an alarm go off — not because there was no danger, but because every alarm was heard and handled by the AI itself.

Which is the first thing I want to say back to that comment thread: the people saying “AI code is all garbage” may be underestimating something. The AI doesn’t only pile up mess. It’s also quietly doing the engineering work that guards against mess — more of it than its own user knows.

4. What tests actually guard: the two blind spots of doing QA by hand

Before I found those tests, I had exactly one way of accepting a feature: use it myself. If it runs and produces output, it passes. The AI says “done,” I treat the module as done.

This is human QA, and plenty of small teams genuinely operate this way — no shame in it. But it has two blind spots, and I’d stepped squarely into both:

Blind spot one: it only tests what you click, and the mess breaks precisely what you didn’t click. The nasty thing about “changing A breaks B” is that you changed A, so of course you go verify A — but the broken B is last month’s feature, and it never occurs to you to re-check it. When a project is small, “use it once” is roughly full coverage; that anchor holds. Once you’ve got dozens of features, each pass only covers the newest sliver and everything else sits unwatched. I did the math on my own project: clicking through every feature from scratch would take well over half an hour — meaning full manual acceptance had quietly become impossible ages ago.

Blind spot two: “it runs and produces output” and “the output is correct” are two different things. This is the ID incident exactly: the program kept running, kept producing output, the output was just wrong. Silent bad data sails right past the “it runs, ship it” anchor — until the day you make a decision based on it.

Automated testing targets those two blind spots directly. Put plainly: it records the act of “clicking through it yourself,” and has a machine re-click every old feature after every change, checking each output for correctness along the way. It isn’t engineer mysticism. It’s a bulk copy of the acceptance method you already use.

5. If you want the AI to work in batches, hire it an invigilator first

The back half of that spaghetti conversation hit a more practical question for a vibe coder. Babysitting the AI one feature at a time felt slow, and I wanted to move to batch mode: one project-level spec up front, a plan per feature, then let the AI run through them in bulk on its own.

Here’s the relationship I hadn’t worked out: batch-running and testing aren’t two separate things. Testing is the precondition for batch-running.

When you’re not there watching, the layer of supervision you used to get from “aligning direction as we chat” disappears, and something has to take its place. The only thing that can is tests: make “all tests green” the stop condition for each batch, so the AI finishes a batch, and if it hasn’t passed, it keeps fixing on its own; only a pass counts as handing in the paper. Batch-running without tests is letting the AI turn in stacks of exams with no invigilator in the room — and batching is exactly the mode most prone to piling up mess, where a patch smeared in one batch becomes the foundation of the next.

But letting the AI write its own tests has a famous trap: it cheats. This even has a formal name — reward hacking. Hard-coding the answer (asked for 1+1, it writes “when you see this question, answer 2” instead of actually adding), quietly deleting the tests that won’t pass, writing hollow tests that look like they check something and verify nothing. It’s the student writing the exam, taking the exam, and grading the exam.

There are two countermeasures, and neither requires you to read code:

One: exam questions come from the spec, not the code. A test should translate “what the requirement said,” not “what the code does.” By the same logic, tests always follow the spec, never the plan — the spec answers “what counts as correct,” the plan only answers “how we intend to build it.” Writing questions from the plan is writing questions from the answer key, and the moment the implementation changes, the alarm starts blaring for nothing.

Two: make the AI report the exam list in plain language. You can’t read test code, but you can read “this test verifies: after three wrong password attempts, the account locks.” Before the run, have the AI generate a list like that from the big spec, spend ten minutes scanning it, and confirm nothing’s missing or watered down. This is the one thing in the whole process you actually need to read carefully. Then you turn the batch loose.

For someone like me, the math works out in favor: what I resented about specs and plans was really the “I have to be present to chat every time” part. The exam list compresses my supervision cost from “chaperone the whole way” down to “read one list before the run.” Manual acceptance doesn’t get thrown out either — it just retreats from full patrol to spot checks.

In fairness: I’ve only reasoned this through. I haven’t run it end to end on a real project yet. Once I have, it’s worth a follow-up written from practice.

6. Back to that comment thread

Which brings me back to the original jab: AI products are stuck as MVPs and can’t ship.

Mine shipped. The public domain went live, and requests strangers submit through the web page land, for real, in my business table — end to end, verified. And you might not believe this: shipping was the least dramatic part of the whole project. Once the chain was wired together, going public was basically downhill. It was actually the stage I procrastinated on the most — not because it was hard, but because it wasn’t urgent. I’d do a bit whenever I happened to remember.

So “AI products can’t ship” splits, for me, into two halves. Shipping itself isn’t the technical wall. Staying able to change it over the long haul — not getting buried alive by your own mess — that’s the real wall. And getting over it doesn’t take knowing engineering. It takes getting a few things straight: debt is paid when you change things; running isn’t the same as correct; the places you can’t click through need an alarm; letting the AI do the work is fine, but hire it an invigilator.

I said I had no concept of testing. That’s now only half true. I still can’t read a single test file — but I know what those 342 tests have been standing guard over. For a vibe coder, that’s probably the most important stretch of distance between you and the mess.