How-tos
How to Write Unit Tests That Actually Catch Bugs
Learn practical techniques for writing unit tests that detect real bugs, including behavioral testing, property-based testing, regression tests, error path coverage, and proper mocking.
June 2026 · 6 min read · 1 views · 0 hearts
Advertisement
How to Write Unit Tests That Actually Catch Bugs
Let's be honest: most unit tests are terrible at catching bugs. They pass when they should fail, test the wrong things, and give you a false sense of security. The problem isn't testing itself—it's how you write those tests.
Here's the uncomfortable truth: a test that always passes is worse than no test at all. It wastes your time, consumes CI resources, and lulls you into thinking your code works.
So how do you write tests that actually catch real bugs? Let's break it down.
Test Behavior, Not Implementation
The number one mistake developers make is testing how code works instead of what it does.
# BAD: Tests implementation details
def test_user_service():
service = UserService()
assert service._cache == {} # Tests internal state
service.create_user("Alice")
assert "Alice" in service._users # Tests data structure
This test breaks when you refactor the internals—even if the behavior stays perfect. That's noise, not signal.
# GOOD: Tests behavior
def test_user_creation():
service = UserService()
user = service.create_user("alice@example.com")
assert user.email == "alice@example.com"
assert user.is_active == True
Behavioral tests survive refactoring. They test contracts, not code.
Use Property-Based Testing for Edge Cases
You can hand-write tests for happy paths, but what about the weird inputs? Property-based testing generates hundreds of random inputs and checks invariants.
# Standard test - only checks one scenario
def test_sort_returns_list():
assert sorted([3, 1, 2]) == [1, 2, 3]
# Property-based test - checks many scenarios
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_sort_invariants(items):
result = sorted(items)
assert len(result) == len(items)
assert all(result[i] <= result[i+1] for i in range(len(result)-1))
assert set(result) == set(items)
The property-based test catches edge cases you'd never think to write manually—empty lists, duplicates, negative numbers, huge values.
Write Tests for Bugs You've Already Fixed
When you fix a bug, write a test that fails before your fix and passes after. This is called regression testing, and it's the most effective way to prevent reintroducing bugs.
The workflow looks like: 1. Reproduce the bug (test fails) 2. Write the failing test 3. Fix the code 4. Test passes 5. Commit both the fix and the test
This ensures your test actually detects the bug—because you've proven it does.
Test Error Paths, Not Just Happy Paths
Most developers write tests that check "it works" and forget to check "it fails correctly."
def transfer_funds(from_account, to_account, amount):
if amount <= 0:
raise ValueError("Amount must be positive")
if from_account.balance < amount:
raise InsufficientFundsError("Insufficient balance")
from_account.balance -= amount
to_account.balance += amount
Now test all the ways it can fail:
def test_negative_amount_raises_error():
with pytest.raises(ValueError, match="Amount must be positive"):
transfer_funds(account_a, account_b, -100)
def test_insufficient_balance_raises_error():
account_a.balance = 10
with pytest.raises(InsufficientFundsError):
transfer_funds(account_a, account_b, 100)
def test_successful_transfer_works():
account_a.balance = 100
account_b.balance = 0
transfer_funds(account_a, account_b, 50)
assert account_a.balance == 50
assert account_b.balance == 50
Each error path is a potential bug. Test them all.
Mock External Dependencies—But Not Too Much
Mocks are necessary for isolating unit tests, but they're also a trap. Over-mocking hides real problems.
The rule: mock at your system's boundaries. Mock the database connection, not the repository. Mock the HTTP client, not the service that calls it.
# GOOD: Mock at boundary
def test_user_repository():
mock_db = MagicMock(spec=DatabaseConnection)
mock_db.execute.return_value = [{"id": 1, "name": "Alice"}]
repo = UserRepository(mock_db)
users = repo.get_all()
assert len(users) == 1
# BAD: Over-mocking
def test_user_service():
mock_repo = MagicMock(spec=UserRepository) # Mocking your own code
mock_repo.get_all.return_value = [User(id=1, name="Alice")]
service = UserService(mock_repo)
users = service.get_active_users()
assert users[0].name == "Alice"
Over-mocking tests your mocks, not your code.
Boundary Conditions Reveal Hidden Bugs
Bugs love boundaries. Off-by-one errors, empty collections, and edge values are where things break.
def paginate(items, page_size, page_number):
start = page_size * (page_number - 1)
end = start + page_size
return items[start:end]
Test the boundaries explicitly:
def test_first_page():
assert paginate(range(10), 5, 1) == [0, 1, 2, 3, 4]
def test_last_page():
assert paginate(range(9), 5, 2) == [5, 6, 7, 8]
def test_out_of_bounds():
assert paginate(range(5), 5, 99) == []
def test_zero_items():
assert paginate([], 5, 1) == []
Make Tests Read Like Specifications
A well-written test document behavior. Use descriptive names and structure them clearly.
def test_when_user_has_invalid_email_then_registration_fails():
invalid_emails = ["notanemail", "", "user@", "@domain.com"]
for email in invalid_emails:
result = register_user(email)
assert result.is_error()
assert "Invalid email" in result.message
The test name tells you exactly what scenario it covers and what outcome to expect. When a test fails, you know immediately what broke without reading the code.
The Final Check: Does Your Test Actually Catch Bugs?
Before committing, run this mental checklist:
- Does it test behavior, not implementation?
- Does it cover at least one error path?
- Would it fail if someone introduced a regression?
- Can I understand what's being tested by reading the name?
If you answered "no" to any of these, rewrite it. A test that doesn't catch bugs is just line noise—and your codebase deserves better.
Advertisement
Comments
Questions, corrections, and tips stay visible for everyone reading this page.
Join the discussion
No comments yet
Be the first to leave a note — it helps the next reader.