Организация тестов

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

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

Модульные тесты

Целью модульных тестов является тестирование каждого блока кода, изолированное от остального функционала, чтобы можно было быстро понять, что работает некорректно или не так как ожидается. Мы разместим модульные тесты в папке src, в каждый тестируемый файл. Но в Rust принято создавать тестирующий модуль tests и код теста сохранять в файлы с таким же именем, как компоненты которые предстоит тестировать. Также необходимо добавить аннотацию cfg(test) к этому модулю.

Модуль тестов и аннотация #[cfg(test)]

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

Напомним, что когда мы генерировали новый проект adder в первом разделе этой главы, то Cargo сгенерировал для нас код ниже:

Файл: src/lib.rs


#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}
}

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

Тестирование приватных функций (private)

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

Файл: src/lib.rs


#![allow(unused)]
fn main() {
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: Тестирование приватных функций

Обратите внимание, что функция internal_adder не объявлена публичной (pub), но так как тесты являются обычным Rust кодом и модуль tests является просто другим модулем, у вас есть возможность подключить код use super::*; в область видимости тестов и вызвать его. Если же вы считаете, что приватные функции не должны быть тестируемыми, то Rust не будет вас в этом ограничивать, сопровождая предупреждениями компиляцию кода.

Интеграционные тесты

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

Каталог tests

Для создания интеграционных тестов, мы создаём папку tests в корневой папке вашего проекта, рядом с папкой src. Cargo знает, что файлы с интеграционными тестами будут храниться в этой директории. В этой директории можно создать столько интеграционных тестов, сколько нужно. Каждый такой файл будет компилироваться в отдельный крейт.

Давайте создадим интеграционный тест. Рядом с кодом из листинга 11-12, который в файле src/lib.rs, создайте каталог tests, создайте новый файл с именем tests/integration_test.rs и введите код из листинга 11-13.

Файл: tests/integration_test.rs

use adder;

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

Листинг 11-13: Интеграционная тест функция из крейта adder

Мы добавили use adder в начале кода, который был не нужен в модульных тестах. Причина его присутствия в том, что каждый файл в каталоге tests является отдельным крейтом, поэтому нужно подключить нашу библиотеку в область видимости каждого интеграционного тест крейта.

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

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 1.31s
     Running unittests (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

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

Раздел интеграционных тестов начинается со строкиRunning target/debug/deps/integration_test-ce99bcc2479f4607 (хеш в конце вашего вывода будет другим). Далее есть строка для каждой тестовой функции в этом интеграционном тесте и итоговая строка для результатов интеграционных тестов непосредственно перед началом раздела Doc-tests adder.

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

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

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

Подмодули в интеграционных тестах

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

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

Различное поведение файлов в каталоге tests наиболее заметно, когда у вас есть набор вспомогательных функций, которые будут полезны в нескольких интеграционных тест файлах и вы пытаетесь выполнить действия, описанные в разделе «Разделение модулей в разные файлы» главы 7, чтобы извлечь их в общий модуль. Например, если мы создадим tests/common.rs и поместим в него функцию с именем setup, то можно добавить некоторый код в setup, который мы хотим вызвать из нескольких тестовых функций в нескольких тестовых файлах

Файл: tests/common.rs


#![allow(unused)]
fn main() {
pub fn setup() {
    // setup code specific to your library's tests would go here
}
}

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

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.89s
     Running unittests (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 и появление сообщения в результатах выполнения тестов типа running 0 tests это не то, чтобы мы хотели. Мы только хотели использовать некоторый общий код с другими интеграционными файлами тестов.

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

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

Файл: 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. Затем в тестовой функции мы можем вызвать функцию common::setup().

Интеграционные тесты для бинарных крейтов

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

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

Итоги

Функции тестирования в Rust позволяют указать, как должен функционировать код, чтобы убедиться, что он продолжает работать как этого вы ожидаете, даже если вы вносите изменения. Модульные тесты проверяют разные части библиотеки по отдельности и могут тестировать частные детали реализации. Интеграционные тесты проверяют, что части библиотеки работают вместе правильно и они используют открытый API библиотеки для тестирования кода таким же образом, как внешний код будет использовать его. Хотя система типов Rust и правила владения помогают предотвратить некоторые виды ошибок, тесты по-прежнему важны для уменьшения количества логических ошибок, связанных с поведением вашего кода.

Давайте объединим знания полученные в этой и предыдущей главах, чтобы поработать над проектом!