Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Как писать тесты

Тесты в Rust – это функции, которые проверяют, что код, не являющийся тестовым, работает ожидаемым образом. Тела тестовых функций обычно выполняют три действия:

  • Подготавливают необходимые данные или состояние.
  • Запускают код, который нужно проверить.
  • Проверяют, что результаты соответствуют ожиданиям.

Рассмотрим возможности, которые Rust предоставляет специально для написания тестов, выполняющих эти действия: атрибут test, несколько макросов и атрибут should_panic.

Структура тестовых функций

В самом простом виде тест в Rust – это функция, помеченная атрибутом test. Атрибуты – это метаданные о частях кода Rust; один пример – атрибут derive, который мы использовали со структурами в главе 5. Чтобы превратить функцию в тестовую, добавьте #[test] в строке перед fn. Когда вы запускаете тесты командой cargo test, Rust собирает бинарный файл запуска тестов, который выполняет помеченные функции и сообщает, прошла или провалилась каждая тестовая функция.

Каждый раз, когда мы создаем новый библиотечный проект с помощью Cargo, для нас автоматически создается тестовый модуль с тестовой функцией внутри. Этот модуль дает шаблон для написания тестов, чтобы вам не приходилось каждый раз при создании нового проекта искать точную структуру и синтаксис. Можно добавлять сколько угодно дополнительных тестовых функций и тестовых модулей!

Мы изучим некоторые стороны работы тестов, экспериментируя с шаблонным тестом, прежде чем начнем проверять настоящий код. Затем напишем более практичные тесты, которые вызывают написанный нами код и проверяют, что его поведение корректно.

Создадим новый библиотечный проект с именем adder, который будет складывать два числа:

$ cargo new adder --lib
     Created library `adder` project
$ cd adder

Содержимое файла src/lib.rs в вашей библиотеке adder должно выглядеть как в листинге 11-1.

Filename: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}
Listing 11-1: Код, автоматически сгенерированный cargo new

Файл начинается с примерной функции add, чтобы нам было что тестировать.

Пока сосредоточимся только на функции it_works. Обратите внимание на аннотацию #[test]: этот атрибут указывает, что функция является тестовой, и запускатель тестов знает, что ее нужно рассматривать как тест. В модуле tests у нас также могут быть функции, не являющиеся тестами, чтобы помогать подготавливать общие сценарии или выполнять общие операции, поэтому всегда нужно указывать, какие функции являются тестами.

Тело примерной функции использует макрос assert_eq!, чтобы проверить, что result, содержащий результат вызова add с 2 и 2, равен 4. Это утверждение служит примером формата типичного теста. Запустим его и убедимся, что тест проходит.

Команда cargo test запускает все тесты в нашем проекте, как показано в листинге 11-2.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
     Running unittests src/lib.rs (target/debug/deps/adder-01ad14159ff659ab)

running 1 test
test tests::it_works ... ok

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

   Doc-tests adder

running 0 tests

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

Listing 11-2: Вывод запуска автоматически сгенерированного теста

Cargo скомпилировал и запустил тест. Мы видим строку running 1 test. Следующая строка показывает имя сгенерированной тестовой функции, tests::it_works, и то, что результат запуска этого теста – ok. Общая сводка test result: ok. означает, что все тесты прошли, а часть 1 passed; 0 failed суммирует количество прошедших и провалившихся тестов.

Тест можно пометить как игнорируемый, чтобы в конкретном запуске он не выполнялся; мы рассмотрим это позже в этой главе, в разделе «Игнорирование тестов, если они не запрошены явно». Поскольку здесь мы этого не делали, сводка показывает 0 ignored. Команде cargo test также можно передать аргумент, чтобы запускались только тесты, имя которых совпадает со строкой; это называется фильтрацией, и мы рассмотрим ее в разделе «Запуск подмножества тестов по имени». Здесь мы не фильтровали запускаемые тесты, поэтому в конце сводки показано 0 filtered out.

Статистика 0 measured относится к benchmark-тестам, измеряющим производительность. На момент написания benchmark-тесты доступны только в nightly Rust. Подробнее см. документацию по benchmark-тестам.

Следующая часть вывода тестов, начинающаяся с Doc-tests adder, относится к результатам тестов документации. У нас пока нет тестов документации, но Rust может компилировать любые примеры кода, встречающиеся в API-документации. Эта возможность помогает поддерживать документацию и код в согласованном состоянии! О том, как писать тесты документации, мы поговорим в разделе «Комментарии документации как тесты» главы 14. Пока мы проигнорируем вывод Doc-tests.

