Skip to main content

Python Testing with pytest

Mentor's Note: Testing is not about finding bugs โ€” it's about proving your code works correctly, every time, forever. A test suite is your safety net for fearless refactoring. ๐Ÿงช


The Scenario: The Bridge Inspectorโ€‹

Imagine you're building a bridge.

  • Without Testing: You build the whole bridge, walk across it, and hope it holds. If it breaks, you don't know which beam failed.
  • With Testing: You test each beam separately (unit tests), then sections (integration tests), then the whole bridge (end-to-end test). Every part is verified before the next is added.
  • The Result: Safe bridges built faster, with confidence. โœ…

Why Test?โ€‹

BenefitWhy It Matters
Catch regressionsA change in one place breaks something elsewhere โ€” tests catch it immediately.
DocumentationTests show exactly how your code is supposed to be used.
Refactor with confidenceYou can rewrite large sections knowing tests will catch mistakes.
Better designCode that's hard to test is usually poorly designed. Testing forces clean interfaces.
Automated verificationCI runs tests on every commit โ€” you never ship broken code.

Getting Started with pytestโ€‹

pip install pytest

Your First Testโ€‹

Create a file named test_example.py:

# test_example.py

def test_addition():
result = 2 + 2
assert result == 4

def test_string_uppercase():
result = "hello".upper()
assert result == "HELLO"

def test_list_contains():
items = [1, 2, 3]
assert 2 in items

Run the tests:

pytest

Output:

==================== test session starts ====================
collected 3 items

test_example.py ... [100%]

===================== 3 passed in 0.02s =====================

With verbose output:

pytest -v

The assert Statementโ€‹

pytest uses Python's built-in assert โ€” no special API needed. When an assertion fails, pytest gives you detailed output showing the actual vs expected values.

def test_compare():
assert 10 > 5 # Simple comparison
assert "python" in "python is fun" # Membership
assert [1, 2, 3] == [1, 2, 3] # Equality
assert not False # Negation

def test_failure_demo():
x = 10
y = 20
# pytest shows you exactly what x and y are
assert x == y # AssertionError: assert 10 == 20

Testing Functions with Different Inputsโ€‹

# calculator.py
def add(a: float, b: float) -> float:
return a + b

def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b

def is_even(n: int) -> bool:
return n % 2 == 0
# test_calculator.py
from calculator import add, divide, is_even

def test_add_positive():
assert add(2, 3) == 5

def test_add_negative():
assert add(-1, 1) == 0

def test_add_float():
assert add(0.1, 0.2) == pytest.approx(0.3)

def test_is_even_true():
assert is_even(4) is True

def test_is_even_false():
assert is_even(7) is False

Parametrize Testsโ€‹

Test the same logic with multiple inputs using @pytest.mark.parametrize:

import pytest

def multiply(a: int, b: int) -> int:
return a * b

@pytest.mark.parametrize("a, b, expected", [
(2, 3, 6),
(0, 5, 0),
(-2, 3, -6),
(10, 10, 100),
(1, 999, 999),
])
def test_multiply(a, b, expected):
assert multiply(a, b) == expected

This generates 5 separate test cases โ€” if one fails, you know exactly which input caused it.


Testing Exceptionsโ€‹

Use pytest.raises to verify your code raises the right exceptions:

import pytest

def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b

def test_divide_by_zero():
with pytest.raises(ValueError) as exc_info:
divide(10, 0)

# Verify the error message
assert str(exc_info.value) == "Cannot divide by zero"

def test_divide_normal():
assert divide(10, 2) == 5.0

Fixturesโ€‹

Fixtures provide setup and teardown for tests โ€” reusable test data, database connections, or configuration.

import pytest

# A simple fixture
@pytest.fixture
def sample_data():
return {"name": "Alice", "age": 30, "scores": [85, 92, 78]}

@pytest.fixture
def empty_list():
return []

# Use fixtures as function parameters
def test_sample_data_name(sample_data):
assert sample_data["name"] == "Alice"

def test_sample_data_average(sample_data):
scores = sample_data["scores"]
average = sum(scores) / len(scores)
assert average == 85.0

def test_empty_list_append(empty_list):
empty_list.append(1)
assert len(empty_list) == 1

Fixture with teardown (yield)โ€‹

import pytest
import tempfile
import os

@pytest.fixture
def temp_file():
# Setup: create a temp file
f = tempfile.NamedTemporaryFile(delete=False)
f.write(b"test data")
f.close()

yield f.name # Test runs here

