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

Jak pisać testy

Testy to funkcje Rusta, które weryfikują, czy kod poza testami działa zgodnie z oczekiwaniami. Ciała funkcji testowych zazwyczaj wykonują trzy działania:

  • Przygotowanie potrzebnych danych lub stanu.
  • Uruchomienie kodu, który chcesz przetestować.
  • Sprawdzenie, czy wyniki są zgodne z oczekiwaniami.

Przyjrzyjmy się funkcjom, które Rust dostarcza specjalnie do pisania testów wykonujących te działania, w tym atrybutowi test, kilku makrom i atrybutowi should_panic.

Struktura funkcji testowych

Najprościej rzecz ujmując, test w Rust to funkcja opatrzona atrybutem test. Atrybuty to metadane dotyczące fragmentów kodu Rust; jednym z przykładów jest atrybut derive, którego używaliśmy ze strukturami w Rozdziale 5. Aby zmienić funkcję w funkcję testową, dodaj #[test] w linii przed fn. Kiedy uruchamiasz testy za pomocą polecenia cargo test, Rust buduje binarny runner testów, który uruchamia adnotowane funkcje i raportuje, czy każda funkcja testowa przeszła, czy nie powiodła się.

Ilekroć tworzymy nowy projekt biblioteczny za pomocą Cargo, automatycznie generowany jest dla nas moduł testowy z funkcją testową. Moduł ten udostępnia szablon do pisania testów, dzięki czemu nie musisz za każdym razem, gdy zaczynasz nowy projekt, szukać dokładnej struktury i składni. Możesz dodać tyle dodatkowych funkcji testowych i modułów testowych, ile tylko chcesz!

Przeanalizujemy niektóre aspekty działania testów, eksperymentując z szablonowym testem, zanim faktycznie przetestujemy jakikolwiek kod. Następnie napiszemy kilka rzeczywistych testów, które wywołają napisany przez nas kod i sprawdzą, czy jego zachowanie jest prawidłowe.

Stwórzmy nowy projekt biblioteczny o nazwie adder, który będzie dodawał dwie liczby:

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

Zawartość pliku src/lib.rs w Twojej bibliotece adder powinna wyglądać jak Listing 11-1.

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);
    }
}
Listing 11-1: Kod wygenerowany automatycznie przez cargo new

Plik zaczyna się od przykładowej funkcji add, abyśmy mieli coś do testowania.

Na razie skupmy się wyłącznie na funkcji it_works. Zwróć uwagę na adnotację #[test]: Ten atrybut wskazuje, że jest to funkcja testowa, więc program uruchamiający testy wie, aby traktować tę funkcję jako test. Możemy również mieć funkcje nietestowe w module tests, aby pomóc w konfigurowaniu typowych scenariuszy lub wykonywaniu typowych operacji, więc zawsze musimy wskazywać, które funkcje są testami.

Przykładowe ciało funkcji używa makra assert_eq!, aby sprawdzić, czy result, które zawiera wynik wywołania add z argumentami 2 i 2, jest równe 4. To twierdzenie służy jako przykład formatu typowego testu. Uruchommy je, aby zobaczyć, że ten test przechodzi.

Polecenie cargo test uruchamia wszystkie testy w naszym projekcie, jak pokazano w Listingu 11-2.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
     Running unittests src/lib.rs (target/debug/deps/adder-01ad14159ff659ab)

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

Listing 11-2: Wynik uruchomienia automatycznie wygenerowanego testu

Cargo skompilował i uruchomił test. Widzimy linię running 1 test. Następna linia pokazuje nazwę wygenerowanej funkcji testowej, nazwanej tests::it_works, oraz że wynik uruchomienia tego testu to ok. Ogólne podsumowanie test result: ok. oznacza, że wszystkie testy przeszły, a część 1 passed; 0 failed sumuje liczbę testów, które przeszły lub nie powiodły się.

