DEV Community

Sir Max
Sir Max

Posted on

3 Testing Habits That Caught Bugs Before My Users Did

Last month a user reported that our checkout endpoint charged them twice. The code looked fine in review. Every test was green. The bug only appeared when two requests hit the same endpoint within the same millisecond — a race condition between the "check if order exists" query and the "insert order" query.

That single bug cost a refund, a support ticket, and an afternoon of my time. It also reminded me of something I already knew but kept ignoring: tests that only cover the happy path don't catch much.

Since then I've adopted three habits that have caught bugs before my users did. They're not fancy. They're just specific.

Habit 1: Write the failure test before the success test

When I add a feature, I used to start by writing the test that proves it works. Now I start with the test that proves it breaks the right way.

The reason is simple: the success test tells you the feature does what you want. The failure test tells you it doesn't do what you don't want — and in production, "what you don't want" is usually where the bugs live.

Here's a concrete example. Imagine a function that parses a comma-separated list of IDs:

def parse_ids(raw: str) -> list[int]:
    """Parse a comma-separated string into a list of ints."""
    return [int(x) for x in raw.split(",")]
Enter fullscreen mode Exit fullscreen mode

The happy-path test looks like this:

def test_parse_ids_valid_input():
    assert parse_ids("1,2,3") == [1, 2, 3]
Enter fullscreen mode Exit fullscreen mode

But what happens with "1,2,"? With "1,,2"? With ""? With "1, 2, 3" (spaces)?

The failure tests are where the real design decisions show up:

import pytest

def test_parse_ids_trailing_comma():
    assert parse_ids("1,2,") == [1, 2]  # or should it raise?

def test_parse_ids_empty_string():
    with pytest.raises(ValueError):
        parse_ids("")

def test_parse_ids_spaces():
    # whitespace tolerance is a product decision, not an afterthought
    assert parse_ids("1, 2, 3") == [1, 2, 3]
Enter fullscreen mode Exit fullscreen mode

Writing these forces me to decide what "invalid" means before I ship it. The bug that motivated this habit was a CSV import that silently dropped the last row whenever the file had a trailing newline — because I never wrote a test for that exact case.

Habit 2: Use property-based testing for the edge cases you can't think of

You can't enumerate every edge case by hand. That's the whole problem.

Property-based testing flips the approach: instead of writing specific inputs and expected outputs, you describe a property that should always hold, and the framework generates hundreds of random inputs trying to break it.

The hypothesis library makes this almost free in Python:

from hypothesis import given, strategies as st

@given(st.text())
def test_parse_ids_never_crashes(s: str):
    # whatever the input, the function must either return a list
    # of ints or raise a clean ValueError — never a raw TypeError
    try:
        result = parse_ids(s)
        assert all(isinstance(x, int) for x in result)
    except ValueError:
        pass  # clean rejection is fine
Enter fullscreen mode Exit fullscreen mode

On the first run, Hypothesis found that parse_ids("1.5") raises ValueError (from int("1.5")), which is fine — but it also probed inputs I would never have written by hand, like strings with Unicode digits and whitespace-only bodies. None of them were bugs in this case, but the point stands: it tested inputs I would never have thought to write.

The one time property-based testing paid for itself instantly: we had a function that rounded currency amounts. I wrote the property "rounding twice equals rounding once" — and Hypothesis found a value in under a second where it didn't hold. That was a real rounding bug in a money path, caught before it reached a customer.

The habit isn't "always use Hypothesis." It's "find the one property that must always be true, and let the machine try to break it."

Habit 3: Every bug becomes a regression test

This is the cheapest, highest-value habit on the list, and it took me embarrassingly long to adopt.

The rule: when a bug reaches production (or even just code review), the first commit is a failing test that reproduces it. The second commit fixes it.

The test stays in the suite forever. If the bug ever comes back, the test catches it instantly.

Here's the double-charge bug from the intro, turned into a regression test:

import threading

def test_no_double_charge_under_concurrency():
    results = []

    def charge():
        # simulate two simultaneous checkouts for the same order
        results.append(checkout(order_id=42, amount=1990))

    threads = [threading.Thread(target=charge) for _ in range(2)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()

    # exactly one charge should succeed; the other should be rejected
    assert len([r for r in results if r.status == "charged"]) == 1
Enter fullscreen mode Exit fullscreen mode

This test is flaky-prone if checkout() doesn't actually use a lock or a database unique constraint — which is exactly the point. Writing it forced me to add a UNIQUE constraint on (order_id) in the database, which is the real fix. The test just makes sure that fix never regresses.

Why this habit matters more than the others: it turns every production incident into permanent institutional knowledge. Six months from now, when someone refactors the checkout code, the test will still be there. They won't have to remember what happened — the test remembers.

What I'd tell my past self

None of these habits require new tooling or a big rewrite. They're about where I point my attention:

  1. Test the failures first — decide what "invalid" means before you ship it.
  2. Find the one invariant that must always hold — and let property-based testing try to break it.
  3. Every bug becomes a test — turn incidents into permanent knowledge.

The double-charge bug happened because I only tested the happy path. It cost an afternoon. The regression test I wrote afterward has run thousands of times since and will catch that class of bug forever.

Cheap insurance. Start with habit three — it's the one that pays off fastest.

Top comments (0)