Testowanie systemu

Budowanie każdego dużego systemu oznacza testowanie każdej funkcjonalności z osobna. W momencie w którym system staje się duży i złożony zadanie takie wykonywane ręcznie przestaje być efektywne i prowadzi do wielu przeoczeń oraz gigantycznych strat czasowych. Z drugiej strony zarzucenie testowania systemu jest niesamowicie ryzykowne dla egzystencji systemu (i zapewne kariery jego twórcy).

Rozwiązaniem przedstawionego problemu są testy zautomatyzowane. Z tym zagadnieniem spotkał się już (lub na pewno w przyszłości spotka) każdy programista. W dużych firmach częstą praktyką jest delegowanie specjalnych programistów do pisania tylko testów. Związana jest z tym metodyka wytwarzania oprogramowania nazwana TDD (Test Driven Development), której podstawową zasadą jest pisanie kodu pod testy, nie odwrotnie. W takiej sytuacji tworzenie systemu rozpoczynane jest od tworzenia scenariuszy używania systemu (często we współpracy z klientem) i przepisywania tych scenariuszy na faktyczne testy. Następnie programiści nie tworzą w pełni sprawnego systemu (którego testy są długie i podatne na niedopatrzenia) tylko zaspokajają poszczególne testy (zamiast uruchamiać cały system, odpalać serwer i przeglądarkę - wystarczy uruchomić testy). Dzięki takiemu podejściu system zawsze w każdym momencie testuje nie tylko konkretny kawałek kodu nad którym pracuje programista, ale cały kontekst i inne części systemu, które nieopatrznie można było uszkodzić.

Rodzaje testów

Rozpoznaje się wiele rodzajów testów (ich opisy są bardzo krótkie, testowanie jest potężnym tematem a dobrym źródłem wiedzy na ten temat jest blog testowanie.net - polecam!):

  • Testy jednostkowe - najniższy poziom testowania polegający na testowaniu najmniejszych jednostek funkcyjnych systemu (np klas). W naszym przypadku testom jednostkowym podlegać będą kontrolery oraz modele
  • Testy integracyjne - testowanie połączeń pomiędzy modułami, wyszukujący błędy podczas komunikacji poszczególnych elementów aplikacji między sobą. W naszym przypadku są to testy formularzy symulując prawdziwe zachowanie przeglądarki (gem Capybara)
  • Testy systemowe - testowanie systemu pod kątem spełniania wszystkich założeń i wymagań zawartych w specyfikacji. Te testy często są wykonywane ręcznie.
  • Testy akceptacyjne - bardzo zbliżone do testów systemowych testowanie systemu pod kątem spełnienia żądań klienta. Testy te przeprowadzane są na środowisku developerskim przez klienta (dajemy sprawny system do rąk klientowi), jednak najczęściej przy obecności producenta. W naszym przypadku klienta brak, a testy akceptacyjne pojawią się w innej formie - oficjalnych testów alpha i beta.

Rozwiązanie testowe apki.org

W związku z tym że apki.org oparte są o język Ruby naszym wyborem technologii do testów jest rspec. Technologia ta zapewnia nam wszystko co potrzebujemy podczas testów, łącznie z mocną integracją ze środowiskiem rails. Rozszerzeniem do testów integracyjnych jest gem Capybara, który symuluje działanie przeglądarki i prawdziwą nawigację po modelu DOM i formularzach. Dzięki temu scenariusze testowe możemy maksymalnie zautomatyzować.

Przykładem testów jednostkowych są u nas testy kontrolerów. Dla podstawowych kontrolerów jak strona główna, lista newsów czy konkretnych news testy są zasadniczo zbędne, aczkolwiek można przetestować takie przypadki jak poprawna obsługa odwołania do nieistniejącego id newsa itp. To co dla nas jest o wiele bardziej istotne to testy api.

Kursy na naszym portalu oparte są o frontend napisany w AngularJS, który komunikuje się z serwerem za pośrednictwem RESTowego api. Teoretycznie proste zapytania testowe bardzo szybko zmieniają się w bolesne tasiemce powtarzanych zapytań, zmieniania parametrów, testowego logowania itd. Testy jednostkowe robią to wszystko za nas! Żeby nie poprzestać na teorii, jedziemy z przykładami.

Testy jednostkowe api

Mamy poniższą metodę api do przetestowania:

Potencjalnie trywialna czynność taka jak sprawdzenie czy użytkownik zaliczył wszystkie quizy wraz ze spełnianiem różnych założeń zaczyna być złożoną maszyną, która musi przewidzieć wiele rzeczy zanim przejdzie do wykonywania faktycznego kodu sprawdzania (w naszym przypadku jest to moduł Course::CourseChecker którego nie będę tutaj omawiać). Co się dzieje przy każdym żądaniu:

Użytkownik jest zalogowany?

Korzystając z filtra before_action sprawdzamy czy użytkownik jest zalogowany. Jeżeli nie to kontroler ApplicationController zwróci odpowiedź json.

{
  "error": "Musisz być zalogowany aby mieć tu dostęp"
}

