Free GuideIntermediate
40 min read
#python#testing#pytest#unittest#mocking#tdd#fixtures

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.

bash
pip 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)
bash
pytest # 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()
bash
python -m unittest test_calculator.py python -m unittest discover # auto-discover all test files

Common unittest assertion methods#

MethodChecks
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 Flow
project/
├── 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_*.py or *_test.py.
  • Test functions: named test_*.
  • Test classes (if used): named Test*, methods named test_*.

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.

bash
pip install pytest-cov --break-system-packages pytest --cov=src --cov-report=term-missing
text
Name 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:

  1. Red — write a failing test for behavior that doesn't exist yet.
  2. Green — write the minimum code needed to make the test pass.
  3. 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 plain assert syntax with automatic detailed failure messages is generally why it's preferred over unittest for new projects, despite unittest being built into the standard library.
  • Fixtures should be scoped as narrowly as needed — function scope 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.parametrize instead 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
Knowledge Checkpoint

Testing in Python & Pytest Checkpoint

Q1.What is a Pytest `fixture` used for?
ATo compile Python tests into binary executables.
BTo provide reusable setup and teardown logic, test data, or mock dependencies across test functions.
CTo measure line coverage automatically.
DTo generate randomized passwords for tests.
Q2.Which Pytest decorator allows running a single test function across multiple parameterized inputs and expected outputs?
A@pytest.mark.parametrize
B@pytest.mark.repeat
C@pytest.mark.loop
D@pytest.mark.cases
Q3.What is the primary purpose of `unittest.mock.patch` in unit testing?
ATo replace external dependencies (like HTTP requests or third-party APIs) with controllable mock objects during tests.
BTo patch Python security vulnerabilities.
CTo speed up disk I/O.
DTo automatically fix failing assertions.
Track Your Learning

Finished studying this notebook?

Mark this guide as completed to update your course progress roadmap.