Accent colour
 
Shortcuts: press ?
 

Unit Testing Explained — How to Write Tests That Actually Catch Bugs

Muhammad Tahir November 11, 2026

Introduction

Every developer has shipped a bug that a simple test would have caught. A price calculation that was off by one. A login form that accepted an empty password. The fix took five minutes. Finding the bug took a day, and a customer found it first.

That is the problem unit testing solves. A unit test is a small, automated check that one piece of your code — usually one function or one class — behaves the way you expect. You write the test once, and it runs in seconds every time you change the code. When something breaks, the test tells you immediately, before the bug reaches a user.

And yet many teams treat testing as a chore, writing tests only to hit a coverage number. The difference between a test suite that protects you and one that just slows you down is not how many tests you have. It is what you test, and how clearly you write them.

What a Unit Test Actually Is

A unit is the smallest piece of code you can test on its own. In most applications, that means a single function or a single method of a class. A unit test calls that function with known inputs, checks the result, and reports whether the result matches what you expected.

Consider a simple function that calculates a discount. A customer spends £120, and members get 10% off. The function should return £108. A unit test calls the function with 120, checks that the answer is 108, and fails loudly if it is anything else.

That is all a unit test does. It does not start a server, open a database, or click buttons in a browser. Those are jobs for other kinds of tests, and they are valuable too. But unit tests are the foundation, because they are fast, focused, and easy to run every time you change the code.

What to Test First

If you are starting from a codebase with no tests, do not try to test everything at once. Start with the code where a bug costs the most.

Business logic comes first. These are the rules that make your product what it is: how a price is calculated, when a discount applies, whether a user is allowed to do something. If the logic is wrong, the product is wrong, even if everything else works perfectly.

Edge cases come second. Happy-path testing — checking that the normal case works — is necessary, but it is the edge cases that produce most real bugs. What happens when the list is empty? When the number is zero or negative? When the string is blank? When the date is in the past? Write a test for each of these, because they are exactly the inputs real users will eventually provide, usually at the worst possible moment.

Test public behaviour, not private implementation. A test should answer: "If I call this function with this input, do I get the right output?" When a test checks how the function gets there, it breaks every time you refactor, even when the behaviour is still correct.

What Not to Test

Not everything deserves a unit test, and testing the wrong things is one of the fastest ways to make a suite useless.

Do not test code you did not write. Your framework, your database driver, and your standard library are already tested by their authors. Writing a test that checks that a library function sorts a list correctly is a waste of time. Test your own logic around the library, not the library itself.

Do not test trivial code. A function that only returns a stored value, or a getter that does nothing but return a field, does not need a test. The test would only repeat the code, and it would break if you renamed the field, without ever catching a real bug.

Do not test every private helper. If a private function is only ever called by one public function, test the public function. The private helper is an implementation detail. When you test through the public interface, you are free to change the helper, split it, or delete it, as long as the public behaviour stays the same.

Finally, do not chase a coverage number for its own sake. A project with 95% coverage can still be full of bugs if the tests check nothing meaningful. Coverage is a useful warning light, not a measure of quality.

Anatomy of a Good Test

A good test is easy to read, and that matters more than most developers realise. Tests are read far more often than they are written. When a test fails at 5pm on a Friday, the person reading it needs to understand in seconds what it was checking and why it failed.

Give the test a name that describes the behaviour, not the code. "test_calculate_discount_returns_zero_for_empty_cart" tells you what the test checks. "test_calculate_discount_3" tells you nothing. A descriptive name is documentation: it lists the behaviours your code promises.

Keep each test focused on one behaviour. A test that checks five different things is hard to debug, because when it fails you do not know which of the five broke. If you need to check five things, write five small tests. They will run just as fast, and each one will tell you exactly what went wrong.

Use realistic data in your tests. If your function processes customer orders, use an order that looks like a real one, not "test123" and "999999". And keep test code boring: a test that uses loops and conditionals of its own is a test that can contain bugs. Write tests plainly, even if that means repeating a few lines.

Mocking: When and How to Use It

Sooner or later, the code you want to test will depend on something slow or unreliable: a database, a payment service, a file system, the current time. Calling the real thing in a unit test makes the test slow, fragile, and hard to run anywhere. This is where mocking comes in.

A mock is a stand-in. It replaces a real dependency with a fake version that behaves the way you tell it to. Instead of calling the real payment service, your test uses a mock that always says "payment succeeded" — or, in another test, always says "payment failed". This lets you check how your code handles both cases without ever touching a real card.

