Exercise
In this exercise, you'll use pytest with @pytest.mark.parametrize to test a function, including cases that expect an exception. Then, you'll refactor a class-based test from the xUnit-style setup_method() and teardown_method() approach to a fixture. Using parametrization and fixtures helps you write tests that cover more cases with less repetition and clearer setup.
For setup help, see the Python documentation for virtual environments, the pip documentation for pip install, and the pytest documentation for installing pytest. For more details about the pytest features in this exercise, see the pytest documentation for parametrizing test functions, assertions about expected exceptions, fixtures, xUnit-style setup, and temporary directories and files.
Before you start
Use Python 3.10 or later, as described in the module prerequisites. If you already have a virtual environment with pytest installed and activated, continue to Step 1. Otherwise, create and activate a virtual environment in the folder where you'll create test_advanced.py.
On Windows PowerShell:
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install pytest
python -m pytest --version
On macOS, Linux, or Windows Subsystem for Linux:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install pytest
python -m pytest --version
The version command should print a pytest version, similar to this output:
pytest x.y.z
The install commands don't pin pytest to a specific version. Pip uses package metadata to choose a stable pytest release compatible with the Python version in the virtual environment.
Use python -m pytest throughout this exercise so pytest runs from the active environment. If Windows PowerShell blocks Activate.ps1, see the Python venv documentation for the PowerShell execution policy option, or run the virtual environment's Python directly for the pip and pytest commands. For example:
.\.venv\Scripts\python -m pip install --upgrade pip
.\.venv\Scripts\python -m pip install pytest
.\.venv\Scripts\python -m pytest -v test_advanced.py
Step 1 - Add a file with tests for this exercise
In the same folder where you'll run pytest, create a new test file named test_advanced.py. Don't put the file inside the .venv folder. Add the following code:
import pytest def str_to_bool(string): normalized = string.lower() if normalized in ["yes", "y", "1"]: return True if normalized in ["no", "n", "0"]: return False raise ValueError(f"Cannot convert {string!r} to a boolean")The function
str_to_bool()accepts a string as input. It returnsTruefor recognized true values, returnsFalsefor recognized false values, and raises aValueErrorfor any other value.In the same file, append the tests for the
str_to_bool()function. Use@pytest.mark.parametrizeto test the true values first:@pytest.mark.parametrize("string", ["Y", "y", "1", "YES"]) def test_str_to_bool_true(string): assert str_to_bool(string) is TrueNext, append another test with the false values:
@pytest.mark.parametrize("string", ["N", "n", "0", "NO"]) def test_str_to_bool_false(string): assert str_to_bool(string) is FalseThere are now two test functions covering representative inputs for both the
TrueandFalsereturn values.Finally, append a parametrized test for values that the function shouldn't accept. Use
pytest.raises()as a context manager so the test passes only whenstr_to_bool()raises the expected exception:@pytest.mark.parametrize("string", ["maybe", "2"]) def test_str_to_bool_invalid(string): with pytest.raises(ValueError, match="Cannot convert"): str_to_bool(string)The
matchargument checks the exception message with a regular expression. Here, the test only checks the stable part of the message so it doesn't depend on the exact invalid value.
Note
For simplicity, this exercise keeps the code under test and the tests in the same file. In real Python projects, application code and tests are usually separated into different files and directories, such as a src/ package and a tests/ directory.
Step 2 - Run the tests and explore the report
After you add the tests, run pytest and inspect the output. Use the increased verbosity flag (-v) so that you can see each input value treated as a separate test.
On Windows PowerShell:
python -m pytest -v test_advanced.py
On macOS, Linux, or Windows Subsystem for Linux:
python -m pytest -v test_advanced.py
The output should be similar to the following report:
============================= test session starts ==============================
platform ... -- Python 3.x.y, pytest-x.y.z, pluggy-x.y.z -- ...
rootdir: ...
...
collected 10 items
test_advanced.py::test_str_to_bool_true[Y] PASSED [ 10%]
test_advanced.py::test_str_to_bool_true[y] PASSED [ 20%]
test_advanced.py::test_str_to_bool_true[1] PASSED [ 30%]
test_advanced.py::test_str_to_bool_true[YES] PASSED [ 40%]
test_advanced.py::test_str_to_bool_false[N] PASSED [ 50%]
test_advanced.py::test_str_to_bool_false[n] PASSED [ 60%]
test_advanced.py::test_str_to_bool_false[0] PASSED [ 70%]
test_advanced.py::test_str_to_bool_false[NO] PASSED [ 80%]
test_advanced.py::test_str_to_bool_invalid[maybe] PASSED [ 90%]
test_advanced.py::test_str_to_bool_invalid[2] PASSED [100%]
============================== 10 passed in 0.01s ==============================
Your operating system, Python version, pytest version, executable path, root directory, plugin and cache lines, collection-progress formatting, line wrapping, and run time might differ. Although you wrote only three test functions, pytest collected 10 tests because each parametrized input value runs as its own test case.
Step 3 - Refactor setup and teardown into a fixture
Append a class-based test to the test_advanced.py file. This test uses xUnit-style
setup_method()andteardown_method()methods to create and remove a file around each test method:from pathlib import Path class TestFile: def setup_method(self): self.path = Path("done") self.path.write_text("1", encoding="utf-8") def teardown_method(self): self.path.unlink(missing_ok=True) def test_done_file(self): contents = self.path.read_text(encoding="utf-8") assert contents == "1"This test can pass, but it writes a fixed file name in the current working directory and requires separate cleanup code. If a test run is interrupted or tests run concurrently, such as with
pytest-xdistor overlapping local runs, this approach can leave stale files, overwrite an existing file named done, or cause tests to affect each other.Add a module-level fixture that uses the built-in
tmp_pathfixture to create a temporary file. In the next step, you'll update the class to use this fixture instead of the setup and teardown methods. For readability, place this module-level fixture after the parametrizedstr_to_booltests and before theTestFileclass. Pytest discovers module-level fixtures regardless of where they appear in the module, so the position is for human readers, not pytest:@pytest.fixture def tmp_file(tmp_path): def write(): path = tmp_path / "done" path.write_text("1", encoding="utf-8") return path return writeThe
tmp_file()fixture uses pytest'stmp_pathfixture, which provides a uniquepathlib.Pathtemporary directory for each test function invocation, including each parametrized case. Pytest manages the temporary directory, so the test doesn't depend on a hard-coded file path.Delete the earlier
TestFileclass, including itssetup_method()andteardown_method()methods, and replace it with this final version that uses the fixture instead of the setup and teardown methods:class TestFile: def test_done_file(self, tmp_file): path = tmp_file() contents = path.read_text(encoding="utf-8") assert contents == "1"You can also remove the
from pathlib import Pathimport you added in the previous step, because the test no longer creates aPathobject directly.
Check your work
By now you should have a Python file named test_advanced.py that contains:
- A
str_to_bool()function that accepts a string and returns a boolean value for recognized true and false values. - Three parametrized tests for the
str_to_bool()function: one that testsTruevalues, one that testsFalsevalues, and one that checks invalid values raiseValueError. - A custom pytest fixture that uses the built-in
tmp_pathfixture to create a temporary done file with some contents. - A single
TestFileclass with one method that uses the customtmp_filefixture to create and read the file. - No
from pathlib import Pathimport and nosetup_method()/teardown_method()methods in the final version.
The final file should look similar to the following:
import pytest
def str_to_bool(string):
normalized = string.lower()
if normalized in ["yes", "y", "1"]:
return True
if normalized in ["no", "n", "0"]:
return False
raise ValueError(f"Cannot convert {string!r} to a boolean")
@pytest.mark.parametrize("string", ["Y", "y", "1", "YES"])
def test_str_to_bool_true(string):
assert str_to_bool(string) is True
@pytest.mark.parametrize("string", ["N", "n", "0", "NO"])
def test_str_to_bool_false(string):
assert str_to_bool(string) is False
@pytest.mark.parametrize("string", ["maybe", "2"])
def test_str_to_bool_invalid(string):
with pytest.raises(ValueError, match="Cannot convert"):
str_to_bool(string)
@pytest.fixture
def tmp_file(tmp_path):
def write():
path = tmp_path / "done"
path.write_text("1", encoding="utf-8")
return path
return write
class TestFile:
def test_done_file(self, tmp_file):
path = tmp_file()
contents = path.read_text(encoding="utf-8")
assert contents == "1"
Run the test file again.
On Windows PowerShell:
python -m pytest -v test_advanced.py
On macOS, Linux, or Windows Subsystem for Linux:
python -m pytest -v test_advanced.py
The final report should show 11 collected tests: 10 cases from the parametrized tests and one class-based test.
============================= test session starts ==============================
platform ... -- Python 3.x.y, pytest-x.y.z, pluggy-x.y.z -- ...
rootdir: ...
...
collected 11 items
test_advanced.py::test_str_to_bool_true[Y] PASSED [ 9%]
test_advanced.py::test_str_to_bool_true[y] PASSED [ 18%]
test_advanced.py::test_str_to_bool_true[1] PASSED [ 27%]
test_advanced.py::test_str_to_bool_true[YES] PASSED [ 36%]
test_advanced.py::test_str_to_bool_false[N] PASSED [ 45%]
test_advanced.py::test_str_to_bool_false[n] PASSED [ 54%]
test_advanced.py::test_str_to_bool_false[0] PASSED [ 63%]
test_advanced.py::test_str_to_bool_false[NO] PASSED [ 72%]
test_advanced.py::test_str_to_bool_invalid[maybe] PASSED [ 81%]
test_advanced.py::test_str_to_bool_invalid[2] PASSED [ 90%]
test_advanced.py::TestFile::test_done_file PASSED [100%]
============================== 11 passed in 0.01s ==============================
All tests should pass with no errors.