I used to ship code and pray. You know the feeling — you push a commit, close your laptop, and hope nothing breaks overnight. Then Monday morning comes, and your Slack is on fire because the login flow stopped working for everyone on Safari.

That was me, two years ago. I wrote a command-line todo app in Python and had zero tests. It worked on my machine. It broke on my colleague’s Windows box. I spent three hours debugging a path separator issue that a single test would have caught in seconds.
pytest changed how I write Python. Not because it’s fancy — it’s actually one of the simplest testing tools out there — but because it makes writing tests feel less like a chore and more like building a safety net you actually trust.
Here’s everything you need to go from zero tests to a solid test suite, with examples I ran and verified on my own machine.
Why pytest Beats unittest (And Why You Should Care)
Python ships with unittest in the standard library. It works. But it feels like writing Java in Python — classes everywhere, verbose method names, and that annoying self.assertEqual() that makes your eyes glaze over.
pytest strips all that away. You write plain functions. You use plain assert statements. It figures out the rest.
Here’s the difference. The same test in unittest:
# unittest style
import unittest
from calculator import add
class TestAdd(unittest.TestCase):
def test_add_positive(self):
self.assertEqual(add(2, 3), 5)
And the same test in pytest:
# pytest style
from calculator import add
def test_add_positive():
assert add(2, 3) == 5
No classes. No imports. No self. Just a function that starts with test_ and a plain assert. That’s it.
pytest automatically discovers tests in any file named test_*.py or *_test.py. You don’t need to register test cases or write test runners. Run pytest and it finds everything.
Writing Your First Tests
Let’s start with a simple calculator module. Create calculator.py:
"""Simple calculator module."""
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
Now create test_calculator.py:
"""Basic tests for the calculator module."""
from calculator import add, subtract, multiply, divide
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-1, -1) == -2
def test_add_zero():
assert add(5, 0) == 5
def test_subtract_basic():
assert subtract(10, 3) == 7
def test_multiply_positive():
assert multiply(4, 5) == 20
def test_divide_evenly():
assert divide(10, 2) == 5.0
def test_divide_by_zero_raises():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
Run it:
$ pytest test_calculator.py -v
============================= test session starts =============================
platform linux -- Python 3.11.15, pytest-9.0.2
collected 7 items
test_calculator.py::test_add_positive_numbers PASSED [ 14%]
test_calculator.py::test_add_negative_numbers PASSED [ 28%]
test_calculator.py::test_add_zero PASSED [ 42%]
test_calculator.py::test_subtract_basic PASSED [ 57%]
test_calculator.py::test_multiply_positive PASSED [ 71%]
test_calculator.py::test_divide_evenly PASSED [ 85%]
test_calculator.py::test_divide_by_zero_raises PASSED [100%]
============================== 7 passed in 0.02s ==============================
Seven tests, all passing, in under 20 milliseconds. The -v flag gives you verbose output so you can see each test name. Without it, pytest just shows dots for passing tests.
What Happens When a Test Fails
This is where pytest really shines. Let’s write a test that’s intentionally wrong:
def test_this_will_fail():
assert add(2, 2) == 5 # Wrong! Should be 4
pytest’s failure output tells you exactly what went wrong:
$ pytest -v --tb=short
=================================== FAILURES ===================================
_____________________________ test_this_will_fail ______________________________
test_example.py:7: in test_this_will_fail
assert add(2, 2) == 5 # Wrong! Should be 4
^^^^^^^^^^^^^^^^^^^^^
E assert 4 == 5
E + where 4 = add(2, 2)
FAILED test_example.py::test_this_will_fail - assert 4 == 5
============================== 1 failed in 0.02s ===============================
See that + where 4 = add(2, 2) line? pytest shows you the actual value, the expected value, and where the actual value came from. You don’t need to add print statements or debug anything — the answer is right there in the output.
Grouping Tests with Classes
When your test file grows, grouping related tests into classes keeps things organized:
"""Grouped tests for the calculator module."""
from calculator import add, subtract, multiply, divide
class TestAddition:
def test_positive_numbers(self):
assert add(2, 3) == 5
def test_negative_numbers(self):
assert add(-1, -1) == -2
def test_zero(self):
assert add(5, 0) == 5
class TestDivision:
def test_evenly(self):
assert divide(10, 2) == 5.0
def test_returns_float(self):
result = divide(7, 2)
assert result == 3.5
def test_by_zero_raises(self):
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
pytest runs these just like standalone functions — the class is just organizational sugar. The output shows the class name in the test path:
test_calculator.py::TestAddition::test_positive_numbers PASSED [ 16%]
test_calculator.py::TestDivision::test_by_zero_raises PASSED [ 83%]
Parametrized Tests: One Function, Many Inputs
This is my favorite pytest feature. Instead of writing ten separate tests for ten inputs, you parametrize one test:
"""Parametrized tests — one function, many inputs."""
import pytest
from calculator import add, is_even, factorial
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
(100, -100, 0),
])
def test_add_parameterized(a, b, expected):
assert add(a, b) == expected
@pytest.mark.parametrize("n, expected", [
(0, True),
(1, False),
(2, True),
(7, False),
(100, True),
])
def test_is_even_parameterized(n, expected):
assert is_even(n) == expected
pytest runs each combination as a separate test case. The output shows the parameter values in brackets:
$ pytest test_parametrize.py -v
test_parametrize.py::test_add_parameterized[2-3-5] PASSED [ 14%]
test_parametrize.py::test_add_parameterized[-1-1-0] PASSED [ 28%]
test_parametrize.py::test_add_parameterized[0-0-0] PASSED [ 42%]
test_parametrize.py::test_add_parameterized[100--100-0] PASSED [ 57%]
test_parametrize.py::test_is_even_parameterized[0-True] PASSED [ 71%]
test_parametrize.py::test_is_even_parameterized[1-False] PASSED [ 85%]
test_parametrize.py::test_is_even_parameterized[2-True] PASSED [100%]
============================== 7 passed in 0.02s ==============================
One test function, seven test cases. If you need to add another input later, you just append to the list. No copy-pasting test methods.
Fixtures: Set Up Once, Use Everywhere
Fixtures handle the boring setup and teardown that every test needs. Instead of repeating the same setup code in every test, you define a fixture and pytest injects it automatically:
"""Fixtures for reusable test setup."""
import pytest
from calculator import add, divide
@pytest.fixture
def sample_numbers():
"""Provide a reusable set of numbers."""
return {"a": 10, "b": 5, "sum": 15, "product": 50}
def test_add_with_fixture(sample_numbers):
assert add(sample_numbers["a"], sample_numbers["b"]) == sample_numbers["sum"]
def test_divide_with_fixture(sample_numbers):
assert divide(sample_numbers["a"], sample_numbers["b"]) == 2.0
@pytest.fixture
def temp_file(tmp_path):
"""Create a temporary file for testing file operations."""
file_path = tmp_path / "test_output.txt"
file_path.write_text("hello world")
return file_path
def test_file_exists(temp_file):
assert temp_file.exists()
def test_file_content(temp_file):
content = temp_file.read_text()
assert content == "hello world"
The tmp_path fixture is built into pytest — it creates a temporary directory that’s cleaned up automatically after the test. No messy /tmp files piling up.
Fixtures can also be parameterized. Here’s a fixture that provides three different division scenarios:
@pytest.fixture(params=[
{"a": 10, "b": 2, "expected": 5.0},
{"a": 9, "b": 3, "expected": 3.0},
{"a": 100, "b": 10, "expected": 10.0},
])
def division_cases(request):
return request.param
def test_division_cases(division_cases):
result = divide(division_cases["a"], division_cases["b"])
assert result == division_cases["expected"]
Each parameter combination becomes its own test case in the output.
Markers: Tag Tests for Selective Running
Markers let you label tests so you can run subsets. pytest has built-in markers and you can create your own:
"""Marking tests for selective execution."""
import pytest
from calculator import add, factorial
@pytest.mark.slow
def test_large_factorial():
"""Marked as slow — run only when needed."""
result = factorial(20)
assert result == 2432902008176640000
@pytest.mark.skip(reason="Known bug: floating point precision issue")
def test_known_broken():
"""Skipped until the bug is fixed."""
assert add(0.1, 0.2) == 0.3
@pytest.mark.xfail(strict=True)
def test_expected_failure():
"""Expected to fail — demonstrates xfail behavior."""
assert add(1, 1) == 3 # This will fail, which is what we expect
def test_quick_smoke():
"""No marker — runs by default."""
assert add(1, 1) == 2
The three key markers:
@pytest.mark.skip— skips the test entirely, with a reason@pytest.mark.xfail— expects the test to fail; passes if it does, fails if it unexpectedly succeeds@pytest.mark.slow— custom marker for tagging (you need to register it inpytest.iniorpyproject.tomlto avoid warnings)
Run only fast tests:
$ pytest -m "not slow" -v
Run only skipped tests (to check if the bug is fixed):
$ pytest --runxfail -v
Running Tests: The Commands You’ll Actually Use
Here are the pytest commands you’ll reach for daily:
# Run all tests in the current directory
pytest
# Verbose output — shows each test name
pytest -v
# Run tests in a specific file
pytest test_calculator.py
# Run tests matching a keyword
pytest -k "add"
# Stop on first failure
pytest -x
# Show only the last line of each failure
pytest --tb=short
# List all collected tests without running them
pytest --co -q
# Run with parallel execution (needs pytest-xdist)
pytest -n auto
The -k filter is especially useful. Say you want to run only addition tests across your entire suite: pytest -k "add" matches any test with “add” in its name, regardless of which file it’s in.
conftest.py: Your Shared Test Configuration
The conftest.py file is where you put fixtures and hooks that apply to all tests in a directory. pytest loads it automatically — no imports needed:
"""conftest.py — shared fixtures for all tests."""
import pytest
@pytest.fixture
def app_config():
"""Sample application configuration for testing."""
return {
"debug": True,
"database_url": "sqlite:///:memory:",
"api_key": "test-key-12345",
"max_retries": 3,
}
@pytest.fixture(autouse=True)
def reset_env(monkeypatch):
"""Automatically reset environment for every test."""
monkeypatch.setenv("APP_ENV", "testing")
The autouse=True flag makes this fixture run for every test in the directory, even if the test doesn’t explicitly request it. Useful for setup that must always happen — resetting environment variables, clearing caches, or establishing database connections.
Testing Real-World Code: A Practical Example
Let’s put it all together with a more realistic example. Say you’re building a user registration function:
"""user_registration.py — a more realistic module to test."""
import re
class RegistrationError(Exception):
pass
def validate_email(email: str) -> bool:
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return bool(re.match(pattern, email))
def validate_password(password: str) -> tuple[bool, str]:
if len(password) < 8:
return False, "Password must be at least 8 characters"
if not re.search(r'[A-Z]', password):
return False, "Password must contain an uppercase letter"
if not re.search(r'[0-9]', password):
return False, "Password must contain a number"
return True, ""
def register_user(email: str, password: str, confirm_password: str) -> dict:
if not validate_email(email):
raise RegistrationError(f"Invalid email: {email}")
valid, msg = validate_password(password)
if not valid:
raise RegistrationError(msg)
if password != confirm_password:
raise RegistrationError("Passwords do not match")
return {"email": email, "status": "active"}
And here’s how you’d test it thoroughly:
"""test_user_registration.py — testing a realistic module."""
import pytest
from user_registration import (
validate_email, validate_password, register_user, RegistrationError
)
class TestValidateEmail:
@pytest.mark.parametrize("email", [
"[email protected]",
"[email protected]",
"[email protected]",
])
def test_valid_emails(self, email):
assert validate_email(email) is True
@pytest.mark.parametrize("email", [
"",
"not-an-email",
"@missing-local.com",
"user@",
"[email protected]",
])
def test_invalid_emails(self, email):
assert validate_email(email) is False
class TestValidatePassword:
def test_valid_password(self):
valid, msg = validate_password("StrongPass1")
assert valid is True
assert msg == ""
def test_too_short(self):
valid, msg = validate_password("Sh0rt")
assert valid is False
assert "at least 8 characters" in msg
def test_no_uppercase(self):
valid, msg = validate_password("alllower1")
assert valid is False
assert "uppercase" in msg
def test_no_number(self):
valid, msg = validate_password("NoNumberHere")
assert valid is False
assert "number" in msg
class TestRegisterUser:
def test_successful_registration(self):
result = register_user("[email protected]", "StrongPass1", "StrongPass1")
assert result["email"] == "[email protected]"
assert result["status"] == "active"
def test_invalid_email_raises(self):
with pytest.raises(RegistrationError, match="Invalid email"):
register_user("bad-email", "StrongPass1", "StrongPass1")
def test_weak_password_raises(self):
with pytest.raises(RegistrationError, match="at least 8 characters"):
register_user("[email protected]", "weak", "weak")
def test_mismatched_passwords_raises(self):
with pytest.raises(RegistrationError, match="Passwords do not match"):
register_user("[email protected]", "StrongPass1", "DifferentPass1")
This test suite covers the happy path, error cases, and edge cases. It uses parametrized tests for email validation, class grouping for organization, and exception testing for error handling.
Quick Reference: pytest Cheat Sheet
| What You Want | How To Do It |
|---|---|
| Run all tests | pytest |
| Verbose output | pytest -v |
| Run one file | pytest test_foo.py |
| Filter by keyword | pytest -k "login" |
| Stop on first failure | pytest -x |
| Show short tracebacks | pytest --tb=short |
| Run only slow tests | pytest -m slow |
| Skip slow tests | pytest -m "not slow" |
| List tests without running | pytest --co -q |
| Test with coverage | pytest --cov=mymodule |
Where to Go From Here
If you’re building APIs with FastAPI, pytest integrates naturally — the TestClient class lets you test endpoints without starting a server. If you’re working with Docker, you can run your test suite inside a container to match production. And if you’re managing dependencies, pytest output pairs well with CI/CD pipelines that catch failures before they reach production.
The real payoff isn’t the green checkmarks — it’s the confidence to refactor, push, and sleep without wondering if something broke. I learned that the hard way after my todo app disaster. Now every Python project I start gets a tests/ directory on day one.
Start small. Test one function. Then another. Before long, you’ll wonder how you ever wrote code without a safety net.