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.
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);
}
}
cargo newPlik 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
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.
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");
}
}
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`
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!.
#[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
}
}
Rectangle i jej metoda can_hold z Rozdziału 5Metoda 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.
#[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));
}
}
can_hold, który sprawdza, czy większy prostokąt może rzeczywiście pomieścić mniejszy prostokątZwróć 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!.
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);
}
}
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.
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);
}
}
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.
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);
}
}
panic! z komunikatem paniki zawierającym określony podciągTen 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.