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 делит тесты на две основные категории: модульные и интеграционные. Модульные тесты (unit tests) — маленькие и узконаправленные: они проверяют один модуль в изоляции от остального кода и могут тестировать приватные интерфейсы. Интеграционные тесты (integration tests) находятся полностью вне вашей библиотеки и пользуются вашим кодом так же, как любой другой внешний код: только через публичный интерфейс, причём один тест может затрагивать сразу несколько модулей.

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

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

Задача модульных тестов — проверить каждую единицу кода отдельно от остального кода, чтобы быстро найти, где код работает как надо, а где нет. Модульные тесты кладут в папку src — в каждый файл рядом с тем кодом, который они проверяют. По соглашению в каждом файле создают модуль tests для тестовых функций и помечают его атрибутом cfg(test).

Модуль tests и #[cfg(test)]

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

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

Файл: 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);
    }
}

У автоматически созданного модуля tests атрибут cfg (от configuration — конфигурация) говорит Rust, что следующий за ним элемент нужно включать, только если задан определённый параметр конфигурации. Здесь это параметр test, который Rust задаёт при компиляции и запуске тестов. Благодаря атрибуту cfg Cargo компилирует тестовый код, только когда мы явно запускаем тесты через cargo test. Это касается не только функций с #[test], но и любых вспомогательных функций в этом модуле.

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

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

Файл: src/lib.rs
pub fn add_two(a: u64) -> u64 {
    internal_adder(a, 2)
}

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

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

    #[test]
    fn internal() {
        let result = internal_adder(2, 2);
        assert_eq!(result, 4);
    }
}
Листинг 11-12: Тестирование приватной функции

Обратите внимание: функция internal_adder не помечена pub. Тесты — это просто код на Rust, а модуль tests — просто ещё один модуль. Как мы обсуждали в разделе «Пути к элементам в дереве модулей», элементы дочерних модулей могут пользоваться элементами модулей-предков. В этом тесте мы подключаем все элементы родителя модуля tests через use super::*, и тест может вызвать internal_adder. Если вы считаете, что приватные функции тестировать не нужно, — Rust вас к этому никак не принуждает.

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

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

Папка tests

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

Создадим интеграционный тест. Оставив в src/lib.rs код из листинга 11-12, создайте папку tests и в ней новый файл tests/integration_test.rs. Структура папок должна выглядеть так:

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

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

Файл: tests/integration_test.rs
use adder::add_two;

#[test]
fn it_adds_two() {
    let result = add_two(2);
    assert_eq!(result, 4);
}
Листинг 11-13: Интеграционный тест функции из крейта adder

Каждый файл в папке tests — отдельный крейт, поэтому нашу библиотеку нужно подключать в область видимости каждого тестового крейта. Для этого в начале кода стоит use adder::add_two; — в модульных тестах такое не требовалось.

Помечать код в 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

В выводе три раздела: модульные тесты, интеграционный тест и документационные тесты. Учтите: если в каком-то разделе провалится хотя бы один тест, следующие разделы не запустятся. Например, если провалится модульный тест, вывода для интеграционных и документационных тестов не будет — они запускаются, только если все модульные тесты прошли.

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

Раздел интеграционных тестов начинается со строки Running tests/integration_test.rs. Дальше идёт по строке на каждую тестовую функцию этого интеграционного теста и итоговая строка с результатами — прямо перед началом раздела Doc-tests adder.

У каждого файла интеграционных тестов свой раздел, так что если добавить в папку tests ещё файлы, разделов интеграционных тестов станет больше.

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

$ 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.

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

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

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

Файл: tests/common.rs

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 появлялся в результатах тестов с пометкой running 0 tests. Мы просто хотели поделиться кодом с другими файлами интеграционных тестов. Чтобы common не появлялся в выводе, создадим не tests/common.rs, а tests/common/mod.rs. Теперь структура проекта такая:

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

Это старое соглашение об именах, которое Rust тоже понимает, — мы упоминали его в разделе «Альтернативные пути к файлам» главы 7. Такое имя файла говорит 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::add_two;

mod common;

#[test]
fn it_adds_two() {
    common::setup();

    let result = add_two(2);
    assert_eq!(result, 4);
}

Заметьте, что объявление mod common; — точно такое же объявление модуля, как в листинге 7-21. А в тестовой функции можно вызвать common::setup().

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

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

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

Итоги

Средства тестирования в Rust позволяют описать, как должен работать код, и убедиться, что он продолжает работать как ожидается, даже когда вы его меняете. Модульные тесты проверяют разные части библиотеки по отдельности и могут тестировать приватные детали реализации. Интеграционные тесты проверяют, что многие части библиотеки правильно работают вместе, и пользуются её публичным API — так же, как будет пользоваться внешний код. Хотя система типов и правила владения в Rust помогают предотвратить некоторые виды багов, тесты всё равно важны: они помогают ловить логические ошибки в том, как должен вести себя ваш код.

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