Тестирование

Program testing can be a very effective way to show the presence of bugs, but it is hopelessly inadequate for showing their absence.

Edsger W. Dijkstra, "The Humble Programmer" (1972)

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

Дейкстра, Эдсгер Вибе, «The Humble Programmer» (1972)

Давайте поговорим о том, как тестировать код на Rust. Мы не будем рассказывать о том, какой подход к тестированию Rust кода является верным. Есть много подходов, каждый из которых имеет свое представление о правильном написании тестов. Но все эти подходы используют одни и те же основные инструменты, и мы покажем вам синтаксис их использования.

Тесты с атрибутом test

В самом простом случае, тест в Rust — это функция, аннотированная атрибутом test. Давайте создадим новый проект Cargo, который будет называться adder:

$ cargo new adder
$ cd adder

При создании нового проекта, Cargo автоматически сгенерирует простой тест. Ниже представлено содержимое src/lib.rs:

#[test]
fn it_works() {
}

Обратите внимание на #[test]. Этот атрибут указывает, что это тестовая функция. В этом примере она не имеет тела. Но такого вида функции достаточно, чтобы удачно выполнить тест. Запуск тестов осуществляется командой cargo test.

$ cargo test
   Compiling adder v0.0.1 (file:///home/you/projects/adder)
     Running target/adder-91b3e234d4ed382a

running 1 test
test it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured

Cargo скомпилировал и запустил наши тесты. В результате мы получили выходные данные, поделенные на два раздела: один содержит информацию о тесте, который мы написали, а другой — информацию о тестах из документации. Но об этом позже. А сейчас посмотрим на эту строку:

test it_works ... ok

Обратите внимание на it_works. Это название нашей функции:

fn it_works() {
# }

Мы также получили итоговую строку:

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

Так почему же наш ничего не делающий тест был выполнен удачно? Любой тест, который не вызывает panic!, выполняется удачно, а любой тест, который вызывает panic!, выполняется неудачно. Давайте сделаем тест, который выполнится неудачно:

#[test]
fn it_works() {
    assert!(false);
}

assert! — это макрос, определенный в Rust, и принимающий один аргумент: если аргумент имеет значение true, то ничего не происходит; если аргумент является false, то вызывается panic!. Давайте запустим наши тесты снова:

$ cargo test
   Compiling adder v0.0.1 (file:///home/you/projects/adder)
     Running target/adder-91b3e234d4ed382a

running 1 test
test it_works ... FAILED

failures:

---- it_works stdout ----
        thread 'it_works' panicked at 'assertion failed: false', /home/steve/tmp/adder/src/lib.rs:3



failures:
    it_works

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured

thread '<main>' panicked at 'Some tests failed', /home/steve/src/rust/src/libtest/lib.rs:247

Rust сообщает, что наш тест выполнен неудачно:

test it_works ... FAILED

Это же отражается в итоговой строке:

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured

Мы также получаем ненулевой код состояния:

$ echo $?
101

Это бывает полезно, если вы хотите интегрировать cargo test в сторонний инструмент.

Мы можем инвертировать ожидаемый результат теста с помощью атрибута: should_panic:

#[test]
#[should_panic]
fn it_works() {
    assert!(false);
}

Теперь этот тест будет выполнен удачно, если вызывается panic!, и неудачно, если panic! не вызывается. Давайте попробуем:

$ cargo test
   Compiling adder v0.0.1 (file:///home/you/projects/adder)
     Running target/adder-91b3e234d4ed382a

running 1 test
test it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured

Rust предоставляет и другой макрос, assert_eq!, который проверяет равенство двух аргументов:

#[test]
#[should_panic]
fn it_works() {
    assert_eq!("Hello", "world");
}

А теперь этот тест будет выполнен удачно или неудачно? Из-за атрибута should_panic он завершится удачно:

$ cargo test
   Compiling adder v0.0.1 (file:///home/you/projects/adder)
     Running target/adder-91b3e234d4ed382a

running 1 test
test it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured

Тесты should_panic могут быть хрупкими, поскольку трудно гарантировать, что тест не вызовет панику по неожиданной причине. Чтобы помочь в этом аспекте, к атрибуту should_panic может быть добавлен необязательный параметр expected. Тогда тест также будет проверять, что сообщение об ошибке содержит ожидаемый текст. Ниже представлен более безопасный вариант приведенного выше примера:

#[test]
#[should_panic(expected = "assertion failed")]
fn it_works() {
    assert_eq!("Hello", "world");
}

Вот и все, что касается основ! Давайте напишем один «настоящий» тест:

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

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

Это распространенное использование макроса assert_eq!: вызывать некоторую функцию с известными аргументами и сравнить результат её вызова с ожидаемым результатом.

Тесты в модуле test

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

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

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

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

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

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


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

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

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

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

$ cargo test
    Updating registry `https://github.com/rust-lang/crates.io-index`
   Compiling adder v0.0.1 (file:///home/you/projects/adder)
     Running target/adder-91b3e234d4ed382a

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

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured

Работает!

Данный подход представляет собой использование модуля test, содержащего модульные тесты (unit tests). Любой код, задачей которого является только лишь тестирование небольшого кусочка функциональности, имеет смысл перенести в этот модуль. Но что если мы хотим написать «интеграционные тесты» (integration tests)? Для этого следует использовать директорию tests.

Тесты в директории tests

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

extern crate adder;

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

Выглядит примерно так же, как и наши предыдущие тесты, но есть некоторые отличия. Теперь сверху у нас extern crate adder. Это потому, что тесты в директории tests — это отдельный контейнер, и, следовательно, мы должны компоноваться с нашей библиотекой. Это также объясняет, почему директория tests — наиболее подходящее место для написания интеграционных тестов: они используют библиотеку, как это делал бы любой другой потребитель.

Давайте запустим их:

$ cargo test
   Compiling adder v0.0.1 (file:///home/you/projects/adder)
     Running target/adder-91b3e234d4ed382a

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

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

     Running target/lib-c18e7d3494509e74

running 1 test
test it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured

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

Это все, что касается директории tests. Модуль test здесь не нужен, так как здесь всё относится к тестам.

Давайте, наконец, перейдем к третьей части: тесты в документации.

Тесты в документации

Нет ничего лучше, чем документация с примерами. Нет ничего хуже, чем примеры, которые на самом деле не работают, потому что код изменился с тех пор, как была написана документация. Для того, чтобы такой ситуации не возникало, Rust поддерживает автоматический запуск примеров в документации. Вот дополненный src/lib.rs с примерами:

//! Контейнер `adder` предоставляет функции сложения чисел.
//!
//! # Examples
//!
//! ```
//! assert_eq!(4, adder::add_two(2));
//! ```

/// Эта функция прибавляет 2 к своему аргументу.
///
/// # Examples
///
/// ```
/// use adder::add_two;
///
/// assert_eq!(4, add_two(2));
/// ```
pub fn add_two(a: i32) -> i32 {
    a + 2
}

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

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

Обратите внимание на документацию уровня модуля, начинающуюся с //! и на документацию уровня функции, начинающуюся с ///. Документация Rust поддерживает Markdown в комментариях, поэтому блоки кода помечают тройными символами `. В комментарии документации обычно включают раздел # Examples, содержащий примеры, такие как этот. (Примечание переводчика: заголовок # Examples имеет особое значение: его нельзя написать по-другому или написать на русском языке, иначе Rust не найдёт примеров кода в документации.)

Давайте запустим тесты снова:

$ cargo test
   Compiling adder v0.0.1 (file:///home/steve/tmp/adder)
     Running target/adder-91b3e234d4ed382a

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

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

     Running target/lib-c18e7d3494509e74

running 1 test
test it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

   Doc-tests adder

running 2 tests
test add_two_0 ... ok
test _0 ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured

Теперь у нас запускаются все три вида тестов! Обратите внимание на имена тестов из документации: _0 генерируется для модульных тестов, и add_two_0 — для функциональных тестов. Цифры на конце будут увеличиваться автоматически, если вы добавите еще примеров. Например, при добавлении ещё одного функционального теста, он получит имя add_two_1.