Why Your Tests Are Your Best Friends (Even When They Fail)
A failing test is not bad news. It's the most honest thing in your codebase. Here's how to learn to love them.
Why Your Tests Are Your Best Friends (Even When They Fail)
Nobody starts their programming journey excited about tests. You start excited about making things: games, apps, scripts that automate the boring bits of your life. Tests feel like paperwork. You know you're supposed to do them. You do the minimum required. You move on.
Then, about three years in, something happens. You make a change that seems safe — a refactor, a quick fix, a dependency upgrade. The CI pipeline goes red. You look at the failure. And you realise: the test just caught something real, something you would have shipped, something you would have only discovered when a user hit it on a Friday afternoon.
The relationship with testing changes after that.
What a Test Actually Is
A test is a permanent record of a decision. When you write it should return 404 when the product does not exist, you are encoding a business rule in executable form. That rule can now be verified, automatically, every time the code changes. Forever.
This is not about catching bugs in the moment — though tests do that. It's about encoding intent so that future developers (including future you) can change things confidently. The test suite is a safety net, but more than that, it's documentation that can't go stale. Code changes. Comments don't always follow. Tests break and demand to be updated.
A codebase with good tests is a codebase you can refactor without fear. A codebase without tests is a codebase that calcifies — because nobody's confident enough to touch anything.
The Unit Test vs Integration Test Spectrum
There's a spectrum, and knowing where to invest is important.
Unit tests test a single function or class in isolation. Everything external — databases, APIs, other services — is mocked. They're fast (milliseconds per test), they're specific (the failure tells you exactly what broke), and they're numerous. A well-tested service might have hundreds of them.
Integration tests test how pieces fit together. They use real databases, real file systems, real HTTP stacks. They're slower, but they catch a different class of bug: the one that happens because two components that work fine individually have a misunderstanding about the interface between them.
The mistake is treating these as mutually exclusive. They're complementary. Unit tests give you fast feedback on logic. Integration tests give you confidence that the system behaves correctly end-to-end.
The rule of thumb: test business logic with unit tests. Test system boundaries with integration tests. Don't mock the database in an integration test — the whole point is to exercise the SQL.
The Discipline of Writing Tests First
Test-Driven Development (TDD) is one of those practices that sounds academic until you've actually done it for a week, and then you wonder how you lived without it.
The loop is simple: write a failing test, write the minimum code to make it pass, refactor. Red, green, refactor.
The benefit isn't really the test — it's the design pressure. When you write the test first, you're forced to think about the interface before the implementation. What does this function need to take? What does it need to return? What are the error cases? Thinking through these questions before you write a line of logic results in cleaner, more composable code.
AI tools have made this loop faster. You can describe a behaviour, ask an AI to generate a failing test skeleton, then write the implementation yourself — or ask for help with that too. The test still describes intent in your words, in your naming conventions, for your system. The AI helps with the scaffolding.
What Good Tests Look Like
A good test has a name that reads like a sentence: creates a new user when credentials are valid, not test_user_creation_001. The name is the documentation. If the test fails, the name should tell you exactly what expectation was violated.
A good test is independent. It doesn't depend on another test having run first, doesn't care about global state, sets up everything it needs in its own setup code. Tests that depend on each other are fragile and hard to debug.
A good test tests one thing. If your test has three assert statements on unrelated concerns, split it into three tests. When it fails, you want to know immediately what failed, not which of three things went wrong.
And a good test doesn't test the implementation — it tests the behaviour. expect(getUser(id)).toEqual({ name: "Jako", role: "admin" }) is testing behaviour. expect(db.query).toHaveBeenCalledWith("SELECT * FROM users WHERE id=?") is testing implementation. The first test survives a database rewrite. The second test breaks the moment you change the query, even if the result is identical.
The Failing Test Is Not Bad News
Here is the hardest mental shift: a failing test is not bad news. It is information.
A test that fails means you either broke something (important to know!) or the world changed and the expectation needs updating (also important to know!). Either way, you are better off than if the test didn't exist and you shipped the change without realising.
The bad news would be finding out in production.
There's a version of professional software development that looks like this: you make a change, your tests run in seconds, and you ship with confidence because you know the behaviour is verified. This is achievable. It requires investment, discipline, and a shift in how you think about tests.
But once you've worked this way, going back to "ship and pray" feels genuinely frightening. The tests aren't bureaucracy. They're the thing that lets you move fast without breaking things. Which, it turns out, is what everyone wanted all along.
Jako Heiberg
Software developer with 40+ years of building things that work. Full-stack, FastAPI, React. Based in Cape Town, working remotely, worldwide.