Testing in Python is the practice of writing code that automatically verifies other code behaves correctly. The standard library provides unittest (an xUnit-style framework) and unittest.mock (for replacing dependencies); the third-party pytest library is the most widely used test runner, offering a lighter syntax.
Writing code that verifies your code
What you'll learn: How to write automated tests with Python's built-in unittest framework. What assertions are, how to test for expected exceptions, and how to run a test suite.
How to read this tab: Pick a function you've already written and write 3 tests for it: one with normal input, one with edge case input, one that should raise an exception. The act of writing tests for code you've already written is the fastest way to understand testing.
A test is code that checks your other code does what you expect. Instead of manually running your program and eyeballing the output, you write assertions — "this function should return 5 for these inputs" — and a test runner checks them all automatically. When you change code later, the tests tell you instantly if you broke something.
The structure to know: class MyTests(unittest.TestCase), then methods starting with test_. Each test method should test one behaviour. The name should describe what it tests: test_divide_by_zero_raises_valueerror is a good name. test_math is not.
unittest — the built-in framework
Python ships with unittest, an xUnit-style testing framework (modelled on JUnit). You write test classes that inherit from unittest.TestCase and methods that begin with test_. Inside, you use assertion methods like assertEqual to state what should be true.
import unittest
# The code we want to test
def add(a, b):
return a + b
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
# A test case — a class inheriting from TestCase
class TestMath(unittest.TestCase):
def test_add_positive(self):
self.assertEqual(add(2, 3), 5)
def test_add_negative(self):
self.assertEqual(add(-1, -1), -2)
def test_divide(self):
self.assertEqual(divide(10, 2), 5.0)
def test_divide_by_zero_raises(self):
# assertRaises checks that an exception is raised
with self.assertRaises(ValueError):
divide(10, 0)
if __name__ == "__main__":
unittest.main() # run all tests when executed directly
# Run from the terminal:
# python test_with_unittest.py
# python -m unittest (auto-discovers test files)| Assertion | Checks |
|---|---|
assertEqual(a, b) | a == b |
assertTrue(x) / assertFalse(x) | x is truthy / falsy |
assertIs(a, b) / assertIsNone(x) | identity / x is None |
assertIn(a, b) | a in b |
assertRaises(Error) | the block raises Error |
assertAlmostEqual(a, b) | floats equal to N decimal places |
✅ Beginner tab complete — check your understanding
- I can write a TestCase class with at least 3 test methods
- I can use assertEqual, assertTrue, assertIn, and assertRaises
- I can run tests with python -m unittest and understand the output
- I know that each test should test one thing and be named descriptively
pytest, fixtures, and parametrize
What you'll learn: pytest — the industry standard test runner with simpler syntax. @pytest.fixture for reusable test setup. @pytest.mark.parametrize for running one test with many inputs automatically.
How to read this tab: Install pytest (pip install pytest) and rewrite one of your unittest tests in pytest style. Feel the difference immediately — no class, no self, just plain functions and bare assert.
pytest — the popular alternative
pytest is a third-party framework (installed with pip install pytest) and the most widely used Python test runner. Its appeal is simplicity: tests are plain functions, and you use Python's built-in assert statement rather than special methods. pytest rewrites assertions to give detailed failure messages. It also runs existing unittest test cases without changes.
import pytest
def add(a, b):
return a + b
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
# Just functions named test_* — no class needed
def test_add_positive():
assert add(2, 3) == 5 # plain assert statement
def test_add_negative():
assert add(-1, -1) == -2
def test_divide():
assert divide(10, 2) == 5.0
# Checking exceptions with pytest.raises
def test_divide_by_zero_raises():
with pytest.raises(ValueError):
divide(10, 0)
# Run from the terminal:
# pytest (discovers all test_*.py files)
# pytest test_file.py (one file)
# pytest -v (verbose, shows each test)
# pytest -k "divide" (only tests matching 'divide')@pytest.fixture is more flexible than setUp. setUp runs before EVERY test in the class. A pytest fixture is requested by name — only tests that need it get it. Fixtures can also have different scopes (function, class, module, session) for controlling how often they run. Start with function scope (default) and adjust when needed.
Fixtures and parametrization
A fixture provides a fixed baseline for tests — shared setup like a database connection or sample data. In pytest, fixtures are functions decorated with @pytest.fixture that tests receive as arguments. Parametrization runs the same test against many inputs without duplication.
import pytest
# A fixture supplies reusable test data or resources
@pytest.fixture
def sample_users():
return [{"name": "Priya"}, {"name": "Arjun"}]
# Tests request the fixture by naming it as a parameter
def test_user_count(sample_users):
assert len(sample_users) == 2
def test_first_user(sample_users):
assert sample_users[0]["name"] == "Priya"
# Parametrize — run one test with many input/expected pairs
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
def test_add(a, b, expected):
assert a + b == expected
# This produces 4 separate test results, one per row.
# unittest equivalent of fixtures: setUp and tearDown methods
import unittest
class TestWithSetup(unittest.TestCase):
def setUp(self): # runs before EACH test
self.data = [1, 2, 3]
def tearDown(self): # runs after EACH test
self.data = None
def test_length(self):
self.assertEqual(len(self.data), 3)unittest is built in and uses classes with self.assertEqual methods. pytest is a third-party install that uses plain functions and the bare assert statement. pytest can run unittest tests, so many projects write new tests in pytest style while keeping old unittest ones.setUp/tearDown run around every single test method, giving each test a fresh state. For one-time setup use setUpClass/tearDownClass (class methods) or a module-scoped pytest fixture.✅ Intermediate tab complete — check your understanding
- I can write pytest tests as plain functions (no class, no self)
- I can write a @pytest.fixture that provides test data
- I can use @pytest.mark.parametrize to run one test with 5 different inputs
- I can run pytest -v to see verbose output and pytest -k "pattern" to run matching tests
Mocking, test doubles, and the testing pyramid
What you'll learn: unittest.mock — replacing real dependencies with controlled stand-ins. The @patch decorator for temporarily replacing objects. The critical "patch where it is used" rule. The testing pyramid and coverage.
How to read this tab: Write a test that uses @patch to mock a network request or a file operation. The moment you test a function that calls requests.get() without actually making a network request, mocking clicks.
The golden rule of mocking: patch where it is used, not where it is defined. If mymodule.py does from requests import get, you patch "mymodule.get", not "requests.get". The mock replaces the name in the namespace where Python will look it up. Getting this wrong is the most common mocking mistake.
Mocking with unittest.mock
Tests should be fast and deterministic, so you replace slow or unpredictable dependencies (network calls, databases, the current time) with controlled stand-ins. The standard library's unittest.mock provides Mock and MagicMock objects and the patch tool, which temporarily replaces an object during a test. patch can be used as a decorator or a context manager.
from unittest.mock import Mock, patch
# The code under test calls an external service
import requests
def get_user_name(user_id):
response = requests.get(f"https://api.example.com/users/{user_id}")
return response.json()["name"]
# Test WITHOUT hitting the real network — patch requests.get
@patch("requests.get")
def test_get_user_name(mock_get):
# Configure the fake response
mock_get.return_value.json.return_value = {"name": "Priya"}
result = get_user_name(42)
assert result == "Priya"
# Verify the call was made as expected
mock_get.assert_called_once_with("https://api.example.com/users/42")
# patch as a context manager (scoped to the with block)
def test_with_context_manager():
with patch("requests.get") as mock_get:
mock_get.return_value.json.return_value = {"name": "Arjun"}
assert get_user_name(7) == "Arjun"
# A standalone Mock records how it was used
mock = Mock()
mock.process(1, 2, key="value")
mock.process.assert_called_once_with(1, 2, key="value")
print(mock.process.call_count) # 1A common mistake: when you patch, you must target the name in the module that uses it, not where it is originally defined. If mymodule does from requests import get, you patch "mymodule.get", not "requests.get". The mock replaces the reference in the namespace where the lookup happens.
Coverage is a metric, not a goal. 100% code coverage doesn't mean your code is well-tested — it just means every line was executed. A test that executes a line without asserting anything meaningful is not a useful test. Coverage is best used to find what you've forgotten to test, not to measure quality.
Test organisation, coverage, and the testing pyramid
The widely cited testing pyramid suggests many fast unit tests (testing one function in isolation), fewer integration tests (testing components together), and a small number of end-to-end tests. Code coverage tools such as coverage.py (often run as pytest --cov) measure which lines your tests exercise — useful as a guide, though high coverage alone does not guarantee good tests. The Codex covers the broader strategy in CI/CD, where tests run automatically on every push.
# Install pytest and the coverage plugin
pip install pytest pytest-cov
# Discover and run every test in the project
pytest
# Show which lines are covered by tests
pytest --cov=myapp
# Standard library discovery (no install needed)
python -m unittest discover
# Conventional project layout pytest discovers automatically:
# myproject/
# myapp/
# __init__.py
# calculator.py
# tests/
# test_calculator.pyA good test is fast, isolated (does not depend on other tests or external state), deterministic (same result every run), and focused on one behaviour. Tests double as living documentation: a well-named test like test_divide_by_zero_raises tells the next reader exactly what the code guarantees. Write the test name to describe the behaviour, not the implementation.
✅ Expert tab complete
- I can use @patch to replace a function or class with a Mock during a test
- I know the "patch where it is used" rule — patch the name in the module that uses it, not where it's defined
- I can use mock_obj.assert_called_once_with() to verify a mock was called correctly
- I understand the testing pyramid: many unit tests, fewer integration tests, few E2E tests