Skip to content

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.

why_test.py
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")

Most tests follow three steps:

  1. Arrange — set up inputs and state.
  2. Act — call the thing under test.
  3. Assert — check the result matches what you expect.

unittest groups tests into classes that subclass TestCase. Each method named test_* is a test, and you check results with assert* methods.

unittest_basic.py
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:

terminal
$ python -m unittest test_math.py
...
Ran 3 tests in 0.001s
OK
MethodPasses 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 runs before each test, tearDown after — great for shared fixtures.

setup_teardown.py
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)
unittest_raises.py
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 lets you write tests as plain functions using ordinary assert. Install it first:

terminal
$ pip install pytest
test_pytest.py
# 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
terminal
$ pytest
==== 2 passed in 0.01s ====
parametrize.py
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) == expected
fixtures.py
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) == 6
pytest_raises.py
import pytest
 
def divide(a, b):
    return a / b
 
def test_zero():
    with pytest.raises(ZeroDivisionError):
        divide(1, 0)
unittestpytest
Ships with PythonYesNo (pip install pytest)
Test styleTestCase classesPlain functions
Assertionsself.assertEqual(...)plain assert
FixturessetUp/tearDown@pytest.fixture
Parametrizemanual / subTest@pytest.mark.parametrize

Both are excellent. unittest is always available; pytest is more concise and has a rich plugin ecosystem. Many projects write plain assert tests and run them with pytest.

  • Test functions/methods must start with test or 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).

Exercise 1 – Assert a function’s result

Section titled “Exercise 1 – Assert a function’s result”

Both frameworks find your tests and report failures. What differs is how much the failure tells you before you have to go and look:

diagram Diagram mermaid

The same wrong function, three ways of checking it. Measured output:

unittest: plain assert
FAIL: test_plain_assert_says_nothing
    assert add(2, 3) == 6
           ^^^^^^^^^^^^^^
AssertionError
unittest: assertEqual
FAIL: test_assertEqual_says_what
    self.assertEqual(add(2, 3), 6)
AssertionError: 5 != 6
pytest: plain assert
>       assert got == 6
        ^^^^^^^^^^^^^^^
E       assert 5 == 6

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

parametrize.py
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) == want

Four 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:

subtest.py
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)
the failure names the case
FAIL: test_subtest (__main__.TestAdd.test_subtest) (a=2, b=3)
AssertionError: 5 != 6

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.

sketch Arrange, Act, Assert - and what the failure says p5.js
Both frameworks run the same three phases. They differ in how much the failed assertion reports without extra work from you.
fixture.py
# 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
setup.py
# 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.

unittestpytest
ships with Pythonyesno, pip install pytest
plain assert reports valuesnoyes
many inputssubTest@parametrize
shared setupsetUp per classfixtures, scoped
runs unittest testsyes, 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.

pch.quizTag pch.quizDefaultTitle
  1. Under pytest, a plain `assert got == 6` fails and prints `assert 5 == 6`. How does pytest know the value of got?

    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.

  2. A test module has three test functions, one of which is parametrized with four cases. How many tests run?

    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.

  3. Why loop over cases with self.subTest(...) rather than a plain for loop inside a unittest method?

    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.

  4. How does unittest's setUp differ from a pytest fixture?

    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.

  • Tests arrange, act, assert to lock in expected behaviour.
  • unittest (built in) uses TestCase classes, assert* methods, and setUp/tearDown.
  • pytest (install with pip) uses plain functions, bare assert, fixtures, and @parametrize.
  • Test exceptions with assertRaises / pytest.raises.
  • Keep tests small, independent, and named test_*.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading