Learn how to write fast, reliable tests using the built-in unittest
and the popular pytest
framework—plus fixtures, mocking, parameterization, coverage, and CI.
1) Why Unit Testing?
Unit tests check small, isolated pieces of code (functions/classes) to catch bugs early. They also document behavior and enable safe refactoring. With a fast test suite, you’ll ship changes with confidence.
2) A Clean Project Structure
Recommended layout
myapp/
├─ src/
│ └─ myapp/
│ ├─ __init__.py
│ ├─ mathy.py
│ └─ io_helpers.py
├─ tests/
│ ├─ test_mathy.py
│ └─ test_io_helpers.py
├─ pyproject.toml
└─ README.md
Tip: Put application code in src/myapp
and tests in tests/
. Tools like pytest
discover tests named test_*.py
.
Example code (to be tested)
# src/myapp/mathy.py
def add(a: float, b: float) -> float:
return a + b
def divide(a: float, b: float) -> float:
if b == 0:
raise ZeroDivisionError("b must not be zero")
return a / b
3) The Built-in unittest
(Batteries Included)
unittest
ships with Python 3—no install needed.
# tests/test_mathy_unittest.py
import unittest
from myapp.mathy import add, divide
class TestMathy(unittest.TestCase):
def test_add(self):
self.assertEqual(add(2, 3), 5)
self.assertAlmostEqual(add(0.1, 0.2), 0.3, places=7)
def test_divide_ok(self):
self.assertEqual(divide(10, 2), 5.0)
def test_divide_by_zero(self):
with self.assertRaises(ZeroDivisionError) as cm:
divide(1, 0)
self.assertIn("must not be zero", str(cm.exception))
if __name__ == "__main__":
unittest.main()
Run: python -m unittest or python -m unittest tests/test_mathy_unittest.py
4) pytest
(Fast, Friendly, Powerful)
Install with pip install pytest then run pytest. It offers concise tests, rich output, and powerful plugins.
# tests/test_mathy_pytest.py
from myapp.mathy import add, divide
import pytest
def test_add():
assert add(2, 3) == 5
def test_divide_ok():
assert divide(9, 3) == 3.0
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
divide(1, 0)
Run: pytest -q
5) Fixtures: Reusable Setup/Teardown
Fixtures provide test data or resources and clean up automatically.
# tests/conftest.py
import tempfile, shutil, pathlib, pytest
@pytest.fixture
def temp_dir():
d = tempfile.mkdtemp()
try:
yield pathlib.Path(d)
finally:
shutil.rmtree(d)
# tests/test_io_helpers.py
def test_write_and_read(temp_dir):
p = temp_dir / "hello.txt"
p.write_text("hi")
assert p.read_text() == "hi"
Tip: Place shared fixtures in tests/conftest.py
for auto-discovery.
6) Mocking with unittest.mock
Mock external calls (network, file system, time) so tests are fast and deterministic.
# src/myapp/io_helpers.py
import requests
def fetch_title(url: str) -> str:
r = requests.get(url, timeout=5)
r.raise_for_status()
return r.text.split("<title>")[1].split("</title>")[0]
# tests/test_io_helpers_mock.py
from unittest.mock import patch, MagicMock
from myapp.io_helpers import fetch_title
@patch("myapp.io_helpers.requests.get")
def test_fetch_title_mocks_requests(mock_get):
fake = MagicMock()
fake.text = "<html><title>Hello</title></html>"
fake.raise_for_status = lambda: None
mock_get.return_value = fake
assert fetch_title("https://example.com") == "Hello"
mock_get.assert_called_once()
Rule of thumb: Patch where the object is used, not where it’s defined.
7) Parameterization (More Cases, Less Boilerplate)
# tests/test_mathy_param.py
import pytest
from myapp.mathy import add
@pytest.mark.parametrize(
"a,b,expected",
[
(0, 0, 0),
(2, 3, 5),
(-1, 5, 4),
(0.1, 0.2, 0.3),
],
)
def test_add_param(a, b, expected):
assert add(a, b) == pytest.approx(expected)
Why it rocks: Easier to read, great failure messages, and one test turns into many.
8) Coverage: Know What You Tested
Install pip install coverage, then:
# with pytest
coverage run -m pytest
coverage report -m
coverage html # open htmlcov/index.html
Aim for meaningful coverage (e.g., 80%+), without gaming the metric.
9) A Tiny TDD Loop
- Write a small failing test (red).
- Implement the simplest code to pass (green).
- Refactor for clarity/perf while keeping tests green.
# 1) failing test first
def test_is_even(): # tests/test_even.py
from myapp.mathy import is_even
assert is_even(2) is True
assert is_even(3) is False
# 2) minimal implementation
# src/myapp/mathy.py
def is_even(n: int) -> bool:
return (n % 2) == 0
10) Run Tests on Every Push (CI)
Here’s a minimal GitHub Actions workflow to run tests on Python 3.10–3.12 and generate coverage.
# .github/workflows/tests.yml
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: ${{ matrix.python-version }} }
- run: python -m pip install --upgrade pip
- run: pip install -e . pytest coverage
- run: coverage run -m pytest -q
- run: coverage report -m
Pro tip: Fail the build if coverage drops using coverage report --fail-under=80
.
11) Assertions & Cheatsheet
unittest
assertions
Assertion | Use |
---|---|
assertEqual(a, b) | Exact equality |
assertAlmostEqual(a, b, places=7) | Floats |
assertTrue(x) / assertFalse(x) | Truthiness |
assertIn(x, seq) | Membership |
assertIsNone(x) | None checks |
assertRaises(E) | Exceptions |
pytest
power-ups
Feature | Example |
---|---|
Simple asserts | assert func(x) == y |
Parameterize | @pytest.mark.parametrize(...) |
Fixtures | @pytest.fixture + autouse |
Marks | @pytest.mark.slow , -m "not slow" |
Skip/xfail | pytest.skip() , @pytest.mark.xfail |
12) FAQ
Do I need both unittest
and pytest
?
No. Many teams use just pytest
for new projects. You can still run legacy unittest
tests under pytest
.
How many tests should I write?
Focus on critical paths and tricky logic. Aim for high coverage with meaningful scenarios—not just lines executed.
How do I test time, random, or network calls?
Use unittest.mock
to patch functions like time.time
, random.random
, or HTTP clients. Inject dependencies where possible.
Copy-Paste Quickstart
# 1) install
pip install pytest coverage
# 2) create files
mkdir -p src/myapp tests
# add src/myapp/mathy.py and tests/test_mathy_pytest.py (from above)
# 3) run tests + coverage
pytest -q
coverage run -m pytest
coverage report -m
No comments:
Post a Comment