pytest Test Organization Patterns
Organizing tests scales your suite
pytest starts simple: drop a test_*.py file and it runs. That stops working once your project has dozens of test files and you need shared fixtures across them. This tutorial covers the patterns that keep test suites organized as they grow.
You’ll see how to structure project directories, share fixtures through conftest.py without creating a mess, scope fixtures correctly, and avoid the import pitfalls that bite people who move beyond flat test files.
This assumes you’ve written a few tests already. If you haven’t, start with the pytest basics tutorial first.
Project structure: tests/ directory
Most pytest projects put tests in a tests/ directory at the project root. This isn’t required, but it’s the convention, and it makes several things easier.
project/
├── src/
│ └── calculator.py
├── tests/
│ ├── __init__.py
│ ├── test_core.py
│ └── test_utils.py
├── pyproject.toml
└── pytest.ini
tests/__init__.py makes the directory a Python package. This lets your test files import the code under test using the same paths your application would use. Without it, you get ModuleNotFoundError when running pytest from the project root.
Inline test files for small projects
Some projects keep test files next to the code they’re testing:
project/
├── calculator.py
├── test_calculator.py
└── pytest.ini
This works fine for small, single-module projects. Once you have multiple test files and shared fixtures, the tests/ directory approach pays off.
Organising by type or by module
Two common approaches for what goes inside tests/:
By test type:
tests/
├── unit/
│ ├── test_calculator.py
│ └── test_formatter.py
├── integration/
│ └── test_api.py
└── e2e/
└── test_checkout.py
By module under test:
tests/
├── calculator/
│ ├── __init__.py
│ ├── test_core.py
│ └── test_utils.py
└── formatter/
├── __init__.py
└── test_formatter.py
Both work. The type-based approach makes it easy to run pytest tests/unit/ to run only unit tests. The module-based approach groups tests around the code they cover. Pick one and stay consistent.
pytest configuration
pyproject.toml is the modern way to configure pytest:
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-v --tb=short"
If you prefer the old pytest.ini format, it still works:
[pytest]
testpaths = tests
python_files = test_*.py
The key setting is testpaths. pytest only looks for tests in those directories. If your conftest.py or fixtures live outside testpaths, pytest won’t find them.
Sharing fixtures with conftest.py
conftest.py is pytest’s mechanism for sharing fixtures and hooks across test files. pytest auto-discovers any conftest.py in the test directory tree and loads it before running tests.
Root conftest.py
Put shared fixtures in tests/conftest.py:
import pytest
@pytest.fixture
def calculator():
from src.calculator import Calculator
return Calculator()
Any test file under tests/ can now use the calculator fixture by accepting it as a parameter:
def test_add(calculator):
assert calculator.add(2, 3) == 5
Nested conftest.py
Don’t cram everything into one conftest.py. Create nested ones for specific test groups:
tests/
├── conftest.py # shared fixtures for all tests
├── unit/
│ ├── conftest.py # unit test fixtures only
│ └── test_calculator.py
└── integration/
├── conftest.py # integration test fixtures only
└── test_api.py
pytest loads conftest.py from the test directory and parent directories. Fixtures from a parent conftest.py are available to child directories unless a child overrides them.
Fixture scope
Fixtures run once per test function by default. Use scope to change that:
@pytest.fixture(scope="module")
def database_connection():
# Created once for all tests in this module
conn = connect_to_db()
yield conn
conn.close()
Possible scopes: function (default), class, module, session.
A module-scoped fixture is useful for a database connection that tests share. An session-scoped fixture runs once for the entire test run, which is handy for setting up a test environment.
Fixture with teardown
Use yield for teardown after the test runs:
@pytest.fixture
def temp_db():
db = create_test_db()
yield db
db.destroy() # cleanup runs after test completes
The cleanup code after yield runs regardless of whether the test passed or failed.
Import strategies and the src layout problem
If your code lives in src/, you need to make it importable from tests/. Three options:
Editable install (recommended):
pip install -e .
This puts src on the Python path without any path manipulation in your test files.
Path manipulation:
import sys
sys.path.insert(0, "src")
This works but it’s fragile. Remove it when you forget and wonder why CI fails.
Run with module prefix:
python -m pytest
Running as a module adds the current directory to sys.path, which often resolves the import issue without an editable install.
Common gotchas
Import errors after renaming a module. pytest caches test discovery. Run pytest --collect-only to see what pytest thinks exists, and pytest --cache-clear if you’ve renamed files.
Fixture not found. Check that the file containing the fixture is inside testpaths. If your fixture lives in helpers/conftest.py but testpaths = ["tests"], pytest won’t load it.
Session-scoped fixture with pytest-xdist. When you run tests in parallel with pytest -n auto, each worker is a separate process. A session-scoped fixture runs once per worker, not once for the entire test run. This trips people up who assume session means “exactly once globally.”
Mutable default arguments. This is a Python gotcha that hits fixtures too:
# Bad
@pytest.fixture
def items():
return [] # Same list object reused across tests!
# Good
@pytest.fixture
def items():
return [] # Created fresh for each test
The “good” version still has the same problem. Always use None and initialize inside:
@pytest.fixture
def items(items=None):
if items is None:
items = []
return items
Markers for selecting tests
Mark tests to organize them by type or speed:
import pytest
@pytest.mark.unit
def test_calculator():
pass
@pytest.mark.integration
def test_api_client():
pass
@pytest.mark.slow
def test_full_pipeline():
pass
Declare markers in pyproject.toml:
[tool.pytest.ini_options]
markers = [
"unit: Unit tests",
"integration: Integration tests",
"slow: Tests that take more than 5 seconds",
]
Run only unit tests:
pytest -m unit
Run everything except slow tests:
pytest -m "not slow"
See also
- /tutorials/python-testing/testing-fixtures-parametrize/ — parametrized fixtures for data-driven tests
- /tutorials/python-testing/testing-mocking/ — faking collaborators to isolate the code under test
- /tutorials/python-testing/testing-pytest-basics/ — starting with pytest if you haven’t written tests yet
Where to go next
With your tests organized, the next challenge is scaling the suite itself. The fixtures and parametrize tutorial shows how to reduce test code duplication with parametrized fixtures. For tests that need to fake external dependencies like APIs or databases, the testing mocking tutorial covers unittest.mock and pytest-mock.