// Style Guide v1.5

Python unittest
Style Guide

Conventions and patterns for writing clear, maintainable, and reliable Python unit tests.

// 01

Naming

Names are the first documentation a reader sees. Make them count.
DO

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>

✓ Good
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):
    ...
✗ Avoid
def test_divide(self):
    ...

def test_login2(self):
    ...

def test_stuff(self):
    ...
DO

Name test classes after the unit under test

The class name should mirror the module or class being tested, prefixed with Test.

✓ Good
class TestUserService(unittest.TestCase):
    ...

class TestPaymentProcessor(unittest.TestCase):
    ...
✗ Avoid
class MyTests(unittest.TestCase):
    ...

class Tests(unittest.TestCase):
    ...
// 02

Structure (AAA Pattern)

Every test should follow Arrange → Act → Assert. One assertion focus per test.
DO

Follow Arrange → Act → Assert

Separate test phases visually with blank lines and optional inline comments. Each test should verify one logical behaviour.

✓ Good — clear AAA structure
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)
✗ Avoid — interleaved setup and assertions
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")
AVOID

Multiple unrelated assertions in one test

When a test fails, you want to know exactly what broke. Multiple concerns make failures ambiguous.

Rule of thumb: if you find yourself writing "and" in a test name — test_login_succeeds_and_sets_session_and_logs_event — it should be three separate tests.
// 03

Assertions

Always use the most specific assertion available for better failure messages.
DO

Use specific assertion methods

✓ Specific & informative
self.assertIsNone(result)
self.assertIn("admin", roles)
self.assertIsInstance(obj, MyClass)
self.assertAlmostEqual(x, 3.14, places=2)
self.assertGreater(count, 0)
✗ Generic & unhelpful on failure
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)
REF

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
DO

Add failure messages to ambiguous assertions

✓ Good
self.assertEqual(
    response.status_code, 200,
    msg=f"Expected 200 OK but got {response.status_code}: {response.text}"
)
// 04

Setup & Teardown

Use the right lifecycle method for the right scope.
DO

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
// Example
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()
AVOID

Shared mutable state between tests

Tests must be independent. A passing test suite should pass in any order. Never rely on test execution order.

Signal of a problem: if tests pass individually but fail when run together, you have shared state leaking between tests.
// 05

Mocking

Always prefer constructor injection. Use patch only when injection is not possible — and only in the exact form shown.
DO

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.

✓ Inject — no patch needed
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)
DO

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.

✓ patch.object with .__name__
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()
✗ Hardcoded string — breaks on rename
with patch.object(
    MqttClient,
    "connect",   # silent typo / rename risk
) as mock_connect:
    self.service.connect()
    mock_connect.assert_called_once()
patch.object only — never patch(). The string-based 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.
AVOID

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.

// 06

Organization

Mirror your source tree. Keep tests close to the code they test.
DO

Mirror source structure in test layout

// Recommended 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
DO

Group related tests with inner classes

When a class under test has distinct behaviours, organise with inner test classes.

✓ Good
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): ...
// 07

Testing Exceptions

Always use assertRaises as a context manager to be precise about what raises.
DO

Use assertRaises as a context manager

✓ Precise — only the right line raises
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))
✗ Catches too broadly
def test_divide_by_zero(self):
    self.assertRaises(
        ValueError,
        Calculator().divide,
        10, 0
    )
    # Can't inspect exception message
// 08

Coverage & Quality

Coverage measures what's exercised, not what's actually tested. Aim for meaningful tests, not metrics.
NOTE

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
REF

Pre-commit checklist

✓ Test name describes scenario ✓ AAA structure visible ✓ One concern per test ✓ Specific assertion methods ✓ setUp resets state ✓ Externals are mocked ✓ Exceptions tested as context manager ✓ Tests pass in isolation ✓ No print() or debug code ✓ No time.sleep() in tests ✓ Extends TestCase or IsolatedAsyncioTestCase ✓ No private access (_x / __x) ✓ Direct dep: RealClass(MagicMock()) in setUp ✓ Asserted method replaced in test method only ✓ patch.object uses .__name__ — no string literals ✓ patch only used when constructor injection impossible
// 09

TestCase Base Classes

Always subclass the right base. Never write bare test functions outside a TestCase.
DO

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.

✓ Good
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)
DO

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.

✓ Async tests
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)
✗ Don't mix sync base with async tests
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)
Lifecycle hooks for async: use asyncSetUp / asyncTearDown alongside or instead of setUp / tearDown when setup itself needs to await coroutines.
AVOID

Bare test functions outside a class

Standalone def test_*() functions have no setUp/tearDown, no assertion helpers, and weaker isolation. Always use a class.

// 10

No Private Access in Tests

Tests must only interact with the public API. Private internals are implementation details.
AVOID

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.

✓ Test via the public API
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
✗ Reaching into internals
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
    )
If you feel the urge to access a private member in a test, it usually signals one of two things: the behaviour you want to assert isn't exposed publicly (add a public method or property), or the class is doing too much and should be split.
AVOID

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.

✓ Inject the dependency
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)
✗ Patching private internals
with patch.object(
    self.service,
    "_render_pdf",   # private — don't touch
    return_value=b"..."
):
    result = self.service.generate_report()
// 11

Type-Safe Mocking Patterns

Preserve the type of every object in the test. Two patterns cover all cases.
DO

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.

// Dependency chain
# 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
✓ Real MqttClient type, mocked 2nd-level dep
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)
Why this matters: if you passed a plain 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.
DO

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.

✓ Replace only the method under test
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"
    )
✗ Replacing the whole object loses the type
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()
NOTE

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__)
// 12

Mock Placement: setUp vs Test Method

Where you set up a mock signals its role. Dependencies that keep code running belong in setUp. The method you are asserting is configured in the test itself.
DO

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.

✓ setUp — construction and background stubs only
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)
DO

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.

✓ Method-under-test replaced in the test itself
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"
    )
✗ Asserted method replaced in setUp — intent is hidden
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()
NOTE

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
// 13

Full Example

A complete test file applying every rule in this guide — with a copyable rules summary and an annotated walk-through.
COPY

Rules quick-reference — paste into your team wiki or PR template

// Python unittest rules
# ── 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.
EXAMPLE

Annotated test file

The scenario: a TelemetryService depends on an MqttClient, which in turn takes a Transport. We test three behaviours.

✓ telemetry_service_test.py — full file
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()
Subclasses unittest.TestCase — all sync tests live here. Async counterpart: IsolatedAsyncioTestCase.
Pattern A — 2nd-level dep. MqttClient(MagicMock()) keeps the object fully typed. MagicMock() satisfies Transport so the constructor doesn't fail.
Constructor injection — no patch needed. TelemetryService receives the real, typed MqttClient.
Pattern B — replace only the one method under assertion, inside the test. The object is still a real 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.
// 14

Parameterized Tests

When the same logic must run against multiple inputs, parameterize — never copy-paste test methods.
AVOID

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.

✗ Repeated logic — hard to maintain
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(""))
DO

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.

✓ subTest — stdlib, all cases always run
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))
Failure output: when a subTest case fails, the label and input values appear in the failure message — e.g. FAIL: test_validate_email_rejects_invalid_addresses (case='no-at', email='userexample.com') — so you know exactly which case broke.
DO

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.

// Install
pip install parameterized
✓ @parameterized.expand — each case is a distinct test method
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)
Generated method names: the decorator produces methods named e.g. 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).
NOTE

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