Skip to main content

Python pytest Framework Practice Problems & Exercises

Practice: pytest Framework

11 problems4 Easy4 Medium3 Hard45–60 min
← Back to lesson

Easy

#1Writing Your First pytest TestEasy
pytestasserttest-function

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.01s
Hints

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.


#2pytest.raises — Exception TestingEasy
pytestraisesexceptionsmatch

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.01s
Hints

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.


#3Simple Function FixtureEasy
pytestfixturedependency-injection

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.01s
Hints

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.


#4pytest.mark.parametrize BasicsEasy
pytestparametrizemark

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.01s
Hints

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

#5Fixture with Setup and Teardown via yieldMedium
pytestfixtureyieldteardown

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.01s
Hints

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.


#6Fixture Scope — module vs functionMedium
pytestfixturescopemodule

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.01s
Hints

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.


#7Custom Markers and -m FilteringMedium
pytestmarkcustom-markersfiltering

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.01s
Hints

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"`.


#8tmp_path Fixture — File System TestingMedium
pytesttmp_pathfile-testingbuilt-in-fixtures

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.01s
Hints

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

#9conftest.py — Shared Fixtures Across FilesHard
pytestconftestfixture-sharingscope

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.01s
Hints

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.


#10Parametrize with indirect — Fixture-Driven ParametersHard
pytestparametrizeindirectfixture

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.01s
Hints

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).


#11Plugin Hook — pytest_runtest_logreportHard
pytestpluginhooksreporting

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 errors
Hints

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.

© 2026 EngineersOfAI. All rights reserved.