Начнем настраивать тест под свои нужды. Сначала изменим имя функции it_works на другое, например exploration, вот так:

Файл: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

Затем снова запустим cargo test. Теперь в выводе будет показано exploration вместо it_works:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.59s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::exploration ... ok

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

   Doc-tests adder

running 0 tests

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

Теперь добавим еще один тест, но на этот раз сделаем тест, который проваливается! Тесты проваливаются, когда что-то в тестовой функции паникует. Каждый тест запускается в новом потоке, и когда главный поток видит, что поток теста завершился аварийно, тест помечается как провалившийся. В главе 9 мы говорили, что самый простой способ вызвать панику – вызвать макрос panic!. Введите новый тест как функцию с именем another, чтобы ваш файл src/lib.rs выглядел как в листинге 11-3.

Filename: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}
Listing 11-3: Добавление второго теста, который провалится, потому что мы вызываем макрос panic!

Снова запустите тесты с помощью cargo test. Вывод должен выглядеть как в листинге 11-4: он показывает, что тест exploration прошел, а another провалился.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.72s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok

failures:

---- tests::another stdout ----

thread 'tests::another' panicked at src/lib.rs:17:9:
Make this test fail
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::another

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

error: test failed, to rerun pass `--lib`
Listing 11-4: Результаты тестов, когда один тест проходит, а один проваливается

Вместо ok строка test tests::another показывает FAILED. Между отдельными результатами и сводкой появляются два новых раздела: первый показывает подробную причину каждого сбоя теста. В этом случае мы получаем подробности о том, что tests::another провалился, потому что запаниковал с сообщением Make this test fail в строке 17 файла src/lib.rs. Следующий раздел перечисляет только имена всех провалившихся тестов, что полезно, когда тестов много и подробного вывода об ошибках тоже много. Мы можем использовать имя провалившегося теста, чтобы запустить только его и проще отладить; подробнее о способах запуска тестов мы поговорим в разделе «Управление запуском тестов».

В конце выводится строка сводки: в целом результат наших тестов – FAILED. Один тест прошел, и один тест провалился.

Теперь, когда вы увидели, как выглядят результаты тестов в разных сценариях, рассмотрим несколько полезных в тестах макросов, кроме panic!.

Проверка результатов с помощью assert!

Макрос assert!, предоставляемый стандартной библиотекой, полезен, когда нужно убедиться, что некоторое условие в тесте вычисляется в true. Мы передаем макросу assert! аргумент, который вычисляется в логическое значение. Если значение равно true, ничего не происходит и тест проходит. Если значение равно false, макрос assert! вызывает panic!, чтобы тест провалился. Использование макроса assert! помогает проверить, что наш код работает так, как мы задумали.

В главе 5, в листинге 5-15, мы использовали структуру Rectangle и метод can_hold; они повторены здесь, в листинге 11-5. Поместим этот код в файл src/lib.rs, а затем напишем для него тесты с помощью макроса assert!.

Filename: src/lib.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}
Listing 11-5: Структура Rectangle и ее метод can_hold из главы 5

Метод can_hold возвращает логическое значение, а значит он идеально подходит для макроса assert!. В листинге 11-6 мы пишем тест, который проверяет метод can_hold: создаем экземпляр Rectangle с шириной 8 и высотой 7 и утверждаем, что он может вместить другой экземпляр Rectangle с шириной 5 и высотой 1.

Filename: src/lib.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }
}
Listing 11-6: Тест для can_hold, проверяющий, действительно ли больший прямоугольник может вместить меньший

Обратите внимание на строку use super::*; внутри модуля tests. Модуль tests – обычный модуль, который подчиняется привычным правилам видимости, рассмотренным в главе 7 в разделе «Пути для ссылки на элемент в дереве модулей». Поскольку модуль tests является внутренним модулем, нам нужно ввести тестируемый код из внешнего модуля в область видимости внутреннего модуля. Здесь мы используем glob-импорт, поэтому все, что мы определим во внешнем модуле, будет доступно этому модулю tests.

