Naming
Use descriptive test method names
Test names should read like a sentence describing the scenario and expected outcome. Follow the pattern:
test_<unit>_<scenario>_<expected>
def test_divide_by_zero_raises_value_error(self): ... def test_login_with_invalid_password_returns_false(self): ... def test_cart_adds_item_updates_total(self): ...
def test_divide(self): ... def test_login2(self): ... def test_stuff(self): ...
Name test classes after the unit under test
The class name should mirror the module or class being tested, prefixed with Test.
class TestUserService(unittest.TestCase): ... class TestPaymentProcessor(unittest.TestCase): ...
class MyTests(unittest.TestCase): ... class Tests(unittest.TestCase): ...
Structure (AAA Pattern)
Follow Arrange → Act → Assert
Separate test phases visually with blank lines and optional inline comments. Each test should verify one logical behaviour.
def test_apply_discount_reduces_price_by_percentage(self): # Arrange product = Product(name="Widget", price=100.0) discount = Discount(percentage=20) # Act result = discount.apply(product) # Assert self.assertEqual(result.price, 80.0)
def test_discount(self): product = Product("Widget", 100.0) self.assertEqual(product.price, 100.0) discount = Discount(20) result = discount.apply(product) self.assertEqual(result.price, 80.0) self.assertEqual(result.name, "Widget")
Multiple unrelated assertions in one test
When a test fails, you want to know exactly what broke. Multiple concerns make failures ambiguous.
test_login_succeeds_and_sets_session_and_logs_event — it should be three separate tests.
Assertions
Use specific assertion methods
self.assertIsNone(result) self.assertIn("admin", roles) self.assertIsInstance(obj, MyClass) self.assertAlmostEqual(x, 3.14, places=2) self.assertGreater(count, 0)
self.assertTrue(result is None) self.assertTrue("admin" in roles) self.assertTrue(type(obj) == MyClass) self.assertTrue(abs(x - 3.14) < 0.01) self.assertTrue(count > 0)
Common assertion quick reference
| Assertion | Use When |
|---|---|
| assertEqual(a, b) | Equality check |
| assertNotEqual(a, b) | Inequality check |
| assertTrue(x) / assertFalse(x) | Boolean truthiness only — not equality |
| assertIsNone(x) / assertIsNotNone(x) | None checks — never use assertEqual(x, None) |
| assertIn(a, b) / assertNotIn(a, b) | Membership in collection |
| assertRaises(exc) | Expected exception (use as context manager) |
| assertAlmostEqual(a, b) | Floating point comparisons |
| assertDictEqual / assertListEqual | Detailed diff output on failure |
| assertRegex(text, regex) | String pattern matching |
Add failure messages to ambiguous assertions
self.assertEqual( response.status_code, 200, msg=f"Expected 200 OK but got {response.status_code}: {response.text}" )
Setup & Teardown
Understand the four lifecycle hooks
| Method | Runs | Use For |
|---|---|---|
| setUpClass(cls) | Once before all tests in class | Expensive shared resources (DB connections, servers) |
| setUp(self) | Before each test | Fresh object instances, test-specific state |
| tearDown(self) | After each test | Cleaning up resources, resetting state |
| tearDownClass(cls) | Once after all tests in class | Shutting down shared resources |
class TestOrderService(unittest.TestCase): @classmethod def setUpClass(cls): cls.db = create_test_database() # expensive — do once def setUp(self): self.service = OrderService(self.db) # fresh per test self.db.clear_orders() def tearDown(self): self.service.cleanup() @classmethod def tearDownClass(cls): cls.db.destroy()
Shared mutable state between tests
Tests must be independent. A passing test suite should pass in any order. Never rely on test execution order.
Mocking
Prefer constructor injection over patching
If a class accepts its dependencies via __init__, pass mocks
there. This is the cleanest, most type-safe approach — no string-based patching, no import-path fragility.
class NotificationService: def __init__(self, mailer: Mailer) -> None: self.mailer = mailer # In test setUp — just pass the mock in self.mock_mailer = Mailer(MagicMock()) # real type, mocked internals self.service = NotificationService(mailer=self.mock_mailer)
Use patch.object when injection is not possible
When a dependency cannot be injected (e.g. a module-level function, a third-party object created
internally, or a class you don't own), use patch.object. Always
use the .__name__ attribute to reference the function — never
hardcode the string name. This way a rename is caught immediately by the interpreter.
from unittest.mock import patch, MagicMock def test_connect_calls_broker(self) -> None: with patch.object( MqttClient, MqttClient.connect.__name__, ) as mock_connect: self.service.connect() mock_connect.assert_called_once()
with patch.object( MqttClient, "connect", # silent typo / rename risk ) as mock_connect: self.service.connect() mock_connect.assert_called_once()
patch("myapp.module.ClassName.method") form is banned. It encodes the full import path as a
string literal, which silently breaks on any refactor.
patch.object(TheClass, TheClass.method.__name__) is the only permitted form.
Over-mocking implementation details
Mock external dependencies (network, filesystem, time, 3rd-party SDKs), not every internal function call. Mocking internals couples tests to implementation and makes refactoring painful.
Organization
Mirror source structure in test layout
myproject/
├── src/
│ ├── myapp/
│ │ ├── services/
│ │ │ ├── user_service.py
│ │ │ └── payment_service.py
│ │ └── utils/
│ │ └── validators.py
└── tests/
├── unit/
│ ├── services/
│ │ ├── test_user_service.py
│ │ └── test_payment_service.py
│ └── utils/
│ └── test_validators.py
└── integration/
└── test_checkout_flow.py
Group related tests with inner classes
When a class under test has distinct behaviours, organise with inner test classes.
class TestUserAuth(unittest.TestCase): class WhenCredentialsAreValid(unittest.TestCase): def test_returns_auth_token(self): ... def test_logs_successful_login(self): ... class WhenPasswordIsWrong(unittest.TestCase): def test_raises_auth_error(self): ... def test_increments_failed_attempts(self): ...
Testing Exceptions
Use assertRaises as a context manager
def test_divide_by_zero_raises_value_error(self): calc = Calculator() with self.assertRaises(ValueError) as ctx: calc.divide(10, 0) self.assertIn("zero", str(ctx.exception))
def test_divide_by_zero(self): self.assertRaises( ValueError, Calculator().divide, 10, 0 ) # Can't inspect exception message
Coverage & Quality
Coverage guidelines
| Level | Guidance |
|---|---|
| Below 60% | Critical paths likely untested — increase coverage |
| 60–80% | Acceptable for early-stage or prototype code |
| 80–90% | Good target for most production codebases |
| 90%+ | High confidence; watch for diminishing returns on trivial code |
| 100% | Rarely worth pursuing — test logic, not getters/setters |
Pre-commit checklist
TestCase Base Classes
Use TestCase for synchronous code
All test classes must subclass unittest.TestCase. This gives you
the full assertion API, lifecycle hooks, and proper test discovery.
import unittest from myapp.services.user_service import UserService class TestUserService(unittest.TestCase): def setUp(self) -> None: self.service = UserService() def test_get_user_by_id_returns_user(self) -> None: result = self.service.get_user(1) self.assertIsNotNone(result)
Use IsolatedAsyncioTestCase for async code
For any async def test, subclass unittest.IsolatedAsyncioTestCase. It manages its own event loop per
test, ensuring full isolation. Never mix sync and async test methods in the same class.
import unittest from myapp.services.order_service import OrderService class TestOrderService( unittest.IsolatedAsyncioTestCase ): async def asyncSetUp(self) -> None: self.service = OrderService() async def test_place_order_returns_order_id( self ) -> None: result = await self.service.place_order(...) self.assertIsNotNone(result.id)
class TestOrderService(unittest.TestCase): # asyncio.run() in tests is fragile def test_place_order(self) -> None: result = asyncio.run( self.service.place_order(...) ) self.assertIsNotNone(result.id)
asyncSetUp / asyncTearDown
alongside or instead of setUp / tearDown when setup itself needs to await
coroutines.
Bare test functions outside a class
Standalone def test_*() functions have no setUp/tearDown, no
assertion helpers, and weaker isolation. Always use a class.
No Private Access in Tests
Accessing _protected or __private members
Accessing private or protected attributes couples your tests to the internal implementation. Refactoring internals — without changing public behaviour — should never break a test.
def test_cache_hit_returns_cached_value(self) -> None: # Call public method twice, observe public result first = self.service.get_user(1) second = self.service.get_user(1) self.assertEqual(first, second) # We proved caching works without touching _cache
def test_cache_stores_result(self) -> None: self.service.get_user(1) # Directly reading private state — fragile self.assertIn(1, self.service._cache) self.assertEqual( self.service.__connection.call_count, 1 )
Patching private methods to control behaviour
Similarly, never patch a private method to change how the unit
under test behaves. If you need to control a code path, inject the dependency instead.
class ReportService: def __init__(self, renderer: Renderer) -> None: self.renderer = renderer # In test — supply a mock via constructor mock_renderer = create_autospec(Renderer) service = ReportService(renderer=mock_renderer)
with patch.object( self.service, "_render_pdf", # private — don't touch return_value=b"..." ): result = self.service.generate_report()
Type-Safe Mocking Patterns
Pattern A — 2nd-level dep: pass MagicMock() into the real constructor
When your direct dependency (MqttClient) has its own internal
dependency (e.g. a socket, config, or transport), pass a MagicMock() into that constructor. The result is a real,
typed MqttClient instance — your class under test sees
the correct type — while the internals are fully stubbed out.
# 1st level — injected directly into class under test class MqttClient: def __init__(self, transport: Transport) -> None: self.transport = transport # 2nd-level dep def connect(self) -> None: ... def publish(self, topic: str, payload: str) -> None: ... # Class under test class TelemetryService: def __init__(self, mqtt: MqttClient) -> None: self.mqtt = mqtt
def setUp(self) -> None: # MagicMock() satisfies Transport — MqttClient stays a real typed object self.mqtt_client = MqttClient(MagicMock()) self.service = TelemetryService(mqtt=self.mqtt_client)
MagicMock() as mqtt
directly, your class under test would silently accept any attribute access or method call, masking type
errors. With a real MqttClient, the type contract is enforced by the actual class.
Pattern B — Replace individual methods on the typed object in the test
When a specific test needs to control or assert a particular method call, assign a MagicMock() to that method directly on the already-typed object. The
object stays a real MqttClient — only the one method is replaced.
This preserves typing while giving full control over that method's behaviour and call assertions.
def test_connect_calls_mqtt_connect(self) -> None: # Arrange — replace just this one method self.mqtt_client.connect = MagicMock() # Act self.service.connect() # Assert self.mqtt_client.connect.assert_called_once() def test_send_telemetry_publishes_to_topic(self) -> None: # Arrange — replace only the method this test asserts self.mqtt_client.publish = MagicMock() # Act self.service.send_telemetry("temp", "42.0") # Assert self.mqtt_client.publish.assert_called_once_with( topic="sensors/temp", payload="42.0" )
def test_connect_calls_mqtt_connect(self) -> None: # Replaces MqttClient with a typeless MagicMock self.service.mqtt = MagicMock() self.service.connect() # Passes even if method signature changes self.service.mqtt.connect.assert_called_once()
Decision tree — which pattern to use?
| Situation | Pattern |
|---|---|
| Direct dep has its own constructor dep (2nd level) | A — RealClass(MagicMock()) in setUp |
| Need to assert/control a specific method call | B — self.obj.method = MagicMock() in test |
| Cannot inject at all (internal creation, 3rd party) | patch.object(Cls, Cls.method.__name__) |
Mock Placement: setUp vs Test Method
Create objects and background mocks in setUp
Build the real typed objects (with MagicMock() for 2nd-level
deps) in setUp. Also configure any method stubs needed to keep the
code running in tests that don't care about those methods.
def setUp(self) -> None: # Real typed object — MagicMock() satisfies the 2nd-level Transport dep self.mqtt_client = MqttClient(MagicMock()) self.service = TelemetryService(mqtt=self.mqtt_client)
Replace only the asserted method inside the test method
In each test that asserts a specific method call, replace that single method with a MagicMock() at the top of the test's Arrange block. This makes the
test self-contained — the reader sees exactly what is being controlled and verified without having to look
elsewhere.
def test_connect_calls_mqtt_connect(self) -> None: # Arrange — replace only the method this test asserts self.mqtt_client.connect = MagicMock() # Act self.service.connect() # Assert self.mqtt_client.connect.assert_called_once() def test_send_telemetry_publishes_correct_topic(self) -> None: # Arrange — different method, same object self.mqtt_client.publish = MagicMock() # Act self.service.send_telemetry("temp", "42.0") # Assert self.mqtt_client.publish.assert_called_once_with( topic="sensors/temp", payload="42.0" )
def setUp(self) -> None: self.mqtt_client = MqttClient(MagicMock()) # Bad: replacing methods we will later assert on self.mqtt_client.connect = MagicMock() self.mqtt_client.publish = MagicMock() self.service = TelemetryService(mqtt=self.mqtt_client) def test_connect_calls_mqtt_connect(self) -> None: self.service.connect() # Reader must scroll to setUp to understand what mock_connect is self.mqtt_client.connect.assert_called_once()
Summary — what goes where
| What | Where |
|---|---|
Real typed objects (RealClass(MagicMock())) |
setUp |
| Class under test construction | setUp |
| Background method stubs (not asserted) | setUp |
| Method replacement for the method being asserted | Test method — Arrange block |
| patch.object (when injection not possible) | Test method — as context manager around Act |
Full Example
Rules quick-reference — paste into your team wiki or PR template
# ── Class structure ────────────────────────────────────────────────
# 1. Always subclass unittest.TestCase (sync) or
# unittest.IsolatedAsyncioTestCase (async).
# Class name: Test<OriginalClass> e.g. TestMqttClient, TestOrderService
# 2. Never write bare test functions outside a class.
# ── Public API only ─────────────────────────────────────────────────
# 3. Never access _protected or __private members in tests.
# 4. Never patch private methods. Inject the dependency instead.
# ── Mocking strategy ────────────────────────────────────────────────
# 5. Prefer constructor injection over patching.
# 6. For 2nd-level deps: pass MagicMock() into the real constructor
# so the outer object stays fully typed.
# e.g. self.mqtt = MqttClient(MagicMock())
# 7. To assert a specific method call: replace only that method on
# the typed object — inside the test method, not setUp.
# e.g. self.mqtt.connect = MagicMock()
# 8. Use patch.object ONLY when constructor injection is impossible.
# Mandatory form: patch.object(TheClass, TheClass.method.__name__)
# Never use the string-path form: patch("a.b.c.method")
# ── Mock placement ──────────────────────────────────────────────────
# 9. setUp → build typed objects + background stubs (not asserted)
# 10. test → replace only the one method being asserted (Arrange)
# ── Test structure ──────────────────────────────────────────────────
# 11. Follow Arrange → Act → Assert in every test.
# 12. One logical behaviour per test.
# 13. Use the most specific assertion method available.
# 14. Use assertRaises as a context manager for exception tests.
Annotated test file
The scenario: a TelemetryService depends on an MqttClient, which in turn takes a Transport. We test three behaviours.
import unittest from unittest.mock import MagicMock, patch from myapp.mqtt.client import MqttClient # 1st-level dep from myapp.telemetry.service import TelemetryService # ① subclass TestCase class TestTelemetryService(unittest.TestCase): def setUp(self) -> None: # ② Pattern A — real typed MqttClient, # MagicMock() satisfies its 2nd-level Transport dep self.mqtt_client = MqttClient(MagicMock()) # ③ Inject the typed object — no patching needed self.service = TelemetryService(mqtt=self.mqtt_client) # ──────────────────────────────────────────────────────────────── def test_connect_calls_mqtt_connect(self) -> None: # ④ Pattern B — replace only the method this test asserts self.mqtt_client.connect = MagicMock() # Arrange self.service.connect() # Act self.mqtt_client.connect.assert_called_once() # Assert # ──────────────────────────────────────────────────────────────── def test_send_telemetry_publishes_to_correct_topic(self) -> None: # ④ Different method — same pattern, stays in this test self.mqtt_client.publish = MagicMock() # Arrange self.service.send_telemetry("temp", "42.0") # Act self.mqtt_client.publish.assert_called_once_with( # Assert topic="sensors/temp", payload="42.0", ) # ──────────────────────────────────────────────────────────────── def test_connect_without_broker_raises_connection_error(self) -> None: # ⑤ patch.object — only because MqttClient.connect # internally creates the socket; can't inject it with patch.object( # Arrange MqttClient, MqttClient.connect.__name__, # ⑥ no string literal side_effect=ConnectionError("unreachable"), ): with self.assertRaises(ConnectionError): # Assert wraps Act self.service.connect() # Act if __name__ == "__main__": unittest.main()
unittest.TestCase — all sync tests live here. Async counterpart:
IsolatedAsyncioTestCase.
MqttClient(MagicMock()) keeps the object fully typed. MagicMock() satisfies Transport so the constructor doesn't fail.
patch needed. TelemetryService receives the real, typed MqttClient.
MqttClient. Each test
replaces its own method independently.
patch.object used only because the socket is created internally —
injection is not possible here.
MqttClient.connect.__name__ instead of the string "connect" — a rename is caught by the interpreter immediately.
Parameterized Tests
Duplicating test methods for different inputs
Copy-pasted tests that differ only in their input values bloat the file, are easy to forget when adding a new case, and hide the fact that they are testing the same behaviour.
def test_validate_email_accepts_simple_address(self) -> None: self.assertTrue(validate_email("user@example.com")) def test_validate_email_accepts_subdomain(self) -> None: self.assertTrue(validate_email("user@mail.example.com")) def test_validate_email_rejects_missing_at(self) -> None: self.assertFalse(validate_email("userexample.com")) def test_validate_email_rejects_empty_string(self) -> None: self.assertFalse(validate_email(""))
Option A — subTest() (stdlib, no extra dependency)
self.subTest() is built into unittest.TestCase. Each iteration is reported independently — a
failure in one case does not stop the others from running. Use this when you want zero extra dependencies.
def test_validate_email_accepts_valid_addresses(self) -> None: valid_cases = [ ("simple", "user@example.com"), ("subdomain", "user@mail.example.com"), ("plus-tag", "user+tag@example.com"), ] for label, email in valid_cases: with self.subTest(case=label, email=email): self.assertTrue(validate_email(email)) def test_validate_email_rejects_invalid_addresses(self) -> None: invalid_cases = [ ("no-at", "userexample.com"), ("empty", ""), ("no-domain", "user@"), ] for label, email in invalid_cases: with self.subTest(case=label, email=email): self.assertFalse(validate_email(email))
FAIL: test_validate_email_rejects_invalid_addresses (case='no-at', email='userexample.com') —
so you know exactly which case broke.
Option B — @parameterized.expand (3rd-party, generates named test methods)
The parameterized library expands each case into its own named
test method at class-creation time. This means each case appears as a separate entry in test runners, CI
reports, and IDE test trees — exactly like a hand-written test method would.
pip install parameterized
from parameterized import parameterized class TestEmailValidator(unittest.TestCase): @parameterized.expand([ ("simple_address", "user@example.com", True), ("subdomain", "user@mail.example.com", True), ("plus_tag", "user+tag@example.com", True), ("no_at_sign", "userexample.com", False), ("empty_string", "", False), ("no_domain", "user@", False), ]) def test_validate_email(self, _name: str, email: str, expected: bool) -> None: self.assertEqual(validate_email(email), expected)
test_validate_email_0_simple_address, test_validate_email_3_no_at_sign — each
individually runnable, filterable, and visible in CI. The first tuple element is the human-readable label;
keep it a valid Python identifier (underscores, no spaces).
When to use which
| Need | Use |
|---|---|
| Zero extra dependencies | subTest() |
| Each case visible as its own test in CI / IDE | @parameterized.expand |
| Run a single failing case in isolation | @parameterized.expand (method has a name) |
| Quick boundary-value check inside one test | subTest() |
| Large input matrix shared across the team | @parameterized.expand |