Test Conventions
Comprehensive reference for writing tests in this repository. Applies to all test types: unit, integration, benchmark, and e2e. Valid for both human engineers and AI assistants.
Table of Contents
- Directory Layout
- Test Settings — The Golden Rule
- Settings Helpers Reference
- Fixture Scope Guidelines
- Markers
- Async Tests
- Mocking Conventions
- Integration Test Patterns
- Benchmark Tests
- Running Tests
- Checklist Before Committing a New Test File
Directory Layout
tests/
├── conftest.py ← root conftest: shared fixtures + settings helpers
├── unit/ ← fast, no I/O, no infrastructure
│ └── nala/athomic/...
├── integration/ ← require running infra (Docker Compose)
│ └── nala/athomic/...
├── benchmark/ ← pytest-benchmark performance suite
│ └── nala/athomic/...
├── e2e/ ← end-to-end flows
└── settings/ ← TOML settings files (one per test file)
├── unit/...
├── integration/... ← (same sub-tree as the test files)
├── benchmark/...
└── ...
Every test file that needs settings has a companion TOML in tests/settings/ at the exact same relative path, with the .py extension replaced by .toml:
tests/integration/nala/athomic/ai/llm/test_int_ollama.py
→ tests/settings/integration/nala/athomic/ai/llm/test_int_ollama.toml
tests/benchmark/nala/athomic/ai/features/test_benchmark_feature_encoding.py
→ tests/settings/benchmark/nala/athomic/ai/features/test_benchmark_feature_encoding.toml
Test Settings — The Golden Rule
Never instantiate domain configuration objects inline in test code.
The following are forbidden in test files:
# ❌ WRONG — instantiating config objects inline
_VECTOR_SETTINGS = FeatureVectorSettings(
vector_id="benchmark_v1",
features=[
FeatureDefinitionSettings(name="age_scaled", source_key="age", transformation="min_max", ...),
],
)
_ENGINE_SETTINGS = FeatureEngineConnectionSettings(backend="async_numpy", ...)
The correct approach is:
- Define configuration in a TOML file at
tests/settings/<same path>/<test-name>.toml - Load it via
load_test_settings()/settings_loaderfixture in a fixture - Read it with
get_settings()inside the test or fixture that needs it
# ✅ CORRECT — settings come from the TOML companion file
# tests/settings/benchmark/nala/athomic/ai/features/test_benchmark_feature_encoding.toml
[default.ai.features.vectors.benchmark_v1]
vector_id = "benchmark_v1"
[[default.ai.features.vectors.benchmark_v1.features]]
name = "age_scaled"
source_key = "age"
transformation = "min_max"
params = { min_val = 0.0, max_val = 100.0 }
# test file
from tests.conftest import load_test_settings, clear_test_settings
from nala.athomic.config.settings import get_settings
def setup_module(module):
load_test_settings("tests/settings/benchmark/.../test_benchmark_feature_encoding.toml")
vector_settings = get_settings().ai.features.vectors["benchmark_v1"]
...
Why? Test TOML files are: - Version-controlled and reviewable alongside the code - Consistent with how production settings are loaded (same dynaconf stack) - Isolated per test — no shared mutable Python objects - Easy to diff when settings change
Settings Helpers Reference
All helpers are defined in tests/conftest.py and available everywhere in the test suite without import in most cases (fixtures). For non-fixture contexts (setup_module, etc.) import explicitly:
from tests.conftest import load_test_settings, clear_test_settings
load_test_settings(toml_path, **extra_env)
Sets NALA_SETTINGS_FILES, clears the get_settings LRU cache, and reloads the settings module. Call at the start of a fixture's setup phase.
@pytest.fixture(scope="module", autouse=True)
def setup_settings():
load_test_settings("tests/settings/integration/.../test_foo.toml")
yield
clear_test_settings()
Pass additional env vars as keyword arguments:
load_test_settings("tests/settings/.../test_foo.toml", ENV_FOR_DYNACONF="testing")
clear_test_settings(*extra_keys)
Removes NALA_SETTINGS_FILES (and any extra keys) from os.environ and clears the settings cache. Call in the teardown phase (after yield).
def teardown_module(module):
clear_test_settings() # removes NALA_SETTINGS_FILES
clear_test_settings("ENV_FOR_DYNACONF") # also removes extra vars
settings_loader fixture (function-scoped)
A pytest fixture that wraps load_test_settings with monkeypatch for automatic cleanup — no explicit teardown needed. Use this for function-scoped fixtures.
@pytest.fixture(autouse=True)
def setup_settings(settings_loader):
settings_loader("tests/settings/unit/.../test_foo.toml")
# no yield needed — monkeypatch restores env automatically
Rule: Use
settings_loaderfixture for function-scoped needs. Useload_test_settings/clear_test_settingsfor module-scoped andsetup_modulecontexts (becausemonkeypatchis function-scoped by default).
Fixture Scope Guidelines
| Scope | When to use | Notes |
|---|---|---|
"function" |
Default. Most unit tests. | monkeypatch works; full isolation per test |
"module" |
Integration tests — expensive setUp (LLM/DB/Kafka connects) | load_test_settings + yield + clear_test_settings |
"session" |
Config checks, environment probing (e.g. ollama_available) |
Never mutate state; read-only |
Module-scoped settings fixture template:
@pytest.fixture(scope="module", autouse=True)
def setup_settings():
load_test_settings("tests/settings/integration/nala/athomic/.../test_foo.toml")
yield
clear_test_settings()
Function-scoped settings fixture template:
@pytest.fixture(autouse=True)
def setup_settings(settings_loader):
settings_loader("tests/settings/unit/nala/athomic/.../test_foo.toml")
Markers
Register markers in pytest.ini. Current markers:
| Marker | Description | Run with |
|---|---|---|
unit |
Pure unit test | make test (default) |
integration |
Requires infra | make test-integration RUN_INTEGRATION=true |
e2e |
End-to-end flow | make test-all |
benchmark |
pytest-benchmark perf suite | make benchmark |
ai |
Requires LLM / external AI call | --run-ai flag |
Mark every file at module level:
pytestmark = [pytest.mark.unit]
# or
pytestmark = [pytest.mark.integration, pytest.mark.asyncio(loop_scope="module"), pytest.mark.ai]
Async Tests
- Async mode is
auto(asyncio_mode = "auto"inpytest.ini) — no@pytest.mark.asynciodecorator needed on individual tests. - For module-scoped async fixtures add
loop_scope="module"to both thepytestmarkand each async fixture:
pytestmark = [pytest.mark.asyncio(loop_scope="module")]
@pytest_asyncio.fixture(scope="module", loop_scope="module")
async def provider():
...
yield
...
- For benchmarks, async code must be bridged to sync via an explicit event loop (
asyncio.new_event_loop()+loop.run_until_complete()), because pytest-benchmark requires a sync callable. See Benchmark Tests for the pattern.
Mocking Conventions
- Prefer
MagicMock(spec=SomeProtocol)—spec=ensures the mock only exposes attributes the real object has, catching typos at test time. - Use
AsyncMockfor coroutines (async defmethods,__aenter__,__aexit__). - Patch at the usage site, not the definition site:
# The code under test imports from nala.athomic.config; patch there:
with patch("nala.athomic.config.get_settings") as mock_get_settings:
...
- Do not mock the database in unit tests that test DB interaction — use integration tests with a real database instead.
Integration Test Patterns
Checking service availability
@pytest_asyncio.fixture(scope="module", loop_scope="module")
async def ollama_available() -> bool:
try:
async with httpx.AsyncClient(timeout=2.0) as client:
response = await client.get("http://localhost:11434")
return response.status_code == 200
except httpx.RequestError:
return False
Skipping when unavailable
@pytest_asyncio.fixture(scope="module", loop_scope="module")
async def provider(ollama_available: bool):
if not ollama_available:
pytest.skip("Ollama not reachable — skipping.")
...
Full integration test file skeleton
# tests/integration/nala/athomic/some/test_int_foo.py
import pytest
import pytest_asyncio
from tests.conftest import clear_test_settings, load_test_settings
from nala.athomic.config.settings import get_settings
pytestmark = [
pytest.mark.integration,
pytest.mark.asyncio(loop_scope="module"),
]
@pytest.fixture(scope="module", autouse=True)
def setup_settings():
load_test_settings("tests/settings/integration/nala/athomic/some/test_int_foo.toml")
yield
clear_test_settings()
@pytest_asyncio.fixture(scope="module", loop_scope="module")
async def service():
svc = MyService(settings=get_settings().some)
await svc.start()
yield svc
await svc.stop()
async def test_something(service):
result = await service.do_thing()
assert result is not None
Benchmark Tests
Benchmark tests live in tests/benchmark/ and require the pytest-benchmark plugin. They are excluded from make test and must be run explicitly.
Rules
- Mark with
pytestmark = [pytest.mark.benchmark]. - Load settings from a companion TOML (same rules as all other tests).
- Use
setup_module/teardown_modulefor one-time engine setup (avoids benchmark overhead on every iteration). - For async engines: create ONE event loop at module level and use
loop.run_until_complete()— do not useasyncio.run()per iteration (it creates a new loop and breaks asyncio primitives that bind to the first loop, such asasyncio.Lock/asyncio.Queue). - Build context columns as 2D
(N, 1)arrays for scalar features when feedingAsyncNumpyEngine._execute_tensor_batch_impldirectly — the engine's output matrix slice is(N, k)shaped.
Benchmark file skeleton
# tests/benchmark/nala/athomic/.../test_benchmark_foo.py
from __future__ import annotations
import asyncio
import pytest
from tests.conftest import clear_test_settings, load_test_settings
from nala.athomic.config.settings import get_settings
pytestmark = [pytest.mark.benchmark]
_SETTINGS_TOML = "tests/settings/benchmark/nala/athomic/.../test_benchmark_foo.toml"
_loop: asyncio.AbstractEventLoop
_engine: ... # your engine
def setup_module(module: object) -> None:
global _loop, _engine
load_test_settings(_SETTINGS_TOML)
settings = get_settings()
_loop = asyncio.new_event_loop()
_engine = MyEngine(settings=settings.something)
_loop.run_until_complete(_engine.connect())
def teardown_module(module: object) -> None:
_loop.run_until_complete(_engine.close())
_loop.close()
clear_test_settings()
@pytest.mark.parametrize("batch_size", [1, 32, 128, 512])
def test_foo_latency(benchmark, batch_size):
def _run():
return _loop.run_until_complete(_engine.do_work(batch_size))
result = benchmark(_run)
assert result is not None
Running benchmarks
make benchmark # run without saving
make benchmark-save NAME=baseline # capture baseline JSON
make benchmark-compare # compare against saved baseline
Results are stored under .tests_saves/benchmark/ in pytest-benchmark JSON format with p50/p95/p99 latency, throughput, and mean statistics.
Running Tests
make test # unit tests only (default)
make test-integration RUN_INTEGRATION=true # integration tests (requires infra)
make test-all RUN_INTEGRATION=true # unit + integration
make coverage # unit tests + terminal coverage report
make benchmark # benchmark suite
# Single file
poetry run pytest tests/unit/path/to/test_file.py
# With verbose output + stdout
make td tests/unit/path/to/test_file.py # -s -vv shortcut
# AI-marked tests (LLM calls)
make ta tests/unit/path/to/test_file.py
Checklist Before Committing a New Test File
- [ ] Settings are in a TOML companion file at
tests/settings/<same path>/<test-name>.toml - [ ] No inline instantiation of
FeatureVectorSettings,FeatureDefinitionSettings,FeatureCacheSettings, or similar domain config objects in test code - [ ] Uses
load_test_settings/clear_test_settings(module-scoped) orsettings_loaderfixture (function-scoped) — never bareos.environ["NALA_SETTINGS_FILES"] = ... - [ ] Appropriate marker set via
pytestmark - [ ] Integration tests check for service availability and skip gracefully
- [ ] Async tests use
pytest_asyncio.fixture+loop_scope="module"where needed - [ ] Benchmark tests use a single persistent event loop (
asyncio.new_event_loop()), notasyncio.run()per iteration - [ ]
make teststill passes (unit tests unaffected by new integration/benchmark files)