Organizing Automated Tests in Python Projects

by Damian Piatkowski 8 min read
Automated Testing Pytest Python
Hero image for Organizing Automated Tests in Python Projects

My journey with automated testing has been a long one. Over the past seven years, I’ve gradually refined how I write test suites for my Python applications. It’s one of those skills that feels uncomfortable to learn at first, but the payoff is worth it.

Sure, AI has now made writing tests much faster, but I’m glad I learned the craft before the AI revolution. Understanding how to design a good test suite helps me interpret what a coding assistant produces — and step in to improve it when needed.

Writing tests is one thing; organizing them well is another. This becomes especially important once a project grows beyond a small codebase. Imagine fixing a bug or adding a new feature and then running your entire test suite — only to discover that a few assertions need updating here and there. If your tests are well organized, making those adjustments is quick and painless. Speed matters, and saving time ultimately means saving money, so test organization isn’t just a “nerdy detail” — it’s a practical investment.

In this post, I’ll share my approach to structuring the tests directory in a way that helps me quickly locate specific tests, even after stepping away from a project for weeks or months. I use pytest, so some details — like how setups and teardowns are handled — might differ from the built-in unittest framework. Still, the overall principles I’ll discuss apply broadly to any Python testing setup.

This post focuses specifically on how to organize your test suite — its file structure, naming patterns, and directory layout. It’s not a deep dive into writing good tests, choosing between pytest and unittest, or designing test cases themselves. The goal here is to make your growing collection of tests easy to navigate, extend, and reason about.

Unit or Integration? Deciding Where Each Test Belongs

Before worrying about naming conventions or fixture placement, it’s important to define what unit and integration tests mean in your project. I like to keep this simple — my test suite only has two main directories: unit and integration. I don’t use a separate functional folder, since it often overlaps conceptually with integration tests and adds unnecessary complexity. When I introduce browser-based or Selenium tests, I add a third top-level directory called e2e.

Every test you write will usually fall into one of these categories, but the exact boundary between them can vary depending on your project’s architecture and philosophy.

Defining by Dependency Scope

One common way to separate unit and integration tests is by looking at their dependency scope — in other words, what external systems the test touches.

Under this definition, unit tests focus on isolated pieces of code with no external dependencies. They shouldn’t talk to databases, APIs, or even the file system. Their job is to verify that small units of logic behave correctly in complete isolation.

Integration tests, on the other hand, intentionally involve external systems to verify how different components interact in real-world conditions. These tests might touch a database, call an API, or write temporary files. The goal here is to confirm that the wiring between parts of your application works as expected, not just the parts themselves.

Defining by Logical Scope

Another useful way to distinguish tests is by their logical scope — what level of functionality the test is meant to validate.

When defined this way, unit tests target a narrow, focused piece of logic, such as a utility function or a small class method. They exist to prove that individual building blocks behave as designed.

Integration tests, meanwhile, cover broader flows of logic. They often test orchestrator (aka controller) functions that connect multiple layers — like services, repositories, and utilities — to ensure the overall behavior aligns with expectations.

My Approach: Organize by Subject, Mark by Implementation

Personally, I prefer to organize my tests by logical scope rather than dependency scope. This approach lets me keep all tests for a given function or module together in a single file — whether they use mocks or touch real dependencies.

For example, if I have a controller function that orchestrates multiple parts of the system, I place its tests under the integration directory. Inside that file, I might have a mix of tests: some using mocked services, others making real API calls. To keep things organized, I use pytest markers or test classes to group these variations clearly within the same file.

This strategy works especially well in projects with clear architectural layers. It makes it easy to see which modules naturally belong under unit tests — because they don’t call anything from a lower layer — and which ones clearly orchestrate other parts of the system, making them a better fit for the integration directory. Keeping all tests for a given function together also means I can quickly jump between the implementation and its tests without hopping across directories.

Even though I organize by subject, I can still achieve practical separation when running tests. For example, I mark individual tests or classes with @pytest.mark.unit or @pytest.mark.integration, which allows me to run only one group when needed:

pytest -m unit
pytest -m integration

Here’s a minimal example of what this might look like in practice:

# tests/integration/test_blog_controller.py


import pytest


class TestBlogControllerMocked:
    @pytest.mark.unit
    def test_create_blog_post_with_mocked_service(self, mock_service):
        # This test uses a mock instead of a real API call
        pass