# Teardown: clean up
os.unlink(f.name)

def test_temp_file_read(temp_file):
with open(temp_file) as f:
content = f.read()
assert content == "test data"

Organizing Testsโ€‹

A recommended project structure:

project/
โ”œโ”€โ”€ src/
โ”‚ โ”œโ”€โ”€ calculator.py
โ”‚ โ””โ”€โ”€ utils.py
โ”œโ”€โ”€ tests/
โ”‚ โ”œโ”€โ”€ test_calculator.py
โ”‚ โ”œโ”€โ”€ test_utils.py
โ”‚ โ””โ”€โ”€ conftest.py # Shared fixtures go here
โ”œโ”€โ”€ requirements.txt
โ””โ”€โ”€ pyproject.toml

Run all tests from the project root:

pytest # Discover and run all tests
pytest -v # Verbose
pytest tests/ # Run specific directory
pytest tests/test_calculator.py # Run specific file
pytest -k "multiply" # Run tests matching name
pytest -x # Stop on first failure
pytest --maxfail=3 # Stop after 3 failures

Test Coverageโ€‹

Coverage measures how much of your code is executed by tests. High coverage means low risk of hidden bugs.

pip install pytest-cov
# Run tests with coverage report
pytest --cov=src tests/

# Generate HTML report (open in browser)
pytest --cov=src --cov-report=html tests/
----------- coverage: platform linux, python 3.11 -----------
Name Stmts Miss Cover
------------------------------------------
src/calculator.py 12 0 100%
src/utils.py 20 3 85%
------------------------------------------
TOTAL 32 3 91%

Practical Example: Testing a Calculator Moduleโ€‹

# src/calculator.py
def add(a: float, b: float) -> float:
return a + b

def subtract(a: float, b: float) -> float:
return a - b

def multiply(a: float, b: float) -> float:
return a * b

def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b

def power(base: float, exp: float) -> float:
return base ** exp

def factorial(n: int) -> int:
if n < 0:
raise ValueError("Factorial not defined for negative numbers")
if n == 0:
return 1
return n * factorial(n - 1)
# tests/test_calculator.py
import pytest
from calculator import add, subtract, multiply, divide, power, factorial

class TestCalculator:
def test_add(self):
assert add(2, 3) == 5
assert add(-1, 1) == 0
assert add(0.1, 0.2) == pytest.approx(0.3)

def test_subtract(self):
assert subtract(10, 3) == 7
assert subtract(0, 5) == -5

@pytest.mark.parametrize("a, b, expected", [
(2, 3, 6), (0, 5, 0), (-2, 3, -6), (10, 0, 0)
])
def test_multiply(self, a, b, expected):
assert multiply(a, b) == expected

def test_divide(self):
assert divide(10, 2) == 5.0
assert divide(7, 2) == 3.5

def test_divide_by_zero(self):
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)

@pytest.mark.parametrize("n, expected", [
(0, 1), (1, 1), (5, 120), (10, 3628800)
])
def test_factorial(self, n, expected):
assert factorial(n) == expected

def test_factorial_negative(self):
with pytest.raises(ValueError):
factorial(-1)

def test_power(self):
assert power(2, 3) == 8
assert power(5, 0) == 1
assert power(4, 0.5) == 2.0

Visual Logic: Testing Workflowโ€‹


Sample Dry Runโ€‹

Scenario: Running tests for calculator.py

StepCommandOutput
1pytest tests/test_calculator.py -vRuns 12 test cases
2All pass12 passed โœ…
3Change divide to a / (b + 1) (introduce bug)1 failed, 11 passed โŒ
4pytest shows the failing lineassert divide(10, 2) == 5.0 got 3.333
5Fix the bug and rerun12 passed โœ…
6pytest --cov=src tests/Coverage: 98%

Pro Tipsโ€‹

  • Name test files test_*.py and test functions test_* โ€” pytest discovers them automatically.
  • One assertion per test is ideal โ€” it pinpoints exactly what failed.
  • Use conftest.py for shared fixtures โ€” they're automatically available to all tests in that directory.
  • Don't test implementation details โ€” test behavior and outputs.
  • Run tests frequently โ€” ideally before every commit.

Interview Tipโ€‹

"Interviewers ask: 'What's the difference between unit tests, integration tests, and end-to-end tests?' Unit tests verify single functions in isolation. Integration tests verify that components work together (e.g., function + database). End-to-end tests simulate real user workflows through the entire system."


โ† Back: Packaging | Next: Practice โ†’