Testing
Comprehensive guide on Testing.
Testing
Overview#
Automated tests verify that code behaves correctly, catch regressions
before they reach production, and give developers the confidence to
refactor without fear of silently breaking something. Python has a
built-in testing framework, unittest, and a hugely popular third-party
framework, pytest, which is simpler to write and more powerful in
practice. This notebook covers both, along with test organization,
fixtures, mocking, parametrized tests, and measuring coverage.
1. Why Automated Testing Matters#
Manually re-checking a program after every change doesn't scale — it's slow, easy to forget, and doesn't leave a record of what was actually verified. A test suite:
- Documents expected behavior in a runnable, always-up-to-date form.
- Catches regressions automatically whenever code changes.
- Makes refactoring safer, since passing tests confirm behavior is unchanged.
- Forces you to think through edge cases while writing the code.
2. Testing with pytest#
pytest is the most widely used Python testing framework. It requires no
boilerplate classes — a plain function starting with test_ is a test.
bashpip install pytest --break-system-packages
Python 3.12 (WASM)# 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
Python 3.12 (WASM)# test_calculator.py
from calculator import add, divide
import pytest
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
assert add(0, 0) == 0
def test_divide():
assert divide(10, 2) == 5
def test_divide_by_zero_raises():
with pytest.raises(ValueError):
divide(10, 0)
bashpytest # discovers and runs every test_*.py file automatically
pytest test_calculator.py # run a specific file
pytest -v # verbose output, showing each test name
pytest -k "divide" # run only tests matching a keyword
pytest uses plain assert statements — no special assertion methods to
memorize — and provides detailed failure output automatically, showing
exactly which values didn't match.
3. Testing with unittest (Standard Library)#
unittest ships with Python and uses a class-based, more verbose style
inspired by other languages' testing frameworks (JUnit in particular).
Python 3.12 (WASM)# test_calculator.py
import unittest
from calculator import add, divide
class TestCalculator(unittest.TestCase):
def test_add(self):
self.assertEqual(add(2, 3), 5)
self.assertEqual(add(-1, 1), 0)
def test_divide(self):
self.assertEqual(divide(10, 2), 5)
def test_divide_by_zero_raises(self):
with self.assertRaises(ValueError):
divide(10, 0)
if __name__ == "__main__":
unittest.main()
bashpython -m unittest test_calculator.py
python -m unittest discover # auto-discover all test files
Common unittest assertion methods#
| 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(Error) | code block raises Error |
assertAlmostEqual(a, b) | floats equal within rounding tolerance |
pytest can run unittest-style tests too, so many projects use pytest
as the runner even if some legacy tests are still written with
unittest.TestCase.
4. Fixtures: Reusable Setup and Teardown#
Tests often need shared setup (a database connection, a temp file, sample
data). pytest fixtures provide this cleanly using dependency injection.
Python 3.12 (WASM)import pytest
@pytest.fixture
def sample_data():
return {"name": "Alice", "age": 30}
def test_name(sample_data):
assert sample_data["name"] == "Alice"
def test_age(sample_data):
assert sample_data["age"] == 30
Each test function that declares sample_data as a parameter
automatically receives a fresh copy — pytest matches the parameter name
to the fixture function.
Fixtures with teardown (setup/cleanup around a yield)#
Python 3.12 (WASM)import pytest
@pytest.fixture
def temp_file(tmp_path):
file_path = tmp_path / "data.txt"
file_path.write_text("test content")
yield file_path # test runs here
# cleanup code after yield runs automatically after the test finishes
def test_read_temp_file(temp_file):
assert temp_file.read_text() == "test content"
tmp_path is a built-in pytest fixture that provides a unique temporary
directory per test, automatically cleaned up afterward.
Fixture scope#
Python 3.12 (WASM)@pytest.fixture(scope="function") # default: recreated for every test
def per_test_data():
...
@pytest.fixture(scope="module") # created once per test file
def db_connection():
...
@pytest.fixture(scope="session") # created once for the entire test run
def expensive_resource():
...
Wider scopes (module, session) are useful for expensive setup, like a
real database connection, that doesn't need to be recreated for every
single test.
5. Parametrized Tests#
Instead of writing near-identical test functions for different inputs,
@pytest.mark.parametrize runs the same test body against a whole list of
input/expected-output pairs.
Python 3.12 (WASM)import pytest
from calculator import add
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
(100, 200, 300),
])
def test_add_parametrized(a, b, expected):
assert add(a, b) == expected
pytest runs this as four separate tests, each reported individually,
which makes failures easy to pinpoint without duplicating the test logic.
6. Mocking: Isolating Code Under Test#
When code depends on something external — an API call, a database, the current time — tests should typically replace that dependency with a controllable fake, so tests are fast, deterministic, and don't require real network/database access.
Python 3.12 (WASM)from unittest.mock import Mock, patch
def get_weather(api_client, city):
response = api_client.fetch(city)
return response["temperature"]
def test_get_weather():
mock_client = Mock()
mock_client.fetch.return_value = {"temperature": 25}
result = get_weather(mock_client, "Bengaluru")
assert result == 25
mock_client.fetch.assert_called_once_with("Bengaluru")
Patching a function or module during a test#
Python 3.12 (WASM)# module.py
import requests
def fetch_price(symbol):
response = requests.get(f"https://api.example.com/price/{symbol}")
return response.json()["price"]
Python 3.12 (WASM)# test_module.py
from unittest.mock import patch
@patch("module.requests.get")
def test_fetch_price(mock_get):
mock_get.return_value.json.return_value = {"price": 150.5}
from module import fetch_price
result = fetch_price("AAPL")
assert result == 150.5
mock_get.assert_called_once()
@patch temporarily replaces the target (here, requests.get) with a
mock for the duration of the test, then automatically restores the real
version afterward — even if the test fails.
Mock vs MagicMock#
Python 3.12 (WASM)from unittest.mock import Mock, MagicMock
m = Mock()
m.some_method() # works, returns another Mock automatically
# len(m) # TypeError: Mock doesn't support magic methods
mm = MagicMock()
mm.some_method()
print(len(mm)) # works — MagicMock supports dunder methods too
7. Test Organization and Naming#
Architecture & Data Flowproject/ ├── src/ │ └── calculator.py └── tests/ ├── __init__.py ├── test_calculator.py └── test_utils.py
Conventions pytest and unittest rely on for auto-discovery:
- Test files: named
test_*.pyor*_test.py. - Test functions: named
test_*. - Test classes (if used): named
Test*, methods namedtest_*.
The Arrange-Act-Assert pattern#
A clear structure makes tests easy to read and maintain.
Python 3.12 (WASM)def test_withdraw_reduces_balance():
# Arrange: set up the initial state
account = BankAccount(balance=100)
# Act: perform the action being tested
account.withdraw(30)
# Assert: verify the expected outcome
assert account.balance == 70
8. Measuring Test Coverage#
The coverage tool (often combined with pytest-cov) reports which lines
of your source code were actually executed by the test suite, helping
identify untested code paths.
bashpip install pytest-cov --break-system-packages pytest --cov=src --cov-report=term-missing
textName Stmts Miss Cover Missing ----------------------------------------------- calculator.py 8 1 88% 12 ----------------------------------------------- TOTAL 8 1 88%
High coverage is a useful signal, but it isn't a complete measure of test quality on its own — a line can be "covered" by a test that doesn't actually assert anything meaningful about its behavior.
9. Test-Driven Development (TDD)#
TDD is a workflow where tests are written before the implementation, following a short cycle:
- Red — write a failing test for behavior that doesn't exist yet.
- Green — write the minimum code needed to make the test pass.
- Refactor — clean up the implementation while keeping tests passing.
Python 3.12 (WASM)# Step 1: write the test first (it will fail — function doesn't exist yet)
def test_is_palindrome():
assert is_palindrome("racecar") is True
assert is_palindrome("hello") is False
# Step 2: implement just enough to pass
def is_palindrome(s):
return s == s[::-1]
# Step 3: refactor if needed, re-running tests to confirm nothing broke
TDD isn't mandatory for every project, but the discipline of thinking through expected behavior before writing implementation code often leads to clearer interfaces and better edge-case coverage.
Notes#
pytest's plainassertsyntax with automatic detailed failure messages is generally why it's preferred overunittestfor new projects, despiteunittestbeing built into the standard library.- Fixtures should be scoped as narrowly as needed —
functionscope by default — and widened only when setup is genuinely expensive and safe to share across tests. - Mocking should target the dependency, not the code under test — replace the external API call or database query, not the logic you're actually trying to verify.
- 100% coverage does not guarantee correctness; it only confirms every line executed at least once during the test run.
- Tests should be independent of each other — one test's failure or ordering should never affect whether another test passes.
Best Practices Checklist#
- Write tests for both expected (happy-path) and error/edge-case behavior
- Follow the Arrange-Act-Assert structure for readable tests
- Use fixtures for shared setup instead of duplicating it across tests
- Use
@pytest.mark.parametrizeinstead of near-duplicate test functions for similar cases - Mock external dependencies (APIs, databases, file systems, time) rather than hitting them directly in tests
- Keep each test focused on verifying one specific behavior
- Name test functions descriptively enough to explain what they verify without reading the body
- Run coverage reports periodically to spot untested code paths
- Keep tests independent — no test should depend on another test having run first