Testing best practices¶
This page collects the practices we recommend for Python tests in a Lime CRM solution or package. Tests written this way verify what the code does, not how the code is written, so they keep passing when the code is refactored and fail when the behavior changes.
Test the public interface, not internals¶
- Drive a test through the same entry points that production callers use: public functions, methods, and endpoints.
- Assert on observable outcomes: return values, state changes, and side effects.
- Do not access private attributes, call underscore-prefixed helpers, or assert on internal data structures. Such access couples the test to details that are free to change.
- If a refactor that preserves behavior breaks a test, the test verified the implementation, not the behavior.
- If something is hard to exercise through the public interface, change the design instead of accessing internals. Extract a collaborator, or move the logic to a higher-level interface.
Structure tests as arrange, act, assert¶
A test has three sections, separated by a blank line: arrange the starting state, act by exercising the code under test, and assert the outcome.
def test_person_is_saved(lime_app, save_lime_objects):
person = lime_app.limetypes.person(firstname="Alice")
saved = save_lime_objects(person)
assert saved.id is not None
- Each section does only its own job. Do not place assertions in the arrange section, and do not act again between assertions.
- A unit test has exactly one act section. If it needs a second one, it is more than one test, so split it.
- An integration test can contain several arrange, act, assert blocks when they form one scenario and a later block depends on the state an earlier block left. Start each block after the first with a comment that names the step.
Keep each test focused on one behavior¶
- A unit test verifies one observable behavior. A test that can fail for unrelated reasons verifies more than one, so split it.
- Do not put control flow in a test: no
if, no loop that branches on the case, and notrythat decides what gets asserted. A branch means two test cases in one, and only one of them runs. - When several tests share the same shape and differ only in inputs and expected outputs, use
pytest.mark.parametrize. Each case then becomes its own named test that passes or fails on its own.
import pytest
@pytest.mark.parametrize(
("values", "expected"),
[
([2, 4], 3),
([5], 5),
([1, 2, 6], 3),
],
)
def test_average_of_deal_values(lime_app, values, expected):
deals = [lime_app.limetypes.deal(value=value) for value in values]
result = average(deals, "value")
assert result == expected
The average function in this example is the one from Writing tests.
Make assertions traceable¶
- An assertion should be traceable to the values introduced in the arrange section and to the act section.
- Do not assert on unrelated literals.
- Do not compute the expected value by running the production logic again. Such an assertion verifies the implementation against itself.
Set up only what the test needs¶
- Set up exactly the state the assertion depends on, and nothing more. Every test adds to the run time of the whole suite.
- If two objects make the behavior observable, do not create ten. Extra objects make the test slower and hide which state the assertion depends on.
- Use a lighter fixture when it suffices. Do not use
lime_appwhen the code under test only needs a plain object.
Make test names self-explanatory¶
- Name a test after the scenario: the relevant starting state, the action, and the expected outcome.
- A reader should be able to tell what the test covers without opening the body. Long names are fine.
- Do not add a docstring that restates the name. Add one only to state a reason that the name cannot carry.
| Avoid | Prefer |
|---|---|
test_delete |
test_non_admin_user_cannot_delete_company |
test_average |
test_average_of_deal_values |
Keep fixtures named and focused¶
- Name a fixture after the specific thing it provides.
- Keep each fixture focused on a single responsibility.
- Use the default function scope. Use a wider scope only for setup that is expensive and that no test changes.
- Use
yieldfor teardown when a fixture allocates resources. - Before writing a fixture, look for an existing one in the
conftest.pyfiles of the project and among thelime-testfixtures. - Put a fixture in the test module that uses it. Move it to a
conftest.pyonly when several modules use it, and choose the closestconftest.pythat covers all of them.
Replace dependencies with fakes, not mocks¶
The Lime CRM platform wires services together with a dependency injection container, and that shapes how you should write test doubles. The guiding principle is to keep tests connected to real behavior: prefer real objects, inject test doubles through the container rather than patching, and reach for mocking only at genuine external boundaries.
Prefer real objects over mocks¶
Don't mock what you can pass in or inject. If a dependency can be provided as a parameter or through a fixture, do that instead of reaching for unittest.mock or monkeypatch.
When you do need a test double, write a fake — a small implementation of the interface — rather than a mock. Reserve mocking for true external boundaries (network calls, third-party services) where no other option exists.
import abc
class MyService(abc.ABC):
@abc.abstractmethod
def get_value(self) -> int: ...
class FakeMyService(MyService):
def __init__(self, value: int):
self._value = value
def get_value(self) -> int:
return self._value
@pytest.fixture
def my_service() -> MyService:
return FakeMyService(value=42)
def test_uses_service_value(my_service):
result = compute(my_service)
assert result == 42
A fake stays honest as the interface evolves — if MyService gains a new abstract method, the fake fails to instantiate until it implements it, whereas a Mock(spec=MyService) would silently keep passing.
Swap injected services through the container¶
When the code under test resolves a dependency from the DI container, override that registration for the test instead of patching the implementation. Define a lime_test_component_registrations__ fixture that returns the replacement registration — lime-test collects it and registers it in the container for the duration of the test.
from lime_core.dependencies import (
ComponentRegistration,
ComponentRegistrationMeta,
)
@pytest.fixture
def lime_test_component_registrations__fake_my_service(
request,
) -> list[ComponentRegistration]:
return [
ComponentRegistration(
service=MyService,
factory=lambda: FakeMyService(getattr(request, "param", 42)),
lifestyle="scoped",
meta=ComponentRegistrationMeta(label="test"),
)
]
This keeps production wiring untouched: the application still resolves MyService from the container, it just receives the fake. The request.param indirection lets a single fixture serve many tests through indirect parametrization. See lime_test_component_registrations for activation, naming variants, and the difference between replacing a registration and adding a new one.
Warning
meta=ComponentRegistrationMeta(label="test") replaces an existing registration. If the service is not already registered by the service being bootstrapped, the registration is silently dropped — omit meta to register a brand-new service instead.
Spies are fine — replacing behavior is not¶
Sometimes you need to verify that a dependency was called with the right arguments, or the right number of times. A spy (for example mocker.spy) is fine for this, because the real implementation still runs. What to avoid is replacing behavior with MagicMock, Mock, or patch — that disconnects the test from reality, so nothing production-like actually executes.
# Good — the spy wraps the real method, production code still runs
def test_service_is_queried_once(mocker, my_service):
spy = mocker.spy(my_service, "get_value")
compute(my_service)
spy.assert_called_once()
# Bad — MagicMock replaces behavior, nothing real runs
def test_service_is_queried_once():
my_service = MagicMock(spec=MyService)
compute(my_service)
my_service.get_value.assert_called_once()
Changing the environment is setup, not mocking¶
Setting environment variables or the working directory with monkeypatch.setenv, monkeypatch.delenv, or monkeypatch.chdir prepares the environment the code runs in. It does not replace behavior, so it is fine to use.
Related pages¶
- Writing tests introduces
lime-testand the fixtures used in these examples. - Running tests explains where tests live and how to run them.
- Integration tests explains how to test against SQL Server.