«Копилка» разложена по файлам и умеет сохранять траты на диск. Но каждую правку разбора команд вы по-прежнему проверяете руками: запускаете программу, набираете несколько строк, смотрите на ответ. Сегодня поручим эту скучную работу 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:
#[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!(левое, правое) — если значения не равны. Все три можно ставить в один тест:
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, а у неё он давно есть. Поэтому в проекте меняются две строки:
#[derive(Debug, PartialEq)]
pub enum Command {
Add(Expense),
List,
Report,
Help,
Quit,
}
#[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) модуль с тестами:
#[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):
#[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]ждёт панику.- Проверяйте обычные, крайние и плохие входы: так тесты ловят поломки, а не только подтверждают, что хорошее работает.
Тесты написаны, проект разложен по файлам, траты сохраняются. В следующем уроке — финал: соберём релизную версию «Копилки» и оглянемся на весь пройденный путь.