The testing guide makes the case in one line: tests catch regressions, document behaviour, and force you to think about edge cases before production does it for you. This workbook builds the skill from a bare assert to a complete test suite. Each example is a full file containing both the code under test and its tests, so everything runs as saved. The recommendation the guide lands on, use pytest for new projects, is also the order we follow: pytest first and deepest, unittest afterward so you can read the older style wherever you meet it.
1. The assert statement
Everything in testing rests on one keyword. assert passes silently when its expression is truthy and raises AssertionError when it is not. Save as check_assert.py and run with python check_assert.py.
def is_even(number): return number % 2 == 0# passes silently: nothing prints, nothing happensassert is_even(2) is Trueassert is_even(3) is False# the optional message appears when the assertion failstotal = 2 + 2assert total == 4, f"expected 4, got {total}"print("all assertions passed")# uncomment to see a failure:# assert is_even(2) is False, "this will raise AssertionError"
Silence is the design: a passing assertion costs nothing and says nothing, while a failing one stops the program with a message pointing at the lie. A test framework is essentially machinery for organising thousands of these statements, running them on demand, and reporting which ones broke.
2. Your first pytest test
pytest finds tests by naming convention: files called test_*.py, functions starting with test_. Inside them, plain assert is the whole API. Save as test_helpers.py and run pytest test_helpers.py -v.
# the code under test would normally be imported from your packagedef is_even(number): return number % 2 == 0def slugify(title): return title.lower().replace(" ", "-")# pytest collects any function starting with test_def test_is_even(): assert is_even(2) is True assert is_even(3) is Falsedef test_slugify(): assert slugify("Hello World") == "hello-world" assert slugify("Python Testing") == "python-testing"
Run it and pytest reports 2 passed, having discovered both tests with no registration, no classes, and no imports beyond your own code. The naming rules are the entire wiring: test_ prefixed files and functions are found, anything else is ignored, which is also how you keep helpers out of the test run.
3. Running pytest: the flags that matter
The command line is where pytest earns daily affection. Save this as test_flags.py, then try each command beneath it.
def add_vat(price): return round(price * 1.2, 2)def test_numbers_basic(): assert add_vat(100) == 120.0def test_numbers_decimal(): assert add_vat(9.99) == 11.99def test_text_output(): print("you only see this with -s") assert "vat" in "vat included"def test_broken(): assert add_vat(0) == 999 # deliberately wrong# commands to try, one at a time:# pytest test_flags.py run the file# pytest test_flags.py -v verbose: one line per test# pytest test_flags.py -k numbers only tests matching "numbers"# pytest test_flags.py -x stop at the FIRST failure# pytest test_flags.py -s show print() output
Each flag answers a daily need: -v names every test as it runs, -k numbers filters to the two number tests when you are iterating on one area, -x stops the run at the deliberately broken test instead of ploughing on, and -s unhides the print. The failure report itself is pytest’s best feature, showing the actual and expected values without you writing any message.
4. Testing that errors happen
Good code raises errors on bad input, and that behaviour deserves tests too. pytest.raises passes only when the expected exception fires. Save as test_errors.py and run pytest test_errors.py -v.
import pytestdef divide(a, b): if b == 0: raise ValueError("cannot divide by zero") return a / bdef test_normal_division(): assert divide(10, 2) == 5.0def test_zero_raises(): with pytest.raises(ValueError): # passes ONLY if ValueError is raised divide(10, 0)def test_zero_message(): with pytest.raises(ValueError) as exc_info: divide(10, 0) assert "divide by zero" in str(exc_info.value) # inspect the message too
The logic inverts inside the with block: the exception firing is the pass, and the exception not firing is the fail, because code that silently accepts bad input is the bug. Binding as exc_info goes one step further and asserts on the message itself, pinning down not just that the function complains but that it complains helpfully.
5. Parametrize: many cases, one test
Copy-pasting a test per input is drudgery. @pytest.mark.parametrize runs one test body across a table of cases, each reported separately. Save as test_parametrize.py and run pytest test_parametrize.py -v.
import pytestdef slugify(title): return title.lower().strip().replace(" ", "-")pytest.mark.parametrize("raw, expected", [ ("Hello World", "hello-world"), (" Padded Title ", "padded-title"), ("UPPERCASE", "uppercase"), ("already-a-slug", "already-a-slug"), ("", ""), # the edge case rides along free])def test_slugify(raw, expected): assert slugify(raw) == expected
Run with -v and pytest lists five tests, one per row, each named with its inputs, so a failure points at the exact case that broke. The table format changes behaviour too: adding the next edge case costs one line, which means edge cases actually get added. This is the single highest-leverage feature in pytest.
6. Markers: skip, skipif, and xfail
Not every test should run everywhere, and some failures are expected while a bug awaits its fix. Markers annotate tests with that knowledge. Save as test_markers.py and run pytest test_markers.py -v.
import sysimport pytestdef half(n): return n / 2def test_normal(): assert half(10) == 5pytest.mark.skip(reason="feature not built yet")def test_future_feature(): assert False # never runs, so never failspytest.mark.skipif(sys.platform == "win32", reason="POSIX-only behaviour")def test_posix_only(): assert half(4) == 2pytest.mark.xfail(reason="known rounding bug, ticket #142")def test_known_bug(): assert half(1) == 0.6 # wrong on purpose: reported as xfail, not failure
The verbose run shows four different letters: a pass, a skip, a conditional skip, and an xfail that is expected to fail and therefore does not turn the suite red. The honesty is the point: markers record why a test is dormant, with a reason string, instead of the alternative people actually do, which is deleting or commenting out tests and losing the knowledge.
7. Fixtures: setup, teardown, and scope
Fixtures are pytest’s answer to repeated setup. A function decorated with @pytest.fixture provides a value to any test naming it as a parameter, yield splits setup from teardown, and scope controls how often it rebuilds. Save as test_fixtures.py and run pytest test_fixtures.py -v -s.
import pytestpytest.fixturedef sample_basket(): print("\n [setup: building basket]") basket = {"keyboard": 79.99, "mouse": 24.50} yield basket # the test runs here print(" [teardown: clearing basket]") basket.clear() # runs even if the test FAILEDpytest.fixture(scope="module")def price_list(): print("\n [expensive setup: runs ONCE per file]") return {"keyboard": 79.99, "mouse": 24.50, "monitor": 299.00}def test_basket_total(sample_basket): assert sum(sample_basket.values()) == 104.49def test_basket_size(sample_basket): # gets a FRESH basket, not the used one assert len(sample_basket) == 2def test_prices_one(price_list): assert price_list["monitor"] == 299.00def test_prices_two(price_list): # same object: setup did NOT rerun assert "mouse" in price_list
The -s output tells the story: the basket fixture builds and tears down twice, once per test, so no test inherits another’s leftovers, while the module-scoped price list builds once and is shared. That is the trade fixtures manage: function scope for isolation, wider scopes (class, module, session) for setup too expensive to repeat.
8. Testing data code
Data pipelines deserve tests as much as web apps do. A fixture loads the frame once, a loading test checks the shape, unit tests pin function behaviour, and an integration test checks components working together. Save as test_pipeline.py and run pytest test_pipeline.py -v.
import pandas as pdimport pytest# --- the pipeline code under test ---def load_orders(): return pd.DataFrame({ "brand": ["Acme", "Bolt", "Acme", "Bolt", "Acme"], "cost": [100, 250, 40, 610, 95], })def group_sum(df, key, value): return df.groupby(key)[value].sum()# --- the tests ---pytest.fixture(scope="module")def raw_df(): return load_orders()def test_load(raw_df): # sanity: did loading even work? assert type(raw_df) == pd.DataFrame assert raw_df.shape[0] > 0 assert list(raw_df.columns) == ["brand", "cost"]def test_group_sum_values(raw_df): # unit: exact numbers result = group_sum(raw_df, "brand", "cost") assert result["Acme"] == 235 assert result["Bolt"] == 860def test_group_sum_contract(raw_df): # integration: shape of the handoff result = group_sum(raw_df, "brand", "cost") assert type(result) == pd.Series assert result.sum() == raw_df["cost"].sum() # nothing lost in the grouping
Three layers, three different failures caught: the load test fails if the source changes shape, the unit test fails if the arithmetic breaks, and the contract test fails if the function stops returning what downstream code expects. The last assertion is a favourite for aggregations: the grouped total must equal the raw total, or rows went missing.
9. unittest: the standard library way
unittest ships with Python and organises tests as classes: inherit TestCase, name methods test_, and use assertion methods instead of bare asserts. Save as test_unittest_style.py and run python -m unittest test_unittest_style.py -v.
import unittestdef factorial(n): if n < 0: raise ValueError("no factorials below zero") result = 1 for i in range(2, n + 1): result *= i return resultclass TestFactorial(unittest.TestCase): def setUp(self): # runs before EVERY test method self.known = {0: 1, 1: 1, 5: 120} def tearDown(self): # runs after every test method del self.known def test_known_values(self): for n, expected in self.known.items(): self.assertEqual(factorial(n), expected) def test_type_and_membership(self): self.assertIsInstance(factorial(5), int) self.assertIn(factorial(5), [120]) self.assertTrue(factorial(3) == 6) def test_negative_raises(self): with self.assertRaises(ValueError): factorial(-1)if __name__ == "__main__": unittest.main()
The map from pytest is direct: setUp and tearDown are the fixture, assertEqual and its siblings (assertTrue, assertIn, assertIsInstance, assertAlmostEqual for floats) replace bare assert, and assertRaises replaces pytest.raises. The style is wordier, but it is everywhere in older codebases and needs no installation, and pytest will happily run unittest classes too, which eases any migration.
10. The complete suite, and the pyramid
The finale is a small but complete test file following the guide’s minimum coverage rule: for every function, one happy path, one edge case, one error case. Save as test_suite.py and run pytest test_suite.py -v.
import pytest# --- code under test ---def apply_discount(price, rate): if not 0 <= rate <= 1: raise ValueError(f"rate must be 0-1, got {rate}") return round(price * (1 - rate), 2)def basket_total(items): return round(sum(items.values()), 2)# --- shared fixture ---pytest.fixturedef basket(): return {"keyboard": 79.99, "mouse": 24.50}# --- apply_discount: happy, edge, error ---def test_discount_happy(): assert apply_discount(100, 0.2) == 80.0pytest.mark.parametrize("rate, expected", [(0, 100.0), (1, 0.0)])def test_discount_edges(rate, expected): # the boundaries of the range assert apply_discount(100, rate) == expecteddef test_discount_error(): with pytest.raises(ValueError): apply_discount(100, 3)# --- basket_total: happy, edge, integration ---def test_total_happy(basket): assert basket_total(basket) == 104.49def test_total_edge(): assert basket_total({}) == 0 # the empty basketdef test_discounted_basket(basket): # components together discounted = {k: apply_discount(v, 0.1) for k, v in basket.items()} assert basket_total(discounted) == pytest.approx(94.04, abs=0.01)
Count the coverage: each function has its happy path, its boundaries, and, where errors exist, its error case, with a final integration test exercising both together, and pytest.approx handling the float comparison honestly. The guide’s testing pyramid explains the proportions to aim for across a real project: roughly 70 percent fast unit tests like these, 25 percent integration tests, and a thin 5 percent of slow end-to-end tests, because the cheap tests at the bottom are the ones you will actually run on every save.
Work through these and you have the whole article in practice: assert, pytest discovery and its flags, exception testing, parametrize, markers, fixtures with teardown and scope, data pipeline testing, the unittest dialect, and a suite built on the happy-edge-error rule. The habit that pays first is the smallest one: the next function you write, give it three tests before you move on, and refactoring stops being an act of courage.
Hope this helps.
[…] Python Testing with pytest and unittest: 10 Code-Along Examples […]
[…] Python Testing with pytest and unittest: 10 Code-Along Examples […]