DEV Community

qing
qing

Posted on

Python Testing: Write Tests That Actually Catch Bugs

Python Testing: Write Tests That Actually Catch Bugs

tags: python, testing, pytest, tutorial


tags: python, testing, pytest, tutorial


You’ve written a test that passes, but your code still has a bug. It’s a frustrating moment that happens to everyone: the test suite is green, yet the application crashes in production. The problem isn’t that you’re testing; it’s that you’re testing the wrong things. Most developers write tests that verify code structure instead of validating behavior, creating a fragile safety net that misses the actual bugs. To write tests that actually catch bugs, you need to shift from testing "did the function run?" to "did the function do what it was supposed to do, even when things go wrong?"

The key difference lies in focusing on behavior, edge cases, and isolation. When your tests mirror real-world scenarios and stress the boundaries of your logic, they become true bug detectors rather than just documentation checkers.

Write Tests That Verify Behavior, Not Implementation

The most common mistake in Python testing is writing tests that are too tightly coupled to how the code is implemented. If you refactor your code but break your tests, your tests are likely checking implementation details rather than outcomes.

Good tests assert that the state of the world matches your expectations. They don’t care if you used a for loop or a map function; they care that the result is correct.

The AAA Pattern: Arrange, Act, Assert

To keep tests focused and clear, follow the AAA pattern:

  1. Arrange: Set up the data and environment.
  2. Act: Execute the code being tested.
  3. Assert: Verify the outcome matches expectations.

This structure forces you to isolate the action you’re testing and makes failures easier to read.

import pytest

def calculate_discount(price, user_type):
    if user_type == "premium":
        return price * 0.8
    elif user_type == "vip":
        return price * 0.5
    return price

class TestCalculateDiscount:
    def test_premium_user_gets_20_percent_off(self):
        # Arrange
        price = 100
        user_type = "premium"

        # Act
        result = calculate_discount(price, user_type)

        # Assert
        assert result == 80

    def test_vip_user_gets_50_percent_off(self):
        # Arrange
        price = 100
        user_type = "vip"

        # Act
        result = calculate_discount(price, user_type)

        # Assert
        assert result == 50

    def test_regular_user_gets_no_discount(self):
        # Arrange
        price = 100
        user_type = "regular"

        # Act
        result = calculate_discount(price, user_type)

        # Assert
        assert result == 100
Enter fullscreen mode Exit fullscreen mode

This example demonstrates focused tests: each function tests a single behavior. If the test_vip_user_gets_50_percent_off fails, you immediately know a VIP discount is broken, not some vague "discount logic" issue [1][8].

Test Edge Cases and Error Conditions First

Bugs rarely hide in the middle of your logic; they lurk at the boundaries. If your code handles positive numbers, does it handle zero? Negative numbers? Empty strings? None?

Many developers write tests for the "happy path" first—the scenario where everything works perfectly. This is backwards. Write tests for edge cases first. If your code can’t handle the extremes, it will fail in production.

What to Test at the Boundaries

  • Numeric extremes: Zero, negative numbers, very large numbers, floating-point precision issues [6].
  • Empty inputs: Empty lists, empty strings, None values.
  • Invalid states: Passing a string where an integer is expected, or a negative age.
  • Time boundaries: Midnight, leap years, timezone transitions.
def test_discount_with_zero_price(self):
    assert calculate_discount(0, "premium") == 0

def test_discount_with_negative_price(self):
    # This should likely raise an error or handle gracefully
    with pytest.raises(ValueError):
        calculate_discount(-10, "premium")

def test_discount_with_none_user_type(self):
    # What happens if user_type is None?
    assert calculate_discount(100, None) == 100
Enter fullscreen mode Exit fullscreen mode

Testing these scenarios catches bugs that "happy path" tests would never see [2][3].

Keep Tests Fast, Isolated, and Deterministic

A test that takes 5 seconds to run is a test that developers won’t run. A test that fails sometimes and passes other times is a test that gets ignored. Your unit tests must be fast, isolated, and deterministic.

Fast: Unit Tests Under 100ms

Unit tests should run in milliseconds. Avoid:

  • Network calls (APIs, databases)
  • File I/O
  • Sleeps or delays
  • Randomness [2][3]

If your test needs to interact with a database or API, use mocks to replace those dependencies. Mocks simulate external services so your test stays fast and focused on your logic [1][2].

Isolated: No Shared State

Tests should not depend on each other. If test_a sets up a database record and test_b expects it to exist, your tests are coupled. Run them in random order—if they fail, you have a problem [2][3].

Use fixtures to set up common test data cleanly. Fixtures express setup intent and prevent duplication [1][3].

Deterministic: Same Result Every Time

If your test relies on timing, randomness, or external state, it’s flaky. Flaky tests destroy trust in your test suite. Fix the random seed if you need randomness, or avoid it entirely [1][3].

Use Parametrization to Cover More Cases

Instead of writing five separate test functions for five inputs, use parametrization to run the same test with different data. This reduces code duplication and ensures you’re testing a broader range of scenarios [1].

@pytest.mark.parametrize("user_type,expected_discount", [
    ("premium", 0.8),
    ("vip", 0.5),
    ("regular", 1.0),
    ("none", 1.0),
])
def test_discount_multipliers(user_type, expected_discount):
    price = 100
    result = calculate_discount(price, user_type)
    assert result == price * expected_discount
Enter fullscreen mode Exit fullscreen mode

This single test now covers four scenarios, making it easier to maintain and expand [1].

Follow the Testing Pyramid

Don’t write 100 end-to-end tests. Structure your suite like a pyramid:

  • Unit Tests (70–80%): Fast, isolated, test individual functions with mocks.
  • Integration Tests (15–25%): Test component interactions (e.g., app + database).
  • End-to-End Tests (5–10%): Test full user workflows, slowest but most realistic [1][4].

Unit tests catch bugs quickly. Integration tests catch issues between components. End-to-end tests validate the whole system. Focus your effort on unit tests; they’re your best bug detectors [3][4].

Run Tests Frequently and Automate Them

The best time to catch a bug is before you commit your code. Run tests frequently during development—locally and in CI systems. Don’t wait until your code is "done" [1][9].

Integrate tests into your CI/CD pipeline so they run automatically on every push or pull request. This ensures bugs are caught before they reach production [2][9].

Start Today: Your Action Plan

You don’t need to rewrite your entire test suite to start writing better tests. Here’s what you can do today:

  1. Pick one function in your codebase and write a test for an edge case it doesn’t handle (e.g., None, empty string, negative number).
  2. Refactor one existing test to follow the AAA pattern: separate Arrange, Act, and Assert.
  3. Add parametrization to a test that currently checks multiple inputs with separate functions.
  4. Mock an external dependency in a slow test to make it faster and more reliable.
  5. Run your tests before every commit, and set up a CI check if you haven’t already.

These small changes will make your tests more effective at catching bugs immediately.

Stop Testing Code, Start Testing Outcomes

Tests that pass but miss bugs are a false sense of security. The goal isn’t to have a green test suite; it’s to have a reliable one that catches real issues before they reach users. Focus on behavior, stress the edges, keep tests fast and isolated, and run them frequently.

Your test suite should be your first line of defense, not your last. Start writing tests that challenge your code, not just confirm it runs.

What’s the first edge case you’ll test today? Drop your answer in the comments, and let’s share the bugs we’ve caught by thinking outside


If you found this helpful, consider buying me a coffee ☕ — it keeps these articles coming!

Also check out my AI tools collection: AI 次元世界 — free AI tools for developers.

Top comments (0)