Python · Testing with pytest · Expert
Large Test Suite Architecture
Organize large test suites for speed, isolation, and maintainability.
Quick topic start and explanations before exercises (exercises below):
Test architecture in a mid-size Django project
#Test architecture reference card
#Exercises:
Reorganize a flat test directory into unit/ and integration/
#You have a flat `tests/` directory with these files: ``` tests/ ├── test_models.py ← tests Product and Order models in memory (no DB) ├── test_services.py ← tests pure calculation functions (no DB) ├── test_api.py ← tests API endpoints with a real database └── test_db_queries.py ← tests database query methods (needs DB) ``` Reorganize this into the following structure. Write the new file paths and a `pytest.ini` that sets `testpaths = tests` so pytest finds tests automatically. Target structure: ``` tests/ ├── conftest.py ← empty for now (just create the file) ├── unit/ │ ├── conftest.py ← empty for now │ ├── test_models.py │ └── test_services.py └── integration/ ├── conftest.py ← empty for now ├── test_api.py └── test_db_queries.py ``` Also: what command runs only the integration tests after the reorganization?
# List the new file paths (one per line): # tests/conftest.py # ... # pytest.ini content: # [pytest] # testpaths = ... # Command to run only integration tests: # pytest ...
Solution
# New file paths after reorganization:
# tests/conftest.py
# tests/unit/conftest.py
# tests/unit/test_models.py
# tests/unit/test_services.py
# tests/integration/conftest.py
# tests/integration/test_api.py
# tests/integration/test_db_queries.py
# pytest.ini (create this file at the project root):
# [pytest]
# testpaths = tests
# Command to run only integration tests:
# pytest tests/integration/
Register marks and apply them to tests
#You have this `pytest.ini` and two test files. Your task is to: 1. Add `addopts = --strict-markers` to `pytest.ini` 2. Register three marks: `unit`, `integration`, `slow` 3. Apply marks to the test files using `pytestmark` (module-level) ```ini # pytest.ini — current state [pytest] testpaths = tests ``` ```python # tests/unit/test_pricing.py def test_discount_applied(): assert apply_discount(100, 0.1) == 90 def test_negative_discount_raises(): with pytest.raises(ValueError): apply_discount(100, -0.1) ``` ```python # tests/integration/test_orders.py def test_order_saved_to_db(db): order = Order.objects.create(total=100) assert Order.objects.count() == 1 def test_large_order_sends_email(db, mailoutbox): # takes 2 seconds Order.objects.create(total=10000) assert len(mailoutbox) == 1 ``` Write the updated `pytest.ini` and both test files with `pytestmark` added. Mark `test_large_order_sends_email` as both `integration` and `slow`.
# pytest.ini
[pytest]
testpaths = tests
# add addopts and markers here
# tests/unit/test_pricing.py
import pytest
# add pytestmark here
def test_discount_applied():
assert apply_discount(100, 0.1) == 90
def test_negative_discount_raises():
with pytest.raises(ValueError):
apply_discount(100, -0.1)
# tests/integration/test_orders.py
import pytest
# add pytestmark here
def test_order_saved_to_db(db):
order = Order.objects.create(total=100)
assert Order.objects.count() == 1
def test_large_order_sends_email(db, mailoutbox):
Order.objects.create(total=10000)
assert len(mailoutbox) == 1
Solution
# pytest.ini
[pytest]
testpaths = tests
addopts = --strict-markers
markers =
unit: fast tests with no I/O
integration: tests that use the database or network
slow: tests that take more than 1 second
# tests/unit/test_pricing.py
import pytest
pytestmark = [pytest.mark.unit]
def test_discount_applied():
assert apply_discount(100, 0.1) == 90
def test_negative_discount_raises():
with pytest.raises(ValueError):
apply_discount(100, -0.1)
# tests/integration/test_orders.py
import pytest
pytestmark = [pytest.mark.integration]
def test_order_saved_to_db(db):
order = Order.objects.create(total=100)
assert Order.objects.count() == 1
@pytest.mark.slow
def test_large_order_sends_email(db, mailoutbox):
Order.objects.create(total=10000)
assert len(mailoutbox) == 1
Build a conftest.py hierarchy with scoped fixtures
#Create three `conftest.py` files for a test suite with this layout: ``` tests/ ├── conftest.py ├── unit/ │ └── conftest.py └── integration/ └── conftest.py ``` Requirements: - `tests/conftest.py`: define a **session-scoped** fixture `app_config` that returns a dict `{"env": "test", "debug": False}`. This fixture should be available to all tests. - `tests/unit/conftest.py`: define a **function-scoped** fixture `calculator` that returns a new `Calculator()` instance. Available only to unit tests. - `tests/integration/conftest.py`: define a **function-scoped** fixture `db_session` that prints `"opening db"` before yielding the string `"db_connection"` and prints `"closing db"` after. Available only to integration tests. Also write a test function in `tests/unit/test_calc.py` that uses both `app_config` and `calculator`, and a test function in `tests/integration/test_db.py` that uses both `app_config` and `db_session`.
# tests/conftest.py import pytest # session-scoped app_config fixture here # tests/unit/conftest.py import pytest # function-scoped calculator fixture here # tests/integration/conftest.py import pytest # function-scoped db_session fixture here # tests/unit/test_calc.py # use app_config and calculator # tests/integration/test_db.py # use app_config and db_session
Solution
# tests/conftest.py
import pytest
@pytest.fixture(scope='session')
def app_config():
return {"env": "test", "debug": False}
# tests/unit/conftest.py
import pytest
class Calculator:
def add(self, a, b):
return a + b
@pytest.fixture
def calculator():
return Calculator()
# tests/integration/conftest.py
import pytest
@pytest.fixture
def db_session():
print("opening db")
yield "db_connection"
print("closing db")
# tests/unit/test_calc.py
def test_add(app_config, calculator):
assert app_config["env"] == "test"
assert calculator.add(2, 3) == 5
# tests/integration/test_db.py
def test_connect(app_config, db_session):
assert app_config["env"] == "test"
assert db_session == "db_connection"
Write a Makefile with test targets
#Write a `Makefile` for a project with this test structure: ``` tests/ ├── unit/ └── integration/ ``` And this `pytest.ini`: ```ini [pytest] testpaths = tests addopts = --strict-markers markers = unit: fast tests integration: database tests slow: tests over 1 second ``` The Makefile must have these four targets: | Target | What it runs | |---|---| | `make test-unit` | Unit tests only, quiet output | | `make test-integration` | Integration tests only, quiet output | | `make test-fast` | Unit + integration, excluding slow tests, quiet output | | `make test-ci` | Full suite with coverage (src/), fail under 80%, quiet output | All targets should be declared as `.PHONY`.
# Makefile .PHONY: ... test-unit: ... test-integration: ... test-fast: ... test-ci: ...
Solution
# Makefile
.PHONY: test-unit test-integration test-fast test-ci
test-unit:
pytest tests/unit/ -q
test-integration:
pytest tests/integration/ -q
test-fast:
pytest tests/unit/ tests/integration/ -m "not slow" -q
test-ci:
pytest --cov=src --cov-report=term-missing --cov-fail-under=80 -q
Find and fix a test ordering dependency
#The following test file passes when tests run in the default order, but fails when tests run in a different order. Imagine running them in reverse: ``` test_empty_cart_total → test_remove_item_from_cart → test_cart_total → test_add_item_to_cart ``` Find the ordering dependency and fix it so the tests pass in any order. **Tip:** Install `pytest-randomly` and run `pytest --randomly-seed=0` to verify your fix works regardless of order. ```python # test_cart.py import pytest _cart = [] # shared module-level state def test_add_item_to_cart(): _cart.append({"id": 1, "name": "Widget", "qty": 2}) assert len(_cart) == 1 def test_cart_total(): total = sum(item["qty"] * 10 for item in _cart) assert total == 20 # 2 × £10 def test_remove_item_from_cart(): _cart.clear() assert len(_cart) == 0 def test_empty_cart_total(): total = sum(item["qty"] * 10 for item in _cart) assert total == 0 ``` **Part 1 — Identify:** Which test fails first when run in reverse order? Why? **Part 2 — Fix:** Rewrite the file so all four tests are independent. Use a pytest fixture instead of the module-level `_cart`.
# Part 1 — Which test fails first in reverse order, and why?
# (write your answer as a comment)
#
# Test that fails first: ...
# Reason: ...
# Part 2 — Fixed test_cart.py
import pytest
# define a cart fixture here
def test_add_item_to_cart(cart):
pass # implement
def test_cart_total(cart):
pass # implement
def test_remove_item_from_cart(cart):
pass # implement
def test_empty_cart_total(cart):
pass # implement
Solution
# Part 1 — Which test fails first in reverse order?
#
# Test that fails first: test_cart_total
# Reason: In reverse order the execution is:
# test_empty_cart_total → test_remove_item_from_cart → test_cart_total → test_add_item_to_cart
# test_empty_cart_total runs first; _cart is already empty, total == 0 — accidentally PASSES.
# test_remove_item_from_cart clears an already-empty list — PASSES.
# test_cart_total runs next; _cart is still empty, so total == 0, not 20 — FAIL.
#
# The root cause: _cart is a module-level list shared across all tests.
# Tests communicate through it implicitly, so results depend on run order.
# Part 2 — Fixed test_cart.py
import pytest
@pytest.fixture
def cart():
return []
def test_add_item_to_cart(cart):
cart.append({"id": 1, "name": "Widget", "qty": 2})
assert len(cart) == 1
def test_cart_total(cart):
cart.append({"id": 1, "name": "Widget", "qty": 2})
total = sum(item["qty"] * 10 for item in cart)
assert total == 20
def test_remove_item_from_cart(cart):
cart.append({"id": 1, "name": "Widget", "qty": 2})
cart.clear()
assert len(cart) == 0
def test_empty_cart_total(cart):
total = sum(item["qty"] * 10 for item in cart)
assert total == 0