Writing My First Real Pytest Suite After Years of Only Testing Manually

By James Nguyen Updated September 24, 2026
Writing My First Real Pytest Suite After Years of Only Testing Manually

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.

The first real test, covering the exact bug that came back

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.

The edge case my manual testing had never actually covered

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)

Fixtures, and replacing repeated setup code across multiple tests

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

Parametrizing tests instead of copy-pasting near-identical ones

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

Mocking an external API call, since I didn't want tests hitting a real service

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"

Measuring actual coverage, and what the number honestly revealed

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.

Adding the test suite to CI so it actually enforces itself

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.

Naming tests so a failure message actually explains itself

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.

Deciding what not to test, since covering everything isn't the actual goal

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.

A test isolation problem that only showed up running the full suite

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.

What I'd tell someone still testing everything by hand

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.

Daniel Justin

About the Author

James Nguyen is a full-stack programmer with more than ten years of experience engineering software systems. Specializing in the Node.js and Python ecosystems, he focuses on backend architecture, API design, and clean data integration. Follow me on YouTube and Instagram.

More Articles