class TestBlogControllerReal:
    @pytest.mark.integration
    def test_create_blog_post_with_real_service(self):
        # This test uses a real dependency to verify integration
        pass

This is the minimal example we can start with — and also a simple illustration of how I like to structure my test directories. I’ll expand on this structure in the next sections.

tests/
├── conftest.py
├── unit/
│   └── test_utils.py
└── integration/
    └── test_blog_controller.py

This approach isn’t necessarily the most common, but it works well for me and helps keep related tests close to their subject. The key is to stay consistent — once you choose your organizing principle, apply it across the whole codebase so your test suite remains predictable and easy to navigate.

Mirroring Your Code Structure in Tests

The key to organizing test files within your unit and integration directories is to translate your mental model of the codebase into a matching directory and naming structure. The goal is for it to feel natural — when you look at a test path, you should immediately know what part of the app it verifies.

To achieve this, I like to mirror the structure of the app directory inside tests. This means if there’s an app/services directory, I’ll have tests/unit/services; if there’s app/routes, then tests/integration/routes; and if there’s app/domain, there will be tests/unit/domain, and so on. This extra level of nesting keeps things organized and makes it easy to jump between implementation and tests.

For a more detailed example, if I have a module app/services/email_service.py with a single function called send_contact_email, I’ll place its test file here:

tests
└── unit
    └── services
        └── test_email_service.py

The test file name follows a simple pattern: prefix the module name with test_.

If, however, a module defines several functions, I switch to a nested layout — one directory per module, with each test file named after the function it tests. For example, for app/services/sanitization_service.py, which contains three functions (extract_slug_and_title, sanitize_contact_form_input, and sanitize_html), my structure looks like this:

tests
└── unit
    └── services
        └── sanitization_service
            ├── test_extract_slug_and_title.py
            ├── test_sanitize_contact_form_input.py
            └── test_sanitize_html.py

As soon as a module grows beyond a few functions, I switch from the single test file approach to this nested directory style. In practice, that means a test file named after the module becomes a directory with separate test files for each function. The test suite grows and adapts alongside the project — I never treat it as a fixed structure, but as something that evolves naturally as the codebase becomes more complex.

A Scalable Approach to Fixture Organization

If your project doesn’t have many fixtures, a single conftest.py file at the root of your tests directory is often all you need. Keeping everything in one place makes test setup simple and easy to maintain in smaller projects.

As your project grows, though, that file can quickly become cluttered. In that case, it helps to split your fixtures into separate modules. This approach keeps related fixtures grouped logically while still allowing pytest to discover them automatically.

Here’s how I handle this in my website’s test suite:

# tests/conftest.py


pytest_plugins = [
    "tests.fixtures.app_fixtures",
    "tests.fixtures.blog_data_fixtures",
    "tests.fixtures.db_fixtures",
    "tests.fixtures.drive_api_fixtures",
    "tests.fixtures.log_data_fixtures",
    "tests.fixtures.selenium_fixtures",
    "tests.fixtures.utility_fixtures",
]

This root conftest.py simply lists all fixture modules under tests/fixtures. Each of those modules holds a set of related fixtures — for example, db_fixtures.py for database setup, drive_api_fixtures.py for Google Drive integration, and so on.

Here’s a simplified directory example:

tests/
├── conftest.py
├── fixtures/
│   ├── app_fixtures.py
│   ├── blog_data_fixtures.py
│   ├── db_fixtures.py
│   ├── drive_api_fixtures.py
│   ├── log_data_fixtures.py
│   ├── selenium_fixtures.py
│   └── utility_fixtures.py
├── unit/
│   └── test_utils.py
└── integration/
    └── test_blog_controller.py

If your project is small, a single conftest.py is usually all you need — simple, fast, and easy to navigate. As your suite grows and fixtures start mixing concerns, that’s the time to split them into dedicated modules. The goal isn’t to over-engineer from day one, but to scale gracefully when organization starts paying off.

Organizing tests is never a one-size-fits-all process. The structure you start with will likely evolve as your project grows — and that’s a good thing. The goal isn’t to follow strict rules but to build a layout that feels natural, predictable, and easy to maintain. Once you find a system that fits how you think about your codebase, stick with it. Consistency will always matter more than the exact directory shape.