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
tqcode for this chapter is in9-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.