I fixed a bug in our billing calculation logic, tested it manually by running the function a few times in a Python shell with different inputs, and shipped it, only to have the exact same bug resurface three weeks later after an unrelated refactor touched the same code path. Nothing had caught it because nothing beyond my own memory was checking that specific behavior, and that quiet regression is the actual reason I finally sat down and learned pytest properly instead of continuing to test everything by hand.
I started by writing a test for the specific billing calculation that had regressed, wanting the one scenario I knew mattered most covered before worrying about broader coverage.
def calculate_total(price, discount_percent):
return price - (price * discount_percent / 100)
def test_calculate_total_applies_discount():
assert calculate_total(100, 10) == 90
Running pytest from the project root found and ran this test automatically based on its filename and function naming convention, no configuration required for a case this simple.
Writing the test forced me to think about zero and negative discount values explicitly, cases I'd genuinely never manually tested during my original fix, and adding those cases immediately surfaced a real bug, a negative discount percentage silently increased the price rather than raising an error the way it should have.
def test_calculate_total_rejects_negative_discount():
with pytest.raises(ValueError):
calculate_total(100, -10)
Several tests needed the same sample user object, and I'd initially copied the same setup code into every single test function before learning about fixtures, which let me define that setup once and have pytest inject it automatically wherever a test requests it by name.
import pytest
@pytest.fixture
def sample_user():
return {"id": 1, "name": "Test User", "balance": 100}
def test_user_has_positive_balance(sample_user):
assert sample_user["balance"] > 0
I'd written four nearly identical test functions covering different discount percentages before discovering parametrize, which let me collapse all four into a single test definition running multiple times against a list of input and expected-output pairs.
@pytest.mark.parametrize("price,discount,expected", [
(100, 10, 90),
(200, 25, 150),
(50, 0, 50),
])
def test_calculate_total_various_discounts(price, discount, expected):
assert calculate_total(price, discount) == expected
A function that called out to a payment provider's API couldn't run in a test suite without either hitting a real external service or failing entirely, so I used unittest.mock to replace that specific call with a fake response during testing.
from unittest.mock import patch
@patch("billing.payment_client.charge")
def test_process_payment_success(mock_charge):
mock_charge.return_value = {"status": "success"}
result = process_payment(100)
assert result["status"] == "success"
Running pytest with the coverage plugin showed our billing module sat at a genuinely low percentage despite feeling reasonably tested to me subjectively, a real, humbling gap between how covered I assumed the code was and how covered it actually was.
pytest --cov=billing --cov-report=term-missing
The report's missing-lines output pointed directly at several error-handling branches I'd never actually exercised in any test, including, unsurprisingly, a code path close to the original regression that started this whole effort.
A test suite that only runs when I personally remember to run it manually offers roughly the same protection as no test suite at all, so I added it to our existing CI pipeline as a required check before merging, the same enforcement pattern that had already saved us from a bad deploy on a different project.
My earliest tests had generic names like test_1 and test_billing, and a failing test in CI told me almost nothing beyond "something in billing broke" without opening the file to investigate further. I now name every test after the specific behavior it verifies, so a failure in the CI log alone, before I've even opened the code, tells me roughly what broke and why it likely matters.
Chasing that low coverage number, I briefly wrote tests for a handful of simple getter functions with no real logic in them at all, purely to move the percentage upward, before recognizing that these tests added maintenance burden without ever catching a real bug. I now focus test-writing effort specifically on logic with actual branches and edge cases, treating the coverage number as a signal to investigate rather than a target to chase for its own sake.
Two tests passed individually but failed when run together as part of the full suite, eventually traced to one test mutating a shared fixture's dictionary in place rather than treating it as read-only, leaving state behind that the next test unexpectedly inherited. Returning a fresh copy of the fixture data on each request, rather than the same shared object every time, fixed the interference and taught me to treat fixture mutation as a real, specific risk rather than an edge case I could ignore.
That billing bug resurfacing three weeks after I'd already fixed it once was a genuinely frustrating, avoidable failure, and writing tests for the exact scenarios I'd manually checked, plus the edge cases manual testing never occurred to me to check, has caught at least two similar regressions since before they ever reached production.