Dodatkowo statusem HTTP będzie kod 401 (unauthorized).

Czy lekcja o podanym ID istnieje?

Linijka lesson = Course::Lesson.find(data['ID']) kryje w sobie nie tylko znalezienie odpowiedniej lekcji ale także weryfikację poprawności wysłanych przez klienta danych. Założeniem aplikacji jest to, aby w body znajdował się klucz ID w treści mający id lekcji dla której rozwiązujemy quizy. Jeżeli takie ID nie istnieje lub jest nieprawidłowe to powyższa linijka zwróci wyjątek MongoDB (Mongoid::Errors::DocumentNotFound) mówiący że obiekt nie może być znaleziony w bazie. Dlaczego nie widzimy przechwytywania takiej sytuacji?

Dobrą praktyką w przypadku każdego projektu programistycznego jest unikanie jakiejkolwiek duplikacji kodu. Przechwytywanie wyjątków dla nieprawidłowego pobrania obiektu z bazy danych w każdej metodzie kontrolera jest ewidentnym i bardzo mocnym naruszeniem tej zasady. Zatem jak rozwiązać nasz problem? Odpowiedzią jest funkcja rescue_from umieszczona w ApplicationController:

Metoda ta przechwytuje wszystkie wyjątki typu Mongoid::Errors::DocumentNotFound występujące w kontrolerach pochodnych i obsługuje je (różnie w zależności od tego czy żądanie jest typu json czy zwykłe).

Czy użytkownik jest zapisany do tego kursu?

Znaleźliśmy już lekcję i zweryfikowaliśmy że użytkownik jest zalogowany. Kolejnym założeniem przystąpienia do quizu jest to czy użytkownik ten jest zapisany do kursu w którym ta lekcja się znajduje. Wykonujemy to linijką:

user_course = Course::UserCourse.find_by(user: current_user, course_course_datum: lesson.course_course_datum)  

Weryfikacja tego faktu działa identycznie jak w poprzednim podpunkcie artykułu, werfikujemy czy obiekt istnieje w bazie i jeżeli nie to kontroler nadrzędny obsługuje wyjątek.

Spełniliśmy warunki, możemy pracować!

Dopiero jeżeli dotarliśmy bezawaryjnie do tego momentu system uruchamia kod:

correct = Course::CourseChecker.check_quizes lesson, data, json_response  

Odpowiada on za sprawdzenie poprawności rozwiązania quizu. Jak widać złożoność systemu wprowadza nam bardzo dużo warunków powodzenia lub niepowodzenia w zależności od wprowadzenia różnych danych i stanu bazy.

Jak wygląda przykładowy test takiej metody?

Omówmy krok po kroku.

Przygotowania do testu

Akcja before(:all) definiuje metodę która jest wykonana raz przed wszystkimi testami zawartymi w danej klasie testowej (blok describe). Tworzymy w niej naszego użytkownika oraz zapewniamy hash z przykładowymi danymi.

Akcja before(:each) wykonywana jest przed każdym przypadkiem testowym (blok it). W niej czyścimy naszą bazę i zapełniamy ją na nowo danymi testowym. Dla poniższych przypadków testowym może wydaje się to zbędne (i na chwilę obecną tak jest), jednak jeżeli dochodzą testy które wymagają modyfikacji w bazie danych to taka konwekwentna czystka jest bardzo korzystna, gdyż wprowadza pewność na jakich danych przypadek testowy operuje.

Akcja after(:all) nie należy do przygotowań do testu a raczej do czyszczenia po nim. W naszym przypadku jedyne co musimy tutaj zrobić to wyczyścić listę wszystkich użytkowników których dla testów utworzyliśmy.

Metoda prywatna, która odpowiada za zapełnienie bazy danymi potrzebnymi do wszystkich przypadków testowych. Jako że dane używane przez nas są już dość skomplikowane (kilkupoziomowe relacje) to sam kod zapewniający te dane jest dość obszerny. A na chwilę obecną kod ten nie bierze pod uwagę testów zadań, tylko i wyłącznie quizy!

Co tu robimy? Tworzymy kurs, w kursie jedną przykładową lekcję. Dla tej lekcji definiujemy 3 quizy, których rozwiązywanie będziemy testować. Dodatkowo dodajemy achievement za przejście lekcji (który to przypadek też możemy przy okazji przetestować, aczkolwiek zaleca się takie rzeczy testować osobno).

Testy

Widzimy tu 2 przypadki testowe. Dla jednego użytkownik pomyślnie wykonał quizy, dla drugiego natomiast wykonał jeden błąd.

Linia po linii wyjaśnię teraz co się w naszym przypadku testowym dzieje:

session[:user_id] = @user.id.to_s  

do sesji przypisujemy naszego testowego użytkownika

user_course = Course::UserCourse.create!(user: @user, course_course_datum: @course)  

pobieramy obiekt kursu użytkownika

json_request = {'ID': lesson.id.to_s, 'quizzes': {  
  @quizzes[0].id.to_s => 3,
  @quizzes[1].id.to_s => 0,
  @quizzes[2].id.to_s => 1
}}

