DEV Community

Recca Tsai
Recca Tsai

Posted on • Originally published at recca0120.github.io

pytest: assert Is Enough, Forget self.assertEqual

Originally published at recca0120.github.io

After switching from unittest to pytest, the thing I noticed most wasn't some killer feature — it was not having to remember all the assertXxx methods.

Just write assert result == expected. pytest knows how to expand the failure message on its own.

Why pytest Instead of unittest

unittest ships with the standard library, no install needed, but it has some rough edges:

  • Tests must inherit from TestCase — you can't just write plain functions
  • You need self.assertEqual, self.assertIn, self.assertRaises… hard to keep track of
  • setUp / tearDown scope is fixed at the class level, not flexible

Three things in pytest that made me not go back:

  1. Plain assert — failures show the actual values automatically
  2. Fixtures injected on demand — scope can be function / class / module / session
  3. parametrize — test multiple inputs with one decorator

Install

pip install pytest
Enter fullscreen mode Exit fullscreen mode

The Simplest Test

# test_calc.py
def add(a, b):
    return a + b

def test_add():
    assert add(1, 2) == 3
Enter fullscreen mode Exit fullscreen mode
pytest test_calc.py
Enter fullscreen mode Exit fullscreen mode

On failure:

FAILED test_calc.py::test_add
AssertionError: assert 4 == 3
 +  where 4 = add(2, 2)
Enter fullscreen mode Exit fullscreen mode

No guessing which value is which — pytest expands it.

Fixtures: Better Than setUp

unittest's setUp runs before every test with fixed class scope.

pytest fixtures let you control scope and share across modules:

import pytest

@pytest.fixture
def db():
    conn = create_db_connection()
    yield conn
    conn.close()  # teardown goes after yield

def test_query(db):
    result = db.query("SELECT 1")
    assert result == 1
Enter fullscreen mode Exit fullscreen mode

Setup before yield, teardown after. Much cleaner.

Scope

@pytest.fixture(scope="module")   # one instance per module
def expensive_resource():
    return load_something_slow()
Enter fullscreen mode Exit fullscreen mode
scope lifetime
function default — rebuilt for every test
class shared within a class
module shared within a file
session shared for the entire test run

I typically set database connections to session scope, with each test function running inside its own transaction that rolls back. The full test suite runs without being painfully slow.

conftest.py

Fixtures in conftest.py are available to all test files in the same directory — no imports needed:

tests/
├── conftest.py       # shared fixtures here
├── test_users.py
└── test_orders.py
Enter fullscreen mode Exit fullscreen mode
# conftest.py
import pytest

@pytest.fixture
def admin_user():
    return {"id": 1, "role": "admin"}
Enter fullscreen mode Exit fullscreen mode

Both test_users.py and test_orders.py can use admin_user without importing anything.

parametrize: Multiple Inputs at Once

@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
    (100, -50, 50),
])
def test_add(a, b, expected):
    assert add(a, b) == expected
Enter fullscreen mode Exit fullscreen mode

Each set of inputs runs as a separate test. On failure, it tells you exactly which input set broke:

FAILED test_calc.py::test_add[0-0-1]
Enter fullscreen mode Exit fullscreen mode

I use this for boundary conditions — normal values, zero, negatives, extremes all in one go.

Testing Exceptions

import pytest

def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        1 / 0
Enter fullscreen mode Exit fullscreen mode

To also check the exception message:

def test_value_error():
    with pytest.raises(ValueError, match="invalid input"):
        parse_value("abc")
Enter fullscreen mode Exit fullscreen mode

Running Only Some Tests

# specific file
pytest test_users.py

# specific function
pytest test_users.py::test_login

# by keyword
pytest -k "login or register"

# only last failed
pytest --lf
Enter fullscreen mode Exit fullscreen mode

--lf (last failed) is what I reach for most. Fix a bug, immediately re-run just the tests that were failing — no need to wait through the whole suite.

Useful Options

pytest -v          # show each test name
pytest -s          # don't capture stdout (print shows up)
pytest -x          # stop on first failure
pytest --tb=short  # shorter tracebacks
Enter fullscreen mode Exit fullscreen mode

During development I almost always add -x — one failure at a time, output doesn't get buried.

Summary

The gap between pytest and unittest isn't about features — it's about how comfortable the writing experience is. Plain assert, on-demand fixture composition, parametrize for multiple inputs. Once those habits are in place, testing stops feeling like something that requires opening documentation every time.

If your tests need a lot of fake data, polyfactory generates it from type hints automatically — no hand-crafting fixture data.

References

Top comments (0)