Python Unit Testing: unittest vs pytest
Unit testing is the practice of verifying that individual functions and classes behave correctly in isolation. For Python projects, it is the first line of defense against regressions — every time you change code, your unit tests tell you immediately whether something broke. This guide covers the two dominant frameworks, unittest and pytest, so you can choose the right tool and write tests that actually protect your codebase.
What Is Unit Testing and Why Does It Matter?
A unit test exercises a single unit of behavior — typically one function or one method — with controlled inputs and asserts that the output matches expectations. The unit under test has no network calls, no database, no file I/O. Those external dependencies are replaced with fakes or mocks.
Why bother? A few concrete reasons:
- Regressions surface immediately. A test suite that runs in seconds catches bugs before they reach production.
- Refactoring becomes safe. When you restructure code, passing tests prove the behavior is unchanged.
- Documentation that stays current. Tests describe exactly what a function does, in code, and they break if reality diverges from the description.
- Faster debugging. A failing unit test points directly at the broken function; a failing production report points at nothing.
Python ships with a built-in testing framework, and a popular third-party alternative has largely taken over the ecosystem. Understanding both is worth your time.
The Built-in unittest Module
unittest is part of the Python standard library, modeled on Java's JUnit. No installation required — it works out of the box on any Python 3 installation.
Tests live in classes that inherit from unittest.TestCase. Every method whose name starts with test is treated as a test case. The class provides a rich set of assertion methods.
# calculator.py
def add(a, b):
return a + b
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b# test_calculator_unittest.py
import unittest
from calculator import add, divide
class TestCalculator(unittest.TestCase):
def setUp(self):
# Runs before every test method
self.base = 10
def tearDown(self):
# Runs after every test method — cleanup goes here
pass
def test_add_positive_numbers(self):
self.assertEqual(add(2, 3), 5)
def test_add_with_base(self):
self.assertEqual(add(self.base, 5), 15)
def test_divide_normal(self):
self.assertAlmostEqual(divide(7, 2), 3.5)
def test_divide_by_zero_raises(self):
with self.assertRaises(ValueError):
divide(10, 0)
if __name__ == "__main__":
unittest.main()Run with:
python -m unittest test_calculator_unittestKey unittest Assertions
| Method | Checks |
|---|---|
assertEqual(a, b) |
a == b |
assertNotEqual(a, b) |
a != b |
assertTrue(x) |
bool(x) is True |
assertFalse(x) |
bool(x) is False |
assertIsNone(x) |
x is None |
assertIn(a, b) |
a in b |
assertRaises(exc) |
block raises exc |
assertAlmostEqual(a, b) |
floats roughly equal |
setUp and tearDown run around each individual test. For class-level setup that runs once, use setUpClass and tearDownClass as @classmethod decorators.
pytest — Simpler Syntax, Better Output
pytest is a third-party framework that has become the de facto standard for Python testing. Install it with:
pip install pytestThe most immediate difference: you do not need a class. Write plain functions, use plain assert statements, and pytest takes care of the rest. Its failure output is dramatically more readable — it shows the actual vs. expected values inline, without any extra configuration.
# test_calculator_pytest.py
import pytest
from calculator import add, divide
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, -1) == -2
def test_divide_normal():
assert divide(7, 2) == pytest.approx(3.5)
def test_divide_by_zero_raises():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)Run with:
pytest test_calculator_pytest.py -vpytest discovers tests by scanning for files named test_*.py or *_test.py, then collecting functions and methods that start with test. No registration, no inheritance required.
Side-by-Side Comparison: The Same Test in Both Frameworks
# unittest
class TestUserEmail(unittest.TestCase):
def test_email_normalized_to_lowercase(self):
user = User(email="Alice@Example.COM")
self.assertEqual(user.email, "alice@example.com")
# pytest
def test_email_normalized_to_lowercase():
user = User(email="Alice@Example.COM")
assert user.email == "alice@example.com"The pytest version is six lines shorter and reads like a plain English sentence. When it fails, pytest prints:
AssertionError: assert 'Alice@Example.COM' == 'alice@example.com'whereas unittest prints a generic assertion error with less context unless you add a custom message.
pytest also supports fixtures — a powerful replacement for setUp/tearDown that uses dependency injection instead of inheritance:
@pytest.fixture
def db_connection():
conn = create_test_db()
yield conn # test runs here
conn.close() # teardown runs after yield
def test_user_saved(db_connection):
db_connection.save(User(name="Alice"))
assert db_connection.count("users") == 1Fixtures are reusable across files (place them in conftest.py) and composable — a fixture can depend on other fixtures.
Test Organization: Files, Classes, and Modules
A standard layout for a Python project:
my_project/
├── src/
│ └── my_project/
│ ├── __init__.py
│ ├── calculator.py
│ └── users.py
└── tests/
├── conftest.py # shared pytest fixtures
├── test_calculator.py
└── test_users.pyKeep test files mirroring the source structure. One test file per source module makes it obvious where to look. Group related tests inside classes when they share fixture state — classes work fine in pytest without inheriting from TestCase.
class TestUserCreation:
def test_creates_with_valid_email(self):
...
def test_rejects_invalid_email(self):
...Mocking with unittest.mock and pytest-mock
Mocking replaces real dependencies — HTTP clients, databases, third-party APIs — with controlled fakes so your unit tests stay fast and deterministic.
Python ships unittest.mock in the standard library. Use patch as a decorator or context manager:
from unittest.mock import patch, MagicMock
def test_fetch_user_calls_api():
with patch("myapp.api_client.get") as mock_get:
mock_get.return_value = {"id": 1, "name": "Alice"}
user = fetch_user(1)
mock_get.assert_called_once_with("/users/1")
assert user.name == "Alice"pytest-mock wraps this into a mocker fixture that integrates naturally with pytest's style:
pip install pytest-mockdef test_fetch_user_calls_api(mocker):
mock_get = mocker.patch("myapp.api_client.get")
mock_get.return_value = {"id": 1, "name": "Alice"}
user = fetch_user(1)
mock_get.assert_called_once_with("/users/1")
assert user.name == "Alice"The mocker fixture automatically undoes all patches after each test — no manual cleanup needed.
Coverage with coverage.py and pytest-cov
Coverage tells you which lines of your source code are executed by your tests. Install the tools:
pip install coverage pytest-covRun with coverage reporting:
# pytest-cov — integrated with pytest
pytest --cov=src --cov-report=term-missing
<span class="hljs-comment"># coverage.py standalone
coverage run -m pytest
coverage report -m
coverage html <span class="hljs-comment"># generates htmlcov/ with line-by-line viewSample output:
Name Stmts Miss Cover Missing
----------------------------------------------------
src/calculator.py 10 1 90% 15
src/users.py 25 4 84% 31-34
----------------------------------------------------
TOTAL 35 5 86%The Missing column shows exactly which lines are untested. Aim for 80–90% on business logic. Chasing 100% often produces low-value tests; focus coverage effort on branches and error paths.
Set a coverage threshold in pytest.ini or pyproject.toml to fail the build if coverage drops:
# pytest.ini
[pytest]
addopts = --cov=src --cov-fail-under=80When to Use unittest vs pytest
Use unittest when:
- You are maintaining a legacy codebase already built on
unittest - You need zero external dependencies (air-gapped environments, minimal Docker images)
- Your team is more familiar with xUnit-style frameworks from other languages
Use pytest for everything else — especially new projects. The reasons:
- Less boilerplate: plain functions, plain
assert - Better failure messages out of the box
- Fixtures are more flexible than
setUp/tearDown - Parametrize runs the same test with multiple inputs cleanly
- The plugin ecosystem is large (
pytest-cov,pytest-mock,pytest-asyncio,pytest-xdistfor parallel runs) - It can run
unittest-style tests too, so migration is gradual
# pytest parametrize — run one test with multiple inputs
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(-1, 1, 0),
(0, 0, 0),
])
def test_add(a, b, expected):
assert add(a, b) == expectedRunning Tests in CI with GitHub Actions
Tests that only run locally are nearly useless — they need to run on every push. A minimal GitHub Actions workflow:
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest pytest-cov pytest-mock
- name: Run tests
run: pytest --cov=src --cov-report=xml --cov-fail-under=80
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: coverage.xmlThis runs on every push and pull request, blocks merges if coverage falls below 80%, and uploads the report to Codecov. Replace 3.12 with your project's Python version.
For projects that support multiple Python versions, use a matrix:
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]Python unit testing is not complicated to start — a handful of assert statements in a function named test_something is enough to protect your most critical logic. The investment pays back immediately the first time a test catches a regression before it ships.
Complete Your Testing Stack
Unit tests cover functions. For full confidence, add end-to-end browser tests — HelpMeTest generates and runs them automatically with AI, starting free.