Można oznaczyć test jako zignorowany, aby nie był uruchamiany w konkretnej instancji; omówimy to w sekcji „Ignorowanie testów, chyba że są specjalnie żądane” później w tym rozdziale. Ponieważ tego tutaj nie zrobiliśmy, podsumowanie pokazuje 0 ignored. Możemy również przekazać argument do polecenia cargo test, aby uruchomić tylko te testy, których nazwa pasuje do ciągu znaków; nazywa się to filtrowaniem, i omówimy to w sekcji „Uruchamianie podzbioru testów według nazwy”. Tutaj nie filtrowaliśmy uruchamianych testów, więc na końcu podsumowania wyświetla się 0 filtered out.

Statystyka 0 measured dotyczy testów wydajnościowych, które mierzą wydajność. Testy wydajnościowe, w chwili pisania tego tekstu, są dostępne tylko w nightly Rust. Więcej informacji znajdziesz w dokumentacji testów wydajnościowych.

Następna część danych wyjściowych testu, zaczynająca się od Doc-tests adder, dotyczy wyników wszelkich testów dokumentacyjnych. Nie mamy jeszcze żadnych testów dokumentacyjnych, ale Rust może kompilować wszelkie przykłady kodu, które pojawiają się w naszej dokumentacji API. Ta funkcja pomaga utrzymać dokumentację i kod w synchronizacji! Omówimy, jak pisać testy dokumentacyjne w sekcji „Komentarze dokumentacyjne jako testy” Rozdziału 14. Na razie zignorujemy dane wyjściowe Doc-tests.

Zacznijmy dostosowywać test do naszych własnych potrzeb. Najpierw zmień nazwę funkcji it_works na inną, taką jak exploration, w ten sposób:

Nazwa pliku: src/lib.rs

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

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

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

Następnie ponownie uruchom cargo test. Wynik pokazuje teraz exploration zamiast it_works:

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

Teraz dodamy kolejny test, ale tym razem stworzymy test, który zawiedzie! Testy zawodzą, gdy coś w funkcji testowej panikuje. Każdy test jest uruchamiany w nowym wątku, a gdy główny wątek widzi, że wątek testowy umarł, test jest oznaczany jako nieudany. W Rozdziale 9 rozmawialiśmy o tym, że najprostszym sposobem na panikę jest wywołanie makra panic!. Wprowadź nowy test jako funkcję o nazwie another, tak aby Twój plik src/lib.rs wyglądał jak Listing 11-3.

Nazwa pliku: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

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

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

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}
Listing 11-3: Dodawanie drugiego testu, który zakończy się niepowodzeniem, ponieważ wywołujemy makro panic!