Мы назвали тест larger_can_hold_smaller и создали два нужных экземпляра Rectangle. Затем вызвали макрос assert! и передали ему результат вызова larger.can_hold(&smaller). Это выражение должно вернуть true, поэтому наш тест должен пройти. Проверим!

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 1 test
test tests::larger_can_hold_smaller ... ok

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

   Doc-tests rectangle

running 0 tests

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

Он действительно проходит! Добавим еще один тест, на этот раз утверждая, что меньший прямоугольник не может вместить больший:

Файл: src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        // --snip--
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

Поскольку правильный результат функции can_hold в этом случае – false, нужно отрицать этот результат перед передачей в макрос assert!. В результате наш тест пройдет, если can_hold вернет false:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok

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

   Doc-tests rectangle

running 0 tests

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

Два теста проходят! Теперь посмотрим, что произойдет с результатами тестов, если мы внесем ошибку в код. Изменим реализацию метода can_hold, заменив знак «больше» (>) на знак «меньше» (<) при сравнении ширины:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

// --snip--
impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width < other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

Теперь запуск тестов дает следующий результат:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok

failures:

---- tests::larger_can_hold_smaller stdout ----

thread 'tests::larger_can_hold_smaller' panicked at src/lib.rs:28:9:
assertion failed: larger.can_hold(&smaller)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::larger_can_hold_smaller

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

error: test failed, to rerun pass `--lib`

Наши тесты поймали ошибку! Поскольку larger.width равна 8, а smaller.width равна 5, сравнение ширины в can_hold теперь возвращает false: 8 не меньше 5.

Проверка равенства с помощью assert_eq! и assert_ne!

Распространенный способ проверить функциональность – сравнить результат тестируемого кода со значением, которое вы ожидаете получить. Это можно сделать с помощью макроса assert!, передав ему выражение с оператором ==. Однако такая проверка встречается настолько часто, что стандартная библиотека предоставляет пару макросов – assert_eq! и assert_ne!, – чтобы выполнять ее удобнее. Эти макросы сравнивают два аргумента на равенство или неравенство соответственно. Если проверка провалится, они также напечатают оба значения, благодаря чему проще увидеть, почему тест не прошел; в отличие от них, макрос assert! только сообщает, что получил значение false для выражения ==, не печатая значения, которые привели к false.

В листинге 11-7 мы пишем функцию с именем add_two, которая прибавляет 2 к своему параметру, а затем тестируем ее с помощью макроса assert_eq!.

Filename: src/lib.rs
pub fn add_two(a: u64) -> u64 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}
Listing 11-7: Тестирование функции add_two с помощью макроса assert_eq!

Проверим, что тест проходит!

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... ok

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

   Doc-tests adder

running 0 tests

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

Мы создаем переменную с именем result, которая хранит результат вызова add_two(2). Затем передаем result и 4 как аргументы макросу assert_eq!. Строка вывода для этого теста – test tests::it_adds_two ... ok, и текст ok указывает, что тест прошел!

Внесем ошибку в код, чтобы увидеть, как выглядит assert_eq!, когда тест не проходит. Измените реализацию функции add_two, чтобы она вместо этого прибавляла 3:

pub fn add_two(a: u64) -> u64 {
    a + 3
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}

Снова запустите тесты:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... FAILED

failures:

---- tests::it_adds_two stdout ----

thread 'tests::it_adds_two' panicked at src/lib.rs:12:9:
assertion `left == right` failed
  left: 5
 right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_adds_two

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

error: test failed, to rerun pass `--lib`

Наш тест поймал ошибку! Тест tests::it_adds_two провалился, и сообщение говорит, что провалившаяся проверка была left == right, а также показывает значения left и right. Это сообщение помогает начать отладку: аргумент left, где был результат вызова add_two(2), равен 5, а аргумент right равен 4. Можно представить, насколько это особенно полезно, когда идет много тестов.

Обратите внимание, что в некоторых языках и тестовых фреймворках параметры функций проверки равенства называются expected и actual, и порядок указания аргументов важен. Однако в Rust они называются left и right, и порядок, в котором мы указываем ожидаемое значение и значение, произведенное кодом, не важен. Мы могли бы записать утверждение в этом тесте как assert_eq!(4, result), что привело бы к тому же сообщению об ошибке, где показано assertion `left == right` failed.

