Программирование PC Rust: написание автоматизированных тестов Fri, December 13 2024  

Поделиться

Нашли опечатку?

Пожалуйста, сообщите об этом - просто выделите ошибочное слово или фразу и нажмите Shift Enter.


Rust: написание автоматизированных тестов Печать
Добавил(а) microsin   

В своем эссе 1972 года «Скромный программист» Эдсгер В. Дейкстра сказал, что "тестирование программ может быть очень эффективным способом показать наличие ошибок, но оно безнадежно неадекватно для того, чтобы показать их отсутствие". Это не значит, что мы не должны пытаться проверить как можно больше!

Корректность в наших программах это степень реализации того, что наш код соответствует нашим намерениям. Rust был разработан с акцентом максимального обеспечения корректности программ, однако корректность это сложная тема, и её нелегко доказать. Система типов Rust берет на свои плечи большую часть этой нагрузки, однако система типов не может перехватить все. По этим соображениям в Rust включена поддержка написания автоматизированных программных тестов.

Например, мы пишем функцию add_two, которая добавляет 2 к любому числу, что мы ей передаем. Сигнатура этой функции принимает целое число как параметр, и возвращает целое число в качестве результата. Когда мы реализуем и скомпилируем эту функцию, Rust произведет все проверки типов для гарантии, что мы нигде не передали в эту функцию значение String или недопустимую ссылку. Но Rust не может проверить, что функция будет делать точно то, для чего мы намеревались её создать, т. е. возвращать параметр +2, но не параметр +10 или параметр -50! Это то место, где помогают тесты.

Например, мы можем написать тесты, которые утверждают, что когда мы передали 3 в функцию add_two, возвращаемое значение равно 5. Мы можем запускать эти тесты всякий раз, когда вносим изменения в код, чтобы убедиться, что не поменялось любое существующее корректное поведение.

Тестирование это сложный навык: хотя мы в одной главе не сможем охватить все тонкости написания хороших тестов, мы обсудим механики функционала тестирования Rust (здесь приведен перевод главы 11 обучающей документации по Rust, см. [1]). Давайте поговорим про аннотации и макросы, доступные для написания ваших тестов, поведение по умолчанию и опции, предоставленные для запуска ваших тестов, и как организовать тесты в блок unit-тестов и интеграционные тесты.

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

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

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

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

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

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

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

Давайте создадим новый library-проект, который назовем adder (от слова add - прибавить). Он будет складывать два числа:

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

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

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

