If you have ever pushed code to production and immediately felt a knot in your stomach, you already understand why testing matters. Automated tests are the safety net that lets you ship with confidence. They catch bugs before your users do, give you the freedom to refactor without fear, and serve as living documentation that shows exactly how your code is supposed to behave. In Python, the testing ecosystem is mature, well-designed, and a pleasure to work with. This guide walks you through everything you need to write effective tests, from the built-in unittest module to the more modern pytest framework, including mocking, parameterization, coverage, and the practices that separate professional-grade test suites from fragile afterthoughts.
Python ships with the unittest module in the standard library. It follows the xUnit pattern familiar to developers coming from Java (JUnit) or C# (NUnit). You organize tests into classes that inherit from unittest.TestCase, and each test method must start with the prefix test_.
Let us start with a module we want to test. Create a file called calculator.py:
class Calculator:
"""A simple calculator class."""
def add(self, a, b):
return a + b
def subtract(self, a, b):
return a - b
def multiply(self, a, b):
return a * b
def divide(self, a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
Now create test_calculator.py:
import unittest
from calculator import Calculator
class TestCalculator(unittest.TestCase):
"""Tests for the Calculator class."""
def setUp(self):
"""Create a fresh Calculator instance before each test."""
self.calc = Calculator()
def test_add_positive_numbers(self):
result = self.calc.add(3, 5)
self.assertEqual(result, 8)
def test_add_negative_numbers(self):
result = self.calc.add(-2, -7)
self.assertEqual(result, -9)
def test_subtract(self):
result = self.calc.subtract(10, 4)
self.assertEqual(result, 6)
def test_multiply(self):
result = self.calc.multiply(3, 7)
self.assertEqual(result, 21)
def test_divide(self):
result = self.calc.divide(10, 2)
self.assertEqual(result, 5.0)
def test_divide_by_zero_raises_error(self):
with self.assertRaises(ValueError) as context:
self.calc.divide(10, 0)
self.assertIn("Cannot divide by zero", str(context.exception))
def tearDown(self):
"""Clean up after each test (if needed)."""
self.calc = None
if __name__ == "__main__":
unittest.main()
Run the tests from the terminal:
python -m unittest test_calculator -v
The -v flag gives you verbose output so you can see each test name and its result.
The setUp() method runs before every single test method in the class. Use it to create objects, open connections, or prepare any state your tests need. The tearDown() method runs after every test method, whether the test passed or failed. Use it to close connections, delete temporary files, or reset state.
This is critical: each test must be fully independent. Never rely on one test setting up state for another test. The order tests run in is not guaranteed, and coupling tests together leads to mysterious failures that waste hours of debugging time.
import unittest
import os
import tempfile
class TestFileOperations(unittest.TestCase):
def setUp(self):
"""Create a temporary file before each test."""
self.temp_dir = tempfile.mkdtemp()
self.file_path = os.path.join(self.temp_dir, "test_data.txt")
with open(self.file_path, "w") as f:
f.write("initial content")
def test_file_exists(self):
self.assertTrue(os.path.exists(self.file_path))
def test_file_content(self):
with open(self.file_path, "r") as f:
content = f.read()
self.assertEqual(content, "initial content")
def tearDown(self):
"""Remove the temporary file after each test."""
if os.path.exists(self.file_path):
os.remove(self.file_path)
os.rmdir(self.temp_dir)
There are also class-level equivalents: setUpClass() and tearDownClass(). These run once for the entire test class rather than before each individual test. They are useful for expensive setup operations like creating a database connection.
class TestWithDatabase(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""Runs once before all tests in this class."""
cls.db_connection = create_database_connection()
@classmethod
def tearDownClass(cls):
"""Runs once after all tests in this class."""
cls.db_connection.close()
The unittest.TestCase class provides a rich set of assertion methods. Here are the ones you will use most often:
class TestAssertions(unittest.TestCase):
def test_equality(self):
self.assertEqual(1 + 1, 2) # a == b
self.assertNotEqual(1 + 1, 3) # a != b
def test_truthiness(self):
self.assertTrue(10 > 5) # bool(x) is True
self.assertFalse(5 > 10) # bool(x) is False
def test_identity(self):
a = [1, 2, 3]
b = a
self.assertIs(a, b) # a is b
self.assertIsNot(a, [1, 2, 3]) # a is not b
def test_none_checks(self):
self.assertIsNone(None) # x is None
self.assertIsNotNone("hello") # x is not None
def test_membership(self):
self.assertIn(3, [1, 2, 3]) # a in b
self.assertNotIn(4, [1, 2, 3]) # a not in b
def test_type_checks(self):
self.assertIsInstance(42, int) # isinstance(a, b)
def test_approximate_equality(self):
self.assertAlmostEqual(0.1 + 0.2, 0.3, places=7) # for floats
def test_exceptions(self):
with self.assertRaises(ZeroDivisionError):
_ = 1 / 0
def test_exception_message(self):
with self.assertRaisesRegex(ValueError, "invalid literal"):
int("not_a_number")
Sometimes you need to skip a test conditionally, for example when a feature is only available on certain platforms or a dependency is missing. Use the @unittest.skip decorator or its conditional variants:
import sys
import unittest
class TestConditionalSkipping(unittest.TestCase):
@unittest.skip("Temporarily disabled while refactoring")
def test_feature_in_progress(self):
pass
@unittest.skipIf(sys.platform == "win32", "Does not run on Windows")
def test_unix_only_feature(self):
pass
@unittest.skipUnless(sys.platform.startswith("linux"), "Linux only")
def test_linux_feature(self):
pass
You can group related tests into suites for selective execution:
import unittest
from test_calculator import TestCalculator
def suite():
test_suite = unittest.TestSuite()
test_suite.addTest(TestCalculator("test_add_positive_numbers"))
test_suite.addTest(TestCalculator("test_divide_by_zero_raises_error"))
return test_suite
if __name__ == "__main__":
runner = unittest.TextTestRunner(verbosity=2)
runner.run(suite())
In practice, most teams rely on test discovery rather than manually building suites. Running python -m unittest discover will find and run all test files matching the pattern test*.py.
While unittest is solid, pytest is the tool most Python developers reach for today. It requires less boilerplate, produces better error messages, and has a powerful plugin ecosystem. You do not need to inherit from any class or remember assertion method names — you just use plain assert statements.
pip install pytest
With pytest, tests are just functions. No class inheritance required:
from calculator import Calculator
def test_add():
calc = Calculator()
assert calc.add(2, 3) == 5
def test_subtract():
calc = Calculator()
assert calc.subtract(10, 4) == 6
def test_divide_by_zero():
calc = Calculator()
import pytest
with pytest.raises(ValueError, match="Cannot divide by zero"):
calc.divide(10, 0)
Run the tests:
pytest test_calculator.py -v
When a test fails, pytest gives you a detailed diff showing exactly what went wrong, which is far more helpful than unittest’s default output.
Fixtures in pytest replace setUp and tearDown. They are more flexible because they are composable, can have different scopes, and are injected by name:
import pytest
from calculator import Calculator
@pytest.fixture
def calculator():
"""Provide a Calculator instance to each test that requests it."""
return Calculator()
@pytest.fixture
def sample_data():
"""Provide test data."""
return {"a": 10, "b": 5, "expected_sum": 15}
def test_add(calculator, sample_data):
result = calculator.add(sample_data["a"], sample_data["b"])
assert result == sample_data["expected_sum"]
def test_multiply(calculator):
assert calculator.multiply(4, 5) == 20
Fixtures can also handle teardown using the yield keyword. Everything before yield is setup; everything after is teardown:
import pytest
import tempfile
import os
@pytest.fixture
def temp_file():
"""Create a temporary file, yield it, then clean up."""
path = tempfile.mktemp(suffix=".txt")
with open(path, "w") as f:
f.write("test data")
yield path
# Teardown: runs after the test completes
if os.path.exists(path):
os.remove(path)
def test_temp_file_exists(temp_file):
assert os.path.exists(temp_file)
def test_temp_file_content(temp_file):
with open(temp_file) as f:
assert f.read() == "test data"
Fixture scopes control how often a fixture is created: "function" (default, once per test), "class", "module", or "session" (once for the entire test run).
When you need to test the same logic with multiple inputs, @pytest.mark.parametrize eliminates copy-paste duplication:
import pytest
from calculator import Calculator
@pytest.fixture
def calc():
return Calculator()
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(-5, -3, -8),
(100, 200, 300),
])
def test_add_various_inputs(calc, a, b, expected):
assert calc.add(a, b) == expected
@pytest.mark.parametrize("a, b, expected", [
(6, 3, 2.0),
(10, 5, 2.0),
(7, 2, 3.5),
(0, 1, 0.0),
])
def test_divide_various_inputs(calc, a, b, expected):
assert calc.divide(a, b) == expected
Each parameter set becomes its own test case in the output, making it easy to see exactly which inputs caused a failure.
Real-world code rarely exists in isolation. It calls APIs, queries databases, reads files, and sends emails. You do not want your unit tests making actual HTTP requests or writing to a production database. This is where mocking comes in.
The unittest.mock module (part of the standard library) provides Mock, MagicMock, and the patch decorator to replace real objects with controllable fakes during tests.
Suppose you have a service that fetches user data from an external API:
# user_service.py
import requests
class UserService:
BASE_URL = "https://api.example.com"
def get_user(self, user_id):
"""Fetch a user by ID from the external API."""
response = requests.get(f"{self.BASE_URL}/users/{user_id}")
response.raise_for_status()
return response.json()
def get_user_name(self, user_id):
"""Return just the user's full name."""
user_data = self.get_user(user_id)
return f"{user_data['first_name']} {user_data['last_name']}"
Here is how you test it without making real HTTP calls:
# test_user_service.py
import unittest
from unittest.mock import patch, MagicMock
from user_service import UserService
class TestUserService(unittest.TestCase):
def setUp(self):
self.service = UserService()
@patch("user_service.requests.get")
def test_get_user_returns_data(self, mock_get):
# Arrange: configure the mock response
mock_response = MagicMock()
mock_response.json.return_value = {
"id": 1,
"first_name": "Jane",
"last_name": "Doe",
"email": "jane@example.com"
}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
# Act: call the method under test
result = self.service.get_user(1)
# Assert: verify the result and that the mock was called correctly
self.assertEqual(result["first_name"], "Jane")
mock_get.assert_called_once_with("https://api.example.com/users/1")
@patch("user_service.requests.get")
def test_get_user_name(self, mock_get):
mock_response = MagicMock()
mock_response.json.return_value = {
"first_name": "John",
"last_name": "Smith"
}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
name = self.service.get_user_name(42)
self.assertEqual(name, "John Smith")
@patch("user_service.requests.get")
def test_get_user_handles_http_error(self, mock_get):
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = Exception("404 Not Found")
mock_get.return_value = mock_response
with self.assertRaises(Exception) as context:
self.service.get_user(999)
self.assertIn("404", str(context.exception))
The key insight with @patch is that you patch where the object is used, not where it is defined. Since user_service.py does import requests, you patch "user_service.requests.get".
MagicMock automatically creates attributes and methods as you access them, making it ideal for replacing complex objects:
from unittest.mock import MagicMock
def test_magic_mock_basics():
# MagicMock creates attributes on the fly
mock_db = MagicMock()
mock_db.query.return_value = [{"id": 1, "name": "Alice"}]
# Use it as if it were the real database
results = mock_db.query("SELECT * FROM users")
assert results == [{"id": 1, "name": "Alice"}]
# Verify it was called
mock_db.query.assert_called_once_with("SELECT * FROM users")
# Check call count
assert mock_db.query.call_count == 1
A well-organized test suite is easy to navigate and maintain. Here is the structure used in most professional Python projects:
my_project/ ├── src/ │ ├── calculator.py │ ├── user_service.py │ └── utils.py ├── tests/ │ ├── __init__.py │ ├── test_calculator.py │ ├── test_user_service.py │ └── test_utils.py ├── requirements.txt └── pytest.ini
Follow these naming conventions:
test_ (e.g., test_calculator.py)Test (e.g., TestCalculator)test_ (e.g., test_add_positive_numbers)You can configure pytest using a pytest.ini or pyproject.toml file:
# pytest.ini [pytest] testpaths = tests python_files = test_*.py python_classes = Test* python_functions = test_* addopts = -v --tb=short
Test coverage measures what percentage of your code is actually executed by your test suite. It is a useful metric, but treat it as a guide rather than a goal. One hundred percent coverage does not mean your code is bug-free — it just means every line ran at least once.
Install the coverage tool:
pip install pytest-cov
Run tests with coverage:
pytest --cov=src --cov-report=term-missing tests/
This shows you which lines are not covered by any test. The term-missing report format lists the exact line numbers that were never executed, so you know where to focus your next tests.
---------- coverage: ---------- Name Stmts Miss Cover Missing ----------------------------------------------------- src/calculator.py 12 0 100% src/user_service.py 10 2 80% 15-16 ----------------------------------------------------- TOTAL 22 2 91%
Testing the happy path is the easy part. Senior developers know that testing error handling is just as important:
import pytest
from calculator import Calculator
class TestCalculatorExceptions:
"""Test that error cases are handled correctly."""
def setup_method(self):
self.calc = Calculator()
def test_divide_by_zero_raises_value_error(self):
with pytest.raises(ValueError):
self.calc.divide(10, 0)
def test_divide_by_zero_message(self):
with pytest.raises(ValueError, match="Cannot divide by zero"):
self.calc.divide(10, 0)
def test_divide_by_zero_exception_details(self):
with pytest.raises(ValueError) as exc_info:
self.calc.divide(10, 0)
assert "Cannot divide by zero" in str(exc_info.value)
assert exc_info.type is ValueError
These are the mistakes I see developers make repeatedly. Learn to recognize them early:
1. Testing implementation details instead of behavior. Your tests should verify what the code does, not how it does it. If you refactor the internals of a function without changing its output, your tests should still pass. When tests break because you renamed a private variable or changed an internal algorithm, those tests are too tightly coupled to the implementation.
# Bad: tests implementation details
def test_sort_uses_quicksort():
sorter = Sorter()
sorter.sort([3, 1, 2])
assert sorter._algorithm_used == "quicksort" # fragile
# Good: tests behavior
def test_sort_returns_sorted_list():
sorter = Sorter()
result = sorter.sort([3, 1, 2])
assert result == [1, 2, 3]
2. Not testing edge cases. Always think about boundary conditions: empty inputs, None values, negative numbers, very large inputs, special characters, and concurrent access. These are exactly the scenarios where bugs hide.
@pytest.mark.parametrize("input_list, expected", [
([], []), # empty list
([1], [1]), # single element
([1, 1, 1], [1, 1, 1]), # all same
([-3, -1, -2], [-3, -2, -1]), # negative numbers
([1, 2, 3], [1, 2, 3]), # already sorted
([3, 2, 1], [1, 2, 3]), # reverse sorted
])
def test_sort_edge_cases(input_list, expected):
sorter = Sorter()
assert sorter.sort(input_list) == expected
3. Writing fragile tests that depend on external state. Tests that depend on the current time, a running database, network access, or file system state are unreliable. Use mocking and fixtures to control your test environment completely.
4. Tests that depend on each other. Each test must be independent. If test B fails when test A does not run first, you have a coupling problem. Use setUp/tearDown or fixtures to ensure each test starts from a clean state.
5. Ignoring test maintenance. Tests are code too. They need refactoring, clear naming, and review just like production code. A neglected test suite full of disabled or flaky tests provides a false sense of security.
Follow the AAA pattern: Arrange, Act, Assert. Structure every test in three clear phases. First, set up the preconditions (Arrange). Then, execute the action you are testing (Act). Finally, verify the result (Assert). This makes tests easy to read and reason about.
def test_add_returns_sum(calculator):
# Arrange
a, b = 5, 3
# Act
result = calculator.add(a, b)
# Assert
assert result == 8
Use descriptive test names. A test name should tell you what is being tested, under what conditions, and what the expected outcome is. When a test fails in CI, the name should give you enough context to start debugging without reading the test body.
# Bad
def test_divide():
...
# Good
def test_divide_by_zero_raises_value_error():
...
# Good
def test_get_user_name_returns_full_name_when_both_names_present():
...
Aim for one logical assertion per test. A test that checks five different things gives you less information when it fails. You only know the first assertion that broke. Separate tests pinpoint the exact problem immediately. That said, multiple assertions that verify the same logical concept (like checking several fields of a returned object) are perfectly fine in a single test.
Keep tests fast. A slow test suite does not get run. If your tests take minutes, developers will skip them, and bugs will reach production. Mock expensive operations, use in-memory databases for integration tests, and save end-to-end tests for a separate CI stage.
Test the public interface. Focus on the methods and functions your users (other developers or modules) actually call. Testing private methods creates tight coupling between tests and implementation, which makes refactoring painful.
Use fixtures to eliminate duplication. If you find the same setup code repeated across multiple tests, extract it into a fixture or setUp method. This makes your tests DRY without sacrificing clarity.
unittest is built into Python and follows the xUnit pattern with TestCase classes, setUp/tearDown, and rich assertion methods. It works out of the box with no additional dependencies.pytest reduces boilerplate by letting you write tests as plain functions with plain assert statements. Its fixture system, parameterization support, and plugin ecosystem make it the preferred choice for most projects.unittest.mock (specifically patch and MagicMock) to isolate the code under test from external dependencies like APIs, databases, and file systems.