Testing Guide
This guide defines the pattern for new Python tests in Observal. The current test suite has several styles because it grew over time. Do not rewrite old tests only for style. New tests and touched files should move toward this pattern when it keeps the diff focused.
Goals
Good tests in this repo should be:
Hermetic: no real network, Docker, user config, or external services.
Behavioral: assert user-visible behavior or service contracts, not incidental internals.
Small: one route group, command, service, or behavior area per file.
Explicit: setup is visible in the test or in small local helpers.
Fast: sleeps, external calls, and expensive setup are mocked at the boundary.
File layout
Use this structure for most new test files:
# SPDX-FileCopyrightText: 2026 Your Name <email@example.com>
<!-- SPDX-FileCopyrightText: 2026 RAWx18 <rawx18.dev@gmail.com> -->
# SPDX-License-Identifier: Apache-2.0
"""Tests for the behavior under test.
Cover the most important scenarios in a short list when useful:
* successful path
* denied access
* edge case or regression
"""
from __future__ import annotations
import uuid
from unittest.mock import MagicMock
def _make_item(*, name: str = "example") -> MagicMock:
item = MagicMock()
item.id = uuid.uuid4()
item.name = name
return item
class TestExampleBehavior:
"""Tests for the public behavior of ExampleBehavior."""
def test_returns_name_for_valid_item(self) -> None:
from services.example import item_name
item = _make_item(name="alpha")
result = item_name(item)
assert result == "alpha"Keep the order consistent:
SPDX header
Module docstring
from __future__ import annotationswhen usefulStandard library imports
Test library imports
App imports needed for the test harness
Local helper factories
Test classes or test functions
File scope
Prefer one file per behavior area:
tests/test_agent_name_lookup.pyfor agent lookup behaviortests/test_component_versions_api.pyfor component version endpointsobserval_cli/tests/test_cmd_scan.pyfor thescancommandobserval-server/tests/test_jwt.pyfor JWT service behavior
Split a file when it mixes unrelated layers. For example, schema validation, route behavior, config generation, and resolver service behavior should usually live in separate files.
Imports
Use top-level imports for the test harness:
Standard library modules
pytestunittest.mockTest clients such as
AsyncClient,ASGITransport, andCliRunnerModels, schemas, routers, and dependency functions needed by local helpers
Inline imports are useful for the unit under test:
Use inline imports when importing the unit under test needs environment setup, has side effects, or makes the test easier to isolate. Do not force every import to be inline.
Helpers and fixtures
Prefer small helper functions for local setup:
Use pytest.fixture when pytest lifecycle behavior is helpful:
Temporary directories through
tmp_pathEnvironment isolation through
monkeypatchCleanup with
yieldShared setup used by many files through
conftest.py
Prefer helpers over fixtures when the setup takes parameters or is only used in one file.
Test body shape
Use a simple arrange, act, assert flow with blank lines between phases:
Comments are only needed when the reason is not obvious. A clear test name is better than a comment that repeats the code.
Naming
Use behavior names:
Avoid vague names:
A good pattern is:
Inside a class, the class can provide the subject:
Use a test class when there are at least a few related tests. Module-level test functions are fine for one or two simple cases.
Mocking
Mock boundaries, not the behavior under test.
Good boundaries to mock:
HTTP clients and webhooks
Database session methods
Filesystem writes when the write itself is not under test
Sleep and time delays
Auth providers
External CLIs and subprocesses
Avoid mocking:
Pydantic schemas
Small pure functions
The function being tested
Several layers of internal implementation at once
Use AsyncMock for async methods and MagicMock for sync methods. Use spec when mocking model objects:
Keep patches close to the test that needs them:
Async tests
Mark async tests explicitly:
The root pytest config sets asyncio mode to auto, but the marker keeps async tests easy to spot.
API route tests
Use a small FastAPI app with dependency overrides. Do not boot the full application unless the integration boundary is the thing being tested.
For route tests, assert the response status and response body first. Assert database calls only when they are part of the contract.
CLI tests
CLI tests should not read or write the real home directory. Redirect home and current working directory to a temp path:
Always include result.output in exit code assertions so failures are easy to diagnose.
Parametrize
Use parametrization for the same behavior across multiple inputs:
Do not use a large parameter matrix when separate named tests would be easier to read.
Property-based tests
hypothesis is available for invariants that should hold over many inputs. Use it for pure logic, parsers, serializers, redaction, and validation edge cases.
Keep property-based tests deterministic and focused. If a failing example reveals a bug, add a named regression test too.
Fuzzing
Property tests cover invariants you can name. Fuzzing covers the ones you cannot: the Atheris targets live in fuzz/, and the project is being submitted to Google OSS-Fuzz for continuous fuzzing.
Reach for a fuzz target instead of a property test when the boundary takes untrusted bytes and the interesting failures are crashes rather than wrong answers. Session transcripts, the ingest classifiers, and both redaction layers are covered today.
fuzz/README.md covers adding a target, seed corpora, dictionaries, and the OSS-Fuzz project configuration.
Assertions
Use plain assert. Assert the public result before internal interactions:
Use call assertions when the call is the behavior:
For CLI tests, include output in the assertion message:
For exceptions, assert the important fields:
Test directories
Use the existing directories:
tests/for cross-cutting backend, CLI, and integration-style unit testsobserval-server/tests/for server-focused tests that live with the server packageobserval_cli/tests/for CLI command and CLI package teststests/e2e/for Playwright tests that require the running stack
Shared setup should stay small:
tests/conftest.pyadds the server source path for imports.observal-server/tests/conftest.pysets test environment variables and shared auth fixtures.
Extend conftest.py only when setup is useful across several files.
Running tests
Use the make targets for normal workflows:
Run focused pytest commands from observal-server when iterating on one file:
Use make lint and make format before pushing Python changes. Use make check when you need the full pre-commit suite.
Formatting and lint notes
The root pyproject.toml relaxes a few lint rules for tests. Those ignores are for practical mock setup, not a reason to leave tests messy.
Current per-file ignores include:
In practice:
Unused mock variables are allowed in test files.
Some server tests can use guarded imports after setup.
Tests should import feature modules from their normal server package paths.
New tests should still prefer clear imports, typed helpers, and small setup.
Review checklist
Before submitting a test change, check that:
The test fails without the product fix or covers a meaningful invariant.
External services are mocked or replaced with local fakes.
Real user config and real home directories are not touched.
The test name states the behavior.
Helpers are local unless they are reused across files.
Async tests use
AsyncMockfor awaited methods.CLI tests include command output in exit code assertions.
API route tests override dependencies in a local FastAPI app.
Focused tests pass locally.
Last updated
Was this helpful?