19.5. pytest
Table of Contents
Why pytest?
pytest is a popular Python testing framework that makes tests:
- Short to write
- Easy to read
- Powerful to extend
You will use pytest in almost every modern Python backend project, especially with FastAPI and database code.
Key ideas:
- Tests are just Python functions.
- You do not need to write classes or inherit from anything.
- pytest automatically finds and runs tests based on file and function names.
- pytest integrates nicely with fixtures, mocks, coverage tools, and CI.
Rule: In this course, always use pytest as the main test runner for Python backend projects.
In this chapter you will learn how to:
- Install and run pytest
- Structure test files
- Write basic and parametrized tests
- Use assertions effectively
- Use basic fixtures
- Organize tests for a backend project
Installing pytest
Use pip to install pytest into your virtual environment.
pip install pytestCheck that pytest is installed:
pytest --versionYou should see something like:
pytest 8.3.0
In many real projects, pytest will be added to your requirements.txt or pyproject.toml under development dependencies.
Example requirements.txt snippet:
fastapi
uvicorn
sqlalchemy
pytest
pytest-asyncioLater, when someone runs:
pip install -r requirements.txtpytest will be installed as well.
How pytest discovers tests
pytest looks for tests using a simple naming convention.
By default, pytest:
- Searches for files named:
test_.pyor_test.py- Inside those files, it runs functions named:
test_*
So:
| File name | Function name | Will pytest run it? |
|---|---|---|
test_math.py | test_add | Yes |
math_test.py | test_add | Yes |
math.py | test_add | No (file name wrong) |
test_math.py | add | No (function name wrong) |
Example structure:
project/
app/
main.py
tests/
test_example.py
tests/test_example.py:
def test_example():
assert 1 + 1 == 2Run:
pytest
pytest will find tests/test_example.py, then find test_example and run it.
You usually do not have to list each test file separately. Just run pytest at the project root and let it auto-discover.
Running tests with pytest
The most common commands:
# Run all tests
pytest
# Run tests with more verbose output
pytest -v
# Run tests in a specific file
pytest tests/test_math.py
# Run a specific test function
pytest tests/test_math.py::test_add
# Show print() output
pytest -s
# Stop after first failure
pytest -xExample:
pytest -v tests/test_math.pyOutput (simplified):
==================== test session starts ====================
collected 2 items
tests/test_math.py::test_add PASSED [ 50%]
tests/test_math.py::test_subtract PASSED [100%]
==================== 2 passed in 0.02s ======================Use this pattern often when developing:
- Edit code or tests.
- Run
pytest -qorpytest -q tests/test_something.pyto get quick feedback.
Writing your first pytest tests
A simple test
Create tests/test_math.py:
def add(a, b):
return a + b
def test_add_positive_numbers():
result = add(2, 3)
assert result == 5Run:
pytest -v tests/test_math.pyKey points:
- The test is a normal Python function.
- The test body uses simple Python code.
- The important part is the
assertstatement.
pytest will run the test and interpret any failing assert as a test failure.
Multiple tests in a file
def add(a, b):
return a + b
def subtract(a, b):
return a - b
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-2, -3) == -5
def test_subtract_result_positive():
assert subtract(5, 3) == 2
def test_subtract_result_negative():
assert subtract(3, 5) == -2
Each test_* function is a separate test. If one fails, the others can still pass.
Assertions in pytest
Basic assertions
pytest uses the built-in Python assert keyword, but it gives better error messages.
def test_assertion_examples():
# equality
assert 1 + 1 == 2
# inequality
assert 3 != 4
# truthy / falsy
assert "backend"
assert not ""
# membership
assert "a" in "backend"
assert 2 in [1, 2, 3]
# type checking
value = 10
assert isinstance(value, int)If an assertion fails, pytest shows why.
def test_fail():
assert 1 + 1 == 3Output:
> assert 1 + 1 == 3
E assert 2 == 3
E + where 2 = (1 + 1)This detail helps you debug quickly.
Rule: In pytest tests, always use simple assert statements instead of unittest style methods like self.assertEqual.
Checking exceptions
Sometimes you expect your code to raise an error.
Use pytest.raises as a context manager:
import pytest
def divide(a, b):
if b == 0:
raise ValueError("division by zero")
return a / b
def test_divide_by_zero():
with pytest.raises(ValueError) as exc_info:
divide(10, 0)
assert "division by zero" in str(exc_info.value)This checks that:
- A
ValueErroris raised. - The error message contains "division by zero".
Parametrized tests
Often you want to test the same function with many input/output pairs. Instead of writing many separate test functions, you can parametrize a single test.
Example without parametrization:
def double(x):
return x * 2
def test_double_1():
assert double(1) == 2
def test_double_2():
assert double(2) == 4
def test_double_3():
assert double(3) == 6With parametrization:
import pytest
def double(x):
return x * 2
@pytest.mark.parametrize(
"input_value, expected",
[
(1, 2),
(2, 4),
(3, 6),
(-1, -2),
],
)
def test_double(input_value, expected):
assert double(input_value) == expected
pytest will run test_double four times, with different arguments.
This is very useful in backend tests for:
- Valid and invalid inputs.
- Different HTTP status expectations.
- Different combinations of query parameters.
Example for HTTP-like logic:
import pytest
def is_valid_status(code: int) -> bool:
return 200 <= code < 300
@pytest.mark.parametrize(
"code, expected",
[
(200, True),
(201, True),
(204, True),
(301, False),
(400, False),
(500, False),
],
)
def test_is_valid_status(code, expected):
assert is_valid_status(code) == expectedUsing fixtures (basic idea)
Fixtures are special functions that provide reusable data or setup for tests, such as:
- A temporary directory
- A database connection
- A test user
- A test HTTP client
Here is a very simple example:
import pytest
def connect_to_fake_db():
return {"connected": True}
@pytest.fixture
def db():
# Setup part
connection = connect_to_fake_db()
yield connection
# Teardown part could go here (for example close connection)
def test_db_connection(db):
assert db["connected"] is TrueWhat happens:
- pytest sees
@pytest.fixtureondb, sodbbecomes a fixture. - The
test_db_connectionfunction has a parameterdb. - pytest calls the
dbfixture before the test, gets the value fromyield, and passes it into the test function.
In later chapters you will see more realistic fixtures, for example:
- A FastAPI test client fixture.
- A database session fixture.
- A fixture that clears a Redis cache between tests.
For now, remember:
Rule: If a test function has a parameter name that matches a fixture, pytest automatically provides that fixture.
Test structure and naming
Test directory structure
A common layout in backend projects:
project/
app/
__init__.py
main.py
models.py
services/
users.py
tests/
__init__.py
test_main.py
test_users.py
services/
test_users_service.py- Put all tests into a
testsdirectory at the project root. - Use test file names that mirror your modules.
Example mapping:
| Code file | Test file |
|---|---|
app/main.py | tests/test_main.py |
app/services/users.py | tests/services/test_users.py |
app/models.py | tests/test_models.py |
This makes it easy to find tests for a given piece of code.
Naming tests
Use clear, descriptive names that say what should happen.
Bad:
def test_1():
...Better:
def test_create_user_stores_hashed_password():
...
def test_create_user_rejects_duplicate_email():
...Common patterns:
test_<function>_<condition>_<expected_result>test_<route>_<status_code>_<condition>
Examples:
def test_register_user_returns_201_on_success():
...
def test_login_returns_401_for_invalid_credentials():
...Basic configuration with pytest.ini
You can configure pytest behavior with a pytest.ini file in the project root.
Simple example:
# pytest.ini
[pytest]
minversion = 8.0
addopts = -ra -q
testpaths =
testsExplanation:
minversion: minimum pytest version required.addopts: default command line options.-rashows a summary for skipped, failed, etc.-qruns in quiet mode.testpaths: default directories to search for tests.
In real backend projects you might also add markers or asyncio settings here, but that belongs in later chapters.
Using pytest for backend-style code
Here is a small, backend-flavored example to connect everything.
Imagine you have a simple authentication service:
app/auth.py:
from hashlib import sha256
def hash_password(password: str) -> str:
return sha256(password.encode("utf-8")).hexdigest()
def check_password(password: str, password_hash: str) -> bool:
return hash_password(password) == password_hashYou write tests like this:
tests/test_auth.py:
from app.auth import hash_password, check_password
def test_hash_password_returns_same_length_for_same_algorithm():
hash1 = hash_password("secret1")
hash2 = hash_password("secret2")
assert len(hash1) == len(hash2)
def test_check_password_returns_true_for_correct_password():
password = "mysecret"
password_hash = hash_password(password)
assert check_password("mysecret", password_hash) is True
def test_check_password_returns_false_for_wrong_password():
password_hash = hash_password("correct")
assert check_password("wrong", password_hash) is FalseRun:
pytest -vYou will see three tests executed. This is the same pattern you will use later when testing:
- FastAPI routes
- Database access functions
- Authentication flows
- Business logic services
Summary
In this chapter you learned the basics of pytest:
- Install via
pip install pytest. - pytest auto-discovers tests in
test_.pyand_test.pyfiles. - Tests are simple functions named
test_*. - Use plain
assertfor checks. - Use
pytest.raisesto test exceptions. - Parametrize tests with
@pytest.mark.parametrize. - Use fixtures to share setup code.
- Organize tests in a
testsdirectory mirroring your app structure. - Configure pytest with
pytest.iniif needed.
You will apply these foundations in later chapters when testing FastAPI apps, databases, and authentication logic.
Views: 6
KAHIBARO