top of page

Writing Your First Test That Catches a Real Bug

  • ShiftQuality Contributor
  • Mar 9
  • 6 min read

Reading about testing is not testing. You can understand the theory — confidence, regression prevention, living documentation — and still not know what it feels like to write a test that saves you from shipping a broken feature.

This post closes that gap. You are going to write a test. It is going to find a bug. The bug is the kind that passes code review, survives manual testing, and breaks quietly in production three weeks later. You are going to catch it in seconds.

The Setup: A Function That Looks Correct

Here is a Python function that calculates a user's age from their date of birth. It looks right. It reads right. Most developers would approve it in a code review without hesitation.

from datetime import date

def calculate_age(birth_date, today=None):
    if today is None:
        today = date.today()
    age = today.year - birth_date.year
    return age

Simple subtraction. Born in 1990, checked in 2026, the function returns 36. Ship it.

Except there is a bug. A specific, common, real-world edge case that this function gets wrong every single time. If you see it already, good. If you do not, that is exactly the point — this is the kind of bug that humans miss and tests catch.

Writing the Test: Step by Step

You need Python and pytest. If you do not have pytest installed, run pip install pytest. That is the only dependency.

Create a file called test_age.py in the same directory as your function. Start with the obvious case — the one that works.

from datetime import date
from age import calculate_age

def test_age_simple():
    birth = date(1990, 1, 15)
    today = date(2026, 6, 20)
    assert calculate_age(birth, today) == 36

Run it:

$ pytest test_age.py -v
test_age.py::test_age_simple PASSED

Green. The function works. Time to ship, right?

Not yet. Think about the edge cases. What happens when someone's birthday has not occurred yet this year?

def test_age_before_birthday():
    birth = date(1990, 8, 15)
    today = date(2026, 3, 10)
    assert calculate_age(birth, today) == 35

This person was born in August 1990. The date is March 2026. They are 35 — their 36th birthday is five months away. The function should return 35.

Run it:

$ pytest test_age.py -v
test_age.py::test_age_simple PASSED
test_age.py::test_age_before_birthday FAILED

    assert calculate_age(birth, today) == 35
    AssertionError: assert 36 == 35

There it is. The function returns 36 when the correct answer is 35. It subtracts the years but never checks whether the birthday has actually occurred yet. Every user born later in the year than the current date gets an age that is one year too high.

This is not a contrived example. This exact bug exists in production systems. It affects every user whose birthday falls after today's date. It is invisible during casual testing because most manual checks use round-number birth years or do not think to test the pre-birthday scenario.

A two-line test found it in milliseconds.

Fixing the Bug

Now you know the bug exists, and you have a test that proves it. Fix the function:

from datetime import date

def calculate_age(birth_date, today=None):
    if today is None:
        today = date.today()
    age = today.year - birth_date.year
    if (today.month, today.day) < (birth_date.month, birth_date.day):
        age -= 1
    return age

The fix checks whether today's month and day are before the birth month and day. If so, subtract one from the age. Run the tests again:

$ pytest test_age.py -v
test_age.py::test_age_simple PASSED
test_age.py::test_age_before_birthday PASSED

Both pass. The bug is fixed, and the test that caught it will prevent it from ever coming back. If someone refactors this function six months from now and accidentally removes that check, the test fails immediately. That is regression prevention in practice.

Anatomy of a Test: Arrange, Act, Assert

Every test you just wrote follows the same three-step pattern. This pattern has a name — Arrange, Act, Assert — and it applies to virtually every test you will ever write, in any language, at any level.

Arrange — set up the inputs and conditions.

birth = date(1990, 8, 15)
today = date(2026, 3, 10)

Act — call the code being tested.

result = calculate_age(birth, today)

Assert — verify the result matches expectations.

assert result == 35

That is it. Three steps. If you can describe what the inputs are, what the code should do, and what the output should be, you can write a test. The pattern scales from a two-line utility function to a complex business workflow. The steps get longer, but the structure stays the same.

What Makes a Good Test

Not all tests are equally useful. The tests that earn their keep share five properties.

Focused. Each test verifies one behavior. test_age_before_birthday does not also check what happens with a null date or an invalid year. One test, one question, one answer.

Independent. Tests do not depend on each other. test_age_before_birthday does not need test_age_simple to run first. Any test should pass or fail on its own, in any order.

Readable. A test is documentation. Someone reading test_age_before_birthday should immediately understand what scenario it covers and what the expected behavior is. If a test requires a paragraph of comments to explain, it is testing too much or testing the wrong thing.

Fast. Unit tests run in milliseconds. If a test takes seconds, something is wrong — it is probably hitting a database, a network, or a file system when it should not be. Slow tests do not get run. Tests that do not get run do not catch bugs.

Deterministic. A test produces the same result every time. No randomness. No dependency on the current time (notice how the function accepts today as a parameter — that is deliberate). A test that sometimes passes and sometimes fails is worse than no test at all, because it trains the team to ignore failures.

Common First-Test Mistakes

Two mistakes account for most of the frustration that beginners experience with testing.

Testing too much at once. A single test that checks age calculation, date validation, error handling, and string formatting is not a test. It is a specification crammed into one function. When it fails, you do not know which behavior broke. Write small tests. More of them. Each one answering a single question.

Testing implementation instead of behavior. A test should verify what a function does, not how it does it. If you test that calculate_age uses tuple comparison internally, your test breaks every time someone refactors the implementation — even if the behavior is still correct. Test the inputs and outputs. Ignore the internals. The function is a black box. Feed it data. Check the result.

The Habit: Test-First Bug Fixing

Here is a practice that will change how you think about bugs.

When you find a bug, do not fix it first. Write a test first. Write a test that fails because of the bug. Then fix the bug. Then watch the test pass.

This approach does three things:

  1. Proves the bug exists. Not a guess, not a hunch — a failing test that demonstrates the exact problem.

  2. Proves the fix works. The test passes after the change. No ambiguity.

  3. Prevents regression. The test stays in the suite permanently. If the bug tries to come back — through a refactor, a merge conflict, a careless edit — the test catches it.

This is the simplest form of test-driven development. You do not need to adopt a full TDD methodology to benefit from it. Just start with bug fixes. Every bug you fix, write the test first. Within a month, you will have a safety net that covers your most failure-prone code — because bugs tend to cluster in the same areas.

Key Takeaway

You just wrote a test that caught a real bug — the kind that ships to production and quietly returns wrong answers for a subset of users. You found it in milliseconds, fixed it with confidence, and created a permanent guard against its return.

That is not theory. That is the practice. Arrange, Act, Assert. One behavior per test. Test what the code does, not how it does it. Write the test before you fix the bug.

Testing is not a skill you learn by reading about it. It is a skill you learn by doing it. You have done it once. Do it again on your next bug fix. Then again on the feature after that. The habit builds fast, and the payoff compounds.

This completes the Understanding Software Quality learning path. You have covered what quality means, why we test, and how to write tests that catch real bugs. From here, you have options:

  • SDLC Fundamentals — Understand the full software development lifecycle and where quality fits at every stage.

  • Automation for Beginners — Learn how to automate repetitive processes, starting with the workflows closest to your daily work.

  • Getting Started with Code — If this was your first time seeing Python, start here to build a foundation before going deeper into testing.

Comments


bottom of page