Макрос assert_ne! пройдет, если два переданных ему значения не равны, и провалится, если они равны. Этот макрос наиболее полезен в случаях, когда мы не уверены, каким значение будет, но точно знаем, каким оно не должно быть. Например, если мы тестируем функцию, которая гарантированно каким-то образом изменяет свой вход, но то, как именно вход меняется, зависит от дня недели, когда мы запускаем тесты, лучшим утверждением может быть то, что вывод функции не равен входу.

Под капотом макросы assert_eq! и assert_ne! используют операторы == и != соответственно. Когда утверждения проваливаются, эти макросы печатают свои аргументы с помощью отладочного форматирования, а значит сравниваемые значения должны реализовывать трейты PartialEq и Debug. Все примитивные типы и большинство типов стандартной библиотеки реализуют эти трейты. Для структур и enum, которые вы определяете сами, нужно реализовать PartialEq, чтобы утверждать равенство таких типов. Также нужно реализовать Debug, чтобы печатать значения при провале утверждения. Поскольку оба трейта являются выводимыми, как упоминалось в листинге 5-12 главы 5, обычно достаточно добавить аннотацию #[derive(PartialEq, Debug)] к определению структуры или enum. Подробнее об этих и других выводимых трейтах см. приложение C, «Выводимые трейты».

Добавление собственных сообщений об ошибках

К сообщениям о сбое можно добавить собственное сообщение как необязательные аргументы макросов assert!, assert_eq! и assert_ne!. Любые аргументы, указанные после обязательных, передаются макросу format! (он обсуждался в разделе «Конкатенация с помощью + или format!» главы 8), поэтому можно передать строку формата с заполнителями {} и значения для этих заполнителей. Собственные сообщения полезны для документирования смысла проверки; когда тест проваливается, вам будет проще понять, в чем проблема с кодом.

Например, допустим, у нас есть функция, которая приветствует людей по имени, и мы хотим проверить, что имя, переданное в функцию, присутствует в выводе:

Файл: src/lib.rs

pub fn greeting(name: &str) -> String {
    format!("Hello {name}!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

Требования к этой программе еще не согласованы, и мы почти уверены, что текст Hello в начале приветствия изменится. Мы решили, что не хотим обновлять тест при изменении требований, поэтому вместо проверки точного равенства значению, возвращаемому функцией greeting, мы просто утверждаем, что вывод содержит текст входного параметра.

Теперь внесем ошибку в этот код, изменив greeting так, чтобы она исключала name, и посмотрим, как выглядит сообщение о провале теста по умолчанию:

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

Запуск этого теста дает следующий результат:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----

thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
assertion failed: result.contains("Carol")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

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

error: test failed, to rerun pass `--lib`

Этот результат только показывает, что проверка провалилась, и указывает строку, на которой она находится. Более полезное сообщение о сбое напечатало бы значение, возвращенное функцией greeting. Добавим собственное сообщение об ошибке, составленное из строки формата с заполнителем, заполненным фактическим значением, которое мы получили от функции greeting:

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(
            result.contains("Carol"),
            "Greeting did not contain name, value was `{result}`"
        );
    }
}

Теперь при запуске теста мы получим более информативное сообщение об ошибке:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.93s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----

thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
Greeting did not contain name, value was `Hello!`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

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

error: test failed, to rerun pass `--lib`

Мы видим значение, которое действительно получили, прямо в выводе теста; это поможет отладить, что произошло, вместо того чтобы только гадать, чего мы ожидали.

Проверка паник с помощью should_panic

Помимо проверки возвращаемых значений важно проверять, что наш код обрабатывает ошибочные условия так, как мы ожидаем. Например, рассмотрим тип Guess, который мы создали в главе 9, листинге 9-13. Другой код, использующий Guess, полагается на гарантию, что экземпляры Guess будут содержать только значения от 1 до 100. Мы можем написать тест, который гарантирует, что попытка создать экземпляр Guess со значением вне этого диапазона вызовет панику.

Мы делаем это, добавляя атрибут should_panic к тестовой функции. Тест проходит, если код внутри функции паникует; тест проваливается, если код внутри функции не паникует.

Листинг 11-8 показывает тест, который проверяет, что ошибочные условия Guess::new возникают тогда, когда мы ожидаем.

Filename: src/lib.rs
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}
Listing 11-8: Проверка того, что условие вызовет panic!

Мы помещаем атрибут #[should_panic] после атрибута #[test] и перед тестовой функцией, к которой он применяется. Посмотрим на результат, когда этот тест проходит:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... ok

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

   Doc-tests guessing_game

running 0 tests

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

