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

Organizacja testów

Jak wspomniano na początku rozdziału, testowanie jest złożoną dyscypliną, a różni ludzie używają różnej terminologii i organizacji. Społeczność Rust rozważa testy w kategoriach dwóch głównych kategorii: testów jednostkowych i testów integracyjnych. Testy jednostkowe są małe i bardziej skoncentrowane, testują jeden moduł w izolacji, i mogą testować interfejsy prywatne. Testy integracyjne są całkowicie zewnętrzne w stosunku do Twojej biblioteki i używają Twojego kodu w taki sam sposób, jak każdy inny kod zewnętrzny, używając tylko publicznego interfejsu i potencjalnie testując wiele modułów na test.

Pisanie obu rodzajów testów jest ważne, aby upewnić się, że części twojej biblioteki działają tak, jak tego oczekujesz, zarówno oddzielnie, jak i razem.

Testy jednostkowe

Celem testów jednostkowych jest przetestowanie każdej jednostki kodu w izolacji od reszty kodu, aby szybko zlokalizować, gdzie kod działa, a gdzie nie, zgodnie z oczekiwaniami. Testy jednostkowe umieszcza się w katalogu src w każdym pliku z kodem, który testują. Konwencją jest tworzenie w każdym pliku modułu o nazwie tests, który zawiera funkcje testowe i oznaczanie modułu atrybutem cfg(test).

Moduł tests i #[cfg(test)]

Adnotacja #[cfg(test)] na module tests informuje Rust, aby kompilował i uruchamiał kod testowy tylko wtedy, gdy uruchamiasz cargo test, a nie gdy uruchamiasz cargo build. To oszczędza czas kompilacji, gdy chcesz tylko zbudować bibliotekę, i oszczędza miejsce w wynikowym skompilowanym artefakcie, ponieważ testy nie są włączone. Zobaczysz, że ponieważ testy integracyjne znajdują się w innym katalogu, nie potrzebują adnotacji #[cfg(test)]. Jednakże, ponieważ testy jednostkowe znajdują się w tych samych plikach co kod, będziesz używał #[cfg(test)] do określenia, że nie powinny być one uwzględniane w skompilowanym wyniku.

Pamiętaj, że kiedy generowaliśmy nowy projekt adder w pierwszej sekcji tego rozdziału, Cargo wygenerował dla nas ten kod:

Nazwa pliku: 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);
    }
}

Na automatycznie wygenerowanym module tests atrybut cfg oznacza konfigurację i informuje Rust, że następny element powinien być uwzględniony tylko przy określonej opcji konfiguracji. W tym przypadku opcją konfiguracji jest test, która jest dostarczana przez Rust do kompilowania i uruchamiania testów. Używając atrybutu cfg, Cargo kompiluje nasz kod testowy tylko wtedy, gdy aktywnie uruchamiamy testy za pomocą cargo test. Dotyczy to wszelkich funkcji pomocniczych, które mogą znajdować się w tym module, oprócz funkcji oznaczonych #[test].

Testy funkcji prywatnych

Istnieje debata w środowisku testowym na temat tego, czy funkcje prywatne powinny być testowane bezpośrednio, a inne języki utrudniają lub uniemożliwiają testowanie funkcji prywatnych. Niezależnie od tego, której ideologii testowania przestrzegasz, zasady prywatności Rust pozwalają testować funkcje prywatne. Rozważ kod w Listingu 11-12 z prywatną funkcją internal_adder.

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

Zauważ, że funkcja internal_adder nie jest oznaczona jako pub. Testy to po prostu kod Rust, a moduł tests to tylko kolejny moduł. Jak omówiliśmy w sekcji „Ścieżki do odwoływania się do elementu w drzewie modułów”, elementy w podmodułach mogą używać elementów w swoich modułach nadrzędnych. W tym teście wprowadzamy wszystkie elementy należące do rodzica modułu tests do zasięgu za pomocą use super::*, a następnie test może wywołać internal_adder. Jeśli nie uważasz, że funkcje prywatne powinny być testowane, w Rust nie ma niczego, co by Cię do tego zmuszało.

Testy integracyjne

