Every Python project reaches a moment where something breaks and nobody can explain why. A function that worked on Tuesday returns garbage on Thursday. A refactor touches twelve files and silently corrupts a data pipeline. The fix for one bug introduces two more. Unit testing exists to prevent this slow erosion of trust in your own codebase -- and Python gives you a surprisingly deep toolkit to do it well, if you understand how the pieces fit together and why each piece exists.
This article walks through the full landscape of Python unit testing: how the standard library's unittest module came to exist, why pytest displaced it as the community's preferred tool, how assertion rewriting actually works under the hood, how unittest.mock lets you isolate code from its dependencies, and what patterns separate fragile tests from resilient ones. It also covers questions that rarely get addressed in testing guides -- how to verify that your tests themselves are effective, how to debug test failures systematically, and how to avoid the anti-patterns that make test suites a liability instead of an asset. Real code throughout, no toy examples that dodge the hard parts.
The Origins: Kent Beck, JUnit, and unittest
Python's built-in testing framework has a lineage that traces back to Smalltalk in 1989. Kent Beck described a testing framework for Smalltalk called SUnit in his paper "Simple Smalltalk Testing: With Patterns," which was later published as Chapter 30 of Kent Beck's Guide to Better Smalltalk (Cambridge University Press, 1998). In 1997, Beck and Erich Gamma adapted the idea to Java as JUnit, and it quickly became the dominant testing tool for Java developers.
What Beck built was not just a library -- it was a conceptual architecture. The SUnit paper introduced the idea that testing Smalltalk code should be done through code, not through user interfaces. As Beck wrote, he preferred writing tests and checking results in code rather than through UI-based approaches (SUnit paper, kent-beck.com). That decision -- tests as executable code, not manual checklists -- remains the philosophical foundation of every modern testing framework.
In 1999, Steve Purcell wrote PyUnit -- a Python port of JUnit -- and contributed it to the community. The source code's copyright header identifies it as being based on the work of Gamma and Beck. Purcell's original documentation, hosted at pyunit.sourceforge.net, praised Beck and Gamma for the design choices underlying the framework.
PyUnit was incorporated into the Python standard library as the unittest module in Python 2.1, released on April 15, 2001 (as confirmed by python.org). This made Python one of the first mainstream languages to ship a testing framework out of the box.
The design carries its Java heritage clearly. You subclass TestCase, name your methods with a test prefix, and call assertion methods on self:
import unittest
class TestStringMethods(unittest.TestCase):
def setUp(self):
self.greeting = "hello, world"
def test_upper(self):
self.assertEqual(self.greeting.upper(), "HELLO, WORLD")
def test_split(self):
result = self.greeting.split(", ")
self.assertEqual(result, ["hello", "world"])
def test_strip(self):
padded = " hello "
self.assertEqual(padded.strip(), "hello")
def tearDown(self):
self.greeting = None
if __name__ == "__main__":
unittest.main()
This works, and it has worked reliably for over two decades. But the boilerplate is real: you need a class even if your tests share no state, you need self.assertEqual instead of a plain assert, and the JUnit-style setUp/tearDown lifecycle requires you to think in terms of object-oriented test fixtures even when your code under test is purely functional.
The unittest documentation itself acknowledges the alternative, listing pytest as "a third-party unittest framework with a lighter-weight syntax for writing tests" (docs.python.org/3/library/unittest.html).
The Shift: How pytest Took Over
In 2003, Holger Krekel was working on the PyPy project (an alternative Python interpreter) and grew frustrated with unittest's verbosity. According to the official pytest history documentation (docs.pytest.org/en/stable/history.html), Krekel refactored the PyPy test framework as early as June 2003, initially building on top of unittest.py. By mid-2004, efforts had begun on a tool called utest that offered something radical for the time: plain assert statements instead of self.assertEqual.
The pytest history page documents the pivotal technical problem: standard Python assert statements only raise an unhelpful AssertionError with no diagnostic information. The early utest framework solved this by reinterpreting assertion expressions and providing details about the values involved -- a capability the original developers described as seeming like magic (docs.pytest.org/en/stable/history.html).
Around September/October 2004, the project was renamed from std.utest to py.test, and the py-dev mailing list (now pytest-dev) was established. In a podcast interview for The Python Podcast.__init__ (Episode 16, 2016), Krekel explained that he was introduced to agile methods through the Zope community. While Zope used unittest, Krekel found the boilerplate didn't feel aligned with how Python worked in practice (pythonpodcast.com). The same interview noted that developers found testing with pytest more enjoyable -- a sentiment that has driven adoption ever since.
Here is the same test suite from above, rewritten for pytest:
import pytest
@pytest.fixture
def greeting():
return "hello, world"
def test_upper(greeting):
assert greeting.upper() == "HELLO, WORLD"
def test_split(greeting):
result = greeting.split(", ")
assert result == ["hello", "world"]
def test_strip():
padded = " hello "
assert padded.strip() == "hello"
No class. No self. No special assertion methods. Just assert. The fixture is declared once and injected by name into any test function that requests it. This is not just less code -- it changes the economics of test-writing. When the cost of adding a test drops, developers write more tests. And more importantly, they maintain those tests, because the code is simple enough to read six months later without re-learning a framework's ceremony.
How Assertion Rewriting Actually Works
The "magic" behind pytest's detailed failure messages is not magic at all. It is a PEP 302 import hook that rewrites your test module's Abstract Syntax Tree (AST) before Python compiles it to bytecode. The pytest documentation for writing plugins explains it precisely: assertion rewriting modifies the parsed AST before bytecode compilation via an import hook installed early in pytest's startup (docs.pytest.org/en/stable/how-to/writing_plugins.html).
When you write:
def test_calculation():
result = compute(42)
assert result == expected_value
Pytest intercepts the import of your test module, parses the source into an AST, finds every assert statement, and rewrites it to evaluate and capture each subexpression independently. The rewritten version is then compiled to bytecode and cached as a .pyc file. If the assertion fails, pytest already has the intermediate values and can display them:
def test_calculation():
result = compute(42)
> assert result == expected_value
E assert 41 == 42
E + where 41 = compute(42)
Under the hood, the AssertionRewritingHook class (in _pytest/assertion/rewrite.py) implements both the importlib.abc.MetaPathFinder and importlib.abc.Loader interfaces. Its find_spec method decides whether a module needs rewriting, and its exec_module method performs the actual AST transformation. The AssertionRewriter uses the visitor pattern to traverse the AST breadth-first, locating ast.Assert nodes and replacing them with instrumented code that captures subexpression values. Each captured value is assigned to a temporary variable (named with the pattern @py_assert0, @py_assert1, etc.) and formatted into a diagnostic message if the assertion fails.
This design has two important properties. First, your production code never sees the rewritten assertions -- pytest only rewrites test modules discovered during collection, not application code. Second, the rewritten bytecode is cached, so the overhead only occurs on the first run after a source change.
You can disable assertion rewriting entirely with --assert=plain if you ever need to debug an import-related issue, or exclude specific modules by adding the string PYTEST_DONT_REWRITE to their docstring. You can also use pytest.register_assert_rewrite("your_module") to enable rewriting for helper modules outside the default test collection scope.
unittest.mock: Isolating Code from Reality
Python 3.3 introduced unittest.mock into the standard library (via PEP 417, discussed and agreed upon at the Python Language Summit 2012), making it the official tool for replacing parts of your system during tests. The module's documentation describes it as providing a core Mock class that eliminates the need for creating stubs throughout a test suite (docs.python.org/3/library/unittest.mock.html).
The fundamental pattern is: replace a dependency with a Mock, exercise the code under test, then verify the mock was used correctly. Here is a realistic example -- testing a function that calls an external API:
from unittest.mock import patch, Mock
def get_user_profile(user_id, http_client):
response = http_client.get(f"/api/users/{user_id}")
if response.status_code != 200:
raise ConnectionError("API unavailable")
return response.json()
def test_get_user_profile_success():
mock_client = Mock()
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"id": 1, "name": "Ada Lovelace"}
mock_client.get.return_value = mock_response
result = get_user_profile(1, mock_client)
assert result == {"id": 1, "name": "Ada Lovelace"}
mock_client.get.assert_called_once_with("/api/users/1")
def test_get_user_profile_failure():
mock_client = Mock()
mock_response = Mock()
mock_response.status_code = 503
mock_client.get.return_value = mock_response
import pytest
with pytest.raises(ConnectionError):
get_user_profile(1, mock_client)
There are two critical concepts in unittest.mock that trip up intermediate developers.
The first is the difference between Mock and MagicMock. A MagicMock is a Mock subclass that pre-configures magic methods (__str__, __len__, __iter__, etc.) with sensible defaults. If your code calls len() on the mocked object or iterates over it, MagicMock will not raise an error. Plain Mock will. In practice, MagicMock is the right default choice unless you specifically want to detect unexpected magic method usage.
The second -- and more common source of bugs -- is where to patch. The unittest.mock documentation states the rule clearly: patch where an object is looked up, not where it is defined (docs.python.org/3/library/unittest.mock.html). Consider this structure:
# myapp/services.py
import requests
def fetch_data():
return requests.get("https://api.example.com/data").json()
# myapp/handlers.py
from myapp.services import fetch_data
def process():
data = fetch_data()
return len(data)
If you want to mock fetch_data in a test for process, you patch it in myapp.handlers, not myapp.services:
from unittest.mock import patch
def test_process():
with patch("myapp.handlers.fetch_data") as mock_fetch:
mock_fetch.return_value = [1, 2, 3]
result = process()
assert result == 3
Patching myapp.services.fetch_data instead would leave the imported reference in handlers.py untouched, and your mock would never be called. This is the single most common mocking mistake in Python testing. The reason is subtle: Python's from X import Y statement creates a new name binding in the importing module. The patch call needs to target that binding, not the original definition.
A third concept worth understanding is spec and autospec. By default, a Mock will silently accept any attribute access or method call, even ones that don't exist on the real object. This can mask bugs -- your test passes because the mock happily accepts a misspelled method name. Using Mock(spec=RealClass) or patch("module.Class", autospec=True) restricts the mock to only accept attributes and methods that exist on the real object, including correct call signatures. This catches a whole class of errors where test code drifts out of sync with production code.
Fixtures: The Pattern That Changes Everything
Pytest fixtures solve a problem that setUp/tearDown cannot: dependency injection with fine-grained scope control.
In unittest, if you need a database connection for your tests, you create it in setUp and destroy it in tearDown. Every test in the class gets the same lifecycle, and sharing expensive resources across tests requires class-level setup with setUpClass, which is awkward.
Pytest fixtures are functions decorated with @pytest.fixture, and they support five scopes: function (default, runs for each test), class, module, package, and session:
import pytest
import sqlite3
@pytest.fixture(scope="module")
def db_connection():
"""Create a database connection shared across all tests in the module."""
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
conn.execute("INSERT INTO users (name) VALUES ('Ada')")
conn.execute("INSERT INTO users (name) VALUES ('Grace')")
conn.commit()
yield conn
conn.close()
@pytest.fixture
def cursor(db_connection):
"""Create a fresh cursor for each test, rolling back after."""
cursor = db_connection.cursor()
yield cursor
db_connection.rollback()
def test_count_users(cursor):
cursor.execute("SELECT COUNT(*) FROM users")
assert cursor.fetchone()[0] == 2
def test_find_user_by_name(cursor):
cursor.execute("SELECT name FROM users WHERE name = ?", ("Ada",))
assert cursor.fetchone()[0] == "Ada"
The db_connection fixture is created once per module and torn down after all tests in the module complete. The cursor fixture depends on db_connection, is created fresh for each test, and rolls back afterward to maintain isolation. Pytest handles the dependency graph automatically.
The yield keyword is the fixture's cleanup mechanism. Everything before yield is setup; everything after is teardown. If the fixture raises an exception during setup, the teardown code still runs (as long as the exception occurs after yield).
This composability is what makes pytest fixtures qualitatively different from xUnit-style setup. You can layer fixtures, parameterize them, and share them across files via conftest.py without any imports. A fixture can also request other fixtures, creating an implicit dependency graph that pytest resolves at runtime. This is a form of dependency injection that happens to be implemented through Python's function signature inspection rather than a DI container.
Parametrization: Testing Many Inputs Without Repetition
One of the highest-value patterns in testing is verifying that a function works correctly across a range of inputs without writing a separate test for each one. Pytest's @pytest.mark.parametrize decorator handles this cleanly:
import pytest
def celsius_to_fahrenheit(celsius):
return (celsius * 9 / 5) + 32
@pytest.mark.parametrize("celsius, expected", [
(0, 32),
(100, 212),
(-40, -40),
(37, 98.6),
])
def test_celsius_to_fahrenheit(celsius, expected):
assert celsius_to_fahrenheit(celsius) == pytest.approx(expected)
Each tuple generates a separate test case. When one fails, the output tells you exactly which input triggered the failure. The pytest.approx wrapper handles floating-point comparison, which is essential for any test involving decimal arithmetic.
The unittest module added a somewhat analogous feature in Python 3.4 with subTest:
import unittest
class TestConversion(unittest.TestCase):
def test_celsius_to_fahrenheit(self):
cases = [(0, 32), (100, 212), (-40, -40), (37, 98.6)]
for celsius, expected in cases:
with self.subTest(celsius=celsius):
self.assertAlmostEqual(celsius_to_fahrenheit(celsius), expected)
The key difference: without subTest, a failing case in a loop would stop the loop and you would only see the first failure. subTest lets the test continue through all cases and reports each failure individually. But unlike pytest.mark.parametrize, subtests are not separate test items -- they all live inside a single test method, which means you cannot re-run a single failing case in isolation.
Parametrization becomes especially powerful when combined with fixtures. You can parametrize fixtures themselves, meaning every test that uses the fixture automatically runs against every parameter value without any additional markup:
@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
def database_engine(request):
engine = create_engine(request.param)
yield engine
engine.dispose()
def test_insert_and_retrieve(database_engine):
# This test runs three times -- once per database engine
database_engine.execute("INSERT INTO items VALUES (1, 'test')")
result = database_engine.execute("SELECT name FROM items WHERE id = 1")
assert result.fetchone()[0] == "test"
Property-Based Testing with Hypothesis
Traditional tests verify specific examples. Property-based testing verifies that properties hold across a wide range of automatically generated inputs. The Hypothesis library, created by David MacIver, brings this approach to Python and integrates seamlessly with both pytest and unittest.
Instead of writing assert sort([3, 1, 2]) == [1, 2, 3], you write tests that express properties of correct behavior:
from hypothesis import given
from hypothesis.strategies import lists, integers
@given(lists(integers()))
def test_sorting_produces_ordered_output(xs):
result = sorted(xs)
assert all(result[i] <= result[i + 1] for i in range(len(result) - 1))
@given(lists(integers()))
def test_sorting_preserves_length(xs):
assert len(sorted(xs)) == len(xs)
@given(lists(integers()))
def test_sorting_preserves_elements(xs):
assert sorted(sorted(xs)) == sorted(xs)
Hypothesis generates hundreds of test cases, including empty lists, single-element lists, lists with duplicates, lists with extreme values, and lists with negative numbers. When it finds a failing case, it "shrinks" the input to the smallest example that still triggers the failure, making debugging far easier.
The Hitchhiker's Guide to Python (docs.python-guide.org) describes Hypothesis as a tool for writing tests parameterized by generated examples, which then finds simple failing cases to help locate bugs efficiently.
Property-based testing is particularly powerful for parsers, serializers, mathematical functions, and any code where edge cases are hard to enumerate by hand. It is also effective for testing round-trip properties -- for example, verifying that json.loads(json.dumps(x)) produces the original value for arbitrary inputs, or that decompress(compress(data)) always recovers the original data.
Structuring a Test Suite
Pytest's official documentation recommends two project layouts. The first places tests outside the application code:
pyproject.toml
src/
mypackage/
__init__.py
core.py
utils.py
tests/
test_core.py
test_utils.py
The second inlines tests alongside application modules:
pyproject.toml
src/
mypackage/
__init__.py
core.py
tests/
__init__.py
test_core.py
utils.py
tests/
__init__.py
test_utils.py
The pytest documentation notes that for new projects, the recommended approach is to use importlib import mode via configuration (docs.pytest.org):
# pyproject.toml
[tool.pytest.ini_options]
addopts = ["--import-mode=importlib"]
This avoids the subtle sys.path manipulation that pytest's default prepend mode uses, and plays better with modern packaging tools.
The conftest.py file deserves special attention. Pytest automatically loads any conftest.py file in a test directory and makes its fixtures available to all tests in that directory and below. This is the mechanism for sharing fixtures across files without explicit imports:
# tests/conftest.py
import pytest
@pytest.fixture
def sample_config():
return {
"database_url": "sqlite:///:memory:",
"debug": True,
"max_retries": 3,
}
Every test file under tests/ can now use sample_config as an argument without importing anything. Multiple conftest.py files at different directory levels create a fixture hierarchy -- a powerful organizing principle for large test suites.
Coverage: Measuring What You Test
The coverage.py tool, maintained by Ned Batchelder, measures which lines and branches of your code are executed during tests. It integrates with pytest through the pytest-cov plugin:
pytest --cov=mypackage --cov-report=term-missing tests/
The output shows each file, its total line count, the number of lines covered, the coverage percentage, and -- critically -- which specific lines were missed. This last piece is what turns coverage from a vanity metric into a practical debugging tool.
Achieving 100% line coverage does not mean your code is correct -- it means every line was executed at least once during some test. A line can be executed without its output being verified. Branch coverage (enabled with --cov-branch) is more meaningful because it checks that every conditional branch was followed, but even that does not guarantee correctness. A test that executes every path but never asserts anything is 100% covered and 0% useful.
The practical approach: use coverage to find untested code paths, then write targeted tests for the paths that matter. Pay special attention to error-handling branches, which are often the hardest to trigger and the most important to verify.
Coverage.py also supports context tracking, which records which test caused each line to be covered. This helps answer a question that basic coverage can't: "if this line changes, which tests do I need to re-run?" You can enable it with --cov-context=test and inspect the results in the generated HTML report.
Mutation Testing: Testing Your Tests
Coverage tells you whether a line was executed. Mutation testing tells you whether your tests would notice if that line were wrong. The idea is simple: take your source code, make small deliberate changes (called "mutants") -- swap + for -, change == to !=, replace True with False -- and run your test suite against each mutated version. If your tests still pass, that mutant "survived," meaning your tests failed to detect the change.
The primary mutation testing tool for Python is mutmut. Running it is straightforward:
pip install mutmut
mutmut run --paths-to-mutate=mypackage/
After it finishes, mutmut results shows which mutants survived. Each survivor represents a gap in your test suite -- a change to production code that your tests didn't catch. Investigating these survivors often reveals tests that are asserting too broadly, testing the wrong thing, or missing edge cases entirely.
Mutation testing is computationally expensive -- it runs your entire test suite once per mutant. For large codebases, you'll want to target specific modules or use mutmut run --paths-to-mutate=mypackage/critical_module.py to keep runtimes manageable. But the insight it provides is unmatched. A test suite with 95% line coverage and a 40% mutation score is telling you something important: your tests are executing code without actually verifying its behavior.
Test Isolation and the Hidden Coupling Problem
One of the hardest-to-diagnose problems in test suites is hidden coupling between tests. Test A modifies some shared state (a module-level variable, a database row, an environment variable), and Test B implicitly depends on that modification. The tests pass when run together in order and fail when run individually, in a different order, or in parallel.
There are several concrete strategies that go beyond "just be careful":
Use pytest-randomly as a default. This plugin shuffles test execution order on every run. If your tests pass 100 times in a row with random ordering, you have strong evidence of proper isolation. If they fail intermittently, you know you have a coupling problem and pytest-randomly will report the seed that reproduced the failure.
pip install pytest-randomly
pytest -p randomly # enable random ordering
pytest -p randomly --randomly-seed=12345 # reproduce a specific order
Use monkeypatch instead of direct assignment for environment variables. Pytest's built-in monkeypatch fixture automatically restores the original value after each test, even if the test fails:
def test_debug_mode(monkeypatch):
monkeypatch.setenv("DEBUG", "1")
assert get_debug_setting() is True
# DEBUG is automatically restored after this test
Isolate file system operations with tmp_path. Never read from or write to fixed paths in your project directory. The tmp_path fixture gives each test a unique temporary directory that is automatically cleaned up.
Be suspicious of module-level state. If your code uses module-level caches, registries, or singletons, test ordering can leak state. Consider using importlib.reload() in fixtures that need a clean module state, or refactor the production code to accept its dependencies explicitly.
When Tests Fail: Debugging Strategies That Scale
A failing test is only useful if you can efficiently trace it to the root cause. Here are strategies that scale better than "add print statements and run again."
Use --tb=short for fast triage, --tb=long for investigation. Pytest's traceback modes give you control over how much context appears in failure output. In CI, --tb=short keeps logs readable. Locally, --tb=long shows the full call stack.
Drop into the debugger on failure with --pdb. When pytest encounters a failing assertion, it drops you into an interactive pdb session at the point of failure. You can inspect local variables, step through code, and test hypotheses without adding any debug code to your tests:
pytest --pdb tests/test_complex_logic.py::test_edge_case
Use --lf (last failed) for rapid iteration. After a test failure, pytest --lf re-runs only the tests that failed in the previous session. Combined with --pdb, this creates a tight debug loop: fail, inspect, fix, re-run just the failing test.
Use -x to stop on the first failure. In a large test suite, running all tests after finding the first failure wastes time. pytest -x stops immediately, letting you focus on one problem at a time.
Capture and inspect logs with caplog. When debugging complex behavior, examining log output is often more informative than stepping through code line by line:
def test_retry_logic(caplog):
with caplog.at_level(logging.WARNING):
result = make_request_with_retries(flaky_endpoint)
assert "Retry attempt 2" in caplog.text
assert result.status_code == 200
Running unittest Tests with pytest
One of pytest's pragmatic features is backward compatibility with unittest. You can run an existing unittest.TestCase suite with the pytest command without modification. Pytest discovers TestCase subclasses, runs them, and reports results in its own format.
The pytest documentation on running existing unittest suites explains that you can incrementally migrate away from subclassing TestCase to plain asserts, gradually adopting pytest's features at your own pace (docs.pytest.org).
There are limitations. Pytest fixtures (the @pytest.fixture kind) do not inject into unittest.TestCase methods -- only autouse fixtures work. parametrize does not apply to TestCase methods either. But the ability to run both styles in a single test suite makes incremental migration practical. The unittest2pytest tool can automate the mechanical conversion of self.assertEqual calls to plain assert statements, handling the straightforward cases and leaving the ambiguous ones for manual review.
Practical Patterns Worth Adopting
Arrange-Act-Assert (AAA). Structure every test into three phases: set up the preconditions (Arrange), execute the code under test (Act), and verify the outcome (Assert). Blank lines between sections make the structure visible. This is not a pytest or unittest feature -- it is a discipline that makes any test readable. When a test violates AAA by interleaving setup and assertions, it becomes hard to tell what's being tested and what's scaffolding.
One assertion per test (loosely). A test that checks five different things fails with the first failing assertion and hides the rest. Multiple focused tests give you more precise feedback. The exception is when multiple assertions verify different aspects of a single result -- testing both the status code and the body of an HTTP response, for instance.
Test names as documentation. test_calculate_discount_for_loyal_customer_returns_ten_percent is long, but when it fails at 2 AM in CI, you know exactly what broke without opening the file.
Prefer dependency injection over patching. If you can pass a dependency into a function as an argument, do that instead of patching a module-level import. It makes the dependency explicit, makes the test simpler, and makes the production code more flexible. Patching should be a last resort for code you don't control, not a first choice for code you do.
Use tmp_path for filesystem tests. Pytest provides a built-in tmp_path fixture that gives each test a unique temporary directory. Never write tests that read from or write to fixed paths in your project directory.
Keep test data close to tests. Use the pytest-datadir plugin or a dedicated fixtures/ directory within your test tree. Avoid hardcoding large data structures inline -- extract them into files or factory functions. If multiple tests need the same complex data structure, a fixture is the right abstraction, not copy-paste.
Anti-Patterns That Erode Trust in Tests
Testing implementation, not behavior. A test that asserts the exact sequence of internal method calls is brittle -- any refactoring that changes internal structure (without changing behavior) breaks the test. Test the observable outputs of your code: return values, side effects, state changes, exceptions raised. If you find yourself writing tests that read like a line-by-line mirror of the production code, step back and ask what behavior the user of this code actually cares about.
Over-mocking. When a test replaces every dependency with a mock, it tests the wiring between mocks rather than actual behavior. If you mock the database, the HTTP client, the file system, and the logger, what's left to test? The answer is often "just the glue code," which is rarely where bugs live. Reserve mocking for boundaries you truly cannot control -- external APIs, system clocks, random number generators -- and let internal dependencies exercise their real implementations.
Flaky tests that are accepted as normal. A test that fails intermittently and is routinely re-run teaches the team to ignore test failures. Every flaky test should either be fixed (usually a test isolation issue) or quarantined with @pytest.mark.skip(reason="Flaky: tracking in JIRA-1234") until the root cause is addressed. pytest-rerunfailures can mask flakiness in CI, but it should be a temporary measure, not a permanent fixture.
Tests without assertions. A test that calls a function without asserting anything only proves the function doesn't raise an exception. It does not verify that the function produces correct output. Coverage reports will show 100% coverage for that code path, giving false confidence. Every test should have at least one meaningful assertion.
Ignoring test performance. A test suite that takes 30 minutes to run is a test suite that developers stop running locally. Use pytest --durations=10 to find your slowest tests, and consider whether they can be sped up (by using in-memory databases instead of disk-based ones, by reducing fixture scope, or by mocking truly slow operations like network I/O). The pytest-xdist plugin can parallelize test execution across multiple CPU cores with a single flag: pytest -n auto.
The Ecosystem Today
Python's testing ecosystem is mature and stable. The unittest module continues to receive updates in each Python release -- Python 3.4 added subTest, Python 3.7 added the mock.seal() function to prevent setting attributes on mocks not in the spec, and recent versions have improved async testing support. Pytest, now at version 9.x (pypi.org/project/pytest), remains the dominant testing framework on PyPI and lists hundreds of active plugins on its official plugin page (docs.pytest.org/en/stable/reference/plugin_list.html). The pytest-cov plugin alone sees tens of millions of monthly downloads. Hypothesis has grown into the standard tool for property-based testing in Python. Coverage.py supports branch coverage, context tracking, and integration with every major CI system.
Beyond the core tools, the ecosystem includes pytest-xdist for parallel test execution, pytest-asyncio for testing async code, pytest-django and pytest-flask for web framework integration, pytest-bdd for behavior-driven development, and factory_boy for creating test fixtures that mirror complex domain objects. Each solves a specific problem, and all integrate cleanly with the pytest runner.
The tooling is there. The frameworks are battle-tested. The only variable left is whether you write the tests. Start with the function that scares you the most -- the one with the complex branching, the one that processes untrusted input, the one that calls three external services. That is where the first test pays for itself. Then write the second test. Then the third. The compound effect of a growing test suite is not just fewer bugs -- it is the confidence to refactor, to delete dead code, to say "yes" to a feature request without wondering what it will break.