Uruchom testy ponownie za pomocą cargo test. Wynik powinien wyglądać jak Listing 11-4, który pokazuje, że nasz test exploration przeszedł, a another zakończył się niepowodzeniem.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [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:17:9:
Make this test fail
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`
Listing 11-4: Wyniki testów, gdy jeden test przechodzi, a jeden kończy się niepowodzeniem

Zamiast ok, linia test tests::another pokazuje FAILED. Pojawiają się dwie nowe sekcje między indywidualnymi wynikami a podsumowaniem: Pierwsza wyświetla szczegółowy powód każdego niepowodzenia testu. W tym przypadku dostajemy szczegóły, że tests::another zawiódł, ponieważ spanikował z wiadomością Make this test fail w linii 17 w pliku src/lib.rs. Następna sekcja zawiera tylko nazwy wszystkich nieudanych testów, co jest przydatne, gdy jest dużo testów i dużo szczegółowych danych wyjściowych nieudanych testów. Możemy użyć nazwy nieudanego testu, aby uruchomić tylko ten test, aby łatwiej go debugować; więcej na temat sposobów uruchamiania testów opowiemy w sekcji „Kontrolowanie sposobu uruchamiania testów”.

Na koniec wyświetla się linia podsumowująca: Ogólnie, nasz wynik testów to FAILED. Jeden test przeszedł, a jeden zawiódł.

Teraz, gdy widziałeś, jak wyglądają wyniki testów w różnych scenariuszach, przyjrzyjmy się innym makrom niż panic!, które są przydatne w testach.

Sprawdzanie wyników za pomocą makra assert!

Makro assert!, dostarczane przez standardową bibliotekę, jest przydatne, gdy chcesz upewnić się, że jakiś warunek w teście ocenia się jako true. Podajemy makru assert! argument, który ocenia się do wartości boolowskiej. Jeśli wartość jest true, nic się nie dzieje i test przechodzi. Jeśli wartość jest false, makro assert! wywołuje panic!, aby spowodować niepowodzenie testu. Użycie makra assert! pomaga nam sprawdzić, czy nasz kod działa w sposób, w jaki zamierzamy.

W Rozdziale 5, Listing 5-15, użyliśmy struktury Rectangle i metody can_hold, które są powtórzone tutaj w Listingu 11-5. Umieśćmy ten kod w pliku src/lib.rs, a następnie napiszmy dla niego kilka testów za pomocą makra assert!.

Nazwa pliku: src/lib.rs
#[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
    }
}
Listing 11-5: Struktura Rectangle i jej metoda can_hold z Rozdziału 5

Metoda can_hold zwraca wartość boolowską, co oznacza, że jest idealnym przypadkiem użycia dla makra assert!. W Listingu 11-6 piszemy test, który wykorzystuje metodę can_hold, tworząc instancję Rectangle o szerokości 8 i wysokości 7 i twierdząc, że może ona pomieścić inną instancję Rectangle o szerokości 5 i wysokości 1.

Nazwa pliku: src/lib.rs
#[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
    }
}

#[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));
    }
}
Listing 11-6: Test dla can_hold, który sprawdza, czy większy prostokąt może rzeczywiście pomieścić mniejszy prostokąt

Zwróć uwagę na linię use super::*; w module tests. Moduł tests to zwykły moduł, który podlega zwykłym zasadom widoczności, które omówiliśmy w Rozdziale 7 w sekcji „Ścieżki do odwoływania się do elementu w drzewie modułów”. Ponieważ moduł tests jest modułem wewnętrznym, musimy wprowadzić kod poddawany testom z modułu zewnętrznego do zakresu modułu wewnętrznego. Używamy tutaj globa, więc wszystko, co zdefiniujemy w module zewnętrznym, jest dostępne dla tego modułu tests.

Nazwaliśmy nasz test larger_can_hold_smaller i utworzyliśmy dwie instancje Rectangle, których potrzebujemy. Następnie wywołaliśmy makro assert! i przekazaliśmy mu wynik wywołania larger.can_hold(&smaller). To wyrażenie powinno zwrócić true, więc nasz test powinien przejść. Sprawdźmy!

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

Przechodzi! Dodajmy kolejny test, tym razem sprawdzający, czy mniejszy prostokąt nie może pomieścić większego prostokąta:

Nazwa pliku: src/lib.rs

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

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

    #[test]
    fn larger_can_hold_smaller() {
        // --snip--
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(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));
    }
}

Ponieważ poprawny wynik funkcji can_hold w tym przypadku to false, musimy zanegować ten wynik, zanim przekażemy go do makra assert!. W rezultacie nasz test przejdzie, jeśli can_hold zwróci false:

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

Dwa testy, które przeszły! Zobaczmy teraz, co się stanie z wynikami naszych testów, gdy wprowadzimy błąd do naszego kodu. Zmienimy implementację metody can_hold, zastępując znak „większe niż” (>) znakiem „mniejsze niż” (<), gdy porównuje szerokości:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

// --snip--
impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width < other.width && self.height > other.height
    }
}

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

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

Uruchomienie testów teraz daje następujące wyniki:

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

Nasze testy wykryły błąd! Ponieważ larger.width wynosi 8, a smaller.width wynosi 5, porównanie szerokości w can_hold zwraca teraz false: 8 nie jest mniejsze niż 5.

Testowanie równości za pomocą makr assert_eq! i assert_ne!

Powszechnym sposobem weryfikacji funkcjonalności jest testowanie równości między wynikiem kodu poddanego testom a wartością, którą kod powinien zwrócić. Można to zrobić, używając makra assert! i przekazując mu wyrażenie używające operatora ==. Jest to jednak tak powszechny test, że standardowa biblioteka dostarcza parę makr — assert_eq! i assert_ne! — aby wygodniej wykonać ten test. Makra te porównują dwa argumenty pod kątem równości lub nierówności. Wyświetlają również dwie wartości, jeśli asercja zawiedzie, co ułatwia zrozumienie, dlaczego test zawiódł; z kolei makro assert! wskazuje tylko, że otrzymało wartość false dla wyrażenia ==, nie wyświetlając wartości, które doprowadziły do wartości false.

W Listingu 11-7 piszemy funkcję o nazwie add_two, która dodaje 2 do swego parametru, a następnie testujemy tę funkcję za pomocą makra assert_eq!.

Nazwa pliku: src/lib.rs
pub fn add_two(a: u64) -> u64 {
    a + 2
}

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

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}
Listing 11-7: Testowanie funkcji add_two za pomocą makra assert_eq!

Sprawdźmy, czy przechodzi!

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

Tworzymy zmienną o nazwie result, która przechowuje wynik wywołania add_two(2). Następnie przekazujemy result i 4 jako argumenty do makra assert_eq!. Linia wyjściowa dla tego testu to test tests::it_adds_two ... ok, a tekst ok wskazuje, że nasz test przeszedł!

Wprowadźmy błąd do naszego kodu, aby zobaczyć, jak wygląda assert_eq!, gdy zawodzi. Zmień implementację funkcji add_two, aby zamiast tego dodawała 3:

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

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

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

Uruchom testy ponownie:

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

Nasz test wykrył błąd! Test tests::it_adds_two nie powiódł się, a komunikat informuje nas, że asercja, która zawiodła, to left == right oraz jakie są wartości left i right. Ten komunikat pomaga nam rozpocząć debugowanie: argument left, gdzie mieliśmy wynik wywołania add_two(2), wynosił 5, ale argument right wynosił 4. Można sobie wyobrazić, że byłoby to szczególnie pomocne, gdybyśmy mieli wiele testów.

Zauważ, że w niektórych językach i frameworkach testowych parametry funkcji asercji równości nazywane są expected i actual, a kolejność, w jakiej określamy argumenty, ma znaczenie. Jednak w Rust nazywają się left i right, a kolejność, w jakiej określamy wartość, której oczekujemy, i wartość, którą produkuje kod, nie ma znaczenia. Moglibyśmy napisać asercję w tym teście jako assert_eq!(4, result), co spowodowałoby ten sam komunikat o błędzie, który wyświetla assertion `left == right` failed.

Makro assert_ne! przejdzie, jeśli dwie podane mu wartości nie są sobie równe, a zawiedzie, jeśli są równe. To makro jest najbardziej przydatne w przypadkach, gdy nie jesteśmy pewni, jaka będzie wartość, ale wiemy, jaka wartość zdecydowanie nie powinna być. Na przykład, jeśli testujemy funkcję, która ma gwarantować zmianę jej danych wejściowych w jakiś sposób, ale sposób zmiany danych wejściowych zależy od dnia tygodnia, w którym uruchamiamy nasze testy, najlepiej byłoby sprawdzić, czy wynik funkcji nie jest równy danym wejściowym.

Pod powierzchnią, makra assert_eq! i assert_ne! używają odpowiednio operatorów == i !=. Kiedy asercje zawiodą, te makra wypisują swoje argumenty za pomocą formatowania debuggowania, co oznacza, że porównywane wartości muszą implementować cechy PartialEq i Debug. Wszystkie typy prymitywne i większość typów standardowej biblioteki implementują te cechy. Dla struktur i wyliczeń, które sam definiujesz, będziesz musiał zaimplementować PartialEq, aby sprawdzić równość tych typów. Będziesz również musiał zaimplementować Debug, aby wyświetlić wartości, gdy asercja zawiedzie. Ponieważ obie cechy są cechami, które można wyprowadzić, jak wspomniano w Listingu 5-12 w Rozdziale 5, jest to zazwyczaj tak proste, jak dodanie anotacji #[derive(PartialEq, Debug)] do definicji struktury lub wyliczenia. Więcej szczegółów na temat tych i innych cech, które można wyprowadzić, znajdziesz w Dodatku C, „Cechy, które można wyprowadzić”.

Dodawanie niestandardowych komunikatów o błędach

Możesz również dodać niestandardowy komunikat do wydrukowania wraz z komunikatem o błędzie jako opcjonalne argumenty do makr assert!, assert_eq! i assert_ne!. Wszelkie argumenty określone po wymaganych argumentach są przekazywane do makra format! (omówionego w „Łączenie za pomocą + lub format! w Rozdziale 8), więc możesz przekazać ciąg formatujący, który zawiera znaczniki {} i wartości, które mają być umieszczone w tych znacznikach. Niestandardowe komunikaty są przydatne do dokumentowania, co oznacza asercja; gdy test zawiedzie, będziesz miał lepsze pojęcie o tym, na czym polega problem z kodem.

Na przykład, powiedzmy, że mamy funkcję, która wita ludzi po imieniu, i chcemy przetestować, czy imię, które przekazujemy do funkcji, pojawia się w danych wyjściowych:

Nazwa pliku: src/lib.rs

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

Wymagania dla tego programu nie zostały jeszcze uzgodnione, a my jesteśmy raczej pewni, że tekst „Hello” na początku pozdrowienia zmieni się. Postanowiliśmy, że nie chcemy aktualizować testu, gdy wymagania się zmienią, więc zamiast sprawdzać dokładną równość z wartością zwróconą przez funkcję greeting, będziemy tylko sprawdzać, czy wynik zawiera tekst parametru wejściowego.

Teraz wprowadźmy błąd do tego kodu, zmieniając greeting tak, aby wykluczało name, aby zobaczyć, jak wygląda domyślne niepowodzenie testu:

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

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

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

Uruchomienie tego testu daje następujące wyniki:

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

Ten wynik wskazuje tylko, że asercja nie powiodła się i w której linii się znajduje. Bardziej użyteczny komunikat o błędzie wyświetliłby wartość z funkcji greeting. Dodajmy niestandardowy komunikat o błędzie składający się z ciągu formatującego z znacznikiem miejsca wypełnionym rzeczywistą wartością, którą otrzymaliśmy z funkcji greeting:

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

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

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(
            result.contains("Carol"),
            "Greeting did not contain name, value was `{result}`"
        );
    }
}

Teraz, gdy uruchomimy test, otrzymamy bardziej informacyjny komunikat o błędzie:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [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:
Greeting did not contain name, value was `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`

Możemy zobaczyć rzeczywistą wartość, którą otrzymaliśmy w wynikach testu, co pomogłoby nam debugować, co się stało, zamiast tego, czego się spodziewaliśmy.

Sprawdzanie paniki za pomocą should_panic

Oprócz sprawdzania wartości zwracanych, ważne jest sprawdzenie, czy nasz kod obsługuje warunki błędów zgodnie z oczekiwaniami. Na przykład, rozważ typ Guess, który utworzyliśmy w Rozdziale 9, Listing 9-13. Inny kod, który używa Guess, zależy od gwarancji, że instancje Guess będą zawierać tylko wartości od 1 do 100. Możemy napisać test, który zapewni, że próba utworzenia instancji Guess z wartością spoza tego zakresu spowoduje panikę.

Robimy to, dodając atrybut should_panic do naszej funkcji testowej. Test przechodzi, jeśli kod wewnątrz funkcji panikuje; test kończy się niepowodzeniem, jeśli kod wewnątrz funkcji nie panikuje.

Listing 11-8 przedstawia test, który sprawdza, czy warunki błędu funkcji Guess::new występują, gdy tego oczekujemy.

Nazwa pliku: src/lib.rs
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }
}

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

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}
Listing 11-8: Testowanie, czy warunek spowoduje panic!

Umieszczamy atrybut #[should_panic] po atrybucie #[test] i przed funkcją testową, której dotyczy. Spójrzmy na wynik, gdy ten test przejdzie:

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

Wygląda dobrze! Teraz wprowadźmy błąd do naszego kodu, usuwając warunek, że funkcja new spanikuje, jeśli wartość jest większa niż 100:

pub struct Guess {
    value: i32,
}

// --snip--
impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }
}

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

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

Kiedy uruchomimy test w Listingu 11-8, zakończy się on niepowodzeniem:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [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 at src/lib.rs:21:8

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`

W tym przypadku nie otrzymujemy zbyt pomocnego komunikatu, ale patrząc na funkcję testową, widzimy, że jest ona opatrzona adnotacją #[should_panic]. Otrzymany błąd oznacza, że kod w funkcji testowej nie spowodował paniki.

Testy wykorzystujące should_panic mogą być niedokładne. Test should_panic przeszedłby nawet wtedy, gdyby test panikował z innego powodu niż ten, którego się spodziewaliśmy. Aby testy should_panic były bardziej precyzyjne, możemy dodać opcjonalny parametr expected do atrybutu should_panic. Test framework zapewni, że komunikat o błędzie będzie zawierał podany tekst. Na przykład, rozważ zmodyfikowany kod dla Guess w Listingu 11-9, gdzie funkcja new panikuje z różnymi komunikatami w zależności od tego, czy wartość jest za mała, czy za duża.

Nazwa pliku: src/lib.rs
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be greater than or equal to 1, got {value}."
            );
        } else if value > 100 {
            panic!(
                "Guess value must be less than or equal to 100, got {value}."
            );
        }

        Guess { value }
    }
}

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

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}
Listing 11-9: Testowanie panic! z komunikatem paniki zawierającym określony podciąg

