Rust + Webassembly – Jak pisać testy

Tworzona przez nas aplikacja rozrasta się z każdą dokładaną linijką kodu. W pewnym momencie może się okazać, że po wprowadzeniu modyfikacji nie jesteśmy pewni, czy czegoś nie popsuliśmy w kodzie, którego teoretycznie nie dotykaliśmy. I tu przychodzą z nam pomocą w WebAssembly testy.

Bazując na aplikacji PathFinder, opisanej w poprzednich artykułach, zauważyliśmy, że algorytm z jakiegoś powodu nie działa prawidłowo. Mając 4 pola (rysunek), możliwe jest przejście po skosie: z pola (0,0) do (1,1) bezpośrednio. Zgodnie ze specyfikacją, nie powinno być to możliwe. A więc – coś nie działa zgodnie z założeniami? Jak sprawdzić, co? Jak dowiedzieć się, że faktycznie coś nie działa, jak powinno?

Zachęcam do zapoznania się z poprzednimi dwoma artykułami na temat aplikacji path-finder:
Rust + WebAssembly – Jak to działa ?
Znajdowanie ścieżki i komunikacja

Co możemy zrobić w tym zakresie?

Tutaj z pomocą przychodzą nam testy jednostkowe, które, jeśli istnieją, wykryją niezgodność.

Chwileczkę, ale czy my nie testujemy przypadkiem czegoś, co uruchamiane jest wewnątrz przeglądarki? Czy nie potrzebujemy do tego chrome albo firefoxa?

Po części tak. Kod wynikowy tego, co tworzymy w Rust, jest zamieniany na kod Webassembly, który ma być uruchamiany albo wewnątrz przeglądarki, albo przez silnik node.js.

Możemy zarówno stworzyć testy, których celem jest zasymulowanie uruchomienia kodu wynikowego wewnątrz przeglądarki, ale także możemy tworzyć testy, które nie wymagają tego trybu uruchomienia.

Dziś skupimy się na tych pierwszych.

Co chcemy osiągnąć?

Chcemy stworzyć taki test, który sprawdzi, czy działa aplikacja w poprawny sposób. Patrząc na funkcję search, której wynikiem ma być ścieżka, czyli lista punktów, które odwiedzimy pokonując naszą planszę.

Pytanie: co w tym wypadku oznacza “poprawny”?

W każdym teście należy określić, co jest poprawną odpowiedzią funkcji, systemu, czy modułu na dane wejściowe. Czyli – innymi słowy – jak się zachowa nasz twór, gdy go nakarmimy odpowiednimi danymi.

Określmy warunki wejściowe dla naszego przypadku testowego:

Określmy warunki oczekiwane, czyli jaką ścieżkę powinien wyznaczyć:

Skoro już wiemy, jak wysokopoziomowo ma wyglądać nasz test, to spróbujmy go napisać w języku Rust.

WebAssembly testy

Po pierwsze, musimy dodać bibliotekę testową do cargo.toml, czyli nowe dependency. Jako, że będziemy używać go tylko w trakcie developmentu, dodajemy go do sekcji dev-dependencies

[dev-dependencies]
wasm-bindgen-test = "0.2"

W folderze głównym tworzymy katalog tests. Narazie wystarczy nam plik web.rs, który będzie naszym głównym plikiem dla testów związanych z przeglądarkami.

W pliku web.rs dodajemy odpowiednie importy.

extern crate wasm_bindgen_test;
use wasm_bindgen_test::*;

Możemy stworzyć nasz pierwszy test. Testy z punktu widzenia Rust to nic innego jak funkcje, które testują kod. Muszą być opatrzone adnotacją #[wasm_bindgen_test]

Bez niej framework nie będzie wiedział, które testy uruchomić. Takie fragmenty kodu będą uruchamiane tylko, gdy wywołamy odpowiednie polecenie.

Test powinien zawierać przynajmniej jedną asercję, czyli sprawdzenie jakiegoś warunku. Jeśli nie będzie posiadał żadnej, to zakończy się po prostu sukcesem.

#[wasm_bindgen_test]
fn OneEqOne() {
   assert_eq!(1, 1);
}

Uruchamianie testów

Do tego służy polecenie wasm-pack test. Gdy uruchomimy to polecenie, to dostaniemy informację, że nie wybraliśmy silnika/przeglądarki, który zostanie wykorzystany do uruchomienia.

Tak – to jest istotne. Testy uruchamiane są w kontekście przeglądarki(firefox, chrome, safari) lub node.

Nas interesują testy związane z Chromem, więc używać będziemy przełącznik –chrome. Dodatkowo wspomnę, że chrome musi być zainstalowany na komputerze. Ani Rust ani Wasm-pack nie dostarczają plików wykonywalnych do testów. Dostarczają nam tylko driver.

Druga warta wspomnienia rzecz, to tryb uruchomienia. Preferowany przeze mnie jest tryb –headless, dzięki któremu możemy uruchomić testy w tle i nie wymaga od nas wyłączenia serwera po zakończeniu testów. W tym trybie startuje serwer, który na odpowiednim porcie prezentuje wyniki testów. Gdy wybierzemy tryb headless, to wyniki będą prezentowane w konsoli.

Sugerowane polecenie

Wasm-pack test --chrome --headless

Wynik uruchomienia:

running 1 test

test web::pass ... ok

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

Gdy popsujemy coś w teście, to zostanie wyświetlona informacja o failu.

Udostępnienie na zewnątrz funkcji i struktury

By móc napisać test, musimy najpierw sprawić, by funkcje i struktury stały się dostępne.
Dlaczego?

Testy traktują naszą aplikację jako bibliotekę, paczkę czy też crate. Jak nie mają do czegoś dostępu, to tego nie przetestują.

Musimy jawnie dokonać importu potrzebnych rzeczy:

extern crate path_finder;
use path_finder::{Board, Point, Cell, SearchState, search};

path_finder jest nazwą naszej crate.

Upewnijmy się, że wszystkie potrzebne rzeczy są publiczne, jeśli nie, warto dodać słowo kluczowe pub na początku linii.

Czy nasz kod jest testowalny?

Jest to bardzo dobre pytanie, które warto sobie zadać. To, że nasz kod da się uruchomić tak, jak widzieliśmy w poprzednich artykułach, to nie oznacza, że da się go testować.

By móc przetestować funkcję search potrzebujemy mieć możliwość:
– Ustawienia konkretnych pól na planszy
– Uruchomienia funkcji
– Sprawdzenia, czy rezultat, czyli ścieżka, jest zgodna z oczekiwaniami

Z tych trzech przypadków tylko 1 nie jest spełniony. Stwórzmy więc funkcje, które przyjmuje 3 parametry:
– Długość planszy
– Szerokość planszy
– Pola

Do tej pory pola były przypisywane randomowo. W przypadku testów chcemy mieć pełną kontrolę nad tym, co jest na wejściu funkcji, a nie liczyć, że raz na milion razy się uda.

Testy powinny być deterministyczne, czyli każde uruchomienie powinno dawać te same wyniki, jeśli kod się nie zmienił.

Stwórzmy więc funkcję, która eliminuje ten problem:

impl Board {
   pub fn new_test(width: u32, height: u32, cells: Vec) -> Board {
       Board {
           width,
           height,
           cells,
       }
   }
}

>

Dzięki temu możemy przekazać Vector pól z zewnątrz, który tak naprawdę ustawimy na poziomie testu.

Test właściwy

Test składa się z kilku faz:
– Given – Stwórz stan początkowy, czyli ustawienie zmiennych do stanu wymaganego przed testem. W naszym wypadku jest to np: stworzenie planszy o odpowiednich polach.
– When – uruchomienie akcji właściwej. W naszym wypadku będzie to wywołanie funkcji search oraz zapisanie wyniku do zmiennej. Użyjemy go poniżej.
– Then – sprawdzenie pewnych warunków. W naszym wypadku to sprawdzenie, czy ścieżka zawiera odpowiednie punkty.

Tak prezentuje się nasz pierwszy test:

#[wasm_bindgen_test]
fn should_return_proper_path() {
   // given
   let board = Board::new_test(2, 2, vec![Cell::Start, Cell::Water, Cell::Tree, Cell::End]);
   // expected
   let expected_path = vec![Point::new(0, 0), Point::new(0, 1), Point::new(1, 1)];
   // when
   let result  = search(&Point::new(0, 0), &Point::new(1, 1), &board);
   // then
   assert_eq!(&result.unwrap().get_path(), &expected_path);
}

>

Sprawdzanie czyli Assert;

 assert_eq!(&result.unwrap().get_path(), &expected_path)

Ta linijka, to prawdziwy test działania naszej aplikacji. Sprawdza, czy dwa wektory są sobie równe, czyli sprawdza, czy działa poprawnie.

Jest cała gama różnych asercji, które pozwalają sprawdzać różne rzeczy. Assert_eq! To makro które sprawdza równość w naszym wypadku dwóch wektorów.

Zazielenienie testów – czyli co zmienić, by wszystko działało.

Chcemy, by można było się poruszać tylko w 4 kierunkach. Jako, że wyznaczanie sąsiadów zakłada, że poruszamy się także po przekątnych, to test jest czerowny.

Tak ma być.

To jest efekt uruchomienia testu:

 panicked at 'assertion failed: `(left == right)`
      left: `[Point { x: 0, y: 0 }, Point { x: 1, y: 1 }]`,
     right: `[Point { x: 0, y: 0 }, Point { x: 0, y: 1 }, Point { x: 1, y: 1 }]`', tests\web.rs:22:5

Oczekujemy, że będą 3 punkty (0,0) ->(0, 1) -> (1,1), a są tylko dwa: (0,0) -> (1,1).

Wynika to z faktu, że jako, iż polem sąsiadującym dla (0,0) są (0,1), (1,0) oraz (1,1) to algorytm wybierze (1,1), gdyż zarówno odległość jego od celu (0), jak i koszt podróży z (0,0) do (1,1) są najmniejsze z wszystkich dostępnych:

  • (0,0) -> (0,1) = = odległość od celu 1, koszt podróży 3
  • (0,0) -> (1,0) = odległość od celu 1, koszt podróży 5
  • (0,0) -> (1,1) = odległość od celu 0, koszt podróży 1

Algorytm wybierze ten o minimalnej sumie spośród wszystkich dostępnych pól docelowych. Jako, że tu dotarliśmy do celu, to w tym miejscu skończyliśmy podróż.

Zmiana kodu

Czerwony test to zmora developera. Plama na jego honorze. Coś zepsuł, albo coś nie działa zgodnie z oczekiwaniami. Tak czy siak – musi to naprawić.

W naszym wypadku zmiana jest prosta. Wystarczy zmienić sposób wyznaczania pól sąsiadujących z aktualnie analizowanym polem. Obecnie pól sąsiadujących z polem jest maksymalnie 8. Chcemy, by możliwy ruch był tylko w 4 kierunkach, więc pól sąsiadujących powinno być maksymalnie tylko 4.

W pliku path_finding funkcja get_successors odpowiada właśnie za to, by znaleźć sukcesorów danego pola. Sukcesorów, czyli pola sąsiadujące, gdzie możemy się ruszyć. Używa do tego wektora kierunków. Wystarczy z niego usunąć te, które są wzbronione – czyli ruch po przekątnych.

let directions = vec![(-1,0), (-1, 1), (0, 1), (1, 1), (1,0), (1, -1), (0, -1), (-1, -1)];

Jeśli jesteśmy na polu (0,0), możemy poruszać się w lewo (-1,0), w prawo(1,0), w górę(0, -1), w dół (0, 1). Pozostałe kierunki można usunąć.

let directions = vec![(-1,0), (0, 1), (1,0), (0, -1)];

Efekt uruchomienia testów:

running 1 test

test web::should_return_proper_path ... ok

Podsumowanie

Możliwe jest testowanie kodu WebAssembly w kilku trybach i korzystając z różnych silników JS przeglądarek takich jak: Chrome, Firefox opera lub Node.

Dzięki temu nie tylko testujemy, czy nasza aplikacja robi to, co powinna, ale także, czy zadziała tak, jak powinna w obrębie przeglądarki docelowej.

Jest to bardzo użyteczne z punktu widzenia testów w środowiskach zbliżonych do docelowych platform, z których będą korzystać użytkownicy.

Pisanie testów do kodu, który już istnieje, nie jest proste, jeżeli wcześniej nie pisaliśmy tego kodu z myślą o testowalności. Prawdopodobnie będzie wymagało to od nas pewnych zmian i dostosowania już istniejącego kodu po to, by dało się go używać w testach.

Kod źródłowy 😉

W dzień Senior Big Data Architect | Lead Developer | Software Developer w firmie Future Processing, w nocy śpi. Ponad 10 lat doświadczenia w zakresie wytwarzania oprogramowania w różnych technologiach oraz domenach, również w takich, w których nikt nie chciał pracować. Jak trzeba usunąć problem w dowolnej dziedzinie to wiesz do kogo dzwonić :) Zafascynowany rozwojem technologii związanej z przetwarzaniem danych a w szczególności tworzeniem rozwiązań z rodziny Big Data. Prelegent oraz organizator licznych wydarzeń, których głównym celem jest dzielenie się wiedzą oraz krzewienie potrzeby stosowania dobrych praktyk, w celu maksymalizacji jakości wytwarzanego produktu. Współorganizator Wakacyjnych Praktyk w Future Processing oraz prowadzący przedmiot na Politechnice Śląskiej „Tworzenie Oprogramowania w Zmiennym Środowisku Biznesowym”.
PODZIEL SIĘ