Module 4 · Working on real code

4.3 Testing, and the gate that protects the codebase

Lesson 4.3 · 10 min read

Here is a question this series has quietly avoided, and it has been building for twenty-seven posts.

You keep hearing that you must verify the code is right. Check what the AI produced. Make sure it works. Know that it does what you meant. Every post has said some version of this, and none of them have told you how anyone actually does it.

The honest answer is that reading code and clicking around cannot possibly be enough. Reading tells you what the code appears to do. Clicking tells you it worked once, on one path, with the data you happened to try, on your machine. Neither tells you it still works after tomorrow's change, or that it handles the empty list, or that the person who regenerates this function next month hasn't quietly broken something three files away.

The mechanism that does tell you is testing, and it is not what most people assume. It's not bureaucratic diligence, and it's not something you do at the end if there's time. A test is a claim about what your software must do, written down in a form a machine can check, forever. In an era where the implementation is increasingly written by a model, that claim is the most durable and most valuable thing in the repository, because it is the part that says what "correct" means.

That's the argument of this post. Let's build it properly.


What a test actually is

Strip away the tooling and a test is three lines of thought.

Put the system in a known situation. Do the thing. Check that what happened is what should have happened.

That's it. A function that adds tax to a price: set up a price of 100 and a rate of 10 percent, call the function, check that it returns 110. If it does, the test passes silently and you never think about it. If it doesn't, the test fails loudly and tells you exactly which claim about your software is no longer true.

The value is not in writing it. The value is that it runs again, every time anyone changes anything, forever, without a human remembering to check. You wrote down a fact about your system once and it is now enforced automatically, on every future change, by a machine that never gets bored or overconfident. That's the whole of it, and it's why the practice exists.

Which reframes what a test suite is. It is not a chore attached to a codebase. It is the accumulated set of everything anyone has ever learned about how this system must behave, encoded so that nobody can forget it. Every bug you fixed and wrote a test for is a bug that can never come back. Every edge case someone discovered painfully at 2am is now a permanent guardrail. The suite is institutional memory, executable.


The three kinds, and what each is actually for

You'll hear three words. They describe how much of the system a test exercises, and each buys you a different thing at a different price.

Unit tests check one small piece in isolation. The tax function. The date formatter. The function deciding whether a user may edit a post. They're fast, running in milliseconds, so you can have thousands and run them constantly. And when one fails, it points at exactly one function, so you know precisely where the problem is.

What they can't tell you is whether the pieces fit together. Every unit can be perfect while the system is broken, because the failure was in the seam between them.

Integration tests check that several pieces work together, usually including something real like a database. Call the endpoint that creates an order, and verify a row appeared with the right values. These are slower, because real things are involved, and they catch an entire category of bug that units cannot: the query that's wrong, the field that's mismatched, the transaction that doesn't commit.

For most backend work, this is where the real value is. Your controllers, from post 1.3, are mostly glue between HTTP and a database, and glue is exactly what integration tests test.

End-to-end tests drive the whole system as a user would: open the browser, click the button, fill the form, assert the confirmation appeared. They test what the user actually experiences, and they are the only tests that would catch a broken button.

They're also slow, and they break for reasons unrelated to your code, a network hiccup, a timing issue, an element that rendered a moment late. A test that fails randomly is worse than no test, because a suite people don't trust is a suite people ignore, and then they ignore the real failure too.

So the practical shape is: many unit tests, a solid layer of integration tests over the paths that matter, and a small number of end-to-end tests covering the handful of journeys that must never break. Signing up. Logging in. Paying. The rest is not worth the flakiness.


What is actually worth testing

The mistake is trying to test everything, discovering it's miserable, and abandoning the practice entirely. So be selective, on purpose.

Test the things that would be bad to get wrong. Money. Permissions, and especially the authorization check from post 2.3, whether this order belongs to the person asking. Anything that deletes. Anything irreversible.

Test the logic that has real thinking in it. The discount rules. The scheduling calculation. The state machine deciding what an order can become next. If a person had to reason carefully to write it, a person will have to reason carefully to change it, and they will be wrong occasionally.

Test the edge cases, because they're where software breaks. The empty list. Zero. Null. The very long string. The user with no orders. The date that crosses a month boundary. Notice that these are exactly the cases nobody clicks through by hand, and exactly the ones real users find within a day.

And test every bug you fix. This one habit, if it's the only one you adopt, is transformative. Before fixing a bug, write a test that fails because of it. Then fix it, and watch the test pass. You've now proven you fixed the real thing, and you have made it impossible for that bug to ever return unnoticed. Bugs come back constantly in untested codebases, because someone refactors and reintroduces the assumption that caused it. A test is the only thing that stands in the way.