#[cfg(test)]
mod tests { use super::*;
#[test] fn it_works() { let result = add(2, 2); assert_eq!(result, 4); } }

Листинг 11-1. Тест-модуль и функция, автоматически сгенерированные командой cargo new (файл src/lib.rs).

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

Пример тела тест-функции it_works использует макрос assert_eq! для утверждения результата, что результат сложения 2 и 2 (переменная result) равен 4. Это утверждение служит примером формата для типового теста. Давайте его запустим и посмотрим, как пройдет этот тест.

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

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.57s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
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

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

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

Есть возможность пометить тест как игнорируемый (ignored), так что он не запустится в определенном экземпляре; мы рассмотрим это позже в секции "Игнорирование некоторых тестов, если они не были запущены специально" этой главы. Поскольку здесь это не применялось, общая сводка показывает "0 ignored". Мы также можем передать аргумент команде cargo test, чтобы запустить только тесты, имя которых соответствует строке. Эта возможность называется фильтрацией тестов (filtering), и это мы рассмотрим в секции "Запуск подмножества тестов по имени". Мы также не применяли фильтрацию запущенных тестов, что показала общая сводка "0 filtered out".

Статистика "0 measured" предназначена для измерения производительности кода, benchmark-тестов. Benchmark-тесты на момент написания документации [1] были доступны только в nightly (т. е. в нестабильной сборке) Rust. Для дополнительной информации по benchmark-тестам см. документацию [3].

Следующая часть вывода теста начинается с "Doc-tests adder", это предназначается для результатов тестов документации. У нес пока нет каких-либо тестов документации, но Rust может компилировать любые примеры кода, которые появляются в API-документации разработчиков. Эта фича помогает держать в соответствии вашу документацию и ваш код. Мы обсудим, как писать тесты документации в секции "Documentation Comments as Tests" главы 14. Пока что мы игнорируем вывод Doc-tests.

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

pub fn add(left: usize, right: usize) -> usize {
    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 [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

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

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

#[cfg(test)]
mod tests { use super::*;
#[test] fn exploration() { let result = add(2, 2); assert_eq!(result, 4); } #[test] fn another() { panic!("Искусственный отказ этого теста"); } }

Листинг 11-3. Добавление второго теста, который не пройдет, потому что мы вызываем макрос panic!.

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

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [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:10:9: Искусственный отказ этого теста 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`

Листинг 11-4. Результаты теста, когда один из тестов не прошел (failed).

Вместо ok строка test tests::another показывает FAILED. Появились две новые секции между индивидуальными результатами тестов и общими результатами: первая показывает подробную причину для каждого отказа теста. В этом случае мы видим информацию о том, что another не прошла тест, потому что она паникует с сообщением 'Искусственный отказ этого теста' на строке 10 в файле src/lib.rs. Следующая секция сообщений результатов теста перечисляет просто имена отказавших тестов, что полезно, когда применяется много тестов, и было много подробных результатов неудачных тестов. Мы можем использовать имя неудачного теста чтобы проще и более подробно отладить его; мы дополнительно поговорим далее про способы запуска тестов в секции "Как управлять работой тестов".

В конце отображается общая сводка по тестам: результат нашего тестирования FAILED. Один тест прошел, и один не прошел.

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

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

В листинге 5-15 главы 5 (см. [2]) мы использовали структуру Rectangle и метод can_hold, которая повторена здесь в листинге 11-5. Давайте поместим этот код в файл src/lib.rs file, затем напишем некоторые тесты для него, используя макрос assert!.

#[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 } }

Листинг 11-5. Использование структуры Rectangle и его метода can_hold из главы 5.

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

#[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)); } }

Листинг 11-6. Тест для метода can_hold, который проверяет, действительно ли больший прямоугольник может содержать в себе меньший прямоугольник.

Обратите внимание, что мы добавили новую строку внутри модуля tests: use super::*;. Модуль tests это обычный модуль, который следует обычным правилам областей видимости, что мы обсуждали в секции "Пути для ссылок на элементы в дереве модулей" главы 7 [5]. Поскольку модуль 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 [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

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

#[cfg(test)]
mod tests { use super::*;
#[test] fn 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 [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 заменой знака "больше чем" на знак "меньше чем", когда сравниваем ширины прямоугольников:

// -- вырезано --
impl Rectangle { fn can_hold(&self, other: &Rectangle) -> bool { self.width < other.width && self.height > other.height } }

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

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished test [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 результат для выражения == expression, без печати значений, которые привели к такому результату.

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

pub fn add_two(a: i32) -> i32 {
    a + 2
}

#[cfg(test)]
mod tests { use super::*;
#[test] fn it_adds_two() { assert_eq!(4, add_two(2)); } }

Листинг 11-7. Тестирование функции add_two с помощью макроса assert_eq! (файл src/lib.rs).

Давайте проверим, что этот тест проходит:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [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

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

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

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

Запустим тесты снова:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [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:11:9: assertion `left == right` failed left: 4 right: 5 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`

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

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

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

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

Добавление своих сообщений о неудаче теста. Вы можете также добавить пользовательское сообщение, которое будет печататься вместе с сообщением отказа, как опциональные аргументы для макросов assert!, assert_eq! и assert_ne!. Любые аргументы, указанные после требуемых аргументов макроса, будут переданы в макрос format! (который обсуждался в секции "Конкатенация оператором + или макросом format!" главы 8, см. [6]), так что вы можете передать строку формата, содержащую позиции печати значений {} и в них сами печатаемые значения. Пользовательские сообщения полезны для документирования, что означает утверждение; когда тест не прошел, у вас будет больше идей по поводу причины проблемы в коде.

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

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

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

Давайте теперь введем баг в этот код путем исключения вывода name, чтобы увидеть, как сработает тест по умолчанию:

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

Запуск этого теста сгенерирует следующее сообщение:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished test [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:

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(
            result.contains("Carol"),
            "Приветствие не содержит имени, значение было `{}`",
            result
        );
    }

Когда мы после этого запустим тест, то увидим более информативное сообщение об ошибке:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished test [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: Приветствие не содержит имени, значение было `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-13 главы 9 (см. [7]). Другой код, который использует Guess, зависит от гарантии, что экземпляры Guess будут содержать значения только между 1 и 100. Мы можем написать тест, который гарантирует, что попытка создать экземпляр Guess со значением вне этого диапазона будет вызывать панику.

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

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

pub struct Guess {
    value: i32,
}

impl Guess { pub fn new(value: i32) -> Guess { if value < 1 || value > 100 { panic!("Значение Guess должно быть в интервале между 1 и 100, а было указано {value}."); }
Guess { value } } }

#[cfg(test)]
mod tests { use super::*;
#[test] #[should_panic] fn greater_than_100() { Guess::new(200); } }

Листинг 11-8. Проверка на возникновение ситуации, которая должна вызывать panic! (файл src/lib.rs).

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

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [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 вызывает панику, когда переданное в неё значение value больше 100:

// -- вырезано --
impl Guess { pub fn new(value: i32) -> Guess { if value < 1 { panic!("Значение Guess должно быть в интервале между 1 и 100, а было указано {value}."); }
Guess { value } } }

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

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [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
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 более точными, мы можем добавить опциональный параметр expected для атрибута should_panic. Тест удостоверится, что сообщение отказа содержит предоставленный текст. Для примера рассмотрим модифицированный код для Guess в листинге 11-9, где функция new вызывает панику с другими сообщениями, в зависимости от того, что было ли value слишком маленьким или слишком большим.

// -- вырезано --

impl Guess { pub fn new(value: i32) -> Guess { if value < 1 { panic!( "Значение Guess должно быть больше или равно 1, а было указано {value}." ); } else if value > 100 { panic!( "Значение Guess должно быть меньше или равно 100, а было указано {value}." ); }
Guess { value } } }

#[cfg(test)]
mod tests { use super::*;
#[test] #[should_panic(expected = "меньше или равно 100")] fn greater_than_100() { Guess::new(200); } }

Листинг 11-9. Проверка на вызов panic!, где сообщение паники содержит определенную подстроку.

Этот тест пройдет успешно, потому что значение, которое мы поместили в параметр expected атрибута should_panic, является частью строки сообщения, с которым происходит паника функции Guess::new. Мы могли бы указать все сообщение паники полностью, потому что знали заранее, что проверяемое на панику значение было 200. То, что вы решите указывать в атрибуте expected, зависит от того, насколько сообщение паники уникально или динамично, и насколько вы намереваетесь сделать точным ваш тест. В этом случае подстрока сообщения паники достаточно для проверки, что код в тест-функции обеспечивает выполнение ветки else if value > 100.

Чтобы увидеть, что произойдет, когда тест should_panic с ожидаемым сообщением не пройдет, давайте снова введем баг путем перестановки тел блоков веток if value < 1 и if value > 100:

        if value < 1 {
            panic!(
                "Значение Guess должно быть меньше или равно 100, а было указано {value}."
            );
        } else if value > 100 {
            panic!(
                "Значение Guess должно быть больше или равно 1, а было указано {value}."
            );
        }

На этот раз, когда мы запустим тест should_panic, он не пройдет:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [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:13: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`

Сообщение отказа теста показывает, что этот вызвал панику, как мы и ожидали, однако сообщение паники не включало в себе ожидаемую строку "меньше или равно 100". Сообщение паники, которое было получено в этом случае, было "Значение Guess должно быть больше или равно 1, а было указано 200". Теперь мы можем начать разбираться, где находится наш баг!

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

#[cfg(test)]
mod tests { #[test] fn it_works() -> Result< (), String> { if 2 + 2 == 4 { Ok(()) } else { Err(String::from("два плюс два не равно четыре")) } } }

Функция 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.

[Как управлять работой тестов]

Точно так же, как команда cargo run компилирует ваш код и запускает получившийся бинарник исполняемой программы, команда cargo test компилирует ваш код и запускает полученный бинарник теста. Поведение по умолчанию бинарника, который генерирует cargo test - запуск все тестов параллельно, и захват вывода, генерируемого во время работы тестов, предотвращая отображение выходных данных, что упрощает чтение вывода, относящегося к результатам теста. Однако вы можете указать опции командной строки для изменения этого поведения по умолчанию.

Некоторые опции командой строки прилагаются к команде cargo test, а некоторые к результирующему бинарнику тестов. Для разделения этих двух типов аргумента вы перечисляете аргументы для cargo test, затем вводите разделитель --, и затем указываете аргументы, предназначенные для бинарника тестов. Запуск команды cargo test --help отобразит опции, которые вы можете использовать вместе с cargo test, а запуск cargo test -- --help отобразит опции, которые вы можете использовать после разделителя --.

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

Для примера предположим, что ваши тесты запускают некоторый код, который создает на диске файл test-output.txt и записывает в него некоторые данные. Затем каждый тест читает этот файл и утверждает, что файл содержит определенное значение, которое отличается для каждого теста. Поскольку эти тесты запускаются одновременно, один тест может перезаписать файл в то время, как другой тест записывает и/или читает файл. Тогда второй тест завершится неудачно, но не потому что код был некорректен, а из-за того, что тесты мешали друг другу при параллельной работе. Одно из решений состоит в том, что каждый тест делает запись в свой отдельный файл; другое решение - запускать тесты по одному, не одновременно.

Если вы не хотите запускать тесты параллельно, или если в хотите более точно управлять количеством запускаемых для тестирования потоков, то можете использовать флаг --test-threads и количество потоков, которые хотите использовать для в тестового бинарника. Давайте рассмотрим следующий пример:

$ cargo test -- --test-threads=1

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

Отображение вывода функции. По умолчанию, если тест прошел, тестовая библиотека Rust захватывает все, что печатается в стандартный вывод. Например если мы вызовем println! в тесте, и тест пройдет, то мы не увидим в терминале вывод этого println!; мы увидим только строку, которая покажет, что тест прошел успешно. Если тест завершится неудачей, то мы увидим, чтоб было напечатано в стандартном выводе с остальной частью сообщения о сбое.

В качестве примера листинг 11-10 содержит глупую функцию, которая печатает значение своего параметра и возвращает 10, а также два теста, один из которых проходит, а другой не проходит.

fn prints_and_returns_10(a: i32) -> i32 {
    println!("Я получил значение {a}");
    10
}

#[cfg(test)]
mod tests { use super::*;
#[test] fn this_test_will_pass() { let value = prints_and_returns_10(4); assert_eq!(10, value); }
#[test] fn this_test_will_fail() { let value = prints_and_returns_10(8); assert_eq!(5, value); } }

Листинг 11-10. Тесты для функции, которая печатает println! (файл src/lib.rs).

Если вы запустите эти тесты командой cargo test, то увидите следующий вывод:

$ cargo test
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished test [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)
running 2 tests test tests::this_test_will_fail ... FAILED test tests::this_test_will_pass ... ok
failures:
---- tests::this_test_will_fail stdout ---- Я получил значение 8 thread 'tests::this_test_will_fail' panicked at src/lib.rs:19:9: assertion `left == right` failed left: 5 right: 10 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures: tests::this_test_will_fail
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`

Обратите внимание, что нигде в выводе не было видно текста "Я получил значение 4", которое печаталось, когда запустился успешно пройденный тест. Этот вывод был перехвачен. Вывод из проваленного теста "Я получил значение 8" был показан в общем сообщении теста, где также показана причина сбоя теста.

Если мы также хотим увидеть печатаемые значения и из успешно пройденных тестов, то можно указать Rust показывать сообщения успешных тестов опцией --show-output.

$ cargo test -- --show-output

Когда мы запустим тесты в листинге 11-10 еще раз с флагом --show-output, то увидим следующий вывод:

$ cargo test -- --show-output
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished test [unoptimized + debuginfo] target(s) in 0.60s
     Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)
running 2 tests test tests::this_test_will_fail ... FAILED test tests::this_test_will_pass ... ok
successes:
---- tests::this_test_will_pass stdout ---- Я получил значение 4
successes: tests::this_test_will_pass
failures:
---- tests::this_test_will_fail stdout ---- Я получил значение 8 thread 'tests::this_test_will_fail' panicked at src/lib.rs:19:9: assertion `left == right` failed left: 5 right: 10 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures: tests::this_test_will_fail
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`

Запуск подмножества тестов по имени. Иногда запуск всех тестов может занять много времени. Если вы работаете над определенной частью кода, то можете захотеть запускать только те тесты, которые относятся именно к этому коду. Вы можете выбрать, какие тесты запускать передачей команде cargo test в качестве аргумента имени теста или имен тестов, которые вы хотите запустить.

Чтобы продемонстрировать, как запустить подмножество тестов, мы сначала создадим 3 теста для нашей функции add_two, как показано в листинге 11-11, и выберем, какой из них запускать.

pub fn add_two(a: i32) -> i32 {
    a + 2
}

#[cfg(test)]
mod tests { use super::*;
#[test] fn add_two_and_two() { assert_eq!(4, add_two(2)); }
#[test] fn add_three_and_two() { assert_eq!(5, add_two(3)); }
#[test] fn one_hundred() { assert_eq!(102, add_two(100)); } }

Листинг 11-11. Три теста с тремя разными именами (файл src/lib.rs).

Если мы запустим тесты без передачи каких-либо аргументов, то все тесты запустятся параллельно:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 3 tests test tests::add_three_and_two ... ok test tests::add_two_and_two ... ok test tests::one_hundred ... ok
test result: ok. 3 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

Запуск одного теста. Мы можем передать имя любой тест-функции в команду cargo test, чтобы запустить только этот тест:

$ cargo test one_hundred
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.69s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test test tests::one_hundred ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s

Запустился только тест с именем one_hundred; другие два теста имеют другое имя, которое не совпало с указанным. Вывод в конце теста дает нам информацию, что у нас есть еще тесты, которые не были запущены: "2 filtered out".

Мы не можем указать таким способом несколько тестов; будет запущен только тест, появившийся первым после команды cargo test. Однако есть способ запустить несколько тестов.

Фильтрация для запуска нескольких тестов. Мы можем указать часть имени теста, и любой тест, имя которого совпадет с этим значением, будет запущен. Например, из-за того, что два наших теста содержат add, мы можем запустить два этих теста, запустив команду cargo test add:

$ cargo test add
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 2 tests test tests::add_three_and_two ... ok test tests::add_two_and_two ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

Эта команда запустит все тесты, у которых в имени встречается add, и тест с именем one_hundred будет отфильтрован. Также обратите внимание, что модуль, в котором появляется тест, становится частью имени теста, поэтому мы можем запустить все тесты в модуле путем фильтрации по имени модуля.

Игнорирование некоторых тестов, если они не были запущены специально. Иногда некоторые тесты могут занимать для своего выполнения очень много времени, так что вы вероятно захотите исключить их из большинства запусков команды cargo test. Вместо того, чтобы перечислять в качестве аргументов команды все тесты, которые вы хотели бы запустить, вы можете пометить длинные тесты атрибутом ignore, чтобы их исключить, как показано ниже:

#[test]
fn it_works() { assert_eq!(2 + 2, 4); }

#[test]
#[ignore]
fn expensive_test() { // код, который работает почти час }

После строки атрибута #[test] мы вводим строку #[ignore] для теста, который мы хотели бы исключить из выполнения командой cargo test. Теперь, когда мы запустим наши тесты, it_works запустится, но expensive_test не запустится:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.60s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 2 tests test expensive_test ... ignored test it_works ... ok
test result: ok. 1 passed; 0 failed; 1 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

В отчете показано, что функция expensive_test указана как проигнорированная (ignored). Если вы хотите запустить только игнорируемые тесты, то можно использовать команду cargo test -- --ignored:

$ cargo test -- --ignored
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test test expensive_test ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 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

С помощью управления, какие тесты запускать, а какие нет, вы можете гарантировать, что быстро получите результаты cargo test. Когда вы дошли до стадии разработки, при которой необходимо запустить игнорируемые тесты, и у вас есть время для ожидания их результатов, вы можете запустить вместо этого команду cargo test -- --ignored. Если вы хотите запустить все тесты, независимо от того, есть ли среди них игнорируемые, то вы можете выполнить команду cargo test -- --include-ignored.

[Организация теста]

Как уже упоминалось в начале этой главы, тестирование это сложная дисциплина, и разные люди используют здесь разную терминологию и организацию процесса. Комьюнити Rust разделяют тесты на 2 основные категории: unit-тесты и integration-тесты. Unit-тесты небольшие и более сфокусированы, тестируя один модуль в отдельности за один раз, и могут тестировать private-интерфейсы. Integration-тесты являются полностью внешними для вашей библиотеки, и они используют ваш код точно так же, как его использовал бы внешний код, используя только public-интерфейс и потенциально проверяя несколько модулей за один тест.

Написание обоих видов этих тестов важно для гарантии, что все части вашей библиотеки делают именно то, что вы от неё ожидаете, как её отдельные части, так и все вместе.

Unit-тесты. Назначение unit-тестов - проверка каждого элемента кода в изоляции от остального кода, чтобы быстро определить, где код работает, и где не работает как ожидалось. Вы поместите unit-тесты в директорию src в каждом файле с кодом, который они тестируют. По соглашению создается модуль с именем tests в каждом файле, где содержатся функции тестирования, и этот модуль помечается аннотацией cfg(test).

Модуль tests и #[cfg(test)]. Аннотация #[cfg(test)] на модуле tests говорит Rust компилировать и запускать тест-код только когда вы запускаете команду cargo test, но не когда запускаете cargo build. Это экономит время компиляции, когда вы хотите только скомпилировать библиотеку, и экономит место на диске, потому что не создаются артефакты кода, относящиеся к тестам, поскольку они не компилируются. Вы увидите, что поскольку integration-тесты идут в другой директории, им не нужна аннотация #[cfg(test)]. Однако, поскольку unit-тесты находятся в тех же файлах, что и рабочий код, вы будете использовать #[cfg(test)] чтобы указать, что они не должны быть включены в компилируемый результат.

Вспомните, что когда мы генерировали новый проект adder в первой части этой главы, Cargo сгенерировал для нас следующий код (файл src/lib.rs):

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

#[cfg(test)]
mod tests { use super::*;
#[test] fn it_works() { let result = add(2, 2); assert_eq!(result, 4); } }

Этот код является автоматически созданным модулем тестирования. Атрибут cfg обозначает конфигурацию и говорит для Rust, что следующий элемент должен быть включен только с определенной опцией конфигурации. В этом случае опцией конфигурации является test, которая предоставляется Rust для компиляции и запуска тестов. С помощью использования атрибута cfg утилита Cargo компилирует наш код тестирования только если мы активируем запуск тестов командой cargo test. Это включает любые вспомогательные функции, которые могут быть в этом модуле, в дополнение к функциям, помеченным аннотацией #[test].

Тестирование приватных функций. В сообществе тестирования ведутся споры от том, должны или нет напрямую тестироваться private-функции, и другие языки делают сложным или невозможным тест private-функций. Независимо от того к какой идеологии тестирования придерживаетесь вы, правила приватности Rust позволяют вам тестировать приватные функции. Рассмотрим код из листинга 11-12 с приватной функцией internal_adder.

pub fn add_two(a: i32) -> i32 {
    internal_adder(a, 2)
}

fn internal_adder(a: i32, b: i32) -> i32 { a + b }

#[cfg(test)]
mod tests { use super::*;
#[test] fn internal() { assert_eq!(4, internal_adder(2, 2)); } }

Листинг 11-12. Тестирование private-функции internal_adder (src/lib.rs).

Обратите внимание, что функция internal_adder не была помечена как pub. Тесты это всего лишь код Rust, и модуль tests это просто еще один другой модуль. Как мы обсуждали в секции "Пути для ссылок на элементы в дереве модулей" главы 7 [5], элементы в дочерних модулях могут использовать элементы в модулях своих родителей. В этом тесте мы приводим все родительские элементы модуля tests в область действия с помощью использования super::*, и затем тест может вызвать internal_adder. Если вы не думаете, что должны быть вызваны private функции, то в Rust нет ничего, что заставит вас это сделать.

Integration-тесты. В Rust integration-тесты являются полностью внешние по отношению к вашей библиотеке. Они используют вашу библиотеку точно таким же способом, как и любой другой код. Это означает, что что интеграционные тесты могут вызывать только те функции, которые являются частью public API вашей библиотеки. Цель этих тестов - проверить, правильно ли работают многие части вашей библиотеки. Элементы кода, которые корректно работают сами по себе, могут иметь проблемы при интеграции, поэтому тестовое покрытие интеграционным кодом также важно. Для создания integration-тестов, вам для начала нужно создать директорию tests.

Директория tests. Мы создаем директорию tests на верхнем уровне директории вашего проекта, рядом с папкой src. Cargo знает, что надо искать файлы integration-тестов в директории tests. Мы затем можем создать столько файлов теста, сколько захотим, и Cargo будет компилировать каждый из этих файлов в отельный крейт (crate).

Давайте создадим integration-тест. С кодом из листинга 11-12, который все еще находится в файле src/lib.rs, создайте директорию tests, и создайте новый файл с именем tests/integration_test.rs. Ваша структура директории проекта будет должна выглядеть следующим образом:

adder
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    └── integration_test.rs

Введите код из листинга 11-13 в файл tests/integration_test.rs:

use adder::add_two;

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

Листинг 11-13. Integration-тест функции в крейте adder.

Каждый файл в директории tests это отдельный крейт, так что нам нужно привести нашу библиотеку в область действия каждого крейта теста. По этой причине мы используем adder::add_two в начале кода, что не нужно было делать в unit-тестах.

Нам не нужно помечать любой код в tests/integration_test.rs атрибутом #[cfg(test)]. Cargo обрабатывает директорию tests специальным образом, и компилирует файлы из этой директории только когда мы запускаем команду cargo test. Теперь запустите cargo test:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.31s
     Running unittests src/lib.rs (target/debug/deps/adder-1082c4b063a8fbe6)
running 1 test test tests::internal ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/integration_test.rs (target/debug/deps/integration_test-1082c4b063a8fbe6)
running 1 test test 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

Здесь 3 секции вывода включают unit-тесты, integration-тесты и doc-тесты. Обратите внимание, что если любой тест в секции не прошел (fails), то последующие секции не запускаются. Например, если unit-тест не прошел, то не будет никакого вывода для тестов integration и doc, потому что эти тесты запускаются только если успешно прошли все unit-тесты.

Первая секция для unit-тестов такая же, как мы уже видели: одна строка для каждого unit-теста (один называется internal, который мы добавили в листинге 11-12), и затем строка итогов (summary) для unit-тестов.

Секция integration-тестов начинается со строки "Running tests/integration_test.rs". Далее идут строки по одной для каждой тест-функции в этом integration-тесте, и строка итогов (summary) для результатов integration-теста, непосредственно перед началом секции Doc-tests adder.

Каждый файл integration-теста имеет свою собственную секцию, так что если мы добавим больше файлов в директорию tests, то здесь будет больше секций integration-теста.

Мы все еще можем запустить отдельную функцию integration-теста путем указания имени тест-функции как аргумент в команде cargo test. Для запуска всех тестов в определенном файле integration-теста, используйте аргумент --test для команды cargo, за которым идет имя файла:

$ cargo test --test integration_test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.64s
     Running tests/integration_test.rs (target/debug/deps/integration_test-82e7799c1bc62298)
running 1 test test it_adds_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Эта команда запустит только тесты в файле tests/integration_test.rs.

Субмодули в integration-тестах. По мере того, как вы добавляете больше integration-тестов, вы можете сделать больше файлов в директории tests, чтобы помочь организовать их. Например, вы можете группировать тест-функции по функционалу, который они тестируют. Как упоминалось ранее, каждый файл в директории tests компилируется как собственный отдельный крейт, что полезно для создания отдельных областей, чтобы более точно имитировать то, как конечные пользователи будут использовать ваш крейт. Однако это означает, что файлы в директории tests не имеют такое же поведение, как файлы в директории src, как вы узнали в главе 7 про разделение кода на модули и файлы [5].

Различное поведение файлов директории tests наиболее заметно, когда у вас есть набор вспомогательных функций (helper functions) для использования в нескольких файлах integration-тестов, и вы пытаетесь выполнить шаги, описанные в секции "Разделение модулей на отдельные файлы" главы 7 [5] для извлечения их в общий модуль. Например, если мы создаем файл tests/common.rs, и помещаем в него функцию setup, то мы можем добавить некоторый код в setup, который мы хотим вызвать из нескольких тест-функций в нескольких тест-файлах:

pub fn setup() {
    // здесь будет код настройки, специфичный для вашей библиотеки
}

Когда мы снова запустим тесты, то увидим новую секцию в выводе теста для файла common.rs, хотя этот файл не содержит никаких тестовых функций и нигде не вызывал функцию setup:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.89s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test test tests::internal ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/common.rs (target/debug/deps/common-92948b65e88960b4)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/integration_test.rs (target/debug/deps/integration_test-92948b65e88960b4)
running 1 test test 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

Появление common в результатах теста с отображаемым 0 запущенных тестов это не то, что мы хотели. Нам просто было нужно сделать общим некоторый код, используемый другими файлами integration-тестов.

Чтобы избежать появления common в выводе теста, вместо создания файла tests/common.rs мы создадим файл tests/common/mod.rs. Директория проекта теперь будет выглядеть следующим образом:

├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── common
    │   └── mod.rs
    └── integration_test.rs

Это старое соглашение именования, которое Rust также понимает, о чем мы упоминали во врезке "Альтернативные пути к файлам" главы 7 [5]. Именование файла таким способом говорит для Rust не обрабатывать модуль common как файл integration-теста. Когда мы перенесем код функции setup в файл tests/common/mod.rs и удалим файл tests/common.rs, соответствующая ему секций в выводе теста больше не появится. Файлы в подкаталогах директории tests не компилируются как отдельные крейты, или не имеют секций в выводе результатов теста.

После того, как мы создали файл tests/common/mod.rs, его можно использовать из любого файла integration-тестов как модуль. Вот пример вызова функции setup из теста it_adds_two в файле tests/integration_test.rs:

use adder;

mod common;

#[test]
fn it_adds_two() { common::setup(); assert_eq!(4, adder::add_two(2)); }

Обратите здесь внимание на декларацию mod common;, которая такая же, как декларация модуля, демонстрируемая в листинге 7-21 [5]. Затем в test-функции мы можем вызвать функцию common::setup().

Integration-тесты для двоичных крейтов. Если ваш проект это двоичный крейт (binary crate), который только содержит файл src/main.rs, и этого проекта нет файла src/lib.rs, мы не можем создать integration-тесты в директории tests и привести функции, определенные в файле src/main.rs, в область действия оператором use. Только библиотечные крейты предоставляют функции, которые могут использовать другие крейты; для бинарного крейта подразумевается, что он предназначен для самостоятельного запуска.

Это одна из причин, по которой проекты Rust, предоставляющие двоичный файл, имеют простой файл src/main.rs, который вызывает логику, живущую в файле src/lib.rs. Используя эту структуру, integration-тесты могут проверять библиотечный крейт, чтобы сделать доступным важный функционал. Если важная функциональность работает, то небольшой объем кода в файле src/main.rs будет также работать, и этот небольшой объем кода не нуждается в тестировании.

[Общие выводы]

Возможности по тестированию Rust предоставляют способ указать, как код должен функционировать, чтобы гарантировать его работу так, как вы ожидаете, даже при внесении в код изменений. Unit-тесты проверяют различные части библиотеки по отдельности, и могут тестировать подробности приватной реализации. Integration-тесты проверяют, что многие части библиотеки работают вместе корректно, и они используют public API библиотеки для тестирования кода таким же способом, как это делает внешний код, который будет использовать тестируемую библиотеку. Несмотря на то, что система типов Rust и правила владения помогают предотвратить некоторые виды ошибок, тесты по-прежнему важны для уменьшения количества логических ошибок, связанных тем, как код будет себя вести в контексте ваших ожиданий.

[Ссылки]

1. Rust Writing Automated Tests site:rust-lang.org.
2. Rust: использование структуры для взаимосвязанных данных.
3. Rust Unstable Book feature test site:rust-lang.org.
4. Rust: что такое Ownership.
5. Rust: управление проектами с помощью пакетов, крейтов и модулей.
6. Rust: коллекции стандартной библиотеки.
7. Rust: обработка ошибок.

 

Добавить комментарий


Защитный код
Обновить

Top of Page