Mocking is powerful, but it is easy to overuse. A good rule is to mock at the boundary of your code: the place where your code talks to the outside world. Mock the database, the network, and the clock. Do not mock your own code. If you find yourself mocking a function from the same module you are testing, that is a sign the code is tangled together and needs to be separated first.

Also, assert on the result your code produces, and only check a mock's recorded calls when the calls themselves are the point — for example, that an email was actually sent. A test that checks every call turns into a mirror of the implementation and breaks on every refactor.

Writing Tests Before or After the Code

Test-driven development, or TDD, means writing the test before the code. You write a test that fails, write just enough code to make it pass, and then tidy the code up. Many developers find this helps them design better code, because writing the test first forces them to think about how the function will be used before they think about how it will work.

You do not have to use TDD for every line of code. A practical middle ground: write the test first for tricky logic, where the design is unclear, and just after the code for straightforward logic, while the behaviour is still fresh in your mind.

The worst time to write a test is months later, when nobody remembers why the code behaves the way it does. If you must add tests to old code, start by writing a test for the bug you just fixed. That test proves the fix works, and it stops the bug from coming back.

Keeping Your Test Suite Fast

A test suite that takes an hour to run is a test suite nobody runs. Speed is not a luxury in testing; it is what makes testing happen at all.

Keep unit tests fast by keeping them isolated. A unit test should not touch a database, a network, or the file system. If a test takes more than a few milliseconds, ask why. Usually the answer is that it is talking to something real instead of using a mock.

Run your tests constantly — automatically on every save, or at least on every commit. When a test fails within seconds of the change that broke it, the cause is obvious. When it fails three weeks later, the cause could be anything.

When the suite does grow slow, split it: a fast set of unit tests that runs on every change, and a slower set of integration tests that runs before a merge or a release. If the fast set takes minutes, people will stop running it, and the whole point is lost.

Common Mistakes That Make Tests Useless

The first mistake is testing with the same data every time. If every test uses the same input, the tests will pass even when the code only works for that one input. Vary your test data, and include the awkward cases: empty values, large values, values at the boundary.

The second mistake is writing tests that depend on each other. If one test creates a record in a shared database and the next test expects it to be there, the tests will fail when run in a different order, or on their own. Each test should set up its own data and clean up after itself. A test that only passes when run after another test is not a test at all.

The third mistake is ignoring a failing test. A test that fails and gets ignored teaches the team that failures do not matter. Soon, several tests fail, and nobody knows which failures are real. Fix a failing test immediately, or delete it if it is genuinely wrong — but never let a red test become normal.

The fourth mistake is writing tests that are too broad. A test that checks an entire workflow, from the database to the user interface, is an integration test wearing a unit test's clothes. Keep unit tests small, and write separate integration tests for the workflows.

Conclusion

Unit testing is not about proving your code is perfect. It is about catching mistakes early, when they are cheap to fix, and giving yourself the confidence to change code without fear. Start with the logic that matters most, test the edge cases, and write tests that check behaviour rather than implementation. A small suite of clear, focused tests will protect you far better than a large suite of vague ones.

The best time to write a test is when you write the code. The second best time is now — starting with the function you are most afraid to change.

Frequently Asked Questions

What is the difference between unit testing and integration testing? Unit testing checks one small piece of code in isolation, usually a single function, with its dependencies replaced by mocks. Integration testing checks how several pieces work together, for example whether your code talks to a real database correctly. Both are useful, but unit tests are faster and more focused, so they form the base of a good testing strategy.

How much of my code should I test? Aim to test the logic that matters: business rules, calculations, and edge cases. Do not chase 100% coverage. Use coverage reports to find untested code, not as a target to hit.

Should I use mocks for everything? No. Mock only the things outside your code: databases, network services, the file system, and the clock. Do not mock your own functions or classes. If you need to mock your own code to test it, the code is probably too tangled and should be split apart first.

Is test-driven development worth learning? Yes, even if you do not use it for every task. Writing the test first forces you to think about how code will be used before you write it, which usually leads to simpler, clearer designs. Try it on your next tricky piece of logic and see how it feels.

What should I do when a test keeps failing for no clear reason? First, run the test on its own to check whether it depends on another test. Then check whether it depends on the time, the order of data, or something outside the code, such as a real database or network. Flaky tests are usually a sign of a hidden dependency. Fix the dependency, or replace it with a mock, rather than ignoring the failure.

Was this article helpful?

Muhammad Tahir

Author

 

Get new articles by email

 

Comments