Python pytest Framework Practice Problems & Exercises
Practice: pytest Framework
← Back to lessonEasy
Task: Fill in the three test functions using plain assert to verify multiply.
Why it matters: pytest's biggest advantage over unittest is zero boilerplate — no class, no imports for assertions. Just write assert.
Solution:
def multiply(a, b):
return a * b
def test_positive():
assert multiply(3, 4) == 12
def test_zero():
assert multiply(0, 99) == 0
def test_negative():
assert multiply(-2, 5) == -10
# test_math.py
def multiply(a, b):
return a * b
def test_positive():
# assert multiply(3, 4) == 12
pass
def test_zero():
# assert multiply(0, 99) == 0
pass
def test_negative():
# assert multiply(-2, 5) == -10
pass
Expected Output
collected 3 items\n\ntest_math.py ... [100%]\n3 passed in 0.01sHints
Hint 1: pytest discovers any function named `test_*` in any file named `test_*.py`.
Hint 2: Use plain `assert` statements — no need to import anything extra.
Hint 3: No class required: bare functions work perfectly.
Task: Write two tests. The first checks that parse_age("abc") raises ValueError. The second checks that parse_age("-5") raises ValueError with a message containing "negative".
Solution:
import pytest
def parse_age(value):
age = int(value)
if age < 0:
raise ValueError(f"Age cannot be negative: {age}")
return age
def test_invalid_string():
with pytest.raises(ValueError):
parse_age("abc")
def test_negative_age_message():
with pytest.raises(ValueError, match="negative"):
parse_age("-5")
import pytest
def parse_age(value):
age = int(value)
if age < 0:
raise ValueError(f"Age cannot be negative: {age}")
return age
def test_invalid_string():
# assert ValueError is raised for "abc"
pass
def test_negative_age_message():
# assert ValueError raised AND message contains "negative"
pass
Expected Output
collected 2 items\n\ntest_exc.py .. [100%]\n2 passed in 0.01sHints
Hint 1: Use `with pytest.raises(ExceptionType):` as a context manager.
Hint 2: Add `match=r"regex"` to also assert the exception message.
Hint 3: You must import pytest at the top of the file.
Task: Implement the sample_list fixture to return [1, 2, 3, 4, 5]. Use it in the three test functions.
Solution:
import pytest
@pytest.fixture
def sample_list():
return [1, 2, 3, 4, 5]
def test_length(sample_list):
assert len(sample_list) == 5
def test_sum(sample_list):
assert sum(sample_list) == 15
def test_first_element(sample_list):
assert sample_list[0] == 1
import pytest
@pytest.fixture
def sample_list():
# return [1, 2, 3, 4, 5]
pass
def test_length(sample_list):
# assert len is 5
pass
def test_sum(sample_list):
# assert sum is 15
pass
def test_first_element(sample_list):
# assert first element is 1
pass
Expected Output
collected 3 items\n\ntest_list.py ... [100%]\n3 passed in 0.01sHints
Hint 1: Decorate a function with `@pytest.fixture` to make it a fixture.
Hint 2: Request a fixture by naming it as a parameter in your test function.
Hint 3: pytest injects the fixture return value automatically.
Task: Add the @pytest.mark.parametrize decorator above test_square with the five input/expected pairs listed in the comment.
Solution:
import pytest
def square(n):
return n * n
@pytest.mark.parametrize("n,expected", [
(0, 0),
(1, 1),
(2, 4),
(3, 9),
(-4, 16),
])
def test_square(n, expected):
assert square(n) == expected
import pytest
def square(n):
return n * n
# parametrize with 5 cases: (0,0), (1,1), (2,4), (3,9), (-4,16)
def test_square(n, expected):
assert square(n) == expected
Expected Output
collected 5 items\n\ntest_param.py ..... [100%]\n5 passed in 0.01sHints
Hint 1: Use `@pytest.mark.parametrize("arg", [val1, val2, ...])` above the test function.
Hint 2: For multiple arguments: `@pytest.mark.parametrize("a,b,expected", [(1,2,3), ...])`.
Hint 3: pytest runs the test once per parameter set and labels each run distinctly.
Medium
Task: Complete the db fixture so it connects before the test (setup) and disconnects after (teardown via yield).
Why it matters: yield fixtures are the idiomatic pytest pattern for any resource that needs cleanup — files, DB connections, network sessions, temporary directories.
Solution:
import pytest
class FakeDB:
def __init__(self):
self.open = False
self.data = {}
def connect(self):
self.open = True
def disconnect(self):
self.open = False
def set(self, key, value):
self.data[key] = value
def get(self, key):
return self.data.get(key)
@pytest.fixture
def db():
database = FakeDB()
database.connect()
yield database
database.disconnect()
def test_set_and_get(db):
db.set("name", "Alice")
assert db.get("name") == "Alice"
def test_db_is_open(db):
assert db.open is True
import pytest
class FakeDB:
def __init__(self):
self.open = False
self.data = {}
def connect(self):
self.open = True
def disconnect(self):
self.open = False
def set(self, key, value):
self.data[key] = value
def get(self, key):
return self.data.get(key)
@pytest.fixture
def db():
database = FakeDB()
# connect before test
yield database
# disconnect after test
def test_set_and_get(db):
db.set("name", "Alice")
assert db.get("name") == "Alice"
def test_db_is_open(db):
assert db.open is True
Expected Output
collected 2 items\n\ntest_db.py .. [100%]\n2 passed in 0.01sHints
Hint 1: Everything before `yield` in a fixture is setup; everything after is teardown.
Hint 2: `yield` the resource you want the test to use.
Hint 3: pytest calls the teardown code even if the test fails.
Task: The fixture is already complete. Read it carefully and explain in comments why call_count == 1 passes even though three tests use the fixture. Then run it to confirm.
Key insight: With scope="module", pytest creates the fixture once and reuses it. Without the scope argument (default "function"), call_count would be 3.
Solution:
import pytest
call_count = 0
# scope="module" means this fixture runs ONCE for all tests in this file.
# Without it, it would run once per test function (3 times total).
@pytest.fixture(scope="module")
def expensive_resource():
global call_count
call_count += 1
return {"data": [1, 2, 3], "ready": True}
def test_is_ready(expensive_resource):
assert expensive_resource["ready"] is True
def test_data_length(expensive_resource):
assert len(expensive_resource["data"]) == 3
def test_called_once(expensive_resource):
assert call_count == 1
import pytest
call_count = 0
@pytest.fixture(scope="module")
def expensive_resource():
global call_count
call_count += 1
return {"data": [1, 2, 3], "ready": True}
def test_is_ready(expensive_resource):
assert expensive_resource["ready"] is True
def test_data_length(expensive_resource):
assert len(expensive_resource["data"]) == 3
def test_called_once(expensive_resource):
# the fixture was only instantiated once across the module
assert call_count == 1
Expected Output
collected 3 items\n\ntest_scope.py ... [100%]\n3 passed in 0.01sHints
Hint 1: Add `scope="module"` to `@pytest.fixture` to create the fixture once for the whole module.
Hint 2: A module-scoped fixture is shared across all tests in the file — state persists between tests.
Hint 3: Use `scope="function"` (default) when tests must be isolated; use `scope="module"` for expensive shared resources.
Task: The markers are already applied. Answer the following by running the commands mentally:
pytest -m smoke— which tests run?pytest -m "not slow"— which tests run?pytest -m "slow or smoke"— which tests run?
Write a fourth test test_integration decorated with both @pytest.mark.slow and @pytest.mark.smoke.
Solution:
import pytest
import time
@pytest.mark.slow
def test_heavy_computation():
time.sleep(0.01)
result = sum(range(10_000))
assert result == 49_995_000
@pytest.mark.smoke
def test_quick_check():
assert 1 + 1 == 2
def test_unmarked():
assert "hello".upper() == "HELLO"
@pytest.mark.slow
@pytest.mark.smoke
def test_integration():
# runs under both -m slow and -m smoke
assert sorted([3, 1, 2]) == [1, 2, 3]
# -m smoke → test_quick_check, test_integration
# -m "not slow" → test_quick_check, test_unmarked
# -m "slow or smoke"→ test_heavy_computation, test_quick_check, test_integration
import pytest
import time
@pytest.mark.slow
def test_heavy_computation():
time.sleep(0.01) # simulates slow work
result = sum(range(10_000))
assert result == 49_995_000
@pytest.mark.smoke
def test_quick_check():
assert 1 + 1 == 2
def test_unmarked():
assert "hello".upper() == "HELLO"
Expected Output
collected 1 item\n\ntest_marks.py . [100%]\n1 passed in 0.01sHints
Hint 1: Register custom markers in `pytest.ini` or `pyproject.toml` under `[tool.pytest.ini_options]` to avoid warnings.
Hint 2: Decorate tests with `@pytest.mark.slow`, `@pytest.mark.smoke`, etc.
Hint 3: Run only marked tests: `pytest -m slow`. Exclude them: `pytest -m "not slow"`.
Task: Fill in the three test functions using the tmp_path fixture. No manual cleanup required.
Solution:
import json
def save_config(path, config):
with open(path, 'w') as f:
json.dump(config, f)
def load_config(path):
with open(path, 'r') as f:
return json.load(f)
def test_save_creates_file(tmp_path):
config_file = tmp_path / "config.json"
save_config(config_file, {"debug": True})
assert config_file.exists()
def test_round_trip(tmp_path):
config_file = tmp_path / "config.json"
original = {"host": "localhost", "port": 5432}
save_config(config_file, original)
assert load_config(config_file) == original
def test_multiple_files(tmp_path):
for name in ["config_a.json", "config_b.json"]:
save_config(tmp_path / name, {"ok": True})
files = list(tmp_path.iterdir())
assert len(files) == 2
import json
def save_config(path, config):
with open(path, 'w') as f:
json.dump(config, f)
def load_config(path):
with open(path, 'r') as f:
return json.load(f)
def test_save_creates_file(tmp_path):
config_file = tmp_path / "config.json"
# save {"debug": True} and assert file exists
def test_round_trip(tmp_path):
config_file = tmp_path / "config.json"
original = {"host": "localhost", "port": 5432}
# save and reload, assert equal
def test_multiple_files(tmp_path):
# create config_a.json and config_b.json
# assert both exist in tmp_path
pass
Expected Output
collected 3 items\n\ntest_files.py ... [100%]\n3 passed in 0.01sHints
Hint 1: `tmp_path` is a built-in pytest fixture — just declare it as a parameter.
Hint 2: It provides a `pathlib.Path` pointing to a unique temporary directory for each test.
Hint 3: pytest cleans up the directory automatically after the test run.
Hard
Task: Write the conftest.py with the user_data fixture. The three test functions in test_user.py should pass without any imports.
Why it matters: conftest.py is how large projects share fixtures across test modules without duplication. It is the cornerstone of pytest architecture in production codebases.
Solution:
# conftest.py
import pytest
@pytest.fixture(scope="module")
def user_data():
return {"name": "Alice", "role": "admin", "active": True}
# test_user.py (no import needed — pytest auto-discovers conftest.py)
def test_name(user_data):
assert user_data["name"] == "Alice"
def test_role(user_data):
assert user_data["role"] == "admin"
def test_is_active(user_data):
assert user_data["active"] is True
# conftest.py (place in the same directory as your tests)
import pytest
# Define a module-scoped fixture called 'user_data' that returns:
# {"name": "Alice", "role": "admin", "active": True}
# test_user.py
def test_name(user_data):
assert user_data["name"] == "Alice"
def test_role(user_data):
assert user_data["role"] == "admin"
def test_is_active(user_data):
assert user_data["active"] is True
Expected Output
collected 3 items\n\ntest_user.py ... [100%]\n3 passed in 0.01sHints
Hint 1: `conftest.py` is auto-loaded by pytest — fixtures defined there are available to all tests in the same directory and subdirectories.
Hint 2: No import needed — pytest injects conftest fixtures by name.
Hint 3: A session-scoped fixture in conftest is the right place for expensive shared resources like a real DB or HTTP client.
Task: Implement the connection fixture using request.param. Add the @pytest.mark.parametrize decorator with indirect=True so the three environment strings are passed through the fixture.
Solution:
import pytest
class Connection:
def __init__(self, env):
self.env = env
self.timeout = 5 if env == "prod" else 30
@pytest.fixture
def connection(request):
return Connection(env=request.param)
@pytest.mark.parametrize("connection", ["dev", "staging", "prod"], indirect=True)
def test_connection_timeout(connection):
if connection.env == "prod":
assert connection.timeout == 5
else:
assert connection.timeout == 30
import pytest
class Connection:
def __init__(self, env):
self.env = env
self.timeout = 5 if env == "prod" else 30
@pytest.fixture
def connection(request):
# create Connection using request.param as the env
pass
# parametrize with indirect so values go to the fixture
# test envs: "dev", "staging", "prod"
def test_connection_timeout(connection):
if connection.env == "prod":
assert connection.timeout == 5
else:
assert connection.timeout == 30
Expected Output
collected 3 items\n\ntest_indirect.py ... [100%]\n3 passed in 0.01sHints
Hint 1: When `indirect=True`, pytest passes the parametrize values to the fixture as `request.param` rather than directly to the test.
Hint 2: The fixture can do setup work based on `request.param` before returning the prepared object.
Hint 3: Useful when fixture creation logic depends on the input (e.g., different DB schemas, different file formats).
Task: Implement pytest_runtest_logreport in conftest.py to print [PASS] or [FAIL] with the test node id during the call phase.
Why it matters: pytest hooks let you build custom reporters, CI integrations, Slack notifiers, or test analytics — without modifying any test files.
Solution:
# conftest.py
def pytest_runtest_logreport(report):
if report.when == "call":
status = "[PASS]" if report.passed else "[FAIL]"
print(f"\n{status} {report.nodeid}")
# test_hooks.py
def test_alpha():
assert 1 == 1
def test_beta():
assert 2 == 2
# conftest.py
def pytest_runtest_logreport(report):
# only act during the "call" phase
# print [PASS] <nodeid> if passed
# print [FAIL] <nodeid> if failed
pass
# test_hooks.py
def test_alpha():
assert 1 == 1
def test_beta():
assert 2 == 2
Expected Output
[PASS] test_alpha\n[FAIL] test_beta\ncollected 2 items\n2 passed, 0 errorsHints
Hint 1: Define a function named `pytest_runtest_logreport(report)` in `conftest.py` — pytest calls it after every test phase (setup, call, teardown).
Hint 2: Check `report.when == "call"` to only act on the actual test execution, not setup/teardown.
Hint 3: `report.passed` and `report.failed` are booleans you can branch on.