W Rust testy integracyjne są całkowicie zewnętrzne w stosunku do twojej biblioteki. Używają one twojej biblioteki w taki sam sposób, jak każdy inny kod, co oznacza, że mogą wywoływać tylko funkcje, które są częścią publicznego API twojej biblioteki. Ich celem jest sprawdzenie, czy wiele części twojej biblioteki działa poprawnie razem. Jednostki kodu, które działają poprawnie samodzielnie, mogą mieć problemy po zintegrowaniu, dlatego pokrycie testowe zintegrowanego kodu jest również ważne. Aby utworzyć testy integracyjne, najpierw potrzebujesz katalogu tests.

Katalog tests

Tworzymy katalog tests na najwyższym poziomie naszego katalogu projektu, obok src. Cargo wie, że powinien szukać plików testów integracyjnych w tym katalogu. Możemy następnie tworzyć dowolną liczbę plików testowych, a Cargo skompiluje każdy z tych plików jako oddzielną skrzynkę.

Utwórzmy test integracyjny. Z kodem z Listingu 11-12, który nadal znajduje się w pliku src/lib.rs, utwórz katalog tests i stwórz nowy plik o nazwie tests/integration_test.rs. Struktura katalogów powinna wyglądać następująco:

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

Wprowadź kod z Listingu 11-13 do pliku tests/integration_test.rs.

use adder::add_two;

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

Każdy plik w katalogu tests jest oddzielną skrzynką, więc musimy wprowadzić naszą bibliotekę do zasięgu każdej skrzynki testowej. Z tego powodu na początku kodu dodajemy use adder::add_two;, czego nie potrzebowaliśmy w testach jednostkowych.

Nie musimy adnotować żadnego kodu w tests/integration_test.rs atrybutem #[cfg(test)]. Cargo traktuje katalog tests w specjalny sposób i kompiluje pliki w tym katalogu tylko wtedy, gdy uruchamiamy cargo test. Uruchom cargo test teraz:

$ 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

Trzy sekcje wyników obejmują testy jednostkowe, testy integracyjne i testy dokumentacji. Zauważ, że jeśli jakikolwiek test w sekcji zakończy się niepowodzeniem, następne sekcje nie zostaną uruchomione. Na przykład, jeśli test jednostkowy zakończy się niepowodzeniem, nie będzie żadnych danych wyjściowych dla testów integracyjnych i dokumentacji, ponieważ te testy zostaną uruchomione tylko wtedy, gdy wszystkie testy jednostkowe przejdą.

Pierwsza sekcja dla testów jednostkowych jest taka sama, jak widzieliśmy wcześniej: jeden wiersz dla każdego testu jednostkowego (jeden o nazwie internal, który dodaliśmy w Listingu 11-12), a następnie wiersz podsumowania dla testów jednostkowych.

Sekcja testów integracyjnych zaczyna się od wiersza Running tests/integration_test.rs. Następnie, dla każdej funkcji testowej w tym teście integracyjnym znajduje się wiersz i wiersz podsumowania wyników testu integracyjnego tuż przed sekcją Doc-tests adder.

Każdy plik testów integracyjnych ma swoją własną sekcję, więc jeśli dodamy więcej plików do katalogu tests, będzie więcej sekcji testów integracyjnych.

Nadal możemy uruchomić konkretną funkcję testową integracji, określając nazwę funkcji testowej jako argument dla cargo test. Aby uruchomić wszystkie testy w konkretnym pliku testowym integracji, użyj argumentu --test cargo test, po którym następuje nazwa pliku:

$ 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

To polecenie uruchamia tylko testy z pliku tests/integration_test.rs.

Podmoduły w testach integracyjnych

W miarę dodawania kolejnych testów integracyjnych, możesz chcieć utworzyć więcej plików w katalogu tests, aby pomóc w ich organizacji; na przykład, możesz grupować funkcje testowe według testowanej przez nie funkcjonalności. Jak wspomniano wcześniej, każdy plik w katalogu tests jest kompilowany jako oddzielna skrzynka, co jest przydatne do tworzenia oddzielnych zasięgów, aby dokładniej naśladować sposób, w jaki użytkownicy końcowi będą używać Twojej skrzynki. Oznacza to jednak, że pliki w katalogu tests nie mają tego samego zachowania, co pliki w src, o czym dowiedziałeś się w Rozdziale 7, dotyczącym sposobu dzielenia kodu na moduły i pliki.