Ten test przejdzie, ponieważ wartość, którą umieściliśmy w parametrze expected atrybutu should_panic, jest podciągiem komunikatu, z którym funkcja Guess::new panikuje. Mogliśmy określić cały oczekiwany komunikat paniki, który w tym przypadku brzmiałby Guess value must be less than or equal to 100, got 200. To, co zdecydujesz się określić, zależy od tego, jak wiele z komunikatu paniki jest unikalne lub dynamiczne i jak precyzyjny chcesz, aby był Twój test. W tym przypadku podciąg komunikatu paniki jest wystarczający, aby upewnić się, że kod w funkcji testowej wykonuje przypadek else if value > 100.

Aby zobaczyć, co się stanie, gdy test should_panic z komunikatem expected zawiedzie, ponownie wprowadźmy błąd do naszego kodu, zamieniając ciała bloków if value < 1 i else if value > 100:

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be less than or equal to 100, got {value}."
            );
        } else if value > 100 {
            panic!(
                "Guess value must be greater than or equal to 1, got {value}."
            );
        }

        Guess { value }
    }
}

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

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

Tym razem, gdy uruchomimy test should_panic, zakończy się on niepowodzeniem:

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

Komunikat o błędzie wskazuje, że ten test rzeczywiście panikował, jak się spodziewaliśmy, ale komunikat paniki nie zawierał oczekiwanego ciągu znaków less than or equal to 100. Komunikat paniki, który otrzymaliśmy w tym przypadku, brzmiał Guess value must be greater than or equal to 1, got 200. Teraz możemy zacząć szukać naszego błędu!

