In the early days of a project, putting all your tests in a single tests/ folder feels intuitive. But as I’ve scaled several enterprise Python applications, I’ve learned that this approach quickly becomes a nightmare. When you hit a certain threshold of complexity, you stop spending time writing features and start spending it hunting for which fixture is causing a collision in a 500-line conftest.py.
If you’re wondering how to structure pytest for large projects, the answer isn’t just about folders—it’s about isolation, dependency management, and execution strategy. In this guide, I’ll walk you through the architectural patterns that prevent test suites from becoming legacy burdens. The goal is a suite that stays fast, readable, and easy to change as the codebase grows, rather than one that people quietly start skipping.
The Fundamentals of Scalable Testing
Before we dive into the directory tree, we need to establish a fundamental rule: Tests should mirror the application structure, but not be coupled to it.
Mirroring means that if your code lives in core/billing.py, you can find its tests under a similarly named path. Coupling is what happens when a test depends on internal details of that module, such as a private helper or the order of its internal calls. When you refactor, mirrored-but-decoupled tests still pass for the right reasons, and tightly coupled ones break for no useful reason.
I’ve found that the most successful large-scale projects treat their test suite as a first-class citizen of the codebase. This means applying the same DRY (Don’t Repeat Yourself) and Single Responsibility principles to your tests as you do to your production code. A test file that checks five unrelated behaviors is as hard to maintain as a class that does five unrelated jobs.
Deep Dive 1: The Directory Hierarchy
The biggest mistake I see is a flat tests/ directory. For large projects, you need a tiered approach based on the type of test. This allows you to run fast unit tests during local development and reserve heavy integration tests for the CI/CD pipeline.
Here is the structure I recommend for high-growth projects:
project_root/
├── src/
│ └── my_app/
│ ├── api/
│ └── core/
├── tests/
│ ├── conftest.py # Global fixtures (e.g., app config)
│ ├── unit/
│ │ ├── conftest.py # Unit-specific fixtures
│ │ └── api/
│ │ └── test_endpoints.py
│ ├── integration/
│ │ ├── conftest.py # DB and external API fixtures
│ │ └── test_database.py
│ └── functional/
│ └── test_e2e_flows.py
├── pytest.ini
└── pyproject.toml
Read this tree from the top down. The root tests/conftest.py holds only what every test needs. Each tier then has its own conftest.py for its specific setup. The test files themselves mirror the source layout inside each tier, so a developer looking at src/my_app/api/ knows exactly where to look for its unit tests.
To move an existing flat suite into this layout, I usually work through it in three passes:
- Sort each test file into
unit/,integration/, orfunctional/based on what it touches. If a test opens a database connection or makes a real HTTP call, it does not belong inunit/. - Move each fixture to the lowest
conftest.pythat still serves all the tests that use it. Fixtures used only in one tier stay in that tier. - Run the suite after each move. Failures at this stage usually reveal a fixture that was silently depending on a sibling file’s import order.
A fixture defined in tests/conftest.py is available everywhere, while one in tests/unit/conftest.py only applies to unit tests. This prevents “fixture pollution” where an integration database fixture is accidentally loaded for a pure logic unit test, slowing down your suite.
Deep Dive 2: Advanced Fixture Management
In large projects, fixture management is where most teams fail. I’ve seen conftest.py files grow to thousands of lines. To avoid this, I use Fixture Modules.
Instead of defining everything in conftest.py, create a tests/fixtures/ directory and load those modules from the root test configuration. Here is a database fixture that yields a real session and tears it down afterward:
# tests/fixtures/db_fixtures.py
import pytest
@pytest.fixture
def db_session():
# Setup complex DB connection
session = create_test_session() # your project's helper
yield session
# Teardown
session.rollback()
session.close()
Then register the module in the root tests/conftest.py. Pytest only honors pytest_plugins in the top-level conftest, not in pyproject.toml:
# tests/conftest.py
pytest_plugins = ["tests.fixtures.db_fixtures"]
Fixture scope matters just as much as location. By default, a fixture runs again for every test that requests it. For expensive setup, such as starting a database container, widen the scope so it runs once per session:
@pytest.fixture(scope="session")
def postgres_container():
container = start_postgres() # runs once for the whole test run
yield container
container.stop()
Use the narrowest scope that gives acceptable speed. Session-scoped fixtures that hold mutable state are a common source of order-dependent failures, so keep per-test isolation for anything a test can change.
This modular approach makes it significantly easier to find where a fixture is defined. If you’re dealing with complex browser interactions, you might also consider how to use playwright with python to keep your functional tests clean and isolated.
Deep Dive 3: Handling Dependencies and Mocking
Large projects usually have massive dependency trees. If you don’t structure your mocking strategy, you’ll end up with fragile tests that break every time you change a private method.
I recommend creating a tests/mocks/ directory for complex mock objects that are reused across multiple test files. This ensures consistency in how external APIs are simulated. The rule I follow is to mock at the boundary: replace the network client, the payment gateway, or the clock, but not the internal functions between them. If a mock needs to know how your code calls its own helpers, it is probably too deep.
Keep each shared mock in one place and give it a clear name, such as FakePaymentGateway, with the same method signatures as the real client. When the real API changes, you update one factory instead of hunting through dozens of test files. While this guide focuses on Python, if you’re coming from a JS background, you’ll notice similarities to mocking dependencies in jest tutorial, where centralized mock factories are key to stability.
Implementation: Optimizing Execution
Structuring the folders is only half the battle; you also need to structure the execution. I use pytest marks to categorize tests for different environments.
# In your test file
@pytest.mark.slow
def test_massive_data_migration():
...
Custom marks should be registered so pytest doesn’t warn about unknown names and so the marks are documented in one place:
# pyproject.toml
[tool.pytest.ini_options]
markers = [
"slow: long-running tests, excluded from pull request runs",
]
In your CI pipeline, you can then run pytest -m "not slow" to get rapid feedback on your PRs, leaving the slow tests for the nightly build. I’ve found this reduces my average CI wait time from 20 minutes to under 4 minutes.
Principles for Long-Term Maintenance
- Avoid the “God Fixture”: If a fixture does five different things, split it into five smaller fixtures.
- Explicit over Implicit: While
conftest.pyis powerful, don’t be afraid to explicitly import a helper function if it makes the test easier to read. - Test-to-Code Ratio: In large projects, aim for high coverage in the
core/logic, but be pragmatic withapi/layers.
Pytest Best Practices for Growing Teams
These habits matter more as the number of contributors grows. I check for them during code review:
- Name tests after behavior:
test_expired_coupon_is_rejectedtells a reader far more thantest_coupon_2. - Keep each test independent: No test should rely on another running first. If a test fails only when run in a certain order, the isolation is broken.
- Use parametrize for variations: Use
@pytest.mark.parametrizeinstead of copy-pasting a test body with different inputs. - Fail with useful messages: Add a short message to assertions in complex checks so the CI log explains what went wrong without opening the code.
- Review the slowest tests regularly: Running
pytest --durations=10shows where time goes, so you can decide what to mark, mock, or rewrite.
Pytest Project Structure for Beginners
If you’re new to pytest, or writing tests for a course project, you don’t need the full tree above on day one. Start with a single tests/ folder and one file per module, named test_<module>.py. Pytest discovers any file starting with test_ automatically, so no configuration is needed to begin.
Split into unit/ and integration/ only when you notice that running the suite feels slow or that tests need different setup. Adding a conftest.py comes next, once two or more test files need the same fixture. Following this order keeps the structure honest: each folder exists because the suite actually needed it.
How to Structure Pytest for Large Projects: Tools for Pytest Scaling
To keep a large project healthy, I rely on these three tools:
- pytest-xdist: For running tests in parallel across multiple CPU cores.
- pytest-cov: To identify “blind spots” in your architecture.
- pytest-benchmark: To ensure that a structural change didn’t introduce a performance regression in your critical paths.
Official resources for How to Structure Pytest for Large Projects
Documentation and references for How to Structure Pytest for Large Projects: