Testing
Testing is one of those things I wish I’d learned earlier in my Python journey. It saves time debugging and gives you confidence when making changes. Here’s how to get started with pytest, one of the most popular testing frameworks.
Installing pytest
You can install pytest
using uv
or with pip
# Using uv (recommended)
uv add pytest
# Or with pip
pip install pytest
That’s it pytest
is ready to go.
Your First Test
Let’s start with something simple. I’ll create a basic function and then test it.
Create a file called calculator.py
:
def add(a, b):
return a + b
def multiply(a, b):
return a * b
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
Now create a test file called test_calculator.py
:
from calculator import add, multiply, divide
import pytest
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
assert add(0, 0) == 0
def test_multiply():
assert multiply(3, 4) == 12
assert multiply(-2, 5) == -10
assert multiply(0, 100) == 0
def test_divide():
assert divide(10, 2) == 5
assert divide(7, 2) == 3.5
def test_divide_by_zero():
with pytest.raises(ValueError):
divide(10, 0)
Run the tests:
pytest test_calculator.py
You’ll see output like this:
========================= test session starts =======================
collected 4 items
test_calculator.py .... [100%]
========================= 4 passed in 0.02s =========================
Understanding Assertions
The assert
statement is your main tool for testing. It lets you verify that your code works as expected by checking if a condition is True. If the condition is False, the test fails and raises an AssertionError. Think of it as saying “I assert that this should be True” - if it’s not, something’s wrong with your code.
def test_assertions():
# Basic equality
assert 2 + 2 == 4
# Not equal
assert 5 != 3
# Greater than, less than
assert 10 > 5
assert 3 < 7
# In/not in
assert 'hello' in 'hello world'
assert 'x' not in 'hello'
# True/False
assert True
assert not False
# None checks
result = None
assert result is None
# Type checks
assert isinstance([1, 2, 3], list)
# Length checks
assert len([1, 2, 3]) == 3
Testing Exceptions
When you expect your code to raise an exception, use pytest.raises()
:
import pytest
def validate_age(age):
if age < 0:
raise ValueError("Age cannot be negative")
if age > 150:
raise ValueError("Age seems unrealistic")
return age
def test_age_validation():
# These should work fine
assert validate_age(25) == 25
assert validate_age(0) == 0
# These should raise exceptions
with pytest.raises(ValueError):
validate_age(-1)
with pytest.raises(ValueError):
validate_age(200)
# You can also check the exception message
with pytest.raises(ValueError, match="Age cannot be negative"):
validate_age(-5)
Running Tests
Here are the different ways to run tests:
# Run all tests
pytest
# Run tests in a specific file
pytest test_calculator.py
# Run a specific test function
pytest test_calculator.py::test_add
# Run tests with more verbose output
pytest -v
# Run tests and stop at first failure
pytest -x
Fixtures
Fixtures in pytest are a powerful feature used to set up and tear down resources needed by your tests. They are helper functions that run before your test setting up your environment or data needed for the test.
Use by decorating the helper function with @pytest.fixture
and then passing it in as a parameter to your test. The return value from the function is set to the function variable.
import pytest
@pytest.fixture
def sample_data():
return [1, 2, 3, 4, 5]
@pytest.fixture
def user_data():
return {
'name': 'John Doe',
'email': 'john@example.com',
'age': 30
}
def test_list_operations(sample_data):
assert len(sample_data) == 5
assert sum(sample_data) == 15
assert max(sample_data) == 5
def test_user_validation(user_data):
assert user_data['name'] == 'John Doe'
assert '@' in user_data['email']
assert user_data['age'] > 0
Use yield
to return a value from setup, and then any code after yield
will be run in teardown. Here’s an example, setting up a database connection or temporary files. Here is an example using yield
to do both for testing a sqlite database from my tasks cli app.
import pytest
@pytest.fixture
def temp_file():
"""Fixture to create temp file"""
with tempfile.NamedTemporaryFile() as tf:
yield tf.name
# tempfile will close and auto delete
def connection(temp_file):
"""Fixture to create temp db and yield connection"""
conn = sqlite3.connection(temp_file)
create_schema(conn)
yield conn
conn.close()
def test_task_create(connection):
entry = { "task_entry": "Test create task" }
task_id = Task.create(connection, entry)
assert task_id is not None
Parametrized Tests
When you want to test the same function with different inputs, use @pytest.mark.parametrize
:
import pytest
def is_even(n):
return n % 2 == 0
@pytest.mark.parametrize("number,expected", [
(2, True),
(3, False),
(4, True),
(5, False),
(0, True),
(-2, True),
(-3, False),
])
def test_is_even(number, expected):
assert is_even(number) == expected
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(10, -5, 5),
])
def test_addition(a, b, expected):
assert add(a, b) == expected
Testing with Files
Use tempfile
to create temporary files for testing code that works with files:
import tempfile
import os
def save_data(filename, data):
with open(filename, 'w') as f:
for item in data:
f.write(f"{item}\n")
def load_data(filename):
with open(filename, 'r') as f:
return [line.strip() for line in f]
def test_file_operations():
# Create a temporary file
with tempfile.NamedTemporaryFile(mode='w', delete=False) as tmp:
tmp_name = tmp.name
try:
# Test saving data
test_data = ['apple', 'banana', 'cherry']
save_data(tmp_name, test_data)
# Test loading data
loaded_data = load_data(tmp_name)
assert loaded_data == test_data
finally:
# Clean up
os.unlink(tmp_name)
Tips for Better Tests
- Test one thing at a time - each test should focus on a single behavior
- Use descriptive test names -
test_user_login_with_invalid_password
is better thantest_login
- Test the happy path and edge cases - what happens with empty inputs, None values, etc.
- Don’t test implementation details - test what the function does, not how it does it
- Keep tests simple - if your test is complex, maybe your code is too complex
- Run tests frequently - I run them before every commit
Testing might feel like extra work at first, but I promise it will save you hours of debugging later. Start small, add tests as you go, and soon it’ll become second nature.