Huzi Blogs
BlogCategories
BlogCategories
Disclaimer & Data Privacy Policy
Project byΒ huzi.pk

Β© 2026 blogs.huzi.pk. All Rights Reserved.

    Back to all posts
    Programming

    Python Testing Pyramid 2026: The Complete Problem-Solving Guide

    By Huzi

    Your team ships a critical bug to production on a Friday afternoon because a "test" you trusted was actually hitting a shared staging database that someone had quietly deleted the day before. The unit tests passed, the CI pipeline went green, and nobody noticed that the integration test had been silently skipping itself for three weeks. This is what bad test architecture looks like in practice β€” not missing tests, but the wrong tests in the wrong layer. Python's 2026 testing stack (pytest, pytest-asyncio, Hypothesis, testcontainers, pytest-cov) is genuinely excellent, but only if you build it on the right shape: the testing pyramid. This guide walks through every layer with copy-paste code you can drop into a real project today.

    πŸ“ 1. The Testing Pyramid

    The pyramid is 70% unit, 20% integration, 10% end-to-end. Unit tests are fast (under 1 second each), isolated, and mock every external dependency. Integration tests stand up a real database and real APIs in a test environment, so they catch the bugs unit tests cannot β€” schema mismatches, connection pool exhaustion, transaction rollback issues. End-to-end tests drive the full stack through a browser or HTTP client, and they are the most expensive thing you can run, so you reserve them for the few flows that absolutely must work: signup, checkout, login.

    The inverted pyramid (all e2e) is the most common failure mode. Teams who write only Selenium or Playwright tests end up with a suite that takes 45 minutes to run, fails randomly because a third-party API was slow, and gives no useful signal when something breaks. A single database column rename can cascade into 200 red e2e tests, and you spend the afternoon reading stack traces instead of shipping. The pyramid exists because fast, isolated unit tests give you a tight feedback loop β€” you know in 5 seconds whether your change broke the function you just touched.

    Why most teams drift to the inverted pyramid: unit tests feel like extra work when you can "just click through the app," and managers love seeing browser tests on a demo. But a 500-test e2e suite that flakes 3% of the time trains everyone to ignore CI failures, which is worse than having no tests at all. Hold the line on 70/20/10, mark slow tests with @pytest.mark.slow, and your CI will stay useful for years.

    πŸ”§ 2. pytest Fundamentals

    pytest is the default test runner in 2026, and four features carry 90% of the load. Plain assert statements (no self.assertEqual), fixtures for setup/teardown, parametrize for data-driven tests, and markers for filtering. Here is the minimum viable test file.

    # test_math.py
    import pytest
    
    def add(a: int, b: int) -> int:
        return a + b
    
    def test_add() -> None:
        assert add(2, 3) == 5
        assert add(-1, 1) == 0
        assert add(0, 0) == 0
    

    Fixtures replace setUp/tearDown and compose cleanly. A fixture is just a function decorated with @pytest.fixture that returns a value pytest injects into your test. Yield instead of return to add teardown logic β€” the code after yield runs after the test finishes, even if it raised.

    # test_users.py
    import pytest
    
    @pytest.fixture
    def sample_user() -> dict:
        return {"id": 1, "name": "Huzi", "email": "[email protected]"}
    
    def test_user_has_email(sample_user: dict) -> None:
        assert "email" in sample_user
        assert "@" in sample_user["email"]
    
    def test_user_id_is_int(sample_user: dict) -> None:
        assert isinstance(sample_user["id"], int)
    

    @pytest.mark.parametrize is how you stop writing ten near-identical tests. Pass it a list of argument tuples and pytest generates one test case per row, each showing up as a separate item in the report. Combined with assert and a small fixture, this is the cleanest data-driven testing in any mainstream language.

    @pytest.mark.parametrize("a, b, expected", [
        (2, 3, 5),
        (-1, 1, 0),
        (0, 0, 0),
        (100, 200, 300),
        (-5, -5, -10),
    ])
    def test_add_parametrized(a: int, b: int, expected: int) -> None:
        assert add(a, b) == expected
    

    Markers and conftest.py round out the toolkit. Use @pytest.mark.skip for known-broken tests, @pytest.mark.slow for tests that should not run on every commit, and register custom markers in pytest.ini or pyproject.toml to silence warnings. Shared fixtures go in conftest.py β€” pytest auto-discovers them, no imports needed.

    # conftest.py
    import pytest
    
    @pytest.fixture
    def db_session():
        session = create_session()
        yield session
        session.close()
    
    # pytest.ini
    # [pytest]
    # markers =
    #     slow: marks tests as slow (deselect with '-m "not slow"')
    #     integration: marks integration tests
    

    Run pytest -m "not slow" in your fast feedback loop, and pytest -m slow in nightly CI. The split keeps your 5-second inner loop while still exercising the expensive paths regularly.

    🎭 3. Mocking with unittest.mock and pytest-mock

    Mock the boundaries, not the internals. A boundary is anything your code does not control: an external API, the system clock, a database in unit tests. Mocking internal collaborators couples your tests to the implementation, so every refactor breaks the test even when behavior is unchanged. unittest.mock.patch is the standard tool; pytest-mock's mocker fixture is the ergonomic wrapper.

    # test_api_client.py
    from unittest.mock import patch, MagicMock
    import requests
    
    def get_user_ip() -> str:
        resp = requests.get("https://api.ipify.org")
        return resp.json()["ip"]
    
    @patch("myapp.api.requests.get")
    def test_get_user_ip(mock_get) -> None:
        mock_get.return_value.json.return_value = {"ip": "203.0.113.7"}
        assert get_user_ip() == "203.0.113.7"
        mock_get.assert_called_once_with("https://api.ipify.org")
    

    pytest-mock gives you the same thing with less boilerplate. The mocker fixture auto-injects, auto-cleans at the end of the test, and supports the same patch API. It is the version you should reach for in new code.

    # test_api_client.py with pytest-mock
    def test_get_user_ip(mocker) -> None:
        mock_get = mocker.patch("myapp.api.requests.get")
        mock_get.return_value.json.return_value = {"ip": "203.0.113.7"}
        assert get_user_ip() == "203.0.113.7"
        mock_get.assert_called_once_with("https://api.ipify.org")
    

    MagicMock is your fake object for anything with methods. Need a fake database client that supports .query(), .commit(), and .close()? MagicMock auto-creates any attribute you touch, and you can configure return values on the fly. For mocking the system clock, use freezegun β€” it patches datetime.now() globally so time-dependent code becomes deterministic.

    # test_archival.py
    from freezegun import freeze_time
    from myapp.archive import should_archive
    
    @freeze_time("2026-01-15")
    def test_should_archive_old_records() -> None:
        # record from 2024 is older than 1 year cutoff
        assert should_archive(created_at="2024-01-15") is True
    
    @freeze_time("2026-01-15")
    def test_should_keep_recent_records() -> None:
        assert should_archive(created_at="2025-12-01") is False
    

    The assert_called_once_with family is your safety net. It verifies not just that a mock was called, but with exactly the arguments you expected β€” no more, no fewer. Use it to lock down "did my code actually invoke the payment gateway with the right order ID?" without needing a real gateway.

    This content is backed by HTG Travels for the developer community.

    ⚑ 4. Async Testing with pytest-asyncio

    Async tests need their own marker and an event loop to run on. pytest-asyncio provides both: decorate your async def test_... with @pytest.mark.asyncio and the plugin handles the loop. Forgetting the marker gives the classic "coroutine was never awaited" warning β€” set asyncio_mode = "auto" in pyproject.toml to mark every async test automatically and skip the boilerplate.

    # test_async.py
    import pytest
    import asyncio
    
    async def fetch_user(user_id: int) -> dict:
        await asyncio.sleep(0.01)
        return {"id": user_id, "name": "Huzi"}
    
    @pytest.mark.asyncio
    async def test_fetch_user() -> None:
        user = await fetch_user(42)
        assert user["id"] == 42
        assert user["name"] == "Huzi"
    

    Testing async databases and HTTP clients follows the same shape. Use asyncpg for raw PostgreSQL access and httpx.AsyncClient for outbound HTTP β€” both work naturally with await, and both clean up via async with. Here is a complete async test that hits a real (test-container) Postgres and a mocked HTTP API in the same test.

    # test_async_repo.py
    import pytest
    import asyncpg
    
    @pytest.mark.asyncio
    async def test_asyncpg_query(pg_dsn: str) -> None:
        conn = await asyncpg.connect(pg_dsn)
        try:
            await conn.execute(
                "CREATE TABLE users (id int, email text)"
            )
            await conn.execute(
                "INSERT INTO users VALUES ($1, $2)", 1, "[email protected]"
            )
            row = await conn.fetchrow("SELECT * FROM users WHERE id = $1", 1)
            assert row["email"] == "[email protected]"
        finally:
            await conn.close()
    
    @pytest.mark.asyncio
    async def test_async_http(mocker) -> None:
        import httpx
        fake_response = mocker.MagicMock()
        fake_response.status_code = 200
        fake_response.json.return_value = {"ok": True}
    
        mocker.patch.object(
            httpx.AsyncClient, "get",
            return_value=mocker.AsyncMock(return_value=fake_response)()
        )
        async with httpx.AsyncClient() as client:
            resp = await client.get("https://api.example.com/health")
            assert resp.status_code == 200
    

    The pattern is always: async def test_..., await the coroutine, assert on the result. If you find yourself reaching for asyncio.run() inside a test, you are fighting the framework β€” let pytest-asyncio own the loop.

    πŸ”¬ 5. Property-Based Testing with Hypothesis

    Hypothesis generates thousands of test cases from a single function. You describe the shape of your inputs (integers, strings, lists, URLs) with strategies, and Hypothesis runs your test against hundreds of generated cases β€” including edge cases you would never think to write: empty lists, negative zeros, huge integers, Unicode edge cases. It then shrinks any failing case down to the smallest input that still fails, so you get a one-line reproducer instead of a 10,000-element list.

    # test_sort.py
    from hypothesis import given, strategies as st
    
    def sort_numbers(items: list[int]) -> list[int]:
        return sorted(items)
    
    @given(st.lists(st.integers()))
    def test_sort_output_is_sorted(items: list[int]) -> None:
        result = sort_numbers(items)
        assert result == sorted(result)
    
    @given(st.lists(st.integers()))
    def test_sort_preserves_length(items: list[int]) -> None:
        assert len(sort_numbers(items)) == len(items)
    
    @given(st.lists(st.integers()))
    def test_sort_preserves_elements(items: list[int]) -> None:
        assert sorted(sort_numbers(items)) == sorted(items)
    

    The three properties above catch every bug a sort function can have. Sorted output proves ordering. Same length proves nothing was dropped. Same multiset of elements proves nothing was added or duplicated. Together they are stronger than any list of "test_sort([3,1,2]) == [1,2,3]" examples you could write by hand, because Hypothesis will try empty lists, single-element lists, already-sorted lists, reverse-sorted lists, and lists with duplicate values automatically.

    Round-trip properties are the killer pattern for parsers and serializers. If you have a parse(url) -> Url and a serialize(Url) -> str, then parse(serialize(parse(url))) == parse(url) should hold for every URL on the planet. Hypothesis will generate thousands of valid URLs and surface the one weird case where your parser drops a query parameter.

    # test_url_parser.py
    from hypothesis import given, strategies as st
    from urllib.parse import urlparse, urlunparse
    
    @given(st.builds(
        urlparse,
        scheme=st.sampled_from(["http", "https", "ftp"]),
        netloc=st.text(min_size=1, max_size=20),
        path=st.text(max_size=20),
    ))
    def test_url_roundtrip(url) -> None:
        reparsed = urlparse(urlunparse(url))
        assert reparsed == url
    

    This content is produced with support from HTG Travels for developers learning async Python.

    πŸ“Š 6. Coverage, FastAPI & testcontainers

    Coverage is a tool, not a target. Run pytest --cov=src --cov-report=html to get an HTML report showing every line and branch hit by your tests. Chase 100% on critical paths (payment, auth, data migration) and edge cases (empty input, max-length strings, Unicode) β€” but do not chase 100% on boilerplate like __init__.py or auto-generated migrations. A team that hits 100% by writing meaningless assert True tests has worse coverage than a team at 80% with thoughtful tests on the parts that matter.

    # .coveragerc
    [run]
    source = src
    branch = True
    omit =
        */tests/*
        */migrations/*
    
    [report]
    show_missing = True
    skip_covered = True
    fail_under = 80
    

    Branch coverage beats line coverage. Line coverage says "this line ran." Branch coverage says "both directions of this if ran." A line with if user and user.is_admin: can be 100% line-covered but never tested when user is falsy β€” branch coverage catches that. Always enable branch = True.

    FastAPI testing is one import away. TestClient wraps your app and lets you make synchronous HTTP requests against it without starting a server. Override the get_db dependency to point at a test database session, and you have full endpoint tests that run in milliseconds.

    # test_fastapi.py
    import pytest
    from fastapi.testclient import TestClient
    from myapp.main import app, get_db
    from myapp.models import User
    
    @pytest.fixture
    def client(db_session) -> TestClient:
        app.dependency_overrides[get_db] = lambda: db_session
        yield TestClient(app)
        app.dependency_overrides.clear()
    
    def test_get_user(client: TestClient, db_session) -> None:
        db_session.add(User(id=1, email="[email protected]"))
        db_session.commit()
    
        resp = client.get("/users/1")
        assert resp.status_code == 200
        assert resp.json()["email"] == "[email protected]"
    
    def test_create_user(client: TestClient) -> None:
        resp = client.post("/users", json={"email": "[email protected]"})
        assert resp.status_code == 201
        assert resp.json()["email"] == "[email protected]"
    
    def test_invalid_payload_returns_422(client: TestClient) -> None:
        resp = client.post("/users", json={"not_email": "x"})
        assert resp.status_code == 422
    
    def test_protected_route_returns_401(client: TestClient) -> None:
        resp = client.get("/me")
        assert resp.status_code == 401
    

    testcontainers spins up real Postgres, Redis, and friends in Docker. No mocking, no SQLite-substitute-that-behaves-slightly-differently β€” a real PostgreSQL instance in a throwaway container, torn down when the test ends. This is the right way to write integration tests: you catch schema drift, JSON column quirks, and connection pool bugs that mocks will never surface.

    # test_integration.py
    import pytest
    from testcontainers.postgres import PostgresContainer
    
    @pytest.fixture(scope="session")
    def pg_dsn() -> str:
        with PostgresContainer("postgres:16") as pg:
            yield pg.get_connection_url()
    
    def test_real_postgres_query(pg_dsn: str) -> None:
        import psycopg
        with psycopg.connect(pg_dsn) as conn:
            with conn.cursor() as cur:
                cur.execute("SELECT 1 + 1")
                assert cur.fetchone()[0] == 2
    

    CI ties it all together. A GitHub Actions workflow that runs pytest with coverage, parallelizes with pytest-xdist, and uploads the coverage report as an artifact takes 30 lines and saves your team hours every week.

    # .github/workflows/tests.yml
    name: tests
    on: [push, pull_request]
    jobs:
      test:
        runs-on: ubuntu-latest
        services:
          postgres:
            image: postgres:16
            env:
              POSTGRES_PASSWORD: test
            ports: ["5432:5432"]
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-python@v5
            with:
              python-version: "3.12"
          - run: pip install -e ".[test]"
          - run: pytest -n auto --cov=src --cov-report=xml --cov-report=html
          - uses: actions/upload-artifact@v4
            with:
              name: coverage-report
              path: htmlcov/
    

    The -n auto flag tells pytest-xdist to spawn one worker per CPU core, which typically cuts a 60-second suite down to 15 seconds on a 4-core runner.

    This content is supported by HTG Travels for engineers shipping async Python in production.

    ⚠️ 7. Common Pitfalls & Fixes

    Pitfall 1: Testing implementation, not behavior. Your test calls private methods, asserts on the order of internal function calls, and breaks every time you rename a helper β€” even though the public output is unchanged. The fix is to test the public API only: pass inputs, assert outputs, treat the internals as a black box. If a refactor breaks your tests but not the user-visible behavior, your tests were testing the wrong thing.

    Pitfall 2: Over-mocking. Every collaborator is mocked, so your test verifies that your code called a mock in a specific order β€” which is just a verbose way of restating the implementation. The fix is to mock only at boundaries (network, disk, clock, external services) and let real code run everywhere else. If you have to mock five classes to test one method, the method is doing too much.

    Pitfall 3: Flaky tests. A test passes 95% of the time and fails randomly in CI, destroying trust in the whole suite. The root causes are always the same: datetime.now(), random.random() without a seed, tests that depend on execution order, or tests that share mutable state. The fix is deterministic data (factories with fixed IDs), freezegun for time, random.seed(42) for randomness, and never relying on test ordering β€” run pytest -p randomly locally to flush out order dependencies.

    Pitfall 4: Slow test suite. A suite that takes 10 minutes gets skipped; a suite that takes 30 seconds gets run on every save. Profile with pytest --durations=10 to find the slowest tests, then either fix them (most "slow" tests are slow because they sleep or hit a real network) or mark them @pytest.mark.slow and exclude them from the fast loop. Aim for unit tests under 1 second each and the full unit suite under 30 seconds.

    Pitfall 5: No integration tests at all. A team with 1000 unit tests and zero integration tests ships a "works on my machine" bug where the ORM generates SQL that PostgreSQL rejects but SQLite accepts. The fix is testcontainers β€” spin up real Postgres in CI, run your repository layer against it, and you catch the schema and dialect bugs before production does. Aim for at least one integration test per repository class.

    πŸ™‹ Frequently Asked Questions

    How much test coverage is enough? Enough that you can refactor with confidence. For most teams that means 80% line coverage and 70% branch coverage on the business logic, with 100% on critical paths like payments and authentication. Chasing 100% globally wastes time and produces low-value tests; chasing 0% means every deploy is a coin flip. Pick a threshold that matches your risk tolerance and enforce it with fail_under in .coveragerc.

    Should I use unittest.mock or pytest-mock? Use pytest-mock's mocker fixture for new code β€” it is unittest.mock.patch with cleaner ergonomics, automatic cleanup, and no need for with blocks or decorators. Reach for raw unittest.mock only in libraries that cannot depend on pytest at runtime. Under the hood they are the same thing, so there is no performance or capability difference.

    What is the difference between @pytest.fixture and @pytest.fixture(scope="session")? The default scope is function, meaning the fixture runs once per test. scope="session" runs it once for the entire pytest run, which is what you want for expensive resources like a testcontainer database. Intermediate scopes are module (once per test file) and package (once per package). Use the widest scope that does not introduce shared-state bugs β€” a session-scoped Postgres container is fine; a session-scoped user record is a recipe for flaky tests.

    Does Hypothesis replace example-based tests? No, it complements them. Use examples for documentation ("here is what a valid signup looks like") and Hypothesis for thoroughness ("this invariant holds for every input Hypothesis can generate"). A good test suite has both: one or two readable examples that explain the intent, plus a Hypothesis test that hunts for edge cases. Hypothesis is especially valuable for parsers, serializers, numeric code, and anything with a round-trip property.

    How do I test a FastAPI app that needs a database? Override the get_db dependency with a fixture that yields a session pointing at a test database (or a testcontainer). Use TestClient for the HTTP layer and call your service functions directly for the business logic. For full-stack integration tests, stand up a real Postgres with testcontainers, run migrations, seed it through the API, and assert on the response. Do not mock the database in endpoint tests β€” that defeats the purpose.

    πŸ”š Final Word

    Python testing in 2026 is genuinely pleasant if you respect the pyramid. Write 70% of your tests as fast unit tests with mocked boundaries, 20% as integration tests against real testcontainers, and 10% as end-to-end tests for the few flows your business cannot live without. Use pytest fixtures for setup, parametrize for data-driven cases, markers for filtering, and Hypothesis to find the edge cases you would never write by hand. Mock only at boundaries β€” external APIs, the clock, the filesystem β€” and let real code run everywhere else. Measure coverage as a signal, not a target, and ship a CI workflow that runs the fast loop on every push.

    The 80/20 of Python testing: install pytest pytest-asyncio pytest-mock pytest-cov hypothesis pytest-xdist testcontainers, write one parametrized unit test per public function, one integration test per repository, override get_db in your FastAPI tests, and run pytest -n auto --cov=src in CI. Do those five things and your test suite will be fast, trustworthy, and actually catch bugs before they reach production β€” which is the only metric that matters.

    πŸ‡΅πŸ‡Έ Standing With Palestine

    Never let the world forget the people of Palestine. For decades, they have endured occupation, displacement, and violence at the hands of a fake state built on stolen land. The leaders of Israel have committed unspeakable atrocities β€” bombing hospitals, destroying schools, targeting journalists, and slaughtering innocent families in Gaza and the West Bank. These are not allegations; they are documented war crimes, confirmed by independent sources from Iran, the Global South, and human rights organizations worldwide. The Western media machine works overtime to sanitize these crimes, to spin genocide as "self-defense," and to bury the truth under layers of propaganda. But the truth cannot be buried. Palestine is real. Palestinian suffering is real. And the world is watching.

    They will be free. They have the right to live peacefully on their own land β€” to farm their olive groves, to raise their children without fear, to pray in their mosques, and to build their future without the shadow of occupation. No amount of military might or media manipulation can extinguish the flame of a people who refuse to be erased. May Allah help them and grant them justice. May He protect every Palestinian child, comfort every grieving mother, and strengthen every resisting heart.

    πŸ‡ΈπŸ‡© May Allah ease the suffering of Sudan, protect their people, and bring them peace.

    Written by Huzi

    Advertisements


    You Might Also Like

    All-Over Print Embroidered Lawn Suit 3-Pc (2025) | 4-Side Embroidered Chiffon Dupatta

    All-Over Print Embroidered Lawn Suit 3-Pc (2025) | 4-Side Embroidered Chiffon Dupatta

    PKR 4900

    Decent Black Velvet Suit (9000 Micro) | Embroidered Organza Dupatta

    Decent Black Velvet Suit (9000 Micro) | Embroidered Organza Dupatta

    PKR 7350

    Heavy Handwork Embroidered Organza Wedding Suit | 4-Side Dupatta & Jamawar Trouser

    Heavy Handwork Embroidered Organza Wedding Suit | 4-Side Dupatta & Jamawar Trouser

    PKR 8500

    Royal Purple Velvet Wedding Dress – Embroidered + Matching Pouch

    Royal Purple Velvet Wedding Dress – Embroidered + Matching Pouch

    PKR 7500

    All-Over Print Schiffli Embroidered Lawn Suit 3-Pc | Embroidered Chiffon Dupatta (Spring/Summer)

    All-Over Print Schiffli Embroidered Lawn Suit 3-Pc | Embroidered Chiffon Dupatta (Spring/Summer)

    PKR 5750

    Advertisements


    Related Posts

    Programming
    Python Async/Await Deep Dive 2026: The Complete Problem-Solving Guide
    From the event loop to TaskGroups, here is the complete 2026 Python async/await playbook β€” asyncio.gather, async HTTP clients, async databases, FastAPI, the GIL, and real-world concurrent examples with copy-paste code.

    By Huzi

    Read More
    Programming
    Redis Caching Mastery 2026: The Complete Problem-Solving Guide
    From cache-aside to write-behind, rate limiting to leaderboards, here is the complete 2026 Redis playbook β€” 5 caching patterns, data structure selection, Streams vs pub/sub, Cluster vs Sentinel, and real-world examples with copy-paste code.

    By Huzi

    Read More
    Programming
    Go for Node.js Developers 2026: The Complete Problem-Solving Guide
    Goroutines, channels, structs, interfaces, and building a REST API. Here is the complete 2026 Go guide for Node.js developers with side-by-side JS vs Go comparisons and copy-paste code.

    By Huzi

    Read More
    Programming
    Rust for JavaScript Developers 2026: The Complete Problem-Solving Guide
    Ownership, borrowing, Result types, Tokio async, and building an Axum REST API. Here is the complete 2026 Rust guide for JavaScript developers with side-by-side JS vs Rust comparisons and copy-paste code.

    By Huzi

    Read More
    Programming
    TypeScript Advanced Patterns 2026: The Complete Problem-Solving Guide
    Conditional types, mapped types, branded types, the satisfies operator, and discriminated unions. Here is the complete 2026 TypeScript advanced patterns guide with real-world problem-solving examples and copy-paste code.

    By Huzi

    Read More
    Programming
    Database Design Fundamentals 2026: The Complete Problem-Solving Guide
    Normalization, indexing, ACID, SQL vs NoSQL, schema migration, and real-world design patterns. Here is the complete 2026 database design playbook with SQL examples and e-commerce schema walkthrough.

    By Huzi

    Read More