Używanie Result<T, E> w testach

Wszystkie nasze dotychczasowe testy panikują, gdy zawiodą. Możemy również pisać testy, które używają Result<T, E>! Oto test z Listingu 11-1, przepisany tak, aby używał Result<T, E> i zwracał Err zamiast panikować:

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

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

    #[test]
    fn it_works() -> Result<(), String> {
        let result = add(2, 2);

        if result == 4 {
            Ok(())
        } else {
            Err(String::from("two plus two does not equal four"))
        }
    }
}

Funkcja it_works ma teraz typ zwracany Result<(), String>. W ciele funkcji, zamiast wywoływać makro assert_eq!, zwracamy Ok(()), gdy test przejdzie, i Err z String w środku, gdy test zawiedzie.

Pisanie testów w taki sposób, aby zwracały Result<T, E>, pozwala na używanie operatora znaku zapytania w ciele testów, co może być wygodnym sposobem pisania testów, które powinny zakończyć się niepowodzeniem, jeśli jakakolwiek operacja w ich obrębie zwróci wariant Err.

Nie możesz używać adnotacji #[should_panic] w testach, które używają Result<T, E>. Aby potwierdzić, że operacja zwraca wariant Err, nie używaj operatora znaku zapytania na wartości Result<T, E>. Zamiast tego użyj assert!(value.is_err()).

Teraz, gdy znasz kilka sposobów pisania testów, przyjrzyjmy się, co dzieje się, gdy uruchamiamy nasze testy i zbadajmy różne opcje, których możemy użyć z cargo test.