← Оглавление

9.1 Проверка

Мастер, который проверял изделие месяц назад, знает одно: месяц назад оно работало. Это полезное знание - и совсем не о том, о чём спрашивают. “Работает сейчас?” - вопрос о настоящем. “Работало, когда смотрел” - воспоминание, облачённое в форму ответа. Большинство людей не замечают разницы. Это и есть проблема.

Что нужно

В каждом крейте уже есть тесты - #[cfg(test)] и модули mod tests рядом с кодом. Они проверяют детали изнутри: видят приватные поля, вызывают непубличные функции, находятся в той же области видимости, что и сам код. Это юнит-тесты - полезные, быстрые, но ограниченные. Они не отвечают на другой вопрос: ведёт ли себя tq-core правильно с точки зрения кода, который его использует?

Интеграционный тест смотрит на крейт снаружи - именно так, как это делает любой код, зависящий от него. Он импортирует только публичное, не видит внутренностей и проверяет то, что видит пользователь библиотеки. В Rust для этого не нужны специальные инструменты - достаточно отдельного места, которое компилируется независимо.

В воркспейсе tq таким местом станет новый крейт tests/, зависящий от tq-core. Его единственная задача - держать тесты.

Постройка

Добавьте "tests" в список участников воркспейса. Корневой Cargo.toml:

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

Создайте tests/Cargo.toml. Этому крейту не нужен src/lib.rs - только тестовые цели и зависимости:

[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 - крейт для временных директорий, которые удаляются автоматически по завершении теста. Cargo добавит его так же, как любую другую зависимость.

Теперь tests/tests/core.rs. Каждый тест создаёт отдельную временную директорию - они не мешают друг другу и не оставляют следов на диске:

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());
}

Обратите внимание: здесь use tq_core::persistence, а не use crate::persistence. Тест не знает о внутреннем устройстве крейта - он видит только то, что тот экспортирует.

Результат

$ 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

Застава теперь включает и крейт tq-tests. Любое изменение в persistence или TaskStore, которое ломает поведение снаружи, через неё не пройдёт.

Полный код этой главы находится в 9-shippable/01-proof/.


Lore: что видит интеграционный тест

Юнит-тест объявлен внутри крейта - в #[cfg(test)] модуле рядом с кодом. Компилятор видит всё: приватные поля, функции без pub, детали, скрытые от внешнего мира. Это удобно для проверки внутренней логики, но означает, что тест может проходить по причинам, невидимым пользователю библиотеки.

Интеграционный тест компилируется как отдельный крейт. Он импортирует tq-core как зависимость - точно так же, как это делает tq-cli или tq-api. Он видит только pub: публичные типы, публичные функции, публичные трейты. Если тест не может вызвать нужную функцию - значит, она недоступна снаружи. Это не обходная проблема; это информация об интерфейсе.

Разница ощутимее всего при рефакторинге: можно изменить внутреннее устройство TaskStore сколько угодно - юнит-тесты нужно будет обновить, интеграционные - нет, если поведение снаружи не изменилось.