Tworzymy testowe dane. Przesyłamy pod ID lekcję, a w obiekcie quizzes wysyłamy nasze testowe odpowiedzi.

request.env['RAW_POST_DATA'] = json_request.to_json do body żądania przypisujemy stworzony obiekt rzutując go przy okazji na json.

post :check_quizzes, format: :json wykonujemy żądanie typu post do akcji :check_quizzes w formacie json.

json_response = JSON.parse response.body uzyskujemy odpowiedź serwera i rzutujemy ją na hash z formatu json.

Teraz następują faktyczne testy:

expect(json_response['is_correct']).to eq true  

Oczekujemy że klucz is_correct będzie mieć wartość true (oznacza to że quiz jest rozwiązany).

quizzes_ids = @quizzes.map { |quiz| quiz.id.to_s }  
json_response['quizzes'].each do |key, value|  
  expect(quizzes_ids.include? key).to eq true
  expect(value).to eq true
end  

Dla każdego quizu w lekcji sprawdzamy czy odpowiedź jest wzięta pod uwagę i jeżeli tak to czy jest oznaczona jako poprawna.

user_course.reload  
expect(user_course.quizzes.include? lesson.id.to_s).to eq true  

Przeładowujemy z bazy model user_course i sprawdzamy czy użytkownik ma lekcję oznaczoną jako zaliczoną. Jeżeli tak - wszystko jest ok!

Zalecam przejrzenie sobie drugiego testu ze zrozumieniem. Jest on bardzo podobny, to co się różni to pętla sprawdzająca poprawność poszczególnych testów oraz oczekiwanie na sukces odpowiedzi.

Testy integracyjne apki.org

Na chwilę obecną testów integracyjnych mamy mało, jednak przewidujemy że w czasie rozrastania się aplikacji staną się one jednymi z najczęstszych.

Przykładową akcją testowaną integracyjnie jest u nas tworzenie nowego newsu przez edukatora.

Przykładowy test takiego kontrolera to przypadek:

Nauczyciel może edytować swój news.

Omawiając linijka po linijce:

news = EducatorNews.create!(title: 'test', content: 'opis', user: @teacher)  

tworzymy testowy news który będziemy edytować i przypisujemy go do nauczyciela.

login @teacher logujemy użytkownika. Metoda login to rozszerzenie klasy testowej pozwalające w prosty sposób zalogować dowolnego użytkownika. Jest to po części hack, gdyż pozwala nam obejść logowanie GitHub w szybki sposób, niedostępny w środowisku produkcyjnym.

visit school_educator_news_path + '?id=' + news.id.to_s  

przechodzimy na podstronę edycji newsa podając jako parametr jego id.

within 'form.simple_form' do  
  fill_in 'educator_news_title', with: 'test2'
  fill_in 'wmd-input-content', with: 'test opisu 2'
end  

W formularzu zmieniamy treść tytułu i opisu newsa na nowe.

click_button 'Zatwierdź' klikamy przycisk zatwierdź, który wysyła formularz do naszego systemu.

expect(page).to have_css '.alert-success'  
expect(page.find('.alert-success').text).to eq 'Zaktualizowano news'  

Oczekujemy że wyskoczy alert z powiadomieniem o sukcesie

news.reload  
expect(current_path).to eq(school_view_news_path(news))  
expect(news.title).to eq 'test2'  
expect(news.content).to eq 'test opisu 2'  

Przeładowujemy model newsa i sprawdzamy czy nauczyciel został przekierowany na odpowiednią podstronę. Następnie sprawdzamy czy nowe treści tytułu i opisu zgadzają się z tymi przesłanymi do formularza. Jeżeli tak - udało się. News został pomyślnie zmodyfikowany.

Dalsze przypadki

Oczywiście to tylko jeden przypadek. Musimy jeszcze przewidzieć masę innych sytuacji, zarówno poprawnych jak i błędnych. Obecnie zaimplementowane testy tylko i wyłącznie dla newsów edukatora:

  • Dodawanie newsa
  • Edycja swojego newsa
  • Próba edycji cudzego newsa (niepowodzenie)
  • Sprawdzanie czy pola formularza są poprawnie walidowane dla nieprawidłowych danych
  • Próba pisania newsa bez zalogowania się lub przez konto studenta

Wszystkie te testy można zobaczyć (a także uruchomić) w pliku educatornews_spec.rb

Podsumowanie

Pomimo ponad 1600 słów użytych w tym artykule temat testowania poruszyłem tylko lekko, pokazując czubek góry lodowej. Jeżeli ktoś jest zainteresowany tematem to zapraszam do lektury wcześniej wspomnianego bloga testowanie.net i do przeglądania tworzonych przez nas testów do projektu apki.org. Cały aktualny kod jest zawsze dostępny na naszym GitHubie. Zapraszamy!

Nekromancer

Programista z zawodu i zamiłowania. W fundacji zajmuje się głównie rozwiązaniami backendowymi oraz aplikacjami mobilnymi. Współtwórca portalu apki.org oraz mentor na forum

Jastrzębie-Zdrój http://ownvision.pl/