$ sudo teach IT
Модуль 7 · Проект целиком · Урок 7.3

Тесты в Cargo

Вы научитесь писать модульные тесты с #[test] и assert_eq!, запускать их командой cargo test и читать результат.

Теория + практика~30 минутСредний

«Копилка» разложена по файлам и умеет сохранять траты на диск. Но каждую правку разбора команд вы по-прежнему проверяете руками: запускаете программу, набираете несколько строк, смотрите на ответ. Сегодня поручим эту скучную работу Cargo.

Зачем нужны автотесты

Перед дальней дорогой можно проверить машину руками: фары, тормоза, масло, шины. А можно подключить диагностический прибор: он сам пройдёт по списку и скажет либо «всё в порядке», либо «не горит левая фара». Автоматический тест — такой прибор для кода. Вы один раз записываете «на этот вход ждём вот такой ответ», а дальше компьютер проверяет это за вас хоть тысячу раз, за долю секунды.

Без тестов история обычно такая. Сегодня вы убедились, что добавить 350 еда обед работает, и забыли об этом. Через неделю правите проверку суммы и случайно ломаете команду отчет без буквы «ё». Узнаете об этом, когда на неё наткнётесь. Тест, наоборот, поднимает тревогу в ту же секунду после правки, пока вы помните, что именно меняли.

Официальный термин: модульный тест (unit test) — небольшая проверка одной функции или одного куска логики отдельно от остальной программы. В Rust такие тесты принято хранить в том же файле, прямо под кодом, который они проверяют. Другие виды тестов вам встретятся позже, а в этом уроке нам хватит модульных.

Первый тест

Тест в Rust — обычная функция без параметров и без результата, только с особой пометкой. Внутри она вызывает проверяемый код и сравнивает результат с ожиданием. Если сравнение не сошлось, функция паникует (помните panic! из модуля про ошибки), и паника означает «тест провален». Дошла до конца спокойно — тест пройден. Это как контрольная, где ответ на задачу напечатан в конце учебника: сошлось — зачёт, не сошлось — разбираемся.

Осталось три вопроса: как пометить функцию, где её держать и чем сравнивать.

Пометка «это тест»: #[test]

Над функцией в квадратных скобках пишется пометка для компилятора. Такие пометки называются атрибутами; один вы уже знаете: #[derive(Debug)]. Атрибут #[test] говорит: «это не обычная функция, а тест, его запускает cargo test».

Отдельная комната для тестов: #[cfg(test)] mod tests

Тесты нужны вам, но не пользователю программы. Поэтому их складывают в отдельный модуль внутри того же файла (модуль можно объявить прямо в фигурных скобках, без отдельного файла) и помечают атрибутом #[cfg(test)]: «компилировать этот модуль только при запуске тестов». Обычная сборка и cargo build --release тестов не увидят, программа не потолстеет. Имя tests — общее соглашение.

Модуль — отдельная комната, и имена из остального файла в ней сами не видны. Строка use super::*; приносит их внутрь: super означает «родительский модуль» (то есть сам файл, например report.rs), а звёздочка — «всё, что там есть». Заодно к тестам приходит всё, что файл сам подключил через use. А правило из урока про модули напомнит, почему это работает: дочерний модуль видит даже приватное содержимое родителя, поэтому тесты могут проверять и внутренние функции вроде first_word.

Чем сравнивать: assert_eq!

Макрос assert_eq!(левое, правое) (от англ. assert — «утверждать», equal — «равны») проверяет, что два значения равны. Равны — идём дальше. Нет — паника с подробным описанием, что именно не сошлось. По договорённости слева пишут то, что вернул код, справа — то, что вы ждёте. Вот первый тест «Копилки», на функцию total из report.rs:

Rust · report.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn total_of_empty_list_is_zero() {
        assert_eq!(total(&[]), 0);
    }
}