Don't test the framework. Don't test that the database saves things or that the language adds numbers correctly. Test your logic, not the tools you're built on.


The part that matters most for you

Now the argument that makes this post belong in this series rather than in a general engineering curriculum.

When you prompt an AI, you write a description in English, it produces code, you read it and it looks right, and you move on. Consider what just happened. The intent existed in your head and in a chat message. The code exists in the repository. The connection between them exists nowhere.

Next month, someone asks the AI to refactor that file. It produces different code, cleaner, still plausible, and subtly different in behavior at some boundary nobody thought about. Nothing catches it. The original intent was never written down anywhere durable. It was a sentence in a conversation that scrolled away.

A test is where that intent lives. The test is the specification, and the implementation is the disposable part. That inversion is worth sitting with, because it is the opposite of how most people think about the relationship. The code can be regenerated, rewritten, replaced entirely by a better model next year. The test says what must remain true through all of it.

This is why coding agents work at all, from post 6.3. An agent editing code without tests is guessing, and it will guess plausibly and be wrong, and nothing will tell it. An agent with tests has a feedback signal: it changes something, runs the suite, sees a failure, and corrects. The tests are the only external verdict in the loop. They are what makes the difference between an agent that converges on correct and an agent that confidently walks away from it.

Which gives you a genuinely powerful way to work. Describe the behavior you want as tests, or have the AI write the tests and check them carefully yourself, because reviewing a test is far easier than reviewing an implementation. A test is a short, concrete statement about behavior, in the domain language you already think in: given a cart with two items and a ten percent discount, the total should be this. You can read that and know instantly whether it's what you meant. Then let the AI write whatever implementation satisfies it.

You have become the person who defines correct, and the machine has become the person who achieves it. That is a very good division of labor, and it is only available to someone who understands what a test is.


The gate: continuous integration

One more piece, and it connects this to the collaboration post.

You have tests. Someone has to run them. Relying on people to remember, on their own machine, before pushing, is relying on people, and people are busy and confident.

So the tests run automatically, on a server, every time anyone opens a pull request. This is continuous integration, and the automated run is a pipeline. It typically installs dependencies, runs the linter, checks types, runs the unit and integration tests, builds the project, and reports back on the pull request: green, or red.

This is the gate between a proposal and the shared truth. Post 4.2 argued that main must always work, and this is the mechanism that enforces it, rather than hoping. A pull request whose pipeline is red does not merge. Not because a rule says so, but because the codebase has stated a set of claims about itself and this change violates one of them.

Two things follow, and they're both about the human system rather than the technical one.

The pipeline must be fast, or people route around it. A suite taking forty minutes is a suite people stop waiting for and start ignoring. Fast tests are not a nicety; they're what keeps the gate real.

And the pipeline must be trusted. A test that fails randomly teaches everyone that red doesn't mean broken, and once that lesson is learned, the gate is decorative. A flaky test must be fixed or deleted. There is no third option, and deleting it is genuinely better than keeping it.

When the pipeline is fast and trusted, something quietly wonderful happens: people become willing to change things. You can refactor aggressively, accept an AI's sweeping rewrite, upgrade a dependency, because the suite will tell you if you broke something. The real product of a test suite is not correctness. It's the confidence to change code. A codebase without tests calcifies, because every change is a risk nobody wants to take, and eventually nobody touches the important parts and the whole thing rots around them.


Do this before the next post

Two things, and the second is the one that will change how you work.

First, find the tests in a real open source project. Look at the test files rather than the source files. Notice that you can read them, because tests are usually the most readable code in any repository: they name a situation, do one thing, and assert one outcome. Read five and notice that you now understand what that part of the system is supposed to do, more clearly than you would have from reading the implementation. That's what people mean when they say tests are documentation that cannot go stale.

Second, take something you've built with AI and write the tests afterwards, which is exactly the wrong order and is precisely why it's instructive. Pick one function that matters, something with money, permissions, or real logic. Now write down, in plain English, four things that must be true about it. The normal case. The empty case. The boundary. The case where it should refuse.

Turn those four sentences into tests, or ask an AI to, and check that the tests say what you meant. Then run them.

I would not be surprised if one fails. That is the point of this entire post. The code looked right, you read it, it worked when you clicked it, and there was a case nobody thought about, sitting there, waiting for a user. You just found it in ten minutes, and it will never come back.