← Table of contents

9.1 Proof

A craftsman who checked the piece a month ago knows one thing: a month ago it worked. That is useful knowledge - and entirely not what is being asked. “Does it work now?” is a question about the present. “It worked when I looked” is a memory wearing the shape of an answer. Most people do not notice the difference. That is the problem.

What You Need

Every crate already has tests - #[cfg(test)] and mod tests modules next to the code. They check details from the inside: they see private fields, call non-public functions, and live in the same scope as the code itself. These are unit tests - useful, fast, but limited. They do not answer a different question: does tq-core behave correctly from the perspective of code that uses it?

An integration test looks at the crate from the outside - exactly as any code that depends on it does. It imports only public items, cannot see the internals, and checks what a library user sees. Rust needs no special tools for this - only a separate place that compiles independently.

In the tq workspace, that place will be a new tests/ crate that depends on tq-core. Its only job is to hold tests.

The Build

Add "tests" to the workspace members list. The root Cargo.toml:

[workspace]
members = ["crates/core", "crates/cli", "crates/api", "tests"]
resolver = "2"

Create tests/Cargo.toml. This crate needs no src/lib.rs - only test targets and dependencies:

[package]
name = "tq-tests"
version = "0.1.0"
edition = "2024"

[[test]]
name = "core"
path = "tests/core.rs"

[dev-dependencies]
tq-core = { path = "../crates/core" }
tempfile = "3"

tempfile is a crate for temporary directories that are removed automatically when the test finishes. Cargo adds it the same way as any other dependency.

Now tests/tests/core.rs. Each test creates its own temporary directory - they do not interfere with each other and leave no traces on disk:

use std::path::PathBuf;
use tq_core::persistence;
use tq_core::store::{Store, TaskStore};

#[test]
fn roundtrip_save_and_load() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("tasks.json");

    let mut store = TaskStore::new(vec![]);
    store.add("Buy coffee").unwrap();
    store.add("Write tests").unwrap();
    persistence::save(store.all(), &path).unwrap();

    let loaded = persistence::load(&path).unwrap();
    assert_eq!(loaded.len(), 2);
    assert_eq!(loaded[0].title, "Buy coffee");
    assert_eq!(loaded[1].title, "Write tests");
}

#[test]
fn load_nonexistent_path_returns_empty() {
    let path = PathBuf::from("/tmp/tq_test_nonexistent_proof.json");
    let tasks = persistence::load(&path).unwrap();
    assert!(tasks.is_empty());
}

#[test]
fn completed_task_survives_roundtrip() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("tasks.json");

    let mut store = TaskStore::new(vec![]);
    let id = store.add("Buy coffee").unwrap();
    store.get_mut(id).unwrap().complete();
    persistence::save(store.all(), &path).unwrap();

    let loaded = persistence::load(&path).unwrap();
    assert_eq!(loaded.len(), 1);
    assert!(loaded[0].is_done());
}

Notice: use tq_core::persistence here, not use crate::persistence. The test knows nothing of the crate’s internal structure - it sees only what the crate exports.

The Result

$ make ci
...
     Running tests/core.rs (target/debug/deps/core-...)

running 3 tests
test completed_task_survives_roundtrip ... ok
test load_nonexistent_path_returns_empty ... ok
test roundtrip_save_and_load ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

The gate now includes the tq-tests crate. Any change to persistence or TaskStore that breaks external behavior will not pass through it.

The complete tq code for this chapter is in 9-shippable/01-proof/.


Lore: What an Integration Test Sees

A unit test is declared inside the crate - in a #[cfg(test)] module next to the code. The compiler sees everything: private fields, functions without pub, details hidden from the outside world. This is convenient for checking internal logic, but means a test can pass for reasons invisible to a library user.

An integration test compiles as a separate crate. It imports tq-core as a dependency - exactly as tq-cli or tq-api do. It sees only pub: public types, public functions, public traits. If the test cannot call a function it needs - that function is not accessible from outside. This is not a problem to work around; it is information about the interface.

The difference is most noticeable during refactoring: internal TaskStore structure can be changed freely - unit tests will need updating, integration tests will not, provided external behavior remains the same.