Разбор по строкам

  • #[cfg(test)] — следующий модуль собирается только для тестов.
  • mod tests { — модуль-комната для тестов, прямо внутри report.rs.
  • use super::*; — принести в комнату всё из report.rs: total, Expense, Category.
  • #[test] — следующая функция является тестом.
  • fn total_of_empty_list_is_zero() — имя теста; пишите его так, чтобы по нему было понятно, что проверяется.
  • assert_eq!(total(&[]), 0); — для пустого списка трат total должна вернуть 0.

У assert_eq! есть два родственника. assert!(условие) проходит, если условие истинно, а assert_ne!(левое, правое) — если значения не равны. Все три можно ставить в один тест:

Rust · expense.rs
assert!(Category::from_word("еда").is_some());
assert_ne!(Category::from_word("еда"), Category::from_word("дом"));

Разбор по строкам

  • assert!(Category::from_word("еда").is_some()) — слово «еда» должно распознаться как категория, то есть Some.
  • assert_ne!(...) — результаты для «еда» и «дом» не должны совпадать.

Запуск: cargo test

Команда cargo test собирает проект в тестовом режиме (с включённым cfg(test)), находит все функции с #[test], запускает каждую и печатает итог. Запускайте её в папке проекта kopilka, как cargo run. Так выглядит запуск готового проекта из практики этого урока, где всего 10 тестов:

Вывод
     Running unittests src/main.rs (target/debug/deps/kopilka-7798489fbec27b7c)

running 10 tests
test command::tests::parse_add_with_note ... ok
test command::tests::parse_add_without_note ... ok
test command::tests::parse_bad_amount ... ok
test command::tests::parse_empty_line ... ok
test report::tests::total_of_empty_list_is_zero ... ok
test command::tests::parse_simple_commands ... ok
test command::tests::parse_missing_args_and_category ... ok
test report::tests::total_sums_all_amounts ... ok
test command::tests::parse_unknown_command ... ok
test report::tests::totals_group_by_category ... ok

test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Разбор по строкам

  • running 10 tests — Cargo нашёл десять функций с #[test].
  • test report::tests::total_sums_all_amounts ... ok — по строке на тест: полный путь (файл, модуль, имя функции) и результат.
  • test result: ok. 10 passed; 0 failed — итог: десять прошло, ни одного провала. Зелёный свет, можно править дальше.
  • Порядок строк у вас может быть другим: тесты запускаются параллельно, кто успел, тот и напечатался. Это нормально.

Теперь самое интересное: что будет, если код сломался. Поменяем в одном тесте ожидаемое число с 1649 на 1650:

Вывод
running 10 tests
test command::tests::parse_empty_line ... ok
test command::tests::parse_add_with_note ... ok
test command::tests::parse_simple_commands ... ok
test command::tests::parse_unknown_command ... ok
test command::tests::parse_bad_amount ... ok
test command::tests::parse_missing_args_and_category ... ok
test command::tests::parse_add_without_note ... ok
test report::tests::total_of_empty_list_is_zero ... ok
test report::tests::totals_group_by_category ... ok
test report::tests::total_sums_all_amounts ... FAILED

failures:

---- report::tests::total_sums_all_amounts stdout ----

thread 'report::tests::total_sums_all_amounts' (2240257) panicked at src/report.rs:76:9:
assertion `left == right` failed
  left: 1649
 right: 1650
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    report::tests::total_sums_all_amounts

test result: FAILED. 9 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--bin kopilka`

Разбор по строкам

  • ... FAILED — этот тест не прошёл; остальные продолжают работать, один провал не останавливает остальных.
  • panicked at src/report.rs:76:9 — где именно сработал assert_eq!: файл, строка, столбец.
  • left: 1649 — левая часть, то есть то, что реально вернул код.
  • right: 1650 — правая часть, то, что ожидал тест. Сразу видно, что разошлось на единицу.
  • test result: FAILED. 9 passed; 1 failed — итог красный, а команда завершилась с ошибкой, что важно для автоматических проверок.

Когда тестов много, нужный можно запустить отдельно: cargo test parse запустит только тесты, в чьём имени встречается parse. Полное имя теста тоже подойдёт. Остальные Cargo пропустит и честно напишет, сколько отфильтровал.

Что тестировать в «Копилке»

Хороший набор проверок смотрит на три вида входов: обычный (добавить 350 еда обед), крайний (пустая строка, сумма 0, пустой список) и плохой (неизвестная команда). Вот что мы проверяем в проекте:

ФункцияЧто проверяем
parse_commandхорошие строки дают Ok(Command::...), плохие — нужную Err(CommandError::...)
totalпустой список даёт 0, несколько трат — их сумму
totals_by_categoryтраты одной категории складываются, категория без трат в карте не появляется
Expense::from_lineстрока 350;еда;обед даёт Some, мусор — None (это ваше домашнее задание в практике)

Почему понадобится PartialEq

Чтобы сравнить две траты, Rust должен знать, что значит «равны». Для чисел и строк он знает. Для вашего struct или enum — нет, пока вы не скажете. Это «умение типа» называется PartialEq, и дать его проще всего через derive, как вы уже делали для Category. Плюс assert_eq! при провале печатает значения через {:?}, значит, нужен ещё и Debug.

Наш тест сравнивает Result<Command, CommandError>. Такое сравнение работает, только если умеют сравниваться оба участника: Command и CommandError (у ошибки PartialEq уже есть). Внутри Command::Add лежит Expense, значит, и ему нужен PartialEq; внутри Expense лежит Category, а у неё он давно есть. Поэтому в проекте меняются две строки:

Rust · command.rs
#[derive(Debug, PartialEq)]
pub enum Command {
    Add(Expense),
    List,
    Report,
    Help,
    Quit,
}
Rust · expense.rs
#[derive(Debug, Clone, PartialEq)]
pub struct Expense {
    pub amount: u32,
    pub category: Category,
    pub note: String,
}

Clone здесь заодно: это «умение сделать копию», в тестах копии трат нужны часто. Сами сегодняшние тесты без него обошлись бы.

Если содержимое ошибки не важно

Иногда нужно проверить лишь вид ошибки, а текст внутри неё неважен. Для этого есть макрос matches!(значение, образец): он отвечает true, если значение подходит под образец. Его кладут внутрь assert!: assert!(matches!(parse_command("добавить 0 еда"), Err(CommandError::BadAmount(_)))). Подчёркивание _ означает «что угодно внутри». Когда содержимое важно, сравнивайте целиком через assert_eq!.

Тест, который ждёт панику

Иногда правильное поведение — именно паника. Тогда над тестом добавляют второй атрибут #[should_panic], и тест считается пройденным, только если внутри случилась паника. Можно уточнить текст: #[should_panic(expected = "нет суммы")] проходит, если сообщение паники содержит эту фразу. Пример: let amount: Option<u32> = None; amount.expect("нет суммы"); внутри такого теста паникует и потому проходит. В «Копилке» паники мы не ждём, так что это просто на запас.

Практика: тесты для «Копилки»

Берём проект kopilka из прошлого урока (пять файлов в src, с storage.rs). Поведение программы не меняется, мы лишь добавляем проверки.

Шаг 1. Научить типы сравниваться

В src/expense.rs найдите строку #[derive(Debug)] над pub struct Expense и замените на #[derive(Debug, Clone, PartialEq)]. В src/command.rs добавьте строку #[derive(Debug, PartialEq)] прямо над pub enum Command. Остальное в файлах оставьте как есть. Запустите cargo run: программа должна вести себя по-прежнему.

Шаг 2. Тесты разбора команд

Допишите в самый конец src/command.rs (после функции parse_command) модуль с тестами:

Rust · command.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_add_with_note() {
        let result = parse_command("добавить 350 еда обед в столовой");
        let expected = Expense::new(350, Category::Food, "обед в столовой");
        assert_eq!(result, Ok(Command::Add(expected)));
    }

    #[test]
    fn parse_add_without_note() {
        let result = parse_command("добавить 99 транспорт");
        let expected = Expense::new(99, Category::Transport, "без заметки");
        assert_eq!(result, Ok(Command::Add(expected)));
    }

    #[test]
    fn parse_simple_commands() {
        assert_eq!(parse_command("список"), Ok(Command::List));
        assert_eq!(parse_command("отчёт"), Ok(Command::Report));
        assert_eq!(parse_command("отчет"), Ok(Command::Report));
        assert_eq!(parse_command("помощь"), Ok(Command::Help));
        assert_eq!(parse_command("выход"), Ok(Command::Quit));
    }

    #[test]
    fn parse_empty_line() {
        assert_eq!(parse_command("   "), Err(CommandError::Empty));
    }

    #[test]
    fn parse_unknown_command() {
        assert_eq!(
            parse_command("танцевать"),
            Err(CommandError::UnknownCommand("танцевать".to_string()))
        );
    }

    #[test]
    fn parse_bad_amount() {
        assert_eq!(
            parse_command("добавить abc еда"),
            Err(CommandError::BadAmount("abc".to_string()))
        );
        assert!(matches!(
            parse_command("добавить 0 еда"),
            Err(CommandError::BadAmount(_))
        ));
        assert!(matches!(
            parse_command("добавить 1000001 еда"),
            Err(CommandError::BadAmount(_))
        ));
    }

    #[test]
    fn parse_missing_args_and_category() {
        assert_eq!(parse_command("добавить"), Err(CommandError::MissingArgs));
        assert_eq!(
            parse_command("добавить 350"),
            Err(CommandError::MissingArgs)
        );
        assert_eq!(
            parse_command("добавить 350 пицца"),
            Err(CommandError::UnknownCategory("пицца".to_string()))
        );
    }
}

Разбор по строкам

  • Все имена начинаются с parse_: это пригодится для фильтра cargo test parse.
  • parse_add_with_note и parse_add_without_note — хорошие строки; ожидаемую трату собираем через Expense::new. Без заметки программа подставляет «без заметки».
  • parse_simple_commands — несколько проверок в одном тесте; обе записи «отчёт» и «отчет» дают Command::Report.
  • parse_empty_line — строка из одних пробелов даёт CommandError::Empty.
  • parse_unknown_command — в ошибку попадает само незнакомое слово, поэтому ожидание целиком сравниваем через assert_eq!.
  • parse_bad_amount — буквы, ноль и сумма выше MAX_AMOUNT; в двух последних текст ошибки неважен, поэтому matches!.
  • parse_missing_args_and_category — нет суммы, нет категории и категория, которой не существует.

Шаг 3. Тесты отчёта

Допишите в конец src/report.rs (после print_report):

Rust · report.rs
#[cfg(test)]
mod tests {
    use super::*;

    fn sample() -> Vec<Expense> {
        vec![
            Expense::new(350, Category::Food, "обед"),
            Expense::new(1200, Category::Home, "интернет"),
            Expense::new(99, Category::Food, "булка"),
        ]
    }

    #[test]
    fn total_of_empty_list_is_zero() {
        assert_eq!(total(&[]), 0);
    }

    #[test]
    fn total_sums_all_amounts() {
        assert_eq!(total(&sample()), 1649);
    }

    #[test]
    fn totals_group_by_category() {
        let totals = totals_by_category(&sample());
        assert_eq!(totals.len(), 2);
        assert_eq!(totals[&Category::Food], 449);
        assert_eq!(totals[&Category::Home], 1200);
        assert_eq!(totals.get(&Category::Fun), None);
    }
}

Разбор по строкам

  • fn sample() -> Vec<Expense> — обычная вспомогательная функция без #[test]: готовит три траты, чтобы не повторять их в каждом тесте. Тестом она не считается.
  • total_sums_all_amounts — 350 + 1200 + 99 = 1649.
  • totals_group_by_category — две категории в карте; еда сложилась (350 + 99 = 449); для категории без трат get вернул None.
  • totals[&Category::Food] — значение из карты по ключу; такая запись сама паникует, если ключа нет, а для теста это ровно то, что нужно.

Шаг 4. Запуск

В папке kopilka выполните cargo test. Должен получиться вывод, как в разделе про запуск выше: десять тестов и строка test result: ok. 10 passed (порядок строк может отличаться). Затем запустите отдельную группу:

Терминал
cargo test parse
Вывод
running 7 tests
test command::tests::parse_add_with_note ... ok
test command::tests::parse_add_without_note ... ok
test command::tests::parse_bad_amount ... ok
test command::tests::parse_simple_commands ... ok
test command::tests::parse_unknown_command ... ok
test command::tests::parse_empty_line ... ok
test command::tests::parse_missing_args_and_category ... ok

test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s

Три теста из report.rs отфильтрованы, их имена не содержат parse. Теперь запустите cargo build и cargo run: предупреждений быть не должно, а тесты в обычную программу не попали.

Шаг 5. Сломайте и почините

Измените в тесте total_sums_all_amounts ожидание на 1650, запустите cargo test и найдите в выводе left и right. Верните 1649. Привычка «сначала увидеть красный, потом зелёный» отлично показывает, что тест вообще способен упасть.

Проверьте себя

  • cargo test показывает 10 passed, а cargo test parse — 7 passed и 3 filtered out.
  • Сломанное ожидание даёт FAILED и строки left: 1649 и right: 1650.
  • Если убрать #[test] у одного теста, число passed уменьшается на единицу и появляется предупреждение.
  • cargo build по-прежнему собирает программу без предупреждений.
  • Дополнительно: допишите в конец expense.rs такой же модуль tests и проверьте Expense::from_line: строка "350;еда;обед" должна дать Some(Expense::new(350, Category::Food, "обед")), а "мусор" — None.

Частые ошибки

1. Тест молча не запускается

Ошибка компилятора
warning: function `total_sums_all_amounts` is never used
  --> src/report.rs:74:8
   |
74 |     fn total_sums_all_amounts() {
   |        ^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default

Что видно: предупреждение «function is never used», а в итоге на один тест меньше (test result: ok. 9 passed вместо десяти). Почему: над функцией забыли #[test], и для Cargo это обычная функция, которую никто не вызывает. Как исправить: добавьте #[test] над fn. Следите за числом в строке passed: если оно меньше, чем вы писали тестов, что-то не помечено.

2. Забыли use super::*;

Ошибка компилятора
error[E0425]: cannot find type `Expense` in this scope
  --> src/report.rs:60:24
   |
60 |     fn sample() -> Vec<Expense> {
   |                        ^^^^^^^ not found in this scope

Почему: mod tests — отдельная комната, и ни Expense, ни total в ней сами не видны. Как исправить: первой строкой модуля поставьте use super::*;.

3. Сравнение без PartialEq

Ошибка компилятора
error[E0369]: binary operation `==` cannot be applied to type `Result<command::Command, command::CommandError>`
   --> src/command.rs:107:9
    |
107 |         assert_eq!(result, Ok(Command::Add(expected)));
    |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    |         |
    |         Result<command::Command, command::CommandError>
    |         Result<command::Command, command::CommandError>
    |
note: an implementation of `PartialEq` might be missing for `command::Command`
   --> src/command.rs:8:1
    |
  8 | pub enum Command {
    | ^^^^^^^^^^^^^^^^ must implement `PartialEq`
help: consider annotating `command::Command` with `#[derive(PartialEq)]`
    |
  8 + #[derive(PartialEq)]
  9 | pub enum Command {
    |

Почему: assert_eq! использует ==, а тип Command не умеет сравниваться. Компилятор сам подсказывает решение в строке help. Как исправить: добавьте #[derive(Debug, PartialEq)] над типом (и над всеми типами, которые лежат внутри него). Если не хватает Debug, ошибка будет error[E0277]: `command::Command` doesn't implement `Debug`: тот же способ лечения.

4. Перепутаны left и right

Что видно: тест падает, и вы час ищете ошибку не в той стороне. Почему: left — значение из первого аргумента assert_eq!, right — из второго. Если вы ставите ожидание слева, а результат справа, подписи в выводе обманут. Как исправить: всегда пишите сначала вызов проверяемого кода, потом ожидаемое значение.

Что важно запомнить

  • Модульный тест — обычная функция с атрибутом #[test]; паника внутри означает провал, спокойное завершение — успех.
  • Тесты живут в конце того же файла в модуле #[cfg(test)] mod tests и начинаются со строки use super::*;. В готовую программу они не попадают.
  • assert_eq!(получено, ожидаемое), assert_ne! и assert! проверяют утверждения; при провале показывают left и right.
  • cargo test запускает все тесты, cargo test слово — только те, в чьём имени есть это слово. Порядок строк в выводе не гарантирован.
  • Чтобы сравнивать свои типы через assert_eq!, им нужны PartialEq и Debug, причём и всем типам внутри.
  • matches! проверяет только форму значения, #[should_panic] ждёт панику.
  • Проверяйте обычные, крайние и плохие входы: так тесты ловят поломки, а не только подтверждают, что хорошее работает.

Тесты написаны, проект разложен по файлам, траты сохраняются. В следующем уроке — финал: соберём релизную версию «Копилки» и оглянемся на весь пройденный путь.

На следующий урок