W języku Rust, testowanie programów może być nieco inne niż w innych językach, szczególnie kiedy chodzi o testowanie interakcji z systemem operacyjnym lub zewnętrznymi bibliotekami. Proces pisania testów w Rust jest oparty na używaniu odpowiednich bibliotek, takich jak assert_cmd, pretty_assertions czy predicates, które ułatwiają porównanie wyników oraz obsługę błędów. Poniżej przedstawiam sposób, w jaki można napisać testy integracyjne dla prostego programu, który emuluje zachowanie standardowego polecenia echo.

Na początku warto zauważyć, że if w Rust jest wyrażeniem, a nie instrukcją, jak ma to miejsce w językach takich jak C czy Java. Wyrażenie if zwraca wartość, podczas gdy instrukcja nie. Dzięki temu, kod staje się bardziej zwięzły i czytelny. Na przykład, zamiast przypisywać wartość do zmiennej, można od razu użyć wyrażenia if w konstrukcji, jak w poniższym kodzie:

rust
let ending = if omit_newline { "" } else { "\n" };

Takie podejście jest bardziej idiomatyczne i "Rustowe". Warto jednak pamiętać, że pisanie testów to również ważna część procesu programowania. Bez odpowiednich testów nie ma pewności, że program działa poprawnie w każdej sytuacji.

Aby napisać testy dla naszego programu, wykorzystamy bibliotekę assert_cmd do uruchamiania komend, a także predicates do sprawdzania wyników. Zaczniemy od najprostszych testów: upewnimy się, że nasz program działa poprawnie, nawet gdy nie otrzyma argumentów lub gdy otrzyma dane wejściowe.

Pierwszym krokiem jest stworzenie pliku testowego w katalogu tests. Przykład testu, który sprawdza, czy program nie wykonuje się, gdy nie dostanie żadnych argumentów, może wyglądać następująco:

rust
#[test] fn dies_no_args() { let mut cmd = Command::cargo_bin("echor").unwrap(); cmd.assert() .failure()
.stderr(predicate::str::contains("Usage"));
}

Test ten uruchamia nasz program, a następnie sprawdza, czy program zakończył się błędem i czy na standardowym wyjściu błędów pojawił się tekst wskazujący na potrzebę podania argumentów.

Kolejny test może dotyczyć sytuacji, w której program otrzyma argument. W tym przypadku sprawdzamy, czy program kończy się sukcesem, gdy dostaje tekst do wyświetlenia:

rust
#[test] fn runs() { let mut cmd = Command::cargo_bin("echor").unwrap();
cmd.arg("hello").assert().success();
}

Dzięki tym testom możemy upewnić się, że nasz program reaguje odpowiednio na brak argumentów oraz na poprawne dane wejściowe. Jednak testowanie poprawności działania to tylko część procesu. Należy także zadbać o porównanie wyników z wynikami oryginalnego programu echo. W tym celu wykorzystamy wcześniej zapisane pliki wyjściowe z echo.

Za pomocą prostego skryptu bash (mk-outs.sh), generujemy pliki, które zawierają wyjście programu echo dla różnych zestawów argumentów. Oto przykład tego skryptu:

bash
#!/usr/bin/env bash
OUTDIR="tests/expected" [[ ! -d "$OUTDIR" ]] && mkdir -p "$OUTDIR" echo "Hello there" > $OUTDIR/hello1.txt
echo "Hello" "there" > $OUTDIR/hello2.txt
echo -n "Hello there" > $OUTDIR/hello1.n.txt echo -n "Hello" "there" > $OUTDIR/hello2.n.txt

Skrypt ten tworzy katalog tests/expected i zapisuje w nim różne kombinacje wyjść programu echo. Następnie w testach porównujemy te pliki z wynikami uzyskanymi z naszego programu. Na przykład:

rust
#[test]
fn hello1() { let outfile = "tests/expected/hello1.txt"; let expected = fs::read_to_string(outfile).unwrap(); let mut cmd = Command::cargo_bin("echor").unwrap();
cmd.arg("Hello there").assert().success().stdout(expected);
}