Różne zachowanie plików z katalogu tests jest najbardziej zauważalne, gdy masz zestaw funkcji pomocniczych do użycia w wielu plikach testów integracyjnych i próbujesz postępować zgodnie z instrukcjami w sekcji „Dzielenie modułów na różne pliki” w Rozdziale 7, aby wyodrębnić je do wspólnego modułu. Na przykład, jeśli utworzymy tests/common.rs i umieścimy w nim funkcję o nazwie setup, możemy dodać do setup kod, który chcemy wywołać z wielu funkcji testowych w wielu plikach testowych:

Nazwa pliku: tests/common.rs

pub fn setup() {
    // setup code specific to your library's tests would go here
}

Kiedy ponownie uruchomimy testy, zobaczymy nową sekcję w wynikach testów dla pliku common.rs, mimo że ten plik nie zawiera żadnych funkcji testowych ani nie wywołaliśmy funkcji setup z żadnego miejsca:

$ 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

Pojawienie się common w wynikach testów z wyświetlonym running 0 tests nie było tym, czego chcieliśmy. Chcieliśmy tylko udostępnić kod innym plikom testów integracyjnych. Aby uniknąć pojawiania się common w wynikach testów, zamiast tworzenia tests/common.rs, utworzymy tests/common/mod.rs. Katalog projektu wygląda teraz tak:

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

Jest to starsza konwencja nazewnictwa, którą Rust również rozumie i o której wspomnieliśmy w sekcji „Alternatywne ścieżki plików” w Rozdziale 7. Nazwanie pliku w ten sposób informuje Rust, aby nie traktował modułu common jako pliku testów integracyjnych. Kiedy przeniesiemy kod funkcji setup do tests/common/mod.rs i usuniemy plik tests/common.rs, sekcja w wynikach testów przestanie się pojawiać. Pliki w podkatalogach katalogu tests nie są kompilowane jako oddzielne skrzynki ani nie mają sekcji w wynikach testów.

Po utworzeniu tests/common/mod.rs możemy go używać z dowolnego pliku testów integracyjnych jako modułu. Oto przykład wywołania funkcji setup z testu it_adds_two w tests/integration_test.rs:

Nazwa pliku: 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);
}

Zauważ, że deklaracja mod common; jest taka sama jak deklaracja modułu, którą zademonstrowaliśmy w Listingu 7-21. Następnie w funkcji testowej możemy wywołać funkcję common::setup().

Testy integracyjne dla binarnych skrzynek

Jeśli nasz projekt jest binarną skrzynką, która zawiera tylko plik src/main.rs i nie ma pliku src/lib.rs, nie możemy tworzyć testów integracyjnych w katalogu tests i wprowadzać funkcji zdefiniowanych w pliku src/main.rs do zasięgu za pomocą instrukcji use. Tylko skrzynki biblioteczne udostępniają funkcje, których mogą używać inne skrzynki; skrzynki binarne są przeznaczone do samodzielnego uruchamiania.

Jest to jeden z powodów, dla których projekty Rust, które udostępniają plik binarny, mają prosty plik src/main.rs, który wywołuje logikę znajdującą się w pliku src/lib.rs. Korzystając z tej struktury, testy integracyjne mogą testować skrzynkę biblioteczną za pomocą use, aby udostępnić ważną funkcjonalność. Jeśli ważna funkcjonalność działa, mała ilość kodu w pliku src/main.rs również będzie działać, a ta mała ilość kodu nie musi być testowana.

Podsumowanie

Funkcje testowe Rust zapewniają sposób na określenie, jak kod powinien działać, aby zapewnić, że będzie on nadal działał zgodnie z oczekiwaniami, nawet po wprowadzeniu zmian. Testy jednostkowe sprawdzają różne części biblioteki oddzielnie i mogą testować prywatne szczegóły implementacji. Testy integracyjne sprawdzają, czy wiele części biblioteki działa poprawnie razem, i używają publicznego API biblioteki do testowania kodu w taki sam sposób, w jaki będzie go używał kod zewnętrzny. Mimo że system typów i zasady własności Rust pomagają zapobiegać niektórym rodzajom błędów, testy są nadal ważne, aby zmniejszyć liczbę błędów logicznych związanych z oczekiwanym zachowaniem kodu.

Połączmy wiedzę zdobytą w tym i poprzednich rozdziałach, aby popracować nad projektem!