Выглядит хорошо! Теперь внесем ошибку в код, удалив условие, по которому функция new должна паниковать, если значение больше 100:

pub struct Guess {
    value: i32,
}

// --snip--
impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

Когда мы запустим тест из листинга 11-8, он провалится:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----
note: test did not panic as expected at src/lib.rs:21:8

failures:
    tests::greater_than_100

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

error: test failed, to rerun pass `--lib`

В этом случае мы не получаем особенно полезного сообщения, но когда смотрим на тестовую функцию, видим, что она аннотирована #[should_panic]. Полученный сбой означает, что код в тестовой функции не вызвал панику.

Тесты, использующие should_panic, могут быть неточными. Тест с should_panic пройдет даже если он паникует по причине, отличной от той, которую мы ожидали. Чтобы сделать тесты с should_panic точнее, можно добавить к атрибуту should_panic необязательный параметр expected. Тестовая обвязка проверит, что сообщение о сбое содержит предоставленный текст. Например, рассмотрим измененный код для Guess в листинге 11-9, где функция new паникует с разными сообщениями в зависимости от того, слишком мало значение или слишком велико.

Filename: src/lib.rs
pub struct Guess {
    value: i32,
}

// --snip--

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be greater than or equal to 1, got {value}."
            );
        } else if value > 100 {
            panic!(
                "Guess value must be less than or equal to 100, got {value}."
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}
Listing 11-9: Проверка panic! с сообщением паники, содержащим указанную подстроку

Этот тест пройдет, потому что значение, которое мы поместили в параметр expected атрибута should_panic, является подстрокой сообщения, с которым паникует функция Guess::new. Мы могли бы указать все ожидаемое сообщение паники целиком; в этом случае это было бы Guess value must be less than or equal to 100, got 200. Что именно указывать, зависит от того, какая часть сообщения паники уникальна или динамична и насколько точным вы хотите сделать тест. В этом случае подстроки сообщения паники достаточно, чтобы убедиться, что код в тестовой функции выполняет ветку else if value > 100.

Чтобы увидеть, что происходит, когда тест should_panic с сообщением expected проваливается, снова внесем ошибку в код, поменяв местами тела блоков if value < 1 и else if value > 100:

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be less than or equal to 100, got {value}."
            );
        } else if value > 100 {
            panic!(
                "Guess value must be greater than or equal to 1, got {value}."
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

На этот раз при запуске тест should_panic провалится:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----

thread 'tests::greater_than_100' panicked at src/lib.rs:12:13:
Guess value must be greater than or equal to 1, got 200.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
      panic message: "Guess value must be greater than or equal to 1, got 200."
 expected substring: "less than or equal to 100"

failures:
    tests::greater_than_100

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

error: test failed, to rerun pass `--lib`

Сообщение о провале указывает, что этот тест действительно запаниковал, как мы и ожидали, но сообщение паники не содержало ожидаемую строку less than or equal to 100. Сообщение паники, которое мы получили в этом случае, было Guess value must be greater than or equal to 1, got 200. Теперь можно начать выяснять, где находится ошибка!

Использование Result<T, E> в тестах

До сих пор все наши тесты паниковали при провале. Мы также можем писать тесты, которые используют Result<T, E>! Вот тест из листинга 11-1, переписанный для использования Result<T, E> и возврата Err вместо паники:

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() -> Result<(), String> {
        let result = add(2, 2);

        if result == 4 {
            Ok(())
        } else {
            Err(String::from("two plus two does not equal four"))
        }
    }
}

Теперь функция it_works имеет возвращаемый тип Result<(), String>. В теле функции вместо вызова макроса assert_eq! мы возвращаем Ok(()), когда тест проходит, и Err со String внутри, когда тест проваливается.

Написание тестов, возвращающих Result<T, E>, позволяет использовать оператор вопросительного знака в теле тестов. Это бывает удобным способом писать тесты, которые должны провалиться, если любая операция внутри них возвращает вариант Err.

Нельзя использовать аннотацию #[should_panic] для тестов, которые используют Result<T, E>. Чтобы утверждать, что операция возвращает вариант Err, не используйте оператор вопросительного знака для значения Result<T, E>. Вместо этого используйте assert!(value.is_err()).

Теперь, когда вы знаете несколько способов писать тесты, посмотрим, что происходит при запуске тестов, и изучим разные параметры, которые можно использовать с cargo test.