Lore testing standards¶
This document defines the standard patterns for testing across the Lore codebase.
Overview¶
| Type | Location | Framework | Purpose |
|---|---|---|---|
| Unit tests | Inline #[cfg(test)] modules |
Rust/tokio | Module-level testing |
| Integration tests | lore-revision/tests/ |
Rust/tokio | Cross-module testing |
| Smoke tests | scripts/test/ |
Python/pytest | CLI and server testing |
| Load tests | Internal infrastructure | Internal harness | Performance testing |
1. Target risk at the cheapest tier that can find the fault¶
Test effort follows risk, not line count. Business logic — session state, ordering guarantees, allocation and placement decisions — earns thorough tests. Plumbing and trivial code earn few or none. A test that restates the implementation costs maintenance and finds nothing.
Then split by what you are checking:
- Edge cases, variants, boundaries, and error paths → unit tests. They are cheap, so be exhaustive.
- Happy paths → tests that run against real things. Mocks cannot tell you whether this works wired to a real dependency or assembled into the binary we ship, and it is not worth asking twenty times over.
Assert on outcomes, not on wording. Check Ok or Err and the error variant, never a substring of the message. Message text is presentation: it changes for readability and the test fails for no reason, or it stops matching a real regression and passes for no reason.
The tiers¶
Cheapest first. Cost tracks how much must be standing before the test can run.
| Tier | Answers | Location | Needs |
|---|---|---|---|
| Unit | Is this logic correct across all its cases? | Inline #[cfg(test)] |
Nothing |
| Smoke | Does a real user flow work across the binaries? | scripts/test/, @pytest.mark.smoke |
lore and loreserver binaries built |
Edge cases belong in unit tests. The smoke test covers the happy path only.
2. Rust unit tests¶
Inline in source modules with #[cfg(test)]:
3. Rust async tests¶
Frameworks: tokio, mockall, async-trait
All async tests use the LORE_CONTEXT.scope() pattern:
#[tokio::test]
async fn test_example() {
LORE_CONTEXT
.scope(setup_test_execution(), async {
// test code
})
.await;
}
Test independence¶
Tests MUST be independent and isolated. Avoid:
#[serial]— Forces sequential execution, indicating shared mutable state.- Test dependencies — Tests that rely on other tests running first.
If you find yourself needing #[serial], refactor the test to:
- Create isolated test fixtures and state per test.
- Use unique identifiers (for example, random repository names or unique temp directories).
- Mock shared resources instead of using real shared state.
// Anti-pattern: shared state requiring serial execution
#[tokio::test]
#[serial] // DON'T DO THIS
async fn test_with_shared_state() { ... }
// Preferred: isolated test with unique fixtures
#[tokio::test]
async fn test_with_isolated_state() {
let test_repo = test_store_create(); // Creates unique test repository
// Test uses only this isolated state
}
Key files:
lore-revision/tests/helper.rs—test_store_create(),setup_test_execution().lore-revision/tests/— Cross-module integration tests.lore-integration-tests/— Tests against real infrastructure, without mocking
Scratch space on disk¶
lore_base::test_util::TempDir, and TempFile for a single file, are the only
way a test asks for scratch space on disk. Both are behind lore-base's
test-util feature, which the crates that need it enable from their
dev-dependencies:
let dir = TempDir::new("my-test-"); // <temp>/my-test-A1b2C3d4
std::fs::write(dir.child("data.bin"), b"...").expect("write");
let file = TempFile::with_contents("my-test-", b"payload");
some_api(file.path());
The name carries a random suffix, so two tests — or two runs on one machine —
never share a directory, and removal happens in Drop, so it happens whether
the test passes or fails an assertion.
Do not build a path under std::env::temp_dir() by hand, and do not
remove a directory on the last line of a test body: a failing assertion panics
before reaching that line, so the run that most needs its output is the one that
leaks it.
Return the guard, never a path derived from it. A helper that ends
temp.path().to_path_buf() drops the guard on return and deletes the directory
its caller is about to read. Hand back the TempDir and let the caller hold it:
fn fixture() -> (TempDir, PathBuf) { ... } // correct
fn fixture() -> PathBuf { ... } // deletes the directory on return
Set LORE_KEEP_TEST_DATA=1 to keep the directories rather than remove them —
the Rust equivalent of the smoke tests' --keep-test-data. The prefix is all
there is to identify one afterwards, so give it the test's name wherever several
tests in a module would otherwise share it.
tempfile is the implementation and a dependency of lore-base only. Do not add
it to another crate: a second guard type is a second set of rules, and it does not
honour LORE_KEEP_TEST_DATA.
Polling for async state¶
Poll for a condition instead of sleeping. Fixed sleeps are flaky on slow CI, wasteful on fast machines, and opaque — a failure does not say whether the timeout was wrong or the code is broken.
// BAD
sleep(Duration::from_millis(500)).await;
// GOOD
wait_for_condition(|| async { manager.session_count() == 2 }, Duration::from_secs(5))
.await
.expect("sessions should appear within 5 seconds");
Fixed sleeps are acceptable only for time-based behavior such as TTL expiry, or pauses under 20ms to let a spawned task start.
4. Smoke tests (scripts/test/)¶
Framework: pytest
Requirement: All Lore CLI commands must have smoke test coverage. When adding a new command, add corresponding tests to scripts/test/.
Key files¶
| File | Purpose |
|---|---|
conftest.py |
Fixtures and server management |
lore.py |
Lore wrapper class for the CLI |
error_types.py |
Exception mapping from CLI output |
Fixtures¶
new_lore_repo— Creates a new test repository.scratch_dir— Hands out a path beside the repositories for the test to create something at: a shared store, a clone target, a moved instance.lore_executable_path— Path to the Lore client binary.auto_lore_local_server— Auto starts the server for the session.
Test data is removed as the run goes¶
A full run writes about ten gigabytes, so nothing is kept once it is no longer needed. Cleanup is in two layers:
- Per test.
new_lore_reporemoves every repository it handed out and every repository those went on to clone;scratch_dirremoves every path it handed out;global_dir_nameremoves the isolated global config and the shared stores under it. All of this runs whether the test passed or failed. A server built inside a test or a class fixture throughgenerate_server_configis removed at that same scope, after the fixture that launched it has stopped it. An autouse fixture removes the per-testtmp_path, which pytest itself only removes under thefailedpolicy and then only for tests that passed. - Per session.
_SessionCleanupinlore_server.pyempties basetemp once every worker has finished, taking the session server's root and anything a per-test cleanup could not.
A test that creates files outside its repository must take scratch_dir and
create them at a path it hands out. Anything written straight to
tmp_path_factory.getbasetemp() survives the test that made it and is only
caught by the session sweep, which is a backstop, not the guard.
Removal is best effort and never fails a test: a locked file is logged and left to the sweep.
Pass --keep-test-data to keep everything on disk — repositories, stores,
server roots and the server log — when investigating a failure.
Usage¶
@pytest.mark.smoke
def test_commit(new_lore_repo):
repo: Lore = new_lore_repo()
repo.stage(offline=True)
repo.commit("Test commit", offline=True)
repo.push()
Running with uv¶
Tests require Python 3.13+. Use uv to manage dependencies and run tests:
# Install dependencies
uv sync
# Run all smoke tests with local server
uv run pytest scripts/test/ --lore-client-binary=release --lore-server-binary=release
# Run only tests marked as smoke
uv run pytest scripts/test/ -m smoke
# Run tests in parallel (uses pytest-xdist)
uv run pytest scripts/test/ -n auto
# Against external server
uv run pytest scripts/test/ --disable-local-server --lore-remote-url=lore://host:port
Command-line options¶
| Option | Default | Description |
|---|---|---|
--lore-client-binary |
release |
Path or "release"/"debug" |
--lore-server-binary |
release |
Path or "release"/"debug" |
--lore-remote-url |
lore://127.0.0.1:41338 |
Server address |
--disable-local-server |
false |
Use external server |
--disable-auto-server |
false |
Don't auto start the server |
--keep-test-data |
false |
Leave repositories, stores and server roots on disk |
Per-test timeout¶
Every test is bounded at ten minutes (timeout in pyproject.toml), and the
stack of every thread is dumped a minute before that (faulthandler_timeout).
Nothing else bounds a test: the helper runs the client with subprocess.run and
no timeout, so a client that never returns would otherwise be a run that never
returns.
A test that reaches the limit is reported as a failure and the run continues. On
Linux the test fails in place; on Windows there is no SIGALRM, so the worker is
killed and xdist reports
worker 'gwN' crashed while running <test> and replaces it. Either way the log
names the test and carries the stack it was stuck in.
Override with --timeout=0 to disable, or --timeout=<seconds> for a run that
is legitimately slower.
pytest markers¶
| Marker | Description |
|---|---|
@pytest.mark.smoke |
Smoke tests for basic functionality |
@pytest.mark.slow |
Slow running tests |
@pytest.mark.disable_auto_server |
Tests requiring the --disable-auto-server flag |
5. Load tests¶
Lore has a load-testing suite that exercises concurrent clone, commit, sync, lock, and compaction workloads. It runs on internal infrastructure and isn't part of the open-source repository, so its harness and scenarios aren't documented here.
6. Best practices¶
- Target risk, not line count — spend test effort on business logic; leave plumbing and trivial code with few or no tests.
- All Lore commands must have smoke tests in
scripts/test/. - Use
LORE_CONTEXT.scope()for all async Rust tests. - Keep tests independent — Avoid
#[serial]and test dependencies; use isolated fixtures. - Poll instead of sleeping for async state; reserve fixed sleeps for time-based behavior or sub-20ms task startup.
- Use the
new_lore_repofixture for smoke tests, andscratch_dirfor anything a test creates outside its repository — both remove what they hand out. - Mark tests with
@pytest.mark.smokefor smoke test runs. - Use
offline=Truefor operations that don't need the server. - Feature-gate integration tests that require external dependencies.