W ten sposób program jest testowany w kontekście rzeczywistych wyników, które powinien generować, a także możemy upewnić się, że nasze rozwiązanie działa zgodnie z oczekiwaniami.

Kiedy zaczynamy pracować z plikami, warto zwrócić uwagę na możliwe błędy związane z odczytem plików. Funkcja fs::read_to_string jest wygodna, ale niezalecana do używania w przypadku dużych plików, ponieważ może prowadzić do wyczerpania pamięci. Lepiej jest używać tej funkcji tylko dla małych plików, aby uniknąć problemów.

Należy również pamiętać, że testowanie to proces ciągły, który nie kończy się tylko na pierwszych testach. Ważne jest, aby rozszerzać testy o nowe przypadki brzegowe, testować program w różnych środowiskach i mieć świadomość, że każda zmiana w kodzie może wymagać dostosowania testów. Ponadto, korzystanie z odpowiednich narzędzi, jak np. anyhow::Result, zamiast Result::unwrap, pozwala na lepsze zarządzanie błędami i sprawdzenie, czy wszystko działa zgodnie z planem.

Jak przechodzić przez plik, wybierać fragmenty danych i testować duże pliki wejściowe?

W wielu językach programowania istnieje mechanizm nawigacji, który pozwala przesunąć wskaźnik, zwany kursorem lub głowicą odczytu, na określoną pozycję w strumieniu danych. W kontekście operacji na plikach, szczególnie tych, które mają duże rozmiary, ważne jest odpowiednie zarządzanie odczytem danych. Poniżej przedstawiam rozwiązanie na przykładzie języka Rust, które obejmuje m.in. wybór fragmentów danych z plików tekstowych, pracę z dużymi plikami oraz testowanie efektywności tych operacji.

Rozważmy funkcję print_bytes, która pozwala na wybór określonego fragmentu danych z pliku:

rust
fn print_bytes(
mut file: T, num_bytes: &TakeValue, total_bytes: i64, ) -> Result<()> { unimplemented!(); }

Typ generyczny T w tym przypadku musi implementować cechy Read i Seek. Oznacza to, że plik, który przekazujemy jako argument do tej funkcji, musi obsługiwać operacje odczytu i przesuwania wskaźnika na określoną pozycję. Argument num_bytes jest reprezentacją wartości określającej liczbę bajtów, które chcemy wybrać, a total_bytes to całkowity rozmiar pliku w bajtach.

Istnieje również alternatywne zapisywanie tych ograniczeń przy pomocy klauzuli where, co może być bardziej czytelne:

rust
fn print_bytes( mut file: T, num_bytes: &TakeValue, total_bytes: i64, ) -> Result<()> where T: Read + Seek, { unimplemented!(); }

Ważnym elementem tego rozwiązania jest funkcja get_start_index, która znajduje indeks początkowy na podstawie określonej wartości w num_bytes oraz całkowitej liczby bajtów w pliku. Dzięki temu możemy określić, od jakiego miejsca w pliku rozpocząć operację odczytu.

Warto zauważyć, że dane, które wybieramy, mogą zawierać niepoprawne sekwencje UTF-8, dlatego moja propozycja rozwiązania używa funkcji String::from_utf8_lossy, która zamienia bajty na ciąg znaków, pomijając wszelkie nieprawidłowe znaki.

Testowanie programu z dużymi plikami wejściowymi

Jeśli chcesz przetestować swój program na dużych plikach, w repozytorium znajduje się program biggie, który umożliwia generowanie dużych plików tekstowych. Możesz go wykorzystać do tworzenia plików z milionem losowych linii tekstu, co będzie idealne do testowania różnych zakresów wierszy i bajtów:

bash
Usage: biggie [OPTIONS]
Options: -o, --outfile Output filename [default: out.txt] -l, --lines Number of lines [default: 100000] -h, --help Print help -V, --version Print version

Dzięki temu programowi możesz łatwo wygenerować duże pliki, które będą doskonałym narzędziem do testowania wydajności programu.

Liczenie wszystkich linii i bajtów w pliku

Początkowo musimy stworzyć funkcję do liczenia wszystkich linii i bajtów w pliku. Używamy tutaj funkcji BufRead::read_until, aby odczytywać surowe bajty, co eliminuje koszt tworzenia niepotrzebnych ciągów znaków:

rust
fn count_lines_bytes(filename: &str) -> Result<(i64, i64)> {
let mut file = BufReader::new(File::open(filename)?);
let mut num_lines = 0; let mut num_bytes = 0; let mut buf = Vec::new(); loop {
let bytes_read = file.read_until(b'\n', &mut buf)?;
if bytes_read == 0 { break; } num_lines += 1; num_bytes += bytes_read as i64; buf.clear(); } Ok((num_lines, num_bytes)) }

Funkcja ta otwiera plik i iteracyjnie liczy linie oraz bajty, aż do końca pliku. Zauważ, że liczba bajtów musi być rzutowana z typu usize na i64, aby móc je dodać do sumy.

Znalezienie indeksu początkowego

Kiedy chcemy wybrać fragment pliku, potrzebujemy znaleźć odpowiedni indeks początkowy, od którego zaczniemy odczyt danych. Funkcja get_start_index realizuje to zadanie, bazując na wartości TakeValue, która wskazuje pozycję, od której należy zacząć:

rust
fn get_start_index(take_val: &TakeValue, total: i64) -> Option<u64> {
match take_val { PlusZero => { if total > 0 { Some(0) } else { None } } TakeNum(num) => { if num == &0 || total == 0 || num > &total { None } else { let start = if num < &0 { total + num } else { num - 1 }; Some(if start < 0 { 0 } else { start as u64 }) } } } }

W tej funkcji rozpatrujemy różne przypadki: jeżeli liczba linii lub bajtów wynosi zero, zwracamy None. Jeśli użytkownik chce rozpocząć od 0, a plik nie jest pusty, zwracamy 0. Jeśli indeks początkowy jest mniejszy niż 0, musimy go odpowiednio dostosować, aby nie przekroczył zakresu dostępnych danych.

Drukowanie wybranych linii

Funkcja print_lines jest odpowiedzialna za wydrukowanie wybranych linii z pliku. Jeśli ustalony został prawidłowy indeks początkowy, funkcja przechodzi przez plik, odczytuje linie i drukuje je:

rust
fn print_lines(
mut file: impl BufRead, num_lines: &TakeValue, total_lines: i64, ) -> Result<()> { if let Some(start) = get_start_index(num_lines, total_lines) { let mut line_num = 0;
let mut buf = Vec::new();
loop { let bytes_read = file.read_until(b'\n', &mut buf)?; if bytes_read == 0 { break; } if line_num >= start {
print!("{}", String::from_utf8_lossy(&buf));
} line_num +=
1; buf.clear(); } } Ok(()) }

Funkcja ta sprawdza, czy linia znajduje się powyżej lub na poziomie indeksu początkowego, a następnie wyświetla ją na ekranie.

Drukowanie wybranych bajtów

Podobnie jak w przypadku linii, funkcja print_bytes umożliwia drukowanie wybranych bajtów z pliku. Przesuwamy wskaźnik na odpowiednią pozycję za pomocą seek, a następnie odczytujemy i wyświetlamy dane:

rust
fn print_bytes( mut file: T, num_bytes: &TakeValue, total_bytes: i64, ) -> Result<()> {
if let Some(start) = get_start_index(num_bytes, total_bytes) {
file.
seek(SeekFrom::Start(start))?; let mut buffer = Vec::new(); file.read_to_end(&mut buffer)?; if !buffer.is_empty() {
print!("{}", String::from_utf8_lossy(&buffer));
} }
Ok(()) }

Testowanie i finalizacja

Po napisaniu programu warto przeprowadzić testy, by upewnić się, że wszystko działa zgodnie z planem. Program generujący duże pliki, jak biggie, oraz testy jednostkowe pomogą w tym procesie. Dzięki tym narzędziom możemy upewnić się, że nasza aplikacja jest skalowalna i wydajna.