Python Testing — unittest & pytest
Automated tests check that your code does what you expect — and keep doing it as the code changes. Python ships unittest in the standard library, and the third-party pytest is the most popular framework in the ecosystem.
def add(a, b):
return a + b
# A test is just code that checks an assumption:
assert add(2, 3) == 5
assert add(-1, 1) == 0
print("all good")The idea: Arrange, Act, Assert
Section titled “The idea: Arrange, Act, Assert”Most tests follow three steps:
- Arrange — set up inputs and state.
- Act — call the thing under test.
- Assert — check the result matches what you expect.
unittest (standard library)
Section titled “unittest (standard library)”unittest groups tests into classes that subclass TestCase. Each method named test_* is a test, and you check results with assert* methods.
import unittest
def add(a, b):
return a + b
class TestAdd(unittest.TestCase):
def test_positive(self):
self.assertEqual(add(2, 3), 5)
def test_negative(self):
self.assertEqual(add(-1, -1), -2)
def test_type(self):
self.assertIsInstance(add(1, 2), int)
if __name__ == "__main__":
unittest.main()Run it from the terminal:
$ python -m unittest test_math.py
...
Ran 3 tests in 0.001s
OKCommon assert methods
Section titled “Common assert methods”| Method | Passes when |
|---|---|
assertEqual(a, b) | a == b |
assertNotEqual(a, b) | a != b |
assertTrue(x) / assertFalse(x) | x is truthy / falsy |
assertIsNone(x) | x is None |
assertIn(a, b) | a in b |
assertIsInstance(a, cls) | a is an instance of cls |
assertRaises(Err) | The block raises Err |
setUp and tearDown
Section titled “setUp and tearDown”setUp runs before each test, tearDown after — great for shared fixtures.
import unittest
class TestList(unittest.TestCase):
def setUp(self):
self.data = [1, 2, 3] # fresh for every test
def test_append(self):
self.data.append(4)
self.assertEqual(self.data, [1, 2, 3, 4])
def test_length(self):
self.assertEqual(len(self.data), 3)Testing exceptions
Section titled “Testing exceptions”import unittest
def divide(a, b):
return a / b
class TestDivide(unittest.TestCase):
def test_zero(self):
with self.assertRaises(ZeroDivisionError):
divide(1, 0)pytest (third-party)
Section titled “pytest (third-party)”pytest lets you write tests as plain functions using ordinary assert. Install it first:
$ pip install pytest# Functions named test_* are collected automatically.
def add(a, b):
return a + b
def test_positive():
assert add(2, 3) == 5
def test_negative():
assert add(-1, 1) == 0$ pytest
==== 2 passed in 0.01s ====Parametrize — one test, many cases
Section titled “Parametrize — one test, many cases”import pytest
def square(n):
return n * n
@pytest.mark.parametrize("value,expected", [
(2, 4),
(3, 9),
(-4, 16),
])
def test_square(value, expected):
assert square(value) == expectedFixtures — reusable setup
Section titled “Fixtures — reusable setup”import pytest
@pytest.fixture
def sample_data():
return [1, 2, 3]
def test_sum(sample_data): # the fixture is injected by name
assert sum(sample_data) == 6Testing exceptions in pytest
Section titled “Testing exceptions in pytest”import pytest
def divide(a, b):
return a / b
def test_zero():
with pytest.raises(ZeroDivisionError):
divide(1, 0)unittest vs pytest
Section titled “unittest vs pytest”| unittest | pytest | |
|---|---|---|
| Ships with Python | Yes | No (pip install pytest) |
| Test style | TestCase classes | Plain functions |
| Assertions | self.assertEqual(...) | plain assert |
| Fixtures | setUp/tearDown | @pytest.fixture |
| Parametrize | manual / subTest | @pytest.mark.parametrize |
Both are excellent.
unittestis always available;pytestis more concise and has a rich plugin ecosystem. Many projects write plainasserttests and run them withpytest.
Common pitfalls
Section titled “Common pitfalls”- Test functions/methods must start with
testor the runner skips them. - Test one thing per test — small, focused tests pinpoint failures.
- Don’t depend on test order — each test should stand alone.
assertEqual(a, b)argument order — convention is(actual, expected).
Practice Exercises
Section titled “Practice Exercises”Exercise 1 – Assert a function’s result
Section titled “Exercise 1 – Assert a function’s result”Exercise 2 – assertEqual in a TestCase
Section titled “Exercise 2 – assertEqual in a TestCase”Exercise 3 – Expect an exception
Section titled “Exercise 3 – Expect an exception”The one difference you feel every day
Section titled “The one difference you feel every day”Both frameworks find your tests and report failures. What differs is how much the failure tells you before you have to go and look:
flowchart TD
F["a test fails"] --> A{"how was the check written?"}
A -->|"plain assert, under unittest"| A1["AssertionError
no values at all"]
A -->|"self.assertEqual"| A2["AssertionError: 5 != 6"]
A -->|"plain assert, under pytest"| A3["assert 5 == 6
pytest rewrote the assert
to capture the operands"]
The same wrong function, three ways of checking it. Measured output:
FAIL: test_plain_assert_says_nothing
assert add(2, 3) == 6
^^^^^^^^^^^^^^
AssertionErrorFAIL: test_assertEqual_says_what
self.assertEqual(add(2, 3), 6)
AssertionError: 5 != 6> assert got == 6
^^^^^^^^^^^^^^^
E assert 5 == 6Under unittest a bare assert tells you only that something was false — which is why
it has forty-odd assertXxx methods. pytest rewrites the assertion at import time so
a plain assert reports the operands, and the assertXxx vocabulary becomes
unnecessary.
Running the same check over many inputs
Section titled “Running the same check over many inputs”import pytest
@pytest.mark.parametrize("a,b,want", [(1, 1, 2), (2, 3, 5), (0, 0, 0), (-1, 1, 0)])
def test_add(a, b, want):
assert add(a, b) == wantFour cases become four separate tests: the run reported 1 failed, 5 passed from
three test functions, because parametrize expanded one of them into four. Each case
fails, reports, and is named independently.
The unittest equivalent is subTest, which keeps them inside one test but still
reports each case:
def test_subtest(self):
for a, b, want in [(1, 1, 2), (2, 3, 6), (0, 0, 0)]:
with self.subTest(a=a, b=b):
self.assertEqual(add(a, b), want)FAIL: test_subtest (__main__.TestAdd.test_subtest) (a=2, b=3)
AssertionError: 5 != 6See it move
Section titled “See it move”Arrange, Act, Assert is not ceremony — it is what makes a failure readable. Step a test through its phases and see what each framework prints when the assertion goes wrong.
Fixtures and setUp do the same job
Section titled “Fixtures and setUp do the same job”# pytest — a fixture is requested by naming it as a parameter
@pytest.fixture
def sample():
return {"n": 3}
def test_fixture(sample):
assert sample["n"] == 3# unittest — setUp runs before every test method in the class
class TestAdd(unittest.TestCase):
def setUp(self):
self.sample = {"n": 3}setUp runs before every method in the class whether that method needs it or not.
A pytest fixture runs only for tests that ask for it by name, and can be scoped to a
module or session so expensive setup happens once.
Which to choose
Section titled “Which to choose”unittest | pytest | |
|---|---|---|
| ships with Python | yes | no, pip install pytest |
plain assert reports values | no | yes |
| many inputs | subTest | @parametrize |
| shared setup | setUp per class | fixtures, scoped |
runs unittest tests | — | yes, it runs both |
pytest discovers and runs unittest.TestCase classes unchanged, so adopting it does
not mean rewriting anything. Use unittest when a third-party dependency is
unacceptable; otherwise pytest costs one install and gives better failure output from
the first run.
Check yourself
Section titled “Check yourself”-
Under pytest, a plain `assert got == 6` fails and prints `assert 5 == 6`. How does pytest know the value of got?
pytest rewrites the bytecode of assert statements when it imports your test module. The same assert under unittest prints a bare AssertionError, which is why unittest needs assertEqual and friends.
pch.quizShowAnswer
B — it rewrites assert statements at import time to capture the operands — pytest rewrites the bytecode of assert statements when it imports your test module. The same assert under unittest prints a bare AssertionError, which is why unittest needs assertEqual and friends.
-
A test module has three test functions, one of which is parametrized with four cases. How many tests run?
parametrize expands one function into four independent tests, so 2 + 4 = 6. The measured run reported '1 failed, 5 passed'. Each case is named and reported separately.
pch.quizShowAnswer
C — 6 — parametrize expands one function into four independent tests, so 2 + 4 = 6. The measured run reported '1 failed, 5 passed'. Each case is named and reported separately.
-
Why loop over cases with self.subTest(...) rather than a plain for loop inside a unittest method?
subTest reports each case independently and keeps going, naming the failure like '(a=2, b=3)'. A plain loop stops at the first failure, so you find bugs one re-run at a time.
pch.quizShowAnswer
C — without it the first failing case raises and the remaining cases never run — subTest reports each case independently and keeps going, naming the failure like '(a=2, b=3)'. A plain loop stops at the first failure, so you find bugs one re-run at a time.
-
How does unittest's setUp differ from a pytest fixture?
setUp is unconditional for the whole class. Fixtures are requested by name and support module or session scope, so expensive setup can be done once rather than per test.
pch.quizShowAnswer
A — setUp runs before every test method in the class; a fixture runs only for tests that request it and can be scoped — setUp is unconditional for the whole class. Fixtures are requested by name and support module or session scope, so expensive setup can be done once rather than per test.
Summary
Section titled “Summary”- Tests arrange, act, assert to lock in expected behaviour.
unittest(built in) usesTestCaseclasses,assert*methods, andsetUp/tearDown.pytest(install with pip) uses plain functions, bareassert, fixtures, and@parametrize.- Test exceptions with
assertRaises/pytest.raises. - Keep tests small, independent, and named
test_*.
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading