You are on page 1of 802

Dane oryginału

Original edition copyright © 2018 by Arnon Axelrod. All rights reserved.

Title of English-language original: Complete Guide to Test Automation:

Techniques, Practices, and Patterns for Building and Maintaining Effective

Software Projects by Arnon Axelrod, ISBN 978-1-48423-831-8, published

by Apress. Polish-language edition copyright © 2019 by Polish Scientific

Publishers PWN Wydawnictwo Naukowe PWN Spółka Akcyjna. All rights

reserved.

Przekład Krzysztof Kapustka na zlecenie WITKOM Witold Sikorski

Projekt okładki polskiego wydania Joanna Andryjowicz

Wydawca Edyta Kawala

Redaktor prowadzący Jolanta Kowalczuk

Redaktor Małgorzata Dąbkowska-Kowalik

Koordynator produkcji Anna Bączkowska

Skład wersji elektronicznej na zlecenie Wydawnictwa Naukowego PWN:

Marcin Kośka/Woblink

Konsultacja merytoryczna

dr hab. Adam Roman, Uniwersytet Jagielloński

Zastrzeżonych nazw firm i produktów użyto w książce wyłącznie w celu

identyfikacji.
Copyright © for the Polish edition by Wydawnictwo Naukowe PWN SA

Warszawa 2019

ISBN 978-83-01-20854-7

eBook został przygotowany na podstawie wydania papierowego z 2019 r.,

(wyd. I)

Warszawa 2019

Wydawnictwo Naukowe PWN SA

02-460 Warszawa, ul. Gottlieba Daimlera 2

tel. 22 69 54 321, faks 22 69 54 288

infolinia 801 33 33 88

e-mail: pwn@pwn.com.pl, reklama@pwn.pl

www.pwn.pl
Spis treści

O autorze

O recenzencie technicznym

Podziękowania

Wprowadzenie

Kto powinien przeczytać tę książkę?

Jak zorganizowana jest ta książka?

Część I: „Dlaczego” oraz „Co”

Część II: „Jak”

Część I. „Dlaczego” oraz „co”

Rozdział 1. Wartość automatyzacji testów

Dlaczego potrzebujemy automatyzacji testów?

Od modelu kaskadowego do zwinnego tworzenia oprogramowania

Koszt złożoności oprogramowania

Utrzymywanie stałego kosztu

Refaktoryzacja

Ciągłe doskonalenie

Rozdział 2. Od testowania ręcznego do automatycznego

Podejście pierwsze: nagrywanie i odtwarzanie

Uzyskiwanie maksimum korzyści z automatyzacji testów

Różnice pomiędzy testami manualnymi i automatycznymi

Testowanie eksploracyjne

Rozważania dotyczące testowania automatycznego

Rozdział 3. Ludzie i narzędzia


Wybieranie właściwych narzędzi

Kto powinien pisać testy?

Promowanie testerów manualnych lub niedoświadczonych deweloperów do

rangi deweloperów automatyzacji

Dzielenie pracy między testerów manualnych i deweloperów automatyzacji

Korzystanie z dedykowanego zespołu automatyzacji

Dedykowany deweloper automatyzacji wewnątrz każdego zespołu

Dawanie deweloperom pełnej odpowiedzialności za automatyzację

Różnorodność narzędzi

Klasyfikacja narzędzi

IDE12 i języki programowania

Biblioteki testowania (jednostkowego)

Biblioteki w stylu BDD

Technologie zapewniające interakcję z testowanym systemem

Pakiety do zarządzania testami

Narzędzia kompilacji oraz potoki ciągłej integracji lub ciągłego

dostarczania

Inne czynniki mające znaczenie przy wybieraniu narzędzi

Rozdział 4. Osiąganie pełnego pokrycia

W jaki sposób mierzymy pokrycie?

Procent przypadków testów manualnych pokrytych przez automatyzację

Procent pokrytych funkcji

Procent pokrycia kodu

Uzyskiwanie korzyści przed osiągnięciem pełnego pokrycia

Co robimy po osiągnięciu pełnego pokrycia?

W jaki sposób uzyskać 100% pokrycia?

Odwracanie koła

Mapa drogowa prowadząca do pomyślnego projektu automatyzacji


Kiedy rozpocząć pracę nad progresją?

Nadawanie priorytetu pracy w celu zlikwidowania luki w regresji

Rozdział 5. Procesy biznesowe

Regularne uruchamianie testów

Najprostsze podejście

Testowanie nocne

Obsługiwanie błędów wykrywanych przez automatyzację

Zachowywanie testów kończących się niepowodzeniem

Wykluczanie testów kończących się niepowodzeniem

Tworzenie obejść w teście

Traktowanie wszystkich niepowodzeń automatyzacji jako błędów

krytycznych

Ciągła integracja

Tworzenie oprogramowania sterowane testami akceptacyjnymi

Ciągłe dostarczanie i ciągłe wdrażanie

Wydania kanarkowe

Podsumowanie

Rozdział 6. Automatyzacja i architektura testów

Założenia dotyczące architektury testów

Poznawanie architektury testowanego systemu

Powrót do podstaw: czym jest system komputerowy?

Czym jest test automatyczny?

Rzeczywiste systemy komputerowe

Alternatywy i założenia w architekturze warstwowej

Związki między zakresem a testem

Omówienie warstw

Alternatywne zakresy testowania

Rzeczywista architektura
Architektura planowana kontra architektura rzeczywista

Typowe warianty

Łączenie testów

Podsumowanie czynników

Co poza architekturą warstwową?

Podsumowanie: dokonywanie własnych wyborów

Rozdział 7. Izolacja i środowiska testowe

Stan

Problemy z izolacją i ich rozwiązania

Problem 1 – testy manualne i test automatyczny wykonywane w różnym

czasie

Problem 2 – testy manualne i automatyczne wykonywane jednocześnie

Problem 3 – kolejność ma znaczenie

Problem 4 – testy automatyczne uruchamiane jednocześnie

Techniki izolacji

Korzystanie z oddzielnych kont

Osobne bazy danych dla testów manualnych i automatyzacji testów

Oddzielne środowisko dla każdego członka zespołu

Resetowanie środowiska przed każdym cyklem testowania

Tworzenie niepowtarzalnych danych dla każdego testu

Każdy test czyści wszystko, co utworzył

Współdzielone dane tylko do odczytu

Podsumowanie

Rozdział 8. Szersza perspektywa

Relacje między architekturą oprogramowania i strukturą biznesu

Prawo Conwaya

Zespoły pionowe kontra zespoły poziome


Zależności między architekturą oprogramowania i strukturą organizacyjną

z automatyzacją testów

Dedykowany zespół automatyzacji

Deweloperzy automatyzacji w zespołach poziomych

Deweloperzy automatyzacji w zespołach pionowych

Elastyczna struktura organizacyjna

Ekspert ds. automatyzacji

Podsumowanie

Część II. „Jak”

Rozdział 9. Przygotowanie do samouczka

Wymagania i założenia wstępne

Stosowanie procesu do istniejących systemów automatyzacji testów

Omówienie procesu

„Z dołu do góry” albo „z góry do dołu”

Proces

Poznawanie testowanego systemu

Omówienie projektu MVCForum

Przygotowanie środowiska pod samouczek

Instalowanie Visual Studio w edycji Community

Pobieranie i instalowanie przeglądarki Chrome

Pobieranie i instalowanie bazy danych SQL Server Express

Pobieranie i budowanie aplikacji

Instalacja dodatku ReSharper (krok opcjonalny)

Korzystanie z narzędzia Git z poziomu Visual Studio

Przełączanie pomiędzy gałęziami

Podsumowanie

Rozdział 10. Projektowanie pierwszego przypadku testowego

Wybieranie pierwszego testu do zautomatyzowania


Wybieranie pierwszego przypadku testowego dla aplikacji MVCForum

Naukowa metoda projektowania przypadku testowego

Projektowanie kroków testu

Myślenie w kontekście obiektów i jednostek

Wzorzec obiektu strony

Podsumowanie

Rozdział 11. Kodowanie pierwszego testu

Tworzenie projektu

Modyfikowanie nazw klas, plików i metod testowych

Pisanie pseudokodu

Uwagi odnośnie do pseudokodu

Uzupełnianie kodu w celu jego skompilowania

Deklarowanie klasy LoggedInUser

Deklarowanie właściwości MVCForum

Deklarowanie metody RegisterNewUserAndLogin

Deklarowanie pozostałych klas i metod

Omówienie kodu modelu

Podsumowanie

Rozdział 12. Uzupełnianie pierwszego testu

Uruchamianie testu w celu znalezienia pierwszej metody do

zaimplementowania

Dodawanie Selenium do projektu

Uruchamianie IISExpress

Implementowanie konstruktora MVCForumClient

Implementowanie metody RegisterNewUserAndLogin

Proszenie dewelopera o dodanie unikalnego identyfikatora automatyzacji

Implementowanie metod ustawiających dla właściwości

Usuwanie duplikacji z metod ustawiających właściwości


Napotykanie błędu izolacji

Implementowanie metody CreateDiscussion i analizowanie niepowodzenia

Kończenie testu

Podsumowanie

Rozdział 13. Badanie niepowodzeń

Integrowanie z najnowszą wersją aplikacji MVCForum

Usprawnianie raportowania błędów

Unikanie debugowania

Badanie głównej przyczyny

Rozwiązywanie problemu

Więcej problemów…

Rejestrowanie oraz inne formy zbierania dowodów

Przechwytywanie ekranu

Rejestrowanie

Rejestrowanie zagnieżdżone

Rejestrowanie wizualne

Dodatkowe opcje rejestrowania i diagnozowania

Dodawanie zagnieżdżonego rejestratora wizualnego do testów aplikacji

MVCForum

Badanie trudniejszych niepowodzeń

Niepowodzenia, które zdarzają się tylko na jednej maszynie

Badanie testów wpływających na inne testy

Badanie testów migoczących

Podsumowanie

Rozdział 14. Dodawanie kolejnych testów

Pisanie kolejnych testów

Planowanie kolejnych testów

Dodawanie testu: dyskusje mogą być filtrowane według kategorii


Podsumowanie procesu dodawania drugiego testu

Wprowadzanie dodatkowych usprawnień

Tworzenie bardziej zrozumiałych identyfikatorów

Organizowanie kodu w foldery

Wyodrębnianie klasy bazowej dla testów

Obsługa wielu użytkowników i przeglądarek

Wskazówki w zakresie korzystania z plików konfiguracyjnych testów

Obsługiwanie wielu przeglądarek

Dodatkowe możliwości usprawniania

Automatyczne ponowne tworzenie bazy danych

Oczyszczanie

Poprawienie wydajności

Dodawanie kolejnych testów

Testy sterowane danymi

Podsumowanie

Rozdział 15. Ciągła integracja

Czy to naprawdę konieczne?

Tworzenie procesu kompilacji testów

Planowanie procesu kompilacji testów

Tworzenie procesu automatycznego wdrażania

Dodawanie testów do kompilacji

Zmiana procesu tworzenia oprogramowania i kultury

Dążenie do „Świętego Graala”

Co jest potrzebne do zmiany kultury?

Określanie punktu wyjścia

Skracanie czasu wykonywania testów

Ulepszanie izolacji

Realizowanie wymagań wstępnych za pośrednictwem API


Równoległe wykonywanie i wirtualizacja

Uruchamianie wyłącznie testów poprawności w ramach ciągłej integracji

Dzielenie potoku CI na etapy

Pisanie głównie testów integracyjnych i jednostkowych

Uruchamianie testów wyłącznie dla konkretnych komponentów

Optymalizowanie wydajności testów

Pokrywanie szerszej macierzy

Podsumowanie

Rozdział 16. Tworzenie oprogramowania sterowane testami

akceptacyjnymi (ATDD)

Omówienie metodyki ATDD

Bycie bardziej zwinnym

Dług techniczny

Co sprawia, że zespół jest zwinny?

Unikanie długu technicznego

Proces

Tworzenie historyjki użytkownika

Pisanie testów automatycznych

Dostarczanie aplikacji i zbieranie opinii na jej temat

Używanie testów akceptacyjnych jako dokumentacji

Wiązanie kroków zamiast testów

Kompromis między możliwością ponownego użycia, poziomem

szczegółów i czytelnością

Wprowadzanie metodyki ATDD do istniejącego projektu

Rozpoczynanie bez testów automatycznych

Retrospektywna implementacja automatyzacji

Rozpoczynanie od naprawy błędów

Zwiększanie pokrycia regresji


Podsumowanie

Rozdział 17. Test jednostkowe i tworzenie oprogramowania sterowane

testami (TDD)

Przyswajanie testów jednostkowych i TDD

Sposoby pisania testów jednostkowych

Mechanizm biblioteki testów jednostkowych

Sposób pisania testu jednostkowego

Testy jednostkowe i operacje wejścia/wyjścia

Mechanizm działania TDD

Czerwone-zielone-refaktoryzacja

Dlaczego najpierw powinniśmy pisać testy?

Prawdziwe wyzwania w testowaniu jednostkowym i TDD

Główne wyzwania związane z testowaniem jednostkowym

Główne wyzwania związane z podejściem TDD

Bardziej szczegółowe wyzwania

Opanowywanie czystego kodu i zasad SOLID

Opanowywanie umiejętności refaktoryzowania

Największe wyzwanie: co testować?

Używanie metodyki TDD w celach, do jakich była projektowana

Podejście „z zewnątrz do środka” kontra podejście „od środka na zewnątrz”

Podsumowanie

Rozdział 18. Inne rodzaje testów automatycznych

Testy wydajności

Mierzenie wydajności w środowisku produkcyjnym

Czego nie robić?

Definiowanie oczekiwanego rezultatu

Ponowne wykorzystywanie kodu pomiędzy testami funkcjonalnymi

i testami wydajności
Badanie wąskich gardeł w wydajności

Wydajność postrzegana a wydajność rzeczywista

Testy obciążeniowe

Jak działają testy obciążeniowe

Definiowanie oczekiwanego rezultatu

Łączenie testów wydajności z testami obciążeniowymi

Uruchamianie testów w środowisku produkcyjnym

Testowanie wdrożenia

Testowanie stanu zdrowia środowiska produkcyjnego

Które testy uruchamiać?

Oczyszczanie danych testu

Testowanie wizualne

Przepływ pracy testowania wizualnego

Testowanie wizualne i testowanie w wielu przeglądarkach/na wielu

platformach

Testy instalacji

Podejścia dla testów instalacji

Testowanie instalacji za pośrednictwem interfejsu użytkownika lub

instalacji dyskretnej

Testowanie programu deinstalacyjnego

Testy aktualizacji

Podejścia dla testów aktualizacji

Testowanie algorytmów statystycznych, niedeterministycznych i sztucznej

inteligencji

Sposoby testowania algorytmów statystycznych

Testowanie aplikacji, które wykorzystują liczby losowe

Testowanie aplikacji analityki biznesowej

Podsumowanie
Rozdział 19. Co dalej?

Popełniaj błędy

Słuchaj, konsultuj się i zasięgaj porad

Poznaj i dostosuj się do celów swojego biznesu

Poznaj swoje narzędzia

Doskonalenie umiejętności programistycznych

Doskonalenie umiejętności w zakresie zapewniania jakości

Poszerzaj swoje horyzonty

Dzielenie się wiedzą

Dziel się własnymi narzędziami

Bawmy się dobrze!

Dodatek A. Rzeczywiste przykłady

Przykład 1 – system monitorowania wodomierzy

Symulowanie serwera komunikacji

Praca z usługą Google Maps

Przykład 2 – system do handlu na rynku Forex

Rozwiązanie

Niestabilność powodowana przez CRM

Izolowanie środowisk

Testowanie aplikacji mobilnej z użyciem abstrakcyjnego zakresu testowania

Przykład 3 – zarządzanie sklepem detalicznym

Opis architektury

Wdrożenie minimalne

Struktura organizacyjna

Rozwiązania automatyzacji testów

Symulator daty i godziny

Testy dla trzech warstw

Testy kompleksowe
DODATEK B. Mechanizm oczyszczania

Wywołania zwrotne i delegaty

Budowanie mechanizmu oczyszczania

Problem

Proste rozwiązanie

Ponowne wykorzystywanie mechanizmu oczyszczania

Obsługiwanie zależności pomiędzy akcjami oczyszczającymi

Obsługiwanie wyjątków w akcjach oczyszczających

Podsumowanie

Dodatek C. Projekt „Test Automation Essentials”

Kontekst

Struktura projektu

Uwaga dotycząca testów jednostkowych i komentarzy XML

Pakiety NuGet

Funkcje i narzędzia

TestAutomationEssentials.Common

TestAutomationEssentials.MSTest

TestAutomationEssentials.CodedUI

TestAutomationEssentials.Selenium

Pomoc w tworzeniu projektu i przenoszenie na inne języki

Dodatek D. Wskazówki i praktyki zwiększające produktywność

programisty

Preferuj korzystanie z klawiatury

Poka-Yoke

Unikaj wartości Null

Unikaj przechwytywania wyjątków

Wybieranie najbardziej odpowiedniego lokalizatora

Trwale zakodowane ciągi znaków w automatyzacji testów: za i przeciw


Przypisy
Pamięci moich zmarłych dziadków,

Nathana i Lei Axelrod, pionierów izraelskiego kina.

Pamięć o Was jest moją inspiracją.


O autorze

Arnon Axelrod jest ekspertem w dziedzinie automatyzacji testów i pracuje

jako starszy konsultant, architekt, szkoleniowiec oraz lider zespołu

automatyzacji w Sela Group. W wieku 10 lat Arnon zaczął programować

swój komputer ZX-Spectrum i od tamtej pory nigdy nie zatracił pasji do

programowania.

Po uzyskaniu w 1999 roku tytułu licencjata z matematyki i informatyki

na Uniwersytecie Ben-Guriona, Arnon rozpoczął pracę w korporacji

Microsoft na stanowisku inżyniera testów oprogramowania, gdzie po raz

pierwszy zetknął się z zagadnieniem automatyzacji testów. Od tamtej pory

pracował w kilku nowoczesnych firmach, głównie jako inżynier


oprogramowania, aż po dzień, w którym ponownie odkrył automatyzację

testów z zupełnie nowej perspektywy. W 2010 roku, po kilku latach

korzystania z metodyki Agile, Arnon – pracując wówczas w firmie Retalix

(zakupionej później przez NCR Corporation) – zdał sobie sprawę, że

efektywna automatyzacja testów, a dokładniej technika tworzenia

oprogramowania sterowanego testami akceptacyjnymi (ATDD), jest

niezbędna do błyskawicznego i efektywnego dostarczania wysokiej jakości

oprogramowania. Podczas pracy w NCR Arnon zaprojektował

infrastrukturę do automatyzacji testów, która była stosowana przez ponad

100 deweloperów i umożliwiała wykonywanie ponad 4000 testów

akceptacyjnych w mniej niż 20 minut.

W 2015 roku Arnon dołączył do Sela Group, gdzie pracuje obecnie,

a jego misją jest popularyzowanie swojej wiedzy wśród jak największej

liczby firm i osób indywidualnych, aby pomóc im efektywniej tworzyć

wysokiej jakości oprogramowanie poprzez właściwe wykorzystywanie

automatyzacji testów.

W wolnym czasie Arnon lubi żeglować, grać na fortepianie i śpiewać

w chórze. Mieszka w mieście Matan w Izraelu wraz ze swoją żoną, Osnat,

oraz ich trzema synami: Orim, Eladem i Avivem.

Arnona Axelroda można obserwować na LinkedIn, czytać jego blog

pod adresem http://blogs.microsoft.co.il/arnona/ lub też skontaktować się

z nim bezpośrednio pod adresem arnonaxelrod@hotmail.com.


O recenzencie technicznym

Bas Dijkstra jest konsultantem oraz szkoleniowcem w zakresie testowania

i automatyzacji. Specjalizuje się w tworzeniu oraz implementowaniu

strategii automatyzacji wspierających testowanie – zaczynając od

udzielania odpowiedzi na pytanie „dlaczego” automatyzacja, aż po samo

pisanie efektywnych rozwiązań automatyzacji.

Bas prowadzi szkolenia na różne tematy związane z automatyzacją.

Ponadto regularnie publikuje posty blogowe i artykuły poświęcone różnym

tematom związanym z automatyzacją testów – zarówno na swojej własnej

stronie (https://www.ontestautomation.com/), jak również w innych

witrynach i magazynach branżowych.


Podziękowania

Przede wszystkim chciałbym podziękować mojej żonie Osnat – ta książka

nie byłaby możliwa bez ogromnego wsparcia, jakie od Ciebie otrzymałem,

a wiem, że nie było to proste! Choć robiłem co w mojej mocy, aby praca

nad tą książką nie miała wpływu na nasze życie prywatne, to jednak

pozostawiałem Cię samotną przez wiele długich wieczorów i zrzucałem na

Ciebie więcej domowych obowiązków niż zazwyczaj. Nie sądzę, że w ten

sposób uda mi się Ci to jakoś wynagrodzić, ale chcę Ci powiedzieć jedną

rzecz: kocham Cię!

Następnie chciałbym bardzo podziękować mojemu bezpośredniemu

menedżerowi i szefowi działu „DevOps and Automation” w firmie Sela,

Shmulikowi Segalowi, który również wspierał mnie w tej pracy i pozwolił

mi poświęcić cenny czas na pracę nad tą książką, pomimo że nie miało to

żadnego ekonomicznego uzasadnienia. Shmulik, pomijając już Twoje

wsparcie przy tworzeniu tej książki, dziękuję Ci jako menedżerowi

i człowiekowi. Dajesz mi siłę, która pozwala mi sięgać szczytów w mojej

karierze, na które nigdy nie myślałem, że będę w stanie się wznieść.

A wszystko to robisz w sposób bardzo sympatyczny.

Chcę również podziękować Sashy Goldshteinowi, byłemu CTO

w firmie Sela (autorowi książki Pro .NET Performance wydawnictwa


Apress z 2012 roku oraz współautorowi Introducing Windows 7 for

Developers od Microsoft Press z 2011 roku), który próbował odwieść mnie

od pisania tej książki, ale jak widać to mu się udało. Miałeś rację

przynajmniej w jednym: napisanie jej zajęło mi znacznie więcej czasu niż

planowałem. Niemniej jednak znacząco mi pomogłeś i doradziłeś, w tym

również zachęciłeś mnie do wysłania propozycji na książkę do

wydawnictwa Apress.

Chciałbym także podziękować Zoharowi Lavy’emu z Seli, który

koordynuje mój harmonogram i pomaga mi przy wielu zadaniach

administracyjnych – praca z Tobą to prawdziwa przyjemność! Dziękuję

całej załodze administracyjnej w Sela za całą istotną i ciężką pracę, jaką

wykonujecie za kulisami, a także moim przełożonym i właścicielom Seli:

dyrektorowi naczelnemu, Davidowi Basa’owi, prezesowi Sela College,

Caro Segalowi oraz wiceprezesowi ds. operacji globalnych, Ishai Ramowi,

za przewodnictwo Seli i uczynienie z niej tak wspaniałego miejsca do

pracy. I w końcu dziękuję wszystkim moim utalentowanym

współpracownikom – od każdego z Was sporo się nauczyłem.

Dziękuję Carlowi Franklinowi i Richardowi Campbellowi,

prowadzącym podcast „.NET Rocks”, za poszerzanie moich horyzontów,

rozbawianie mnie i sprawianie, że moja droga do pracy jest dużo bardziej

przyjemna. Carl, dziękuję również za stworzenie kolekcji „muzyki do

słuchania podczas kodowania”, która pomogła mi skupić się podczas pracy

nad tą książką.

Muszę również podziękować wszystkim ludziom, dzięki którym

książka ta nabrała właściwego kształtu: przede wszystkim Basowi

Dijkstrze, mojemu świetnemu i wysoce profesjonalnemu recenzentowi

technicznemu, za uważne przeczytanie każdego zdania i dostarczenie wielu


cennych uwag, wniosków i sugestii, które pozwoliły mi ulepszyć tę książkę.

Bez Ciebie ta książka byłaby prawdopodobnie nic nie warta…

Na koniec chcę także podziękować całemu zespołowi redaktorskiemu

w Apress: Ricie Fernando Kim, mojej redaktorce koordynującej, za

zarządzanie postępem moich prac i dostarczanie cennych wskazówek

i porad odnośnie wszystkiego, o co pytałem lub powinienem zapytać.

Laurze C. Berendson, redaktor prowadzącej, za pomoc w ukształtowaniu

i zaprezentowaniu moich pomysłów w najlepszy możliwy sposób; Shivangi

(Shiva) Ramachandranowi, redaktorowi, za zarządzanie tym projektem,

a także Susan McDermott, starszej redaktorce, za zaakceptowanie mojej

propozycji na tę książkę i przede wszystkim za wiarę we mnie. Dziękuję

Wam wszystkim!
Wprowadzenie

Istnieje wiele świetnych książek na temat automatyzacji testów, a w

szczególności na temat najlepszych praktyk w tym zakresie. Jednak żadna

z tych książek nie jest uniwersalna. Jak to ktoś kiedyś powiedział: „Te

‚najlepsze praktyki’ są zawsze kontekstowe: nawet coś tak powszechnego

jak oddychanie może mieć katastrofalne skutki, jeśli kontekstem będzie

swobodne nurkowanie…”.

Większość książek, które przeczytałem do tej pory na temat

automatyzacji testów, skierowana jest w dużej mierze do deweloperów

i skupia się głównie na testach jednostkowych lub pisanych przez

deweloperów testach kompleksowych. Inne książki, które albo

przeczytałem, albo o których słyszałem, poświęcone są konkretnej

technologii automatyzacji testów, konkretnej metodyce, lub po prostu są już

zbyt nieaktualne. Choć ogólnie zgadzam się z tym, że idea, zgodnie z którą

to deweloperzy piszą testy, może być w wielu sytuacjach bardzo efektywna,

to w rzeczywistości nie pasuje ona do wszystkich organizacji na wszystkich

etapach. Co więcej, automatyzacja testów jest narzędziem, które służy i ma

wpływ niemal na wszystkich interesariuszy organizacji tworzącej

oprogramowanie, wliczając w to testerów, menedżerów produktu,

architektów oprogramowania, ludzi z zespołów DevOps oraz menedżerów


projektów, a nie tylko deweloperów. Ponieważ każda organizacja i każdy

projekt oprogramowania jest inny, próba dostosowania technik, praktyk

i narzędzi, które nie pasują do potrzeb lub umiejętności danego zespołu,

może doprowadzić do niepowodzenia projektu automatyzacji testów, a w

niektórych przypadkach nawet do upadku całego projektu oprogramowania.

Książka ta ma na celu zaprezentowanie szerokiego poglądu na temat

automatyzacji testów, aby umożliwić czytelnikowi podejmowanie mądrych

decyzji dotyczących jego konkretnego przypadku – biorąc przy tym pod

uwagę jego ograniczenia i korzyści, jakie chce on uzyskać dzięki

automatyzacji testów – ale również dostarczenie szczegółowych

i praktycznych porad w zakresie efektywnej budowy automatyzacji testów,

a przynajmniej dla większości przypadków.

Kto powinien przeczytać tę książkę?

Ponieważ automatyzacja testów wywiera wpływ na prawie wszystkich

interesariuszy organizacji tworzącej oprogramowanie i w tej książce

staramy się omówić prawie każdy aspekt automatyzacji testów, jest ona

przeznaczona dla każdego, kto jest zaangażowany w proces tworzenia

oprogramowania i chce dowiedzieć się, w jaki sposób można uzyskać

więcej korzyści z automatyzacji testów. Do grona tych osób zaliczyć

można: menedżerów zespołów zapewniania jakości, menedżerów zespołów

deweloperów, deweloperów, testerów, architektów, menedżerów produktu

(nazywanych również analitykami biznesowymi, analitykami systemu lub

jeszcze inaczej), ludzi z zespołów DevOps itd. No i oczywiście

deweloperów automatyzacji testów, których głównym zadaniem jest

tworzenie testów automatycznych…


Znaczna część tej książki nie ma zbyt technicznego charakteru

i skierowana jest do szerszego odbiorcy, jednak rozdziały od 11 do 14 są

bardzo techniczne i skierowane do osób, które piszą kod i są dobrze

zaznajomione z programowaniem obiektowym – w szczególności mam tu

na myśli profesjonalnych deweloperów automatyzacji testów. Kod w tej

części napisany został w języku C#, ale same koncepcje i pojęcia można

z łatwością przenieść na inny obiektowy język programowania. Ponieważ

języki C# i Java są do siebie podobne, programiści Java nie powinni mieć

większego problemu ze zrozumieniem tego kodu. Jestem jednak

przekonany, że również programiści innych języków będą w stanie łatwo go

zrozumieć, a przynajmniej jego główne idee.

W szczególności mam nadzieję, że książkę tę przeczyta wielu

menedżerów zespołów deweloperów i zapewniania jakości, ponieważ

zwykle to oni mają największy wpływ na kształtowanie metodyki

i procesów pracy w swojej organizacji, z którymi to automatyzacja testów

powinna się integrować i wspomagać ich rozwój. Ponadto książka ta

zawiera wskazówki i techniki przydatne dla osób niebędących

menedżerami, pozwalające im usprawniać stosowane w organizacji

metodyki i procesy pracy nawet bez żadnej formalnej władzy.

Jak zorganizowana jest ta książka?

Gdy po raz pierwszy usiadłem do pisania tej książki, starałem się myśleć

o jej ogólnej strukturze, ale zorientowałem się, że będzie to bardzo trudne

zadanie, ponieważ wygląda na to, że prawie każdy temat jest powiązany

z wieloma innymi tematami. W tamtym czasie nie mogłem znaleźć

przejrzystego i logicznego sposobu podzielenia jej treści na ogólne części,

tak więc napisałem „gruntowny spis” tematów, które chciałem w tej książce
omówić i po prostu zacząłem je pisać, przelewając swoją wiedzę

bezpośrednio na papier (a mówiąc bardziej precyzyjnie, na klawiaturę…).

Naturalnie rozpocząłem od najprostszych i najbardziej ogólnych rzeczy,

a następnie stopniowo rozbudowywałem je o kolejne, bardziej

zaawansowane i szczegółowe rozdziały. Ponieważ tematy te są ze sobą

ściśle powiązane, często pisałem fragmenty odwołujące się do tematu,

którego jeszcze nie napisałem, a przy bardziej zaawansowanych tematach

odwoływałem się do wcześniejszych rozdziałów. Tak więc ostatecznie,

niczym w dobrym projekcie Agile (a skoro już mowa o odwołaniach do

innych rozdziałów, to zobacz rozdział 1, zawierający więcej informacji na

temat metodyki Agile), ogólna struktura tej książki zaczęła się stopniowo

ujawniać. W pewnym momencie zdałem sobie sprawę, że książka przybrała

dosyć logiczną strukturę złożoną z dwóch części: pierwsza część

odpowiada bardziej na ogólne pytania typu „dlaczego” oraz „co”, zaś druga

część odpowiada na bardziej szczegółowe i techniczne pytania typu „jak”.

Ogólnie zachęcam czytelników do przeczytania całej książki od

początku do końca. Ponieważ jednak książka ta skierowana jest do

szerokiego grona odbiorców o różnych problemach, umiejętnościach,

zainteresowaniach, potrzebach itd., można również skupić się na lekturze

wyłącznie konkretnych rozdziałów, przeglądając lub nawet pomijając

pozostałe. Można przy tym też skakać w przód i w tył do innych rozdziałów

wspominanych w aktualnie czytanym fragmencie, aby w razie potrzeby

uzupełnić swoją wiedzę. Wreszcie warto zawsze trzymać tę książkę

w pobliżu, aby skorzystać z niej później, gdy zastosowanie automatyzacji

testów w organizacji wystarczająco dojrzeje i zacznie stawiać czoło nowym

wyzwaniom.

Oto przegląd poszczególnych części i rozdziałów tej książki:


Część I: „Dlaczego” oraz „Co”

Ta część omawia temat automatyzacji testów pod kątem wielu różnych

aspektów, ale w bardziej „ogólny” sposób. Ta część książki jest niezbędna

dla tych, którzy nie mają dużego doświadczenia z automatyzacją testów

i chcą się dowiedzieć, jak wpasowuje się ona w szeroki obraz tworzenia

oprogramowania, oraz od czego można zacząć. Zawarte w niej rozdziały

pomogą nam również zrozumieć to, czego możemy, a także czego nie

powinniśmy oczekiwać od automatyzacji testów. Jest to szczególnie istotne

dla menedżerów zespołów deweloperów i zapewniania jakości, ponieważ

omawiają one takie aspekty jak struktura biznesu, procesy pracy,

architektura itd. Ta część książki pomoże nam przy podejmowaniu wielu

decyzji, jakie nas czekają (czego wiele osób nie bierze nawet pod uwagę!)

i pokaże nam, jaki wpływ może mieć każda z nich. Nawet jeśli nie jesteśmy

menedżerami i uważamy, że nie mamy żadnego wpływu na te rzeczy,

powinniśmy przeczytać rozdziały z tej części, aby zrozumieć ograniczenia

i zalety w naszej obecnej sytuacji, a także być w stanie lepiej komunikować

je naszym menedżerom.

Jeśli mamy już doświadczenie z automatyzacją testów, to ta pierwsza

część pomoże nam poszerzyć w tym temacie nasze horyzonty i pokaże nam

opcje oraz konsekwencje związane z decyzjami, które wcześniej podjęliśmy

w mniej świadomy sposób.

Część II: „Jak”

Po ogólnym zapoznaniu się z dziedziną automatyzacji testów, czas zakasać

rękawy i zacząć pisać testy wraz z wymaganą infrastrukturą. Po napisaniu

kilku testów wyjaśniamy, w jaki sposób możemy zrobić krok na przód


i najbardziej efektywnie wykorzystać automatyzację testów w cyklu

tworzenia oprogramowania.

Od strony merytorycznej rozdziały w tej części można podzielić na

dwie grupy (przy czym podział ten nie jest nigdzie jawnie podany,

z wyjątkiem tego miejsca): rozdziały od 9 do 14 pisane są jako praktyczny

samouczek, w ramach którego projektujemy i tworzymy system

automatyzacji testów wraz z kilkoma testami (z użyciem narzędzia

Selenium) dla istniejącego projektu open source, zaś rozdziały od 15 do 19

stanowią przewodnik po wykorzystywaniu automatyzacji testów

w najbardziej efektywny sposób, pokazując przy tym, jak wyciągnąć z niej

maksimum korzyści.

Większość rozdziałów z tej pierwszej grupy ma bardzo techniczny

charakter, w przeciwieństwie do rozdziałów drugiej grupy. Z tego powodu

pierwsza grupa rozdziałów jest bardziej odpowiednia dla deweloperów, a w

szczególności dla deweloperów automatyzacji testów posiadających

umiejętności w zakresie programowania obiektowego, natomiast druga

grupa rozdziałów może być użyteczna dla każdego. Doświadczonych

deweloperów zachęcam do podążania za samouczkiem krok po kroku

i wykonywania wszystkich kroków samodzielnie, aby mogli oni

doświadczyć ich w lepszym stopniu. Osoby, które nie potrafią

programować, powinny przejrzeć te bardziej techniczne rozdziały w celu

zapoznania się z głównymi koncepcjami, które są w nich zawarte, nawet

jeśli osoby te nie zamierzają implementować ich w swoim własnym

projekcie.

Oto kompletny opis rozdziałów:

Część I:
Rozdział 1: Wartość automatyzacji testów – w tym rozdziale

wyjaśniono, dlaczego automatyzacja testów jest potrzebna i jakie są jej

krótko- i długoterminowe korzyści.

Rozdział 2: Od testowania ręcznego do automatycznego – ten

rozdział zawiera omówienie różnic pomiędzy testowaniem ręcznym

i automatycznym oraz początek nakreślenia realistycznych oczekiwań

dotyczących automatyzacji testów, ponieważ znacząco różni się ona od

zwyczajnie szybszych testów manualnych.

Rozdział 3: Ludzie i narzędzia – w tym rozdziale wyjaśniono, kto

powinien pisać testy i infrastrukturę automatyzacji, oraz jakie są

konsekwencje stosowania alternatywnych rozwiązań. Dodatkowo

omówiono sposób dobierania właściwych narzędzi w zależności od

wybranej opcji.

Rozdział 4: Osiąganie pełnego pokrycia – w tym rozdziale nakreślono

realistyczne oczekiwania dla długoterminowej mapy drogowej projektu

automatyzacji, a także pokazano, w jaki sposób możemy zacząć czerpać

z niej korzyści jeszcze na długo przed tym, jak automatyzacja zastąpi

większość manualnych testów regresji.

Rozdział 5: Procesy biznesowe – w tym rozdziale wyjaśniono, w jaki

sposób automatyzacja testów powiązana jest z procesami biznesowymi

wytwarzania oprogramowania i podano ogólny zarys tematów, które

omawiane są bardziej szczegółowo pod koniec tej książki.

Rozdział 6: Automatyzacja i architektura testów – w tym rozdziale

omówiono sposób, w jaki automatyzacja testów jest powiązana

z architekturą testowanego systemu, oraz dlaczego ważne jest, aby były

one do siebie dostosowywane.


Rozdział 7: Izolacja i środowiska testowe – w tym rozdziale

wyjaśniono, w jaki sposób należy planować automatyzację testów oraz

jej środowiska wykonywania, aby zagwarantować, że testy są

wiarygodne i nie mają na nie wpływu żadne niepożądane efekty.

Rozdział 8: Szersza perspektywa – w tym rozdziale omówiono

wzajemne zależności pomiędzy wszystkimi tematami omawianymi

w poprzednich rozdziałach – głównie między architekturą, strukturą

biznesu, procesami biznesowymi i oczywiście automatyzacją testów.

Omówiono również sposób, w jaki wszystkie te tematy odnoszą się do

kultury biznesu.

Część II:

Rozdział 9: Przygotowanie do samouczka – ten rozdział zawiera opis

proces wykorzystywany w ramach samouczka, który ma również

zastosowanie w większości projektów automatyzacji testów.

W rozdziale tym pokazano również, jak można skonfigurować własną

maszynę, aby móc samodzielnie wykonywać kolejne kroki tego

samouczka.

Rozdział 10: Projektowanie pierwszego przypadku testowego –

w tym rozdziale uczymy się konkretnej techniki projektowania

przypadków testowych w sposób najlepiej pasujący do testów

automatycznych.

Rozdział 11: Kodowanie pierwszego testu – w tym rozdziale

pokazano, w jaki sposób możemy rozpocząć pisanie kodu dla

pierwszego testu. Zaczynamy od napisania prostego szkieletu testu,

w sposób, który pozwoli nam zaprojektować i utworzyć modułową

infrastrukturę do wielokrotnego użytku. Pod koniec tego rozdziału nasz


test będzie się kompilował, ale nie będzie on wykonywał jeszcze żadnej

pracy.

Rozdział 12: Uzupełnianie pierwszego testu – w tym rozdziale

kończymy pracę, którą zaczęliśmy w rozdziale poprzednim. Pod koniec

tego rozdziału będziemy mieć działający test oraz dobrze

zaprojektowaną infrastrukturę, która będzie go obsługiwać.

Rozdział 13: Badanie niepowodzeń – w tym rozdziale ćwiczymy

sposób badania i radzenia sobie z rzeczywistym niepowodzeniem testu,

które miało miejsce w nowej kompilacji testowanego systemu, oraz

tworzenia raportu, który pomoże nam zbadać dodatkowe

niepowodzenia w przyszłości.

Rozdział 14: Dodawanie kolejnych testów – w tym rozdziale

dodajemy jeden dodatkowy test. Ponadto omawiamy sposób dodawania

coraz większej liczby testów przy jednoczesnym rozszerzaniu

i usprawnianiu wspierającej ich infrastruktury, w tym obsługę

testowania w wielu przeglądarkach, obsługę wielu środowisk i znacznie

więcej.

Rozdział 15: Ciągła integracja – w tym rozdziale (rozpoczynającym

drugą grupę rozdziałów z części II) wyjaśniono, w jaki sposób możemy

integrować testy do postaci kompilacji ciągłej integracji. Poza

aspektami technicznymi, w rozdziale tym pokazano, jak zapewnić

sukces ciągłej integracji jako narzędzia organizacyjnego i podano

porady dla osób bez doświadczenia w programowaniu, jak stopniowo

zmieniać na lepsze kulturę i procesy danej organizacji poprzez

wykorzystywanie zalet ciągłej integracji.

Rozdział 16: Tworzenie oprogramowania sterowane testami

akceptacyjnymi (ATDD) – w tym rozdziale wyjaśniono korzyści ze


stosowania oraz sposób implementacji metodyki tworzenia

oprogramowania sterowanego testami akceptacyjnymi, która dzięki

wykorzystaniu ciągłej integracji obejmuje cały cykl tworzenia

oprogramowania i pomaga zespołowi efektywnie wykorzystywać

metodykę Agile.

Rozdział 17: Testy jednostkowe i tworzenie oprogramowania

sterowane testami (TDD) – w tym rozdziale omówiono techniki, które

tradycyjnie przypisywane są wyłącznie programistom aplikacji: testy

jednostkowe i tworzenie oprogramowania sterowane testami, ale są tak

naprawdę nieodłączną częścią automatyzacji testów.

Rozdział 18: Inne rodzaje testów automatycznych – w tym rozdziale

omówiono dodatkowe rodzaje testów automatycznych, w tym

testowanie wydajności i obciążenia, testowanie w środowisku

produkcyjnym, testowanie wizualne, testy instalacji, testowanie

z wykorzystaniem sztucznej inteligencji i więcej.

Rozdział 19: Co dalej? – w tym rozdziale podano pewne wskazówek

dotyczące dalszego zdobywania i rozwijania umiejętności w zakresie

automatyzacji testów.

Poza tymi rozdziałami, na końcu książki dostępne są również cztery

dodatki:

Dodatek A: Rzeczywiste przykłady – ten dodatek stanowi

uzupełnienie rozdziału 6 („Automatyzacja i architektura testów”)

i zawiera cztery rzeczywiste przykłady architektur aplikacji oraz

odpowiadające im rozwiązania automatyzacji.

Dodatek B: Mechanizm oczyszczania – ten dodatek zawiera opis

sposób budowy mechanizmu oczyszczania, przedstawionego


w rozdziale 7 („Izolacja i środowiska testowe”).

Dodatek C: Projekt „Test Automation Essentials” – w tym dodatku

opisałem stworzony przeze mnie projekt open source o nazwie Test

Automation Essentials, zawierający wiele przydatnych narzędzi kodu

(napisanych w C#) dla projektów automatyzacji testów.

Dodatek D: Wskazówki i praktyki zwiększające produktywność

programisty – ten dodatek stanowi uzupełnienie dla rozdziałów od 9 do

14 i zawiera wskazówki, które pozwolą nam zwiększyć produktywność

podczas programowania. Choć wskazówki te mogą być przydatne dla

dowolnego programisty, będą one szczególnie użyteczne dla

programistów automatyzacji testów.

Przyjemnej lektury!
Część I. „Dlaczego” oraz „co”
Ponieważ książka ta nosi tytuł Automatyzacja testów. Kompletny

przewodnik dla testerów oprogramowania, obejmuje ona teorię i praktykę,

tematy na poziomie początkowym i zaawansowanym, metodologiczne

aspekty i techniczne szczegóły itd. Jest tak, ponieważ próbowałem się

odnieść w tej jednej książce do tak wielu różnych pytań dotyczących

automatyzacji testów, jak to tylko możliwe.

W pierwszej części tej książki staramy się udzielić odpowiedzi głównie na

pytania typu „dlaczego” oraz „co”, pozostawiając większość pytań typu

„jak” na drugą część. Rozpoczynamy od wyjaśnienia, dlaczego w ogóle

potrzebujemy automatyzacji testów i na czym tak naprawdę ona polega (jak

również na czym nie polega). Następnie odpowiadamy na wiele pytań,

rozwiązujemy wiele dylematów i omawiamy wiele czynników (tj. której

opcji powinniśmy użyć i dlaczego) dotyczących automatyzacji testów, które

będą istotne dla każdego, kto planuje rozpocząć korzystanie

z automatyzacji testów lub usprawnić istniejącą automatyzację. Na koniec

spoglądamy na to wszystko z dalszej perspektywy i patrzymy, jak te

wszystkie elementy są ze sobą powiązane.

Przyjemnej lektury!
Rozdział 1. Wartość automatyzacji
testów

Ponieważ tematem tej książki jest automatyzacja testów, powinniśmy

w zasadzie zacząć od jej definicji. Jednak bez nakreślenia właściwego

kontekstu definicja taka może nie być wystarczająco przejrzysta i może

bardziej prowadzić do dezorientacji niż zrozumienia. Jest to na tyle szeroki

i zróżnicowany temat, że trudno jest tu podać taką definicję, która będzie

jednocześnie dokładna, przejrzysta i obejmie wszystkie istniejące rodzaje

automatyzacji testów. Gdybym jednak miał teraz przytoczyć jakąś definicję,

mogłaby ona wyglądać tak: „Używanie oprogramowania w celu ułatwienia

testowania innego oprogramowania”, ale nie jestem do końca pewien, na ile

jest ona przydatna. Dlatego też zamiast skupiać się na formalnych

definicjach, w pierwszej części książki szczegółowo analizuję ten obszerny

temat, starając się w ten sposób wyjaśnić, czym tak naprawdę jest

automatyzacja testów, a także – co równie istotne – czym ona nie jest!

Dlaczego potrzebujemy automatyzacji testów?

Gdy pytam moich klientów, czego spodziewają się uzyskać dzięki

automatyzacji testów, najczęściej udzielaną odpowiedzią jest skrócenie


czasu potrzebnego na przetestowanie oprogramowania przed jego

wydaniem. Z jednej strony, choć jest to niewątpliwie ważny cel, to

w zakresie korzyści, jakie możemy uzyskać dzięki automatyzacji testów,

stanowi on jedynie wierzchołek góry lodowej. Ponadto osiągnięcie celu

w postaci skrócenia cykli testów manualnych zajmuje zwykle sporą ilość

czasu. Z drugiej strony, dużo wcześniej możemy zacząć zauważać pozostałe

korzyści. Ale najpierw zobaczmy, dlaczego ten prosty cel w postaci

skrócenia czasu trwania cyklu testowego stał się w ostatnich latach tak

istotny.

Od modelu kaskadowego do zwinnego


tworzenia oprogramowania

Mimo że niektóre organizacje korzystają z automatyzacji testów już od

dekad, jednak zaczęła być ona powszechnie stosowana dopiero w ostatnich

latach. Jest wiele powodów, dla których tak się stało, ale bez wątpienia

można powiedzieć, że wzrost zapotrzebowania na automatyzację testów

zawdzięczamy w dużej mierze odejściu od tradycyjnego modelu

kaskadowego (waterfall) na rzecz programowania zwinnego (Agile

software development). W tradycyjnym podejściu kaskadowym projekty

oprogramowania postrzegane były jako coś jednorazowego, podobnie jak

budowanie mostu. Najpierw planujemy i projektujemy oprogramowanie,

potem je budujemy, a na końcu testujemy i sprawdzamy jakość końcowego

produktu, naprawiając przy tym pomniejsze błędy, które znaleźliśmy.

Opieramy się tu na założeniu, że jeśli fazy planowania i budowy zostały

przeprowadzone poprawnie, to poza pewnymi drobnym pomyłkami

programistycznymi, które możemy bardzo łatwo naprawić, wszystko

powinno działać zgodnie z planem. Takie podejście sprawia, że proces


weryfikowania, czy rezultat końcowy zachowuje się zgodnie ze

specyfikacją, musimy przeprowadzić tylko raz. Ponowne wykonanie testu

powinno mieć miejsce tylko w przypadku wykrycia jakiegoś błędu

i przygotowania dla niego odpowiedniej poprawki, a następnie jej

sprawdzenia. Jeśli każdy test wykonywany jest tylko raz lub dwa razy, to

w wielu przypadkach znacznie taniej i łatwiej będzie wykonywać je ręcznie

niż je automatyzować.

Po latach stało się jasne, że w większości przypadków podejście

kaskadowe nie spełnia swoich obietnic. Większość projektów

oprogramowania była już na tyle skomplikowana, że zaplanowanie

i domknięcie wszystkich technicznych szczegółów w początkowej fazie

tworzenia było niemożliwe. Nawet w tych przypadkach, w których było to

wykonalne, do czasu ukończenia takiego projektu (trwającego zwykle kilka

lat) zmieniały się zarówno sama technologia, jak i potrzeby biznesowe,

czyniąc takie oprogramowanie mniej adekwatnym niż miało być

początkowo. Z tych właśnie powodów szybkie reagowanie na opinie

klientów stało się cenniejsze od sztywnego trzymania się początkowego

planu. Wraz z upływem czasu większość przemysłu oprogramowania

odeszła od tych jednorazowych projektów, rezygnując z cyklicznego

wydawania nowych wersji tego samego oprogramowania co kilka lat na

rzecz szybkich cyklów wydawniczych. Dzisiaj niektóre z największych

firm działających w sieci Web dostarczają nowe funkcje i poprawki dla

swojego oprogramowania po kilka razy dziennie, a niekiedy nawet kilka

razy na minutę!

MANIFEST PROGRAMOWANIA ZWINNEGO

W 2001 roku 17 liderów z obszaru rozwoju oprogramowania

sformułowało Manifest programowania zwinnego, którego treść jest


następująca1:

Odkrywamy nowe metody programowania dzięki praktyce

w programowaniu i wspieraniu w nim innych. W wyniku naszej pracy

zaczęliśmy bardziej cenić:

Ludzi i interakcje od procesów i narzędzi

Działające oprogramowanie od szczegółowej dokumentacji

Współpracę z klientem od negocjacji umów

Reagowanie na zmiany od realizacji założonego planu

Oznacza to, że elementy wypisane po prawej są wartościowe, ale

większą wartość ma ją dla nas te, które wypisano po lewej.

Kent Beck

James Grenning

Robert C. Martin

Mike Beedle

Jim Highsmith

Steve Mellor

Arie van Bennekum

Andrew Hunt

Ken Schwaber

Alistair Cockburn

Ron Jeffries

Jeff Sutherland

Ward Cunningham

Jon Kern
Dave Thomas

Martin Fowler

Brian Marick

© 2001, autorzy powyżej

Deklaracja ta może być swobodnie kopiowana w dowolnej formie, ale

wyłącznie w całości, z uwzględnieniem tej uwagi.

Oczywiście nie wszystkie firmy i zespoły przyjmują te zasady, ale

prawie każdy, kto jest dziś zaangażowany w rozwój oprogramowania,

preferuje szybsze dostarczanie nowych wersji (i dalsze ich dostarczanie na

przestrzeni długiego okresu czasu), zamiast dostarczać tylko kilka nowych

wersji z długimi przerwami między nimi. Oznacza to również, że zmiany

pomiędzy kolejnymi wydaniami są teraz mniejsze niż w przypadku

dostarczania nowej wersji raz na kilka lat. Naturalnie firmy tworzące

oprogramowanie dla sektorów o kluczowym znaczeniu są mniej skłonne do

podejmowania ryzyka, dlatego zwykle wydają one oprogramowanie

w stosunkowo długich cyklach wydawniczych. Jednak już nawet i one

zaczynają dostrzegać korzyści w szybszym dostarczaniu oprogramowania,

przynajmniej wewnętrznie do zespołów QA.

Manualne testowanie każdej takiej wersji programu może zająć

mnóstwo czasu, co stanowi oczywisty powód, dla którego automatyzacja

testów stała się tak istotna. Nie jest to jednak jedyny ważny powód.

Koszt złożoności oprogramowania

Do każdej nowej wersji programu dodawane są nowe funkcje. W miarę

dodawania funkcji oprogramowanie staję się coraz bardziej złożone,


a wówczas coraz trudniej dodawać do niego nowe funkcje, nie psując przy

tym istniejącego kodu. Jest to szczególnie widoczne przy dużej presji na

szybkie dostarczanie nowych wersji oprogramowania, gdy nie poświęca się

wystarczająco dużo czasu na planowanie i poprawę jakości kodu (jak to

często ma miejsce w przypadku źle wdrożonej metodyki Scrum2).

Ostatecznie następuje spadek szybkości dostarczania nowych wersji, czyli

coś, czego chcemy uniknąć od samego początku!

Części z dodanej w ten sposób złożoności nie da się uniknąć. Będzie

ona istnieć nawet wtedy, gdy skrupulatnie zaplanujemy i zaprojektujemy

z góry całe oprogramowanie. Jest to tzw. złożoność wewnętrzna (inherent

complexity). Jednak w większości przypadków powodami istnienia

przeważającej części złożoności oprogramowania jest szybkie

wprowadzanie nowych funkcji bez odpowiedniego projektu, brak

komunikacji wewnątrz zespołu lub brak odpowiedniej wiedzy – czy to

w zakresie wykorzystywanej technologii, czy też w obszarze potrzeb

biznesowych. Teoretycznie złożoność tę można by zredukować poprzez

skrzętne zaplanowanie z góry całego oprogramowania, ale w rzeczywistości

jest ona naturalną częścią każdego projektu oprogramowania. Ten typ

złożoności nazywany jest często złożonością przypadkową (accidental

complexity).

Każda złożoność – czy to wewnętrzna, czy przypadkowa – niesie ze

sobą pewien koszt. Koszt ten jest oczywiście częścią całkowitego kosztu

wytworzenia oprogramowania, na który wpływ ma głównie liczba

programistów i testerów pomnożona przez czas potrzebny im na

dostarczenie oprogramowania (oczywiście pomnożona również przez ich

wynagrodzenie). Zgodnie z powyższym, gdy złożoność fragmentu

oprogramowania wzrasta, jego koszt również rośnie, ponieważ

przetestowanie wszystkiego oraz naprawa znalezionych błędów (i ponowne


przetestowanie kodu) wymaga większej ilości czasu. Ponadto złożoność

przypadkowa sprawia również, że oprogramowanie jest bardziej ułomne

i trudniejsze w utrzymaniu, a co za tym idzie, wymaga jeszcze więcej czasu

na przetestowanie i naprawę błędów.

Utrzymywanie stałego kosztu

Na rysunku 1.1 pokazano to, co chcemy osiągnąć: koszt utrzymywany na

stałym poziomie w miarę dodawania nowych funkcji. Niestety dodawanie

nowych funkcji oznacza zwykle zwiększanie złożoności oprogramowania,

co naturalnie zwiększa jego koszt. Istnieją jednak dwa czynniki, które mogą

nam pomóc w utrzymaniu kosztu na stałym poziomie:

1. Minimalizowanie kosztów uruchamiania stale powiększającego się

zestawu testów regresji.

2. Pisanie kodu, który jest łatwy w utrzymaniu.

Pierwszy czynnik możemy łatwo osiągnąć poprzez zautomatyzowanie

większości testów. Natomiast na drugi czynnik duży wpływ ma złożoność

przypadkowa, przez co znacznie trudniej go kontrolować.

W kodzie, który jest łatwy w utrzymaniu, złożoność wprowadzana do

oprogramowania w wyniku dodawania do niego nowych funkcji ma bardzo

mały lub zerowy wpływ na złożoność funkcji już istniejących. Oznacza to,

że jeśli wzrost złożoności będziemy utrzymywać w tempie liniowym, to

nadal będziemy mogli utrzymać stabilny koszt, jak to pokazano na rysunku

1.2. Oczywiście tę zdolność do dodawania złożoności chcielibyśmy

zachować jedynie dla złożoności wewnętrznej (tj. nowych funkcji) i nie

marnować jej na złożoność przypadkową. Niestety złożoność przypadkowa

sprawia, że w większości rzeczywistych projektów, w miarę dodawania

kolejnych funkcji złożoność rośnie dużo szybciej niż liniowo (patrz rysunek
1.3). To z kolei powoduje również wzrost kosztów dodawania nowych

funkcji na przestrzeni czasu, co zostało przedstawione na rysunku 1.4.

Rysunek 1.1. Pożądany koszt dodawania nowych funkcji na przestrzeni

czasu

Rysunek 1.2. Pożądany wzrost złożoności po dodaniu nowych funkcji jest

liniowy
Rysunek 1.3. Powszechny przypadek: złożoność rośnie dużo szybciej

w powodu dodanej złożoności przypadkowej

Rysunek 1.4. Koszt rozwoju oprogramowania w typowym przypadku:

dodawanie nowych funkcji staje się coraz bardziej kosztowne wraz

z upływem czasu
W większości przypadków przerywanie dotychczasowych prac

i planowanie wszystkiego od początku w celu obniżenia złożoności

przypadkowej jest całkowicie niepraktyczne. A nawet gdyby byłoby

inaczej, to do czasu zrównania się pod względem funkcjonalnym nowej

(tworzonej od zera) wersji z wersją poprzednią, będzie już ona miała swoją

własną złożoność przypadkową…

Refaktoryzacja

Wydaje się więc, że tworzenie kolejnych nowych funkcji na

ustabilizowanym poziomie kosztów jest niemożliwe, ponieważ złożoność

przypadkowa jest nieunikniona. Czy jesteśmy zatem skazani na porażkę?

Cóż… nie do końca. Rozwiązaniem pozwalającym trzymać złożoność

przypadkową pod kontrolą jest refaktoryzacja. Refaktoryzacja jest

procesem polegającym na usprawnianiu projektu (lub „wewnętrznej

struktury”) danego fragmentu oprogramowania, bez wpływu na jego

zewnętrzne zachowanie. Innymi słowy, refaktoryzacja pozwala nam pozbyć

się złożoności przypadkowej. Refaktoryzacji możemy dokonywać małymi

krokami, usprawniając nasz projekt kawałek po kawałku, bez konieczności

przeprojektowywania całego systemu. W książce Martina Fowlera,

Refaktoryzacja. Ulepszanie struktury istniejącego kodu3, podano

odpowiednie techniki pozwalające dokonywać refaktoryzacji w bezpieczny

sposób. Obecnie najpopularniejsze zintegrowane środowiska

programistyczne4 zawierają pewne narzędzia do automatycznej

refaktoryzacji lub oferują dodatki, które je dostarczają.

Ale nawet w przypadku używania narzędzi do automatycznej

refaktoryzacji może dojść do pomyłki programisty, w wyniku której

w projekcie pojawią się błędy psujące jakąś istniejącą funkcjonalność.


Z tego powodu refaktoryzacja wymaga również przeprowadzania

wyczerpujących testów regresji. Tak więc w celu utrzymania szybkiego

tempa wydawania nowych i stabilnych wersji zawierających nowe funkcje,

musimy regularnie refaktoryzować nasz kod. Aby być w stanie to robić,

musimy bardzo często go testować. Jest to drugi ważny powód, dla którego

powinniśmy stosować automatyzację testów. Rysunek 1.5 pokazuje, w jaki

sposób refaktoryzacja pomaga trzymać złożoność przypadkową pod

kontrolą.

Rysunek 1.5. Refaktoryzacja pomaga trzymać złożoność pod kontrolą

Ciągłe doskonalenie

To, co fascynuje mnie najbardziej w automatyzacji testów, to jej związki ze

wszystkimi innymi aspektami cyklu tworzenia oprogramowania. Poza

związkiem z jakością i produktywnością, który jest oczywisty,

automatyzacja testów powiązana jest również z architekturą tworzonego


produktu, procesami biznesowymi, strukturą organizacyjną, a nawet

z kulturą biznesu (patrz rysunek 1.6). Dla mnie osobiście automatyzacja

testów jest niczym lustro, które odzwierciedla wszystkie te rzeczy. Każdy

z tych aspektów ma pewien wpływ na automatyzację testów. Ale

odzwierciedlenie tych wpływów w automatyzacji testów możemy również

wykorzystać do zmiany i usprawnienia dowolnego z tych aspektów.

W wielu przypadkach klienci, którzy korzystają już z automatyzacji

testów, proszą mnie o pomoc w rozwiązaniu napotkanych problemów.

Problemy te bardzo często objawiają się na poziomie technicznym. Kiedy

jednak przychodzę do tych klientów i pomagam im rozpoznać bezpośrednią

przyczynę tych problemów, często okazuje się, że są one tak naprawdę

związane z co najmniej jednym z tych pozostałych aspektów. Pozbycie się

tych problemów nie zawsze jest proste, ale przynajmniej uświadamiają

sobie znaczenie tych problemów, co jest pierwszym krokiem prowadzącym

do zmiany.
Rysunek 1.6. Automatyzacja testów powiązana jest z wieloma innymi

aspektami tworzenia oprogramowania

Mam nadzieję, że po przeczytaniu tej książki będziemy mogli lepiej

dostrzegać wpływy, jakie tego rodzaju problemy wywierają na budowaną

przez nas automatyzację testów i będziemy w stanie uświadomić ich

istnienie odpowiednim osobom, umożliwiając w ten sposób dokonanie

niezbędnych usprawnień. Oczywiście, jeśli w zespole panuje kultura

ciągłego doskonalenia (np. organizowanie retrospektywnych spotkań

i opieranie na nich swoich działań), wówczas będzie to łatwiejsze do

zrobienia. A nawet jeśli tak nie jest, pamiętajmy, że świadomość jest

kluczem pozwalającym dokonać zmiany i że automatyzacja testów pomoże

to osiągnąć, nawet na stanowisku młodszego programisty ds. automatyzacji


w dużej i biurokratycznej firmie (więcej informacji na temat tego, w jaki

sposób stopniowo zmieniać strukturę swojej organizacji w celu skorzystania

z zalet automatyzacji testów, można znaleźć w rozdziale 17).


Rozdział 2. Od testowania ręcznego
do automatycznego

Spójrzmy prawdzie w oczy: żyjemy w XXI wieku. Nie istnieje żaden

powód, dla którego dowolne z powtarzalnych zadań nie może zostać

poddane pełnej automatyzacji, zwłaszcza w środowiskach o wysokim

stopniu zaawansowania technicznego. Wciąż jednak spora część pracy

testera manualnego polega na wykonywaniu testów regresji5, co jest bardzo

monotonne. Oczywiście wykonywanie ich w sposób ręczny jest znacznie

wolniejsze i bardziej podatne na błędy w porównaniu z tym, co może

potencjalnie robić komputer.

Podejście pierwsze: nagrywanie i odtwarzanie

Pierwszą myślą każdego, kto chciałby usprawnić ten proces, jest

automatyzacja pracy testera manualnego. Jak dowiemy się w dalszej części

tej książki, możemy to osiągnąć na kilka różnych sposobów, ale

najprostszym z nich jest zwykłe nagranie czynności wykonywanych przez

testera manualnego i ich późniejsze wielokrotne odtwarzanie. Zwykle

polega to na nagrywaniu interakcji użytkownika z interfejsem użytkownika,

ale może to być również rejestrowanie ruchu sieciowego, takiego jak


żądania HTTP, lub innego rodzaju danych, które stanowią pośrednie

odzwierciedlenie wykonywanych przez użytkownika czynności.

Gdyby to było takie proste, książka ta nie byłaby nam w ogóle

potrzebna (a ja prawdopodobnie musiałby znaleźć sobie inną pracę…).

W praktyce jednak jest to dużo bardziej skomplikowane. Mimo że duża

część wykonywanych testów regresji jest wysoce powtarzalna, istnieje co

najmniej jedna niepowtarzająca się część, która stanowi całą istotę

wykonywania testów. Tą niepowtarzalną częścią jest część związana

z wykrywaniem błędów! O ile nagrywanie wykonywanych przez testera

czynności i ich późniejsze odtwarzanie jest stosunkowo proste, to już

wykrywanie błędów w zautomatyzowany sposób jest znacznie trudniejsze.

Pewnym, choć dosyć naiwnym podejściem do automatycznego

wykrywania błędów, jest porównywanie obrazu widocznego na ekranie

z obrazem oczekiwanym, który został nagrany. Podejście to ma jednak kilka

wad, z których część ma jedynie charakter techniczny. Przykładowo, mogą

wystąpić pewne różnice na poziomie rozdzielczości ekranu, różnice

w zakresie wyświetlanej na ekranie daty i godziny, różnice w dowolnych

danych wyświetlanych na ekranie, które nie są istotne dla testu itd. Niektóre

narzędzia pozwalają nam przezwyciężyć te techniczne problemy poprzez

wykluczenie tych obszarów zrzutu ekranu, w których mogą pojawiać się

uzasadnione różnice. Podobny problem istnieje w przypadku rejestrowania

ruchu sieciowego HTTP, ponieważ część danych może zawierać

uzasadnione różnice. Tutaj również możemy skorzystać z narzędzi, które

pozwolą nam zdecydować o tym, które fragmenty odpowiedzi chcemy

porównywać, a które wykluczyć. A nawet jeśli te techniczne problemy uda

nam się rozwiązać przy użyciu narzędzi, to nadal istnieć będzie duża wada

będąca nieodłączną częścią koncepcji nagrywania i odtwarzania: każda

dozwolona zmiana w aplikacji będzie uznawana za defekt, co znacząco


utrudni nam rozróżnianie wyników fałszywie dodatnimi od faktycznych

niepowodzeń.

Na tym etapie można by powiedzieć: „Wielka rzecz! Wykonujemy testy

regresji, aby upewnić się, że wszystko działa dokładnie tak jak do tej

pory!”. Pozwólcie, że odpowiem w ten sposób: jeśli nikt nie ruszał kodu6

testowanego systemu (system under test, SUT), to nie wystąpi żadna

regresja, a tym samym nie ma sensu marnować czasu na wykonywanie

jakichkolwiek testów – czy to ręcznie, czy automatycznie. Z drugiej strony,

żaden programista nie będzie raczej zmieniał kodu, jeśli nie zamierza on

wprowadzać zmiany w zachowaniu aplikacji! Stąd też wykonywanie testów

ma sens jedynie w przypadku pojawienia się zmian, a zatem przy każdym

wykonywaniu testów powinniśmy oczekiwać, że coś zostało zmienione.

Gdy wykonujemy testy ręcznie, rzadko uznajemy te zmiany za problem.

Często zmiany te są na tyle małe, że jeśli tylko będziemy kierować się

naszym własnym osądem i zdrowym rozsądkiem, to nadal słowa opisujące

kolejne kroki testu będziemy mogli przełożyć na nowe zachowanie

aplikacji, nawet jeśli nie pasują już do siebie dokładnie. Ten osąd i zdrowy

rozsądek – oparte na naszej wiedzy, doświadczeniu, komunikacji z innymi

członkami zespołu itd. – wykorzystujemy do oceny, czy jakaś zmiana jest

błędem, czy usprawnieniem. Niestety maszyna nie dysponuje takimi

umiejętnościami, więc wszystkie błędy i uprawnione zmiany traktuje na

równi.

Jeśli spodziewane wyniki naszych testów są zbyt ogólne (czyli

„dokładnie tak, jak było”), zamiast odzwierciedlać wyłącznie szczegółowe

informacje, których przetestowanie jest istotne, to nasze testy zbyt często

będą kończyć się niepomyślnie z powodu uprawnionych zmian, a nie

prawdziwych błędów. Innymi słowy, stosunek wyników fałszywie

dodatnich do prawdziwych niepowodzeń będzie zbyt wysoki, przez co


nasze testy będą mniej wiarygodne! Bez przejrzystych i zwięzłych

spodziewanych wyników prawdopodobnie natkniemy się na poniższe

problemy:

1. Każda uprawniona zmiana w aplikacji będzie powodować te same błędy

przy każdym kolejnym uruchomieniu testu, dopóki nie nagramy

ponownie testu lub go nie naprawimy.

2. Jeśli dany test będziemy w kółko nagrywać ponownie, istnieje duża

szansa, że w nagrywanym przez nas scenariuszu pojawią się błędy.

Oczywiście może się zdarzyć, że błąd pojawi się już przy pierwszym

nagrywaniu, a przy kolejnym zostanie już naprawiony, ale inne techniki

(omawiane w dalszej części tego rozdziału) są lepiej dostosowane do

stopniowego usprawniania i umacniania testów. Bez możliwości

umacniania i stabilizowania testów ludzie zaczną tracić zaufanie do

projektu automatyzacji jako całości.

3. Często mała zmiana w aplikacji wpływa na wiele scenariuszy testowych.

Nawet jeśli wpływ tej zmiany jest bardzo mały dla człowieka, powoduje

on, że wiele testów automatycznych kończy się niepowodzeniem. Na

przykład poprawienie literówki w tekście przycisku czy nawet usunięcie

zdublowanej spacji może sprawić, że wiele testów będzie kończyć się

niepomyślnie, jeśli w ramach swojego scenariusza będą one

wyszukiwać przycisku po jego tekście i go klikać.

4. Badanie wyniku wyłącznie na podstawie różnicy pomiędzy wynikiem

faktycznym a oczekiwanym (czy to w formie zrzutu ekranu, czy też

innego rodzaju danych, które można porównać z faktycznym

rezultatem), może nie dać nam wystarczającej ilości informacji

potrzebnych do ustalenia, czy mamy do czynienia z błędem, czy

uprawnioną zmianą. W przypadku gdy będzie to błąd, nie będziemy

mieć również odpowiednich informacji koniecznych do ustalenia jego


przyczyny. Więcej informacji na temat badania testów kończących się

niepowodzeniem można znaleźć w rozdziale 13.

Ostatecznie wysiłek, jaki trzeba będzie podjąć w celu zbadania tych

niepowodzeń i utrzymania testów (ich ponowne nagrywanie i naprawianie),

prawdopodobnie przewyższy koszt ręcznego wykonywania tych testów.

Uzyskiwanie maksimum korzyści


z automatyzacji testów

Spójrzmy na to z drugiej strony: zamiast patrzeć na cele automatyzacji

testów z perspektywy tego, co mamy dzisiaj (testy manualne) i jak możemy

to zautomatyzować, spójrzmy na pożądany, idealny rezultat tego, co

możemy w najlepszym wypadku osiągnąć za jej pomocą.

Zanim to jednak zrobimy, chciałbym najpierw wyjaśnić, że choć

nakreślany tu przeze mnie obraz może być wykonalny dla kilku zespołów,

to dla większości z nich nie będzie on zbyt praktyczny w tej postaci.

Niemniej jednak większość zespołów powinna być w stanie wystarczająco

się do niego zbliżyć i wykorzystać większość z jego zalet, przy założeniu,

że zespoły zostaną właściwie pokierowane przez odpowiednią osobę (może

być to ktokolwiek, również my sami, nawet jeśli nie jesteśmy

menedżerami!). W każdym razie, chciałbym tu przedstawić ogólny zarys

tego, co powinno być naszym celem. W pozostałej części tej książki

będziemy rozmawiać o kompromisach i decyzjach, które będziemy musieli

podjąć, aby zbliżyć się do celu, jaki zamierzamy zaproponować, ale

bądźmy również pragmatyczni i praktyczni w kontekście tego, co

prawdopodobnie osiągniemy. Pamiętajmy jednak, że jeśli podejdziemy do

tego poważnie, to na przestrzeni wystarczająco długiego okresu czasu

będziemy mogli lepiej przywyknąć do tych idei, co przybliży nas do celu.


Więcej szczegółów i pomysłów dotyczących tego, jak stopniowo zmieniać

kulturę naszej organizacji w celu lepszego wykorzystania automatyzacji

testów, można znaleźć w rozdziale 15.

Tak więc teraz uspokójmy się, zamknijmy oczy i wyobraźmy sobie…

no nie, musimy przecież mieć oczy otwarte, aby móc czytać dalej…

Wyobraźmy sobie, że mamy pełne pokrycie automatycznymi testami

regresji, których wykonanie trwa łącznie kilka minut. Wyobraźmy sobie

również, że nasz zespół naprawił wszystkie znane błędy i wszystkie testy

kończą się sukcesem… Wyobraźmy sobie też, że każdy z programistów

może uruchamiać dowolny z tych testów na swoim własnym komputerze

deweloperskim, kiedy tylko ma na to ochotę!

Jak w takim wypadku zmieniłby się sposób wykorzystywania przez nas

automatyzacji testów podczas tworzenia kolejnej wersji, funkcji czy

historyjki użytkownika (zobacz tekst uzupełniający: „historyjki

użytkownika”)? Czy nadal byśmy uruchamiali nasze testy wyłącznie

w nocy i badali ewentualne niepowodzenia następnego ranka? Czy po

natknięciu się na błąd, zgłosimy go w naszym systemie śledzenia błędów

i poczekamy do końca kwartalnego cyklu wydawniczego, aż zostanie on

naprawiony przez programistów? Na wszystkie te pytania odpowiedź

powinna brzmieć „Nie”!

Jeśli testy wykonują się tak szybko, możemy sprawić, że będą się one

uruchamiać automatycznie przed każdą operacją ewidencjonowania zmian7

każdego programisty (co też może być wykonywane bardzo często) i będą

w stanie powstrzymać wykonanie operacji ewidencjonowania w przypadku

niepomyślnego zakończenia jednego lub więcej testów. W ten sposób

możemy zagwarantować, że wszystko, co znajduje się w repozytorium

kontroli źródeł, zawsze będzie przechodzić wszystkie testy! Idea ta

realizowana jest przez koncepcję określaną mianem ciągłej integracji


(Continuous Integration, CI). Więcej informacji na ten temat można znaleźć

w rozdziale 15. W rezultacie posiadanie pełnego pokrycia regresji

działającego w ramach CI potencjalnie zapobiega wkradaniu się wszystkich

błędów regresji! Tak więc, jeśli już na samym początku nie mieliśmy

żadnych błędów, proces ten uchroni nas również przed wystąpieniem

regresji, pozwalając nam potencjalnie w nieskończoność utrzymywać ten

zerowy stan znanych błędów! W rzadkim przypadku znalezienia nowego

błędu regresji (ręcznie, po tym jak programista zaewidencjonował kod),

błąd ten może zostać najpierw odtworzony za pomocą nowego testu

automatycznego i natychmiast naprawiony w celu utrzymania zerowej

liczby znanych błędów.

Co więcej, zachęca to programistów do usprawniania wewnętrznej

jakości kodu i struktury aplikacji, gdyż mogą oni dowolnie refaktoryzować

swój kod i nadal mieć pewność, że niczego w ten sposób nie popsują

(znaczenie refaktoryzacji omawiane jest w rozdziale 1). Ta wewnętrzna

jakość często przekłada się również na jakość zewnętrzną, ponieważ

prostszy kod ma zwykle mniej błędów i jest łatwiej go utrzymać bez

wprowadzania do niego nowych błędów. Dodatkowo kod łatwiejszy

w utrzymaniu, oznacza również wyższą produktywność, ponieważ pozwala

on programistom szybciej, łatwiej i przede wszystkim bezpieczniej

implementować coraz więcej funkcji.

A co z nowymi funkcjami? Nie będziemy tutaj zbytnio wchodzić

w szczegóły (więcej na ten temat można znaleźć w rozdziale 16,

poświęconym metodyce tworzenia oprogramowania sterowanego testami

akceptacyjnymi, ATDD), ale podstawowa idea polega na tym, że za każdym

razem, gdy opracowywana jest jakaś nowa funkcja, wraz z nią tworzone są

powiązane z nią testy. Funkcja taka (lub historyjka użytkownika) uznawana


jest za „ukończoną” tylko wtedy, gdy przejdzie ona pomyślnie wszystkie

testy i nie zostaną w niej znalezione żadne błędy.

Już słyszę te sceptyczne głosy: „Świetnie… ale to nigdy nie zadziała

w moim zespole…”. Przekonajmy się więc, że może być inaczej: jeśli

zaczniemy stosować te idee już od pierwszego dnia, wówczas podejście to

będzie łatwe do zrealizowania. Prawdopodobnie jednak nie zrobimy tego,

tak więc w przypadku ich zastosowania w (dużo) późniejszym czasie

osiągnięcie tego celu będzie dużo trudniejsze. Jednak w rozdziale 4

wyjaśniamy, w jaki sposób możemy stopniowo zbliżać się do tego celu

i osiągnąć większość z omówionych wcześniej korzyści w dużo krótszym

czasie. Ponadto w rozdziale 15 pokazujemy, że dowolny, nawet nasz własny

zespół jest w stanie to osiągnąć. W rozdziale tym prezentujemy konkretne

porady dotyczące tego, w jaki sposób przedstawić szybki wzrost

z inwestycji każdemu interesariuszowi, który może sprzeciwiać się

wprowadzeniu tej zmiany, nawet jeśli nie jesteśmy menedżerami.

HISTORYJKI UŻYTKOWNIKA

Historyjka użytkownika (user story) jest terminem stosowanym

w ramach metodyki zwinnego tworzenia oprogramowania, który

w zwięzły sposób opisuje żądaną funkcję. Zamiast definiować

wyczerpujący i szczegółowy dokument wymagań na początku

projektu, a następnie implementować go przez długi okres czasu, jak

ma to zwykle miejsce w modelu kaskadowym, w ramach metodyki

programowania zwinnego stosuje się podejście polegające na

przyrostowym dodawaniu do oprogramowania małych funkcji. Każda

taka niewielka funkcja lub zmiana jest historyjką użytkownika.

Historyjka użytkownika nie zawsze musi się jednak wiązać

z dodaniem nowej funkcji. W niektórych przypadkach historyjka


użytkownika może być również żądanie zmiany lub nawet usunięcia

istniejącej funkcji w wyniku uzyskania informacji zwrotnej od

użytkownika.

Historyjki użytkownika powinny mieć stosunkowo wąski zakres, tak

aby można je było szybko zaimplementować i dość wcześnie oddać

w ręce klienta (lub przynajmniej w ręce właściciela produktu) w celu

uzyskania informacji zwrotnych. Jednak, mimo że historyjka

użytkownika powinna być zwięźle zdefiniowana, to nadal powinna

ona stanowić jakąś wartość dla użytkownika końcowego. Zwykle

potrzeba pewnej praktyki i kreatywności, aby móc rozłożyć dużą

funkcję na szereg takich historyjek8, ale rzadko kiedy trafiają się

funkcje, których nie da się w ten sposób podzielić.

Choć nic nie powstrzymuje nas przed bardzo szczegółowym

opisywaniem historyjki użytkownika, to powinniśmy się raczej skupić

na ogólnej idei i jej wartości dla użytkownika końcowego, pozwalając

zespołowi na opracowanie własnych kreatywnych rozwiązań danego

problemu.

Dosyć powszechne jest definiowanie historyjki użytkownika za

pomocą poniższego szablonu lub czegoś na jego wzór:

Jako <rola>
Aby móc <cel>

Chcę <ogólny opis funkcji>

Na przykład:

Jako administrator witryny


Aby móc uniemożliwić przeprowadzenie ataku
"Denial of Service"

Chcę być w stanie kontrolować maksymalną liczbę


żądań na sekundę wysyłanych z adresu IP każdego
klienta

Różnice pomiędzy testami manualnymi


i automatycznymi

Gdy już rozumiemy, że ślepe naśladowanie pracy testera manualnego to za

mało, i widzimy, że pomyślna implementacja automatyzacji testów może

nam przynieść wielkie korzyści, których nie jesteśmy w stanie uzyskać

w ramach ręcznego testowania regresji, możemy skonkludować, że

testowanie manualne i automatyczne zasadniczo różnią się między sobą.

Przyjrzyjmy się zatem bardziej szczegółowo różnicom, które między nimi

występują. O różnicach tych warto szczególnie pamiętać wtedy, gdy

przyjdzie nam implementować istniejące plany testów manualnych

w formie testów automatycznych.

Wykonywanie testów manualnych możemy w zasadzie podzielić na

dwa typy:

testowanie eksploracyjne,

testowanie zaplanowane.

Różne organizacje i zespoły mają różne zasady (rygorystyczne

i wymuszane lub istniejące tylko w praktyce) dotyczące tego kto, kiedy

i czy w ogóle tworzy i planuje testy. Niektóre zespoły skupiają się głównie

na testach eksploracyjnych i jeszcze może na kilku scenariuszach


poprawności, które są wyłącznie w głowie testującego. Jest to częściej

spotykane w małych zespołach rozpoczynających działalność lub

w niewielkich zespołach programistów będących częścią większej

organizacji, która nie skupia się na tworzeniu oprogramowania. Po drugiej

stronie tego spektrum wysoce biurokratyczne zespoły opierają się głównie

na szeroko udokumentowanym, precyzyjnie zaplanowanym testowaniu.

Testowanie eksploracyjne

W testowaniu eksploracyjnym tester może dowolnie badać system

w poszukiwaniu błędów, o których nikt wcześniej nie pomyślał. Ten rodzaj

testowania jest przydatny, gdy naszym celem jest znalezienie tak dużej

liczby błędów, jak to tylko możliwe. W wielu przypadkach osoba testująca,

nawet gdy realizuje zaplanowany test, nie tylko ma zupełną swobodę, ale

nawet powinna się dodatkowo rozglądać i obok testów zaplanowanych

wykonywać testowanie eksploracyjne.

Ludzie często myślą, że skoro testy automatyczne mogą wykonywać się

szybko i w krótkim okresie czasu pokrywać dużą część oprogramowania, to

pozwalają one na szybsze wyszukanie dużej ilości błędów poprzez losowe

lub systematyczne próby pokrycia wielu przypadków użycia. Tych, którzy

również tak sądzą, muszę rozczarować, bo niestety nie to jest

podstawowym celem testowania automatycznego. Aby być w stanie znaleźć

błędy, test powinien wiedzieć, czego należy się spodziewać, a czego nie.

O ile tester manualny posiada taką intuicyjną wiedzę, to maszyna już nie.

Jeśli wydaje nam się, że możemy sformalizować te reguły w prosty sposób,

który można zautomatyzować, to powinniśmy pomyśleć jeszcze raz.

W większości przypadków reguły te są tak skomplikowane jak sam

testowany system... Pamiętajmy, że głównym celem testowania

automatycznego nie jest znalezienie tak dużej liczby błędów, jak to tylko
możliwe, ale raczej dostarczenie szybkiej informacji zwrotnej o tym, czy

system zachowuje się w oczekiwany sposób, jak zostało to zdefiniowane

w testach.

Z drugiej strony istnieją przypadki, w których możemy sformalizować

pewne istotne (choć bardzo ogólne) reguły na temat granic możliwych

rezultatów systemu i utworzyć test automatyczny, który sprawdzi wiele

możliwych wejść – losowo lub sekwencyjne – i sprawdzi, czy uzyskiwane

rezultaty faktycznie mieszczą się w oczekiwanym przedziale. Jeśli

spróbujemy napisać lub zmodyfikować takie testy w celu zweryfikowania

pewnych nietrywialnych reguł, wówczas test taki bardzo szybko stanie się

na tyle skomplikowany, że trudno będzie nam go dalej analizować

i utrzymywać. Z tego powodu z techniki tej będziemy chcieli korzystać

jedynie w celu weryfikacji reguł, które są proste do zdefiniowania, ale mają

zasadnicze znaczenie. We wszystkich pozostałych przypadkach należy

stosować proste testy, które weryfikują tylko jeden lub kilka konkretnych

przykładów. Technika ta nazywana jest testowaniem opartym na

własnościach (property-based testing), a najbardziej rozpoznawalnym

narzędziem, które ją obsługuje, jest QuickCheck, napisany pierwotnie

w języku programowania Haskell, a później przeniesiony do wielu innych

popularnych języków, takich jak Java, F# (który może być wykorzystywany

przez C# i inne języki platformy .NET), Python, Ruby, JavaScript, C/C++

i wiele innych. Ponieważ temat ten odnosi się jedynie do rzadkich

przypadków, wykracza on poza zakres tej książki.

Kolejną i prawdopodobnie bardziej przydatną opcją jest tworzenie

półautomatycznych testów lub narzędzi dla testerów manualnych, które

pomogą im szybciej pokrywać wiele przypadków. Samą analizę zgodności

uzyskiwanych rezultatów z oczekiwaniami pozostawia się w rękach

testerów manualnych. Informacje na temat tego, kiedy tworzyć i gdzie


używać takich narzędzi, trzeba znaleźć samodzielnie, ponieważ ten temat

również wykracza poza zakres tej książki. Tak więc od tej pory, jeśli nie

zostanie to określone inaczej, będziemy mówić wyłącznie o zaplanowanym

testowaniu manualnym oraz jego automatyzacji.

MAŁPIE TESTOWANIE – AUTOMATYCZNE TESTOWANIE

EKSPLORACYJNE

Termin małpie testowanie (monkey testing) odnosi się do praktyki

losowego wciskania klawiszy (lub wykonywania operacji bez

rozumienia ich kontekstu) – jak mogą to robić małpy lub małe dzieci –

w celu sprawdzenia, czy dany program ulegnie awarii, czy też nie.

Choć technikę tę można w łatwy sposób zautomatyzować, to jednak

z kilku powodów nie będzie ona zbyt efektywna:

1. Możemy w ten sposób wyłapywać jedynie awarie (lub konkretne

błędy, których poszukujemy), a nie błędy innego rodzaju,

ponieważ nie możemy zdefiniować oczekiwanego rezultatu dla

każdej akcji. Nawet jeśli program zawiesi się (ale nie ulegnie

awarii), to prawdopodobnie nie będziemy w stanie tego wykryć,

nie mówiąc już o ustaleniu, czy aplikacja zachowuje się

w sensowny sposób.

2. Ponieważ automatyzacja powoduje wciskanie klawiszy na

klawiaturze i przycisków myszy na ślepo, szanse na jakieś

interesujące rezultaty są dosyć niskie. Przykładowo może ona

utknąć na kilka godzin przy otwartym oknie dialogowym, dopóki

nie wciśnie losowo klawisza „Enter” lub „Esc” bądź też nie kliknie

przycisku „OK”. Oczywiście możemy stworzyć nieco bardziej

inteligentną „małpę”, która zamiast wysyłać losowe zdarzenia


wciśnięcia klawisza, będzie klikać wyłącznie dostępne przyciski

i elementy menu. W ten sposób będziemy w stanie rozwiązać

wspomniany problem z konkretnym oknem dialogowym, ale

dowolny inny formularz lub okno dialogowe z walidacją wejścia

prawdopodobnie spowoduje ten sam problem.

Rozważania dotyczące testowania automatycznego

Teraz, gdy rozumiemy już, że testy automatyczne nie nadają się zbytnio do

testowania eksploracyjnego, spójrzmy w jaki sposób planowane testowanie

manualne różni się od testowania automatycznego. W poniższych punktach

przeanalizujemy podstawowe różnice pomiędzy nimi oraz wpływ, jaki

powinny mieć na nasze decyzje, gdy przyjdzie nam zaplanować test

automatyczny w przeciwieństwie do planowania testu manualnego.

Dokładność

(Planowane) testy manualne pisane są przez ludzi do wykorzystywania

(czytania i wykonywania) przez ludzi. Co więcej, ich użytkownikami są

zwykle inni członkowie zespołu, którzy znają aplikację i dziedzinę biznesu,

a przy tym mają takie same założenia dotyczące systemu i sposobu jego

wykorzystywania. Mówimy tu o „innych” członkach zespołu, a więc

o bardziej optymistycznym przypadku, mimo że w rzeczywistości

większość przypadków testowych wykonuje ta sama osoba, która je

napisała. W takim wypadku te podstawowe założenia nigdy nie są

kwestionowane i przypadki testowe9 zawierają jedynie te szczegóły, które

zdaniem autora testu będą mu potrzebne, aby sobie przypomnieć, co

zamierzał zrobić podczas tworzenia przypadku testowego.


Wszystkie te założenia, których dokonuje autor przypadku testowego,

wprowadzają pewną niejasność. Ludzie zwykle radzą sobie bez problemu

z odrobiną niejasności, ale komputery już nie. W przypadku pisania testów

automatycznych nie ma miejsca na wprowadzanie żadnych niejasności.

W końcu test automatyczny (podobnie jak każdy inny kod komputerowy)

musi być precyzyjny i szczegółowy, aby komputer był w stanie go

wykonać. Bardzo często podczas konwertowania testów manualnych na

automatyczne pojawia się wiele różnych pytań i to nawet takich, które

dotyczą – jak by się mogło wydawać – mało istotnych szczegółów. Aby

jednak móc zautomatyzować test, musimy znaleźć odpowiedź na każde

takie pytanie. Odpowiedź ta zostanie zawarta w kodzie automatyzacji testu

i będzie wykorzystywana przy każdym wykonaniu testu! Tak naprawdę

pytania te często ujawniają więcej interesujących i istotnych błędów niż jest

rozpoznawanych w wyniku samego wykonania testu automatycznego.

Łatwość utrzymania

Jak wspomnieliśmy w rozdziale 1, nie ma sensu wykonywać testów dla

kodu, który nie uległ żadnej zmianie, więc należy zawsze oczekiwać, że

prawie przy każdym cyklu testowania aplikacja była modyfikowana.

Oznacza to, że przypadki testowe muszą być często modyfikowane w celu

odzwierciedlenia zmian dokonanych w aplikacji. W wypadku manualnych

przypadków testowych dzieje się to bardzo rzadko. W większości

przypadków zmiany w aplikacji są niewielkie, a osoba wykonująca test

może łatwo się zorientować, co uległo zmianie i w jaki sposób powinna

odnieść to, co zostało napisane w przypadku testowym, do stanu

faktycznego. Jednak jak już wspomniano wcześniej, w przypadku

automatyzacji testów liczy się każdy mały szczegół. Z tego powodu testy

automatyczne muszą być stale aktualizowane, aby odzwierciedlić każdą


zmianę, która może potencjalnie wywrzeć na nie wpływ. Załóżmy

przykładowo, że nasza aplikacja zawiera polecenie Zapisz w menu Plik,

a jeden z naszych kroków w przypadku testowym określa, że powinniśmy

„wybrać polecenie Zapisz z menu Plik”. Jeśli później polecenie Zapisz

zostanie przeniesione poza menu Plik do postaci lepiej widocznego

przycisku na pasku narzędzi, wówczas każdy rozsądny tester będzie

wiedział, że krok ten powinien się teraz odnosić do przycisku na pasku

narzędzi zamiast do elementu menu, nawet jeśli sam opis tego kroku się nie

zmienił. Jeśli jednak test automatyczny nie będzie w stanie znaleźć

wskazanego w teście elementu menu „Zapisz”, wówczas test taki zakończy

się niepowodzeniem.

Gdy rozumiemy już, że testy wymagają stałego utrzymywania,

najważniejszym pytaniem jest to, w jaki sposób powinniśmy pisać testy

automatyczne, abyśmy potem mogli w szybki i prosty sposób wprowadzać

te zmiany. Odpowiedzi na te pytania znajdują się w większości rozdziałów

II części tej książki.

Wrażliwość na zmianę – dokładność wraz z łatwością


utrzymania

Bazując na tym, co powiedzieliśmy wcześniej o dokładności, można by

pomyśleć, że skrypty10 testów automatycznych powinny, a nawet muszą,

być wypełnione bardzo precyzyjnymi szczegółami dotyczącymi każdej

operacji, jaką powinny wykonywać. Z drugiej strony, im bardziej opieramy

się na takich szczegółach, tym trudniejsze staje się utrzymywanie naszych

skryptów testowych w aktualnym stanie. Wydaje się więc, że ograniczenia

te stoją ze sobą w sprzeczności i że nie jesteśmy w stanie zrealizować obu

z nich.
Na szczęście dokładność nie oznacza koniecznie zaśmiecania każdego

skryptu wszystkimi drobnymi szczegółami. Wszystkie te szczegóły muszą

gdzieś zostać zdefiniowane, ale nie wszystkie muszą znajdować się

wewnątrz samych skryptów. System automatyzacji testów jest zwykle

czymś więcej niż tylko zwykłą kolekcją skryptów testowych, ale może –

i powinien – być zbudowany w sposób modułowy, tak że niektóre

fragmenty zawierają drobne szczegóły, a skrypty są jedynie złożeniem tych

fragmentów.

Możemy je postrzegać jako plany (rysunki techniczne) samochodu.

Podczas projektowania samochodu nie istnieje pojedynczy rysunek

zawierający wszystkie jego szczegóły. Samochód jest złożonym obiektem,

który składa się wielu mniejszych elementów (podwozie, nadwozie, silnik,

skrzynia biegów, układ sterowania, koła, części wewnętrzne itd.), a każdy

z nich złożony jest z jeszcze mniejszych części. Istnieje zapewne jeden

rysunek, który daje nam „pełny obraz” samochodu, ale z dużo mniejszą

ilością szczegółów, a także wiele mniejszych rysunków, które opisują

szczegóły każdej części z osobna. Gdyby wszystkie te szczegóły zostały

zawarte w jednym szczegółowym rysunku, a inżynier projektujący

siedzenia chciał w dokonać w nim zmiany (niemającej wpływu na jego

zewnętrzne wymiary), wówczas trzeba by zaktualizować cały rysunek!

Podobnie jest przy tworzeniu testów automatycznych: mimo że

wszystkie szczegóły trzeba zdefiniować przed wykonaniem testu, jednak

nie powinny one znajdować się w tym samym miejscu, ale należy je

rozłożyć na kilka komponentów (metody, klasy, moduły itd.), które można

modyfikować lub zastępować innymi, bez wpływu na pozostałe elementy.

Obsługa niepowodzeń
Przy pierwszym wykonaniu zaplanowanego przypadku testowego możemy

napotkać wiele nieoczekiwanych warunków, o których nie pomyśleliśmy

podczas pisania tego przypadku. Przy odpowiednio dobrej organizacji

prawdopodobnie będziemy w stanie naprawić ten przypadek testowy po

tym pierwszym uruchomieniu. Podobny proces następuje podczas

tworzenia testu automatycznego, dopóki test ten przynajmniej raz nie

zakończy się sukcesem.

Ale po tym etapie, bez względu na to, czy mówimy o testach

manualnych czy automatycznych, nadal mogą się pojawiać nieoczekiwane

warunki, które mogą być spowodowane:

1. Nowym błędem w produkcie (regresja)

2. Uzasadnioną zmianą (usprawnieniem) w produkcie, której nie byliśmy

świadomi.

3. Problemem środowiskowym, takim jak awaria sieci, brak dostępnej

pamięci itd.

4. Zdarzeniem w produkcie, którego obsługa nie została uwzględniona

w teście. Załóżmy przykładowo, że codziennie o 16:00 aplikacja

wyświetla okienko wyskakujące z komunikatem przypominającym

użytkownikowi o konieczności utworzenia kopii zapasowej jego pracy.

Test automatyczny zawsze kończy się powodzeniem, z wyjątkiem

sytuacji, gdy zostanie on uruchomiony krótko przed godziną 16:00,

kiedy prezentowana wiadomość może sprawić, że test zakończy się

niepowodzeniem. Jest to oczywiście dosyć prosty przykład, ale

rzeczywiste aplikacje zawierają zwykle złożony kod, która

uniemożliwia prześledzenie wszystkich warunków, jakie mogą

wystąpić, oraz ich odpowiednie obsłużenie w przypadku testowym.

Można powiedzieć, że te luki w projekcie przypadku testowego są tak


naprawdę błędami samego przypadku testowego. W przypadku testów

automatycznych możemy nazywać je… błędami w testach!

5. Ktoś zrobił coś z systemem przed lub w czasie trwania testu, co w sposób

niezamierzony wpłynęło na przebieg lub rezultat tego testu. Ten „ktoś”

może być kolejnym testerem manualnym, który uruchomił jakieś testy,

użytkownikiem lub administratorem, który zmieniał jakieś ustawienia,

bądź też innym testem automatycznym, który wykonał jakieś działania.

Jeśli na przykład jeden test zmienia hasło dla konta użytkownika, za

pośrednictwem którego drugi test próbuje się zalogować, wówczas ten

drugi może zakończyć się niepowodzeniem. Kolejnym przykładem jest

sytuacja, w której dwa testy są wykonywane jednocześnie na tym

samym serwerze i każdy z nich próbuje zmienić pewne dane, które są

wykorzystywane przez ten drugi test. Ta klasa problemów nazywana

jest problemami izolacji (isolation problems) i są one zbliżone do

poprzedniego rodzaju problemów, ale przynajmniej w przypadku testów

automatycznych wskazują one zwykle nie tyle na błąd w określonym

teście, co raczej problem w ogólnej architekturze infrastruktury

testowania. W rozdziałach 6 i 7 omówiono te problemy bardziej

szczegółowo.

Choć wszystkie te warunki możemy napotkać zarówno podczas

wykonywania testu manualnego, jak i uruchamiania testu automatycznego,

to sposób ich obsługi stanowi kluczową różnicę między testami

manualnymi i automatycznymi. Ludzie (testerzy manualni) zwykle łatwo

rozróżniają te warunki i wiedzą, w jaki sposób obsłużyć każdą z nich.

Nawet w przypadku znalezienia błędu w produkcie, po tym jak tester zgłosi

ten błąd, w większości przypadków może on kontynuować wykonywanie

pozostałej części przypadku testowego, na przykład po zastosowaniu

jakiegoś obejścia lub po ponownym wykonaniu kilku ostatnich kroków.


Z drugiej strony, w kontekście automatyzacji słowo „nieoczekiwane”

oznacza, że komputer nie wie, w jaki sposób je obsłużyć!

Ważna uwaga

Automatyzacja testów może do pewnego stopnia obsłużyć trzeci

rodzaj przyczyn niepowodzeń, ale jest to bardzo delikatny temat.

Jeśli możemy zidentyfikować potencjalne zdarzenia, które mogą

wystąpić podczas wykonywania testu, możemy być w stanie

obsłużyć lub obejść je w kodzie w sposób, w jaki zrobiłby to

użytkownik (lub tester manualny). Jednakże należy tego

dokonywać z rozwagą, ponieważ z jednej strony celem tych

obejść jest uczynienie testów bardziej niezawodnymi, ale

z drugiej strony znacznie trudniej jest zweryfikować, czy test sam

w sobie poprawnie obsługuje wszystkie te sytuacje, co może dać

efekt odwrotny od zamierzonego: testy będą mniej

deterministyczne i ostatecznie mniej wiarygodne! Mimo że

w niektórych przypadkach obejścia te są warte zachodu, to

powinniśmy wziąć pod uwagę alternatywy omawiane

w rozdziałach 6 i 7.

CZY TESTY KOŃCZĄCE SIĘ NIEPOWODZENIEM NALEŻY

POWTARZAĆ?

Niektóre osoby wbudowują do swoich bibliotek testowania

mechanizmy ponawiające, które kilkukrotnie powtarzają wszystkie

testy zakończone porażką i oznaczają je jako zakończone

niepomyślnie dopiero wtedy, gdy żadna z tych prób nie zakończyła się
sukcesem. Jednak według mnie popełniają oni w ten sposób błąd.

Każde niepowodzenie testu daje nam jasny sygnał: albo mamy jakiś

problem z kodem testu, albo z testowaną aplikacją. Choć na początku

może to być bardzo czasochłonne, problemy te powinny być

szczegółowo analizowane w celu znalezienia ich głównej przyczyny

i odpowiednio obsłużone, aby nie doszło do ich ponownego

wystąpienia. Ignorowanie tych niepowodzeń poprzez ślepe

powtarzanie całego testu prawdopodobnie sprawi, że nasza

automatyzacja stanie się nierzetelna i będzie potencjalnie zostawiać

w naszym produkcie ważne niedeterministyczne błędy! Nie mówiąc

już o dodatkowym czasie, potrzebnym do ponownego wykonywania

tych niepomyślnych testów…

Długość przypadku testowego

Różnica pomiędzy sposobem obsługiwania nieoczekiwanych warunków

w testach manualnych, a tym, jak robią to testy automatyczne, ma olbrzymi

wpływ na sposób, w jaki powinniśmy pisać automatyzację: poszczególne

przypadki testów manualnych są często dosyć długie i zwykle w całości

pokrywają kompletną funkcję wraz ze wszystkimi jej niuansami. Sensowne

wydaje się, aby przypadki testów manualnych weryfikowały „przy okazji”

wiele mniejszych rzeczy, oszczędzając w ten sposób czas podczas

wykonywania przypadku testowego. Jeśli jakiś pomniejszy błąd lub coś, co

zostało zmienione, wpłynie na te poboczne weryfikacje, tester manualny

będzie mógł je pominąć i kontynuować pozostałą część przypadku

testowego. Jeśli jednak zautomatyzujemy taki dłuższy przypadek testowy

bez żadnych zmian i zakończy się on niepowodzeniem już przy jednej

z pierwszych weryfikacji, to nie będzie on w stanie zdecydować, czy należy

kontynuować działanie, czy też nie.


Niektóre biblioteki automatyzacji pozwalają nam zgłosić błąd

i kontynuować wykonywanie testu. Kiedy to jednak osoba testująca

napotka błąd, zwykle – na podstawie rozeznania natury danego problemu –

podejmuje ona decyzje, czy należy kontynuować test, cofnąć się o kilka

kroków (i o ile dokładnie), czy też może całkowicie przerwać jego

wykonywanie. Uważam, że podejmowanie w czasie wykonywania testu

decyzji dotyczącej sensu jego kontynuowania (bez powtarzania lub

obchodzenia kilku ostatnich kroków) wyłącznie na podstawie ważności

samej weryfikacji nie jest miarodajne i w konsekwencji może negatywnie

wpłynąć na wiarygodność automatyzacji testów jako całości!

W szczególności zagwarantowanie, że testy zachowują się poprawnie we

wszystkich możliwych warunkach niepowodzenia, jest prawie niemożliwe.

W innych bibliotekach (w tym zdecydowanej większości bibliotek

testów jednostkowych) stosowane jest podejście, w którym każde

nieoczekiwane warunki napotykane przez test powodują niepowodzenie

całego przypadku testowego i przejście do wykonywania kolejnego

przypadku testowego zamiast do kolejnego kroku. W mojej opinii jest to

najbezpieczniejszy i najbardziej niezawodny sposób. Oznacza to jednak,

że testy muszą być krótkie i powinny weryfikować wyłącznie jedną

rzecz, gdyż w przeciwnym wypadku nawet najmniejsze niepowodzenie

może zablokować wykonanie dużo istotniejszych fragmentów testu. Jeśli

spróbujemy sprawić, aby nasze testy mądrzej obsługiwały możliwe porażki,

tylko pogorszymy tę sprawę, ponieważ nasz kod testu będzie miał teraz

rozwiniętą logikę, której nie można żaden racjonalny sposób zweryfikować!

Oznacza to również, że prawie każda weryfikacja powinna mieć

swój własny przypadek testowy! Może się to wydawać nieekonomiczne

i uciążliwe, ale w dłuższej perspektywie zdamy sobie sprawę, że jest to

jedyny sposób, aby nasze testy były niezawodne i łatwe w utrzymaniu.


Zależności pomiędzy testami

Przypadki testów manualnych są czasami opisywane są z uwzględnieniem

zależności między nimi: wykonaj test X dopiero po wykonaniu testu Y.

Ponieważ w przypadku testów automatycznych niepowodzenie w jednym

teście zazwyczaj przerywa ten test i uruchamia kolejny, nie chcemy, aby

jakakolwiek porażka miała wpływ na kolejne testy. Musimy więc jakoś

zagwarantować, że każdy test będzie się rozpoczynał od początkowego,

dobrze znanego nam stanu. Innymi słowy, zależności pomiędzy testami

automatycznymi są zdecydowanie odradzane. Szczegóły dotyczące różnych

opcji wymuszania czystego startu każdego testu są omawiane w rozdziale 7.

Rejestrowanie i zbieranie dowodów

Sposób odzyskiwania sprawności przez automatyzację po napotkaniu

nieoczekiwanych warunków, który pozwala przejść do wykonywania

kolejnego przypadku testowego, to jedno, ale równie istotne jest to, co

należy zrobić z tymi nieoczekiwanymi warunkami. Jeśli podczas

wykonywania testu manualnego osoba testująca napotka nieoczekiwany

warunek i będzie przekonana, że problem leży w kodzie aplikacji, zwykle

od razu zgłosi ten błąd, zanim przejdzie do wykonywania pozostałej części

przypadku testowego lub przejdzie do następnego przypadku. W samym

raporcie o błędzie zwykle opisze kroki, których wykonanie doprowadziło

do wystąpienia błędu, dodając niekiedy pewne dodatkowe fakty, które jej

zdaniem mogą być istotne. Podczas pisania takiego raportu osoba testująca

powinna również spróbować prześledzić naturę tego błędu poprzez proste

„eksperymentowanie z nim” w celu poznania jego granic.

Sytuacja wyglądać będzie całkiem inaczej, kiedy to test automatyczny

napotka nieoczekiwany warunek:


Jak już powiedzieliśmy, testy automatyczne traktują każdy

nieoczekiwany warunek jako niepowodzenie, bez odpowiedniej

możliwości wyrażenia natury problemu.

Testy automatyczne zwykle wykonywane są bez nadzoru, a badania

dotyczące ich niepowodzeń wykonywane są już po ich zakończeniu.

Oznacza to, że gdy można przeprowadzić badanie dotyczące

niepowodzenia, część dowodów jest już utracona lub uszkodzona!

Jeśli test w każdym środowisku i za każdym razem odtwarza ten sam

błąd, możemy uruchomić go ponownie w innym środowisku (np. na

maszynie lokalnej, jeśli błąd wystąpił w kompilacji nocnej lub kompilacji

ciągłej integracji), lub wykonać jego kroki ręcznie i w ten sposób zbadać

ten błąd. Nawet w takim przypadku prawdopodobnie zajmie to cenny

dodatkowy czas. Niemniej jednak w przypadku, gdy błąd nie występuje

cały czas, niezwykle istotne jest, aby dysponować dziennikami – zarówno

dla testu, jak i aplikacji – jak również dowolnymi innymi dowodami, które

mogą pomóc zbadać dany problem. Dowodem takim może być zrzut lub

zapis wideo ekranu, migawki bazy danych, źródło HTML strony

internetowej itd. Temat badania testów kończących się niepowodzeniem

omawiany jest bardziej szczegółowo w rozdziale 13.

CZY SYSTEM AUTOMATYZACJI TESTÓW POWINIEN

AUTOMATYCZNIE ZGŁASZAĆ BŁĘDY?

Choć widziałem wiele prób podłączania systemów automatyzacji

testów bezpośrednio do systemów raportowania błędów oraz

automatycznego raportowania błędów w przypadku zakończenia testu

niepowodzeniem, to nie wydaje mi się to być zbyt dobrym pomysłem.

Jak już wspomnieliśmy, wszystkie nieoczekiwane warunki mogą


powodować, że testy automatyczne będą kończyć się

niepowodzeniem, ale nie wszystkie te porażki są tak naprawdę

błędami. Ale nawet jeśli błędy te zostaną przypisane testerowi w celu

ich zbadania, to istnieje wiele przypadków, w których pojedynczy błąd

spowoduje, że wiele innych testów zakończy się niepowodzeniem, co

wprowadzi dodatkowy narzut związany z zarządzaniem i śledzeniem

tych wszystkich automatycznie generowanych błędów. Więcej

szczegółów na temat zalecanego sposobu, w jaki powinny być

traktowane błędy wykrywane przez automatyzację, można znaleźć

w rozdziałach 5 i 15.

Zaufanie

Brak zaufania pomiędzy programistami i testerami manualnymi jest

(niestety) dosyć częsty: testerzy obwiniają programistów o pisanie

niechlujnego kodu, programiści obwiniają testerów o zgłaszanie błędów ze

zbyt małą ilością informacji lub niedokładnymi danymi itd. (a wszyscy

winią menedżerów projektów za pisanie niejasnych wymagań, ale to już

inna historia… w rozdziałach 5 i 16 pokazujemy, że i ten problem może

nam pomóc rozwiązać metodyka ATDD). Ostatecznie jednak wszyscy

zgadzają się, że ta druga rola jest istotna i niezbędna.

Jeśli chodzi o automatyzację testów, to zarówno programiści, jak

i testerzy, a także ich menedżerowie, muszą ufać maszynie. Z początku

może się to wydawać oczywiste: maszyny zawsze wytwarzają spójne

rezultaty, więc są lepsze od ludzi! Dlaczego zatem trudno im zaufać? Ale

jak już wspomniano wcześniej, testy automatyczne mogą kończyć się

niepowodzeniem z wielu różnych powodów, a nie tylko w wyniku

napotkania błędów. Tak naprawdę abyśmy mogli ufać testom

automatycznym, musimy wierzyć, że:


Testy kończą się niepowodzeniem tylko w przypadku napotkania

prawdziwych błędów.

Testy zawsze wykrywają błędy

Bez względu na to, jak bardzo byśmy tego chcieli i próbowali

urzeczywistnić te twierdzenia, to nie możemy ich zagwarantować. Ale za

pomocą dobrego zestawu testów automatycznych możemy zapewnić ich

łagodniejszą wersję:

Testy kończą się niepowodzeniem zwykle z powodu istnienia

prawdziwych błędów (i dosyć łatwo jest zbadać i ustalić ich prawdziwą

przyczynę)

Testy zawsze wykrywają błędy, do wyłapywania których zostały

zaprojektowane.

Jeśli nasze testy automatyczne będziemy projektować tak, aby były

krótkie i proste, co powinniśmy robić, wówczas dosyć łatwo będzie nam

udowodnić to drugie twierdzenie. Ale udowodnienie pierwszego

twierdzenia będzie już trudniejsze. Sytuacja, w której twierdzenie to jest

fałszywe, objawia się wtedy, gdy istnieje wiele testów, które przez długi

okres czasu kończą się niepowodzeniem, mimo że podstawowa

funkcjonalność weryfikowana przez te testy działa prawidłowo, lub też gdy

testy często kończą się niepowodzeniem z niewyjaśnionych powodów. Gdy

tak się dzieje, interesariusze (zwłaszcza menedżerowie) przestają ufać

wynikom testów automatycznych. Gdy rezultaty testów automatycznych są

ignorowane, a do rozwiązania tych problemów nie są podejmowane żadne

środki, testy dosyć szybko stają się nieistotne i nieaktualne,

zaprzepaszczając wszelkie inwestycje, które zostały poczynione na budowę

systemu automatyzacji testów!


Niestety istnieje duży odsetek projektów automatyzacji testów, które

rozpoczynane są z dużym entuzjazmem, ale po pewnym czasie przestają

spełniać swoje obietnice i chwilę później marnie kończą. Miejmy nadzieję,

że książka ta pomoże nam uniknąć tego przeznaczenia i poprowadzi

nas w kierunku sukcesu!

Zanim zagłębimy się dalej w aspekty związane z zapewnianiem sukcesu

projektom automatyzacji testów, chciałbym jeszcze wspomnieć o pewnych

kluczowych praktykach, dzięki których nasz projekt automatyzacji uniknie

unicestwienia i osiągnie sukces:

1. Wszystkie niepowodzenia, a już na pewno błędy automatyzacji, muszą

zostać obsłużone i naprawione tak szybko, jak to tylko możliwe (więcej

informacji na ten temat można znaleźć w rozdziałach 5 i 15).

2. Każde niepowodzenie powinno zostać dokładnie zbadane, aby znaleźć

jego główną przyczynę. Co prawda „przykrywanie” błędów może nam

na krótką metę pomóc rozwiązać niektóre problemy, ale w przyszłości

może powodować kolejne, trudniejsze do zidentyfikowania

i naprawienia (więcej informacji na ten temat można znaleźć

w rozdziale 13).

3. Testy powinny być tworzone w sposób gwarantujący spójne wyniki. Jeśli

rezultaty zależą od warunków zewnętrznych, to w wypadku

niepowodzenia istnieje tendencja do obwiniania warunków

zewnętrznych i unikania badania prawdziwej przyczyny (więcej

informacji na ten temat można znaleźć w rozdziałach 6 i 7).


Rozdział 3. Ludzie i narzędzia

Większość klientów zatrudniających mnie w roli konsultanta, który ma im

pomóc zacząć korzystać z automatyzacji testów, zadaje na początku pytanie:

„Jakie narzędzia są dostępne?” i „Z których narzędzi powinniśmy

korzystać?”. Jeśli sami chcemy zacząć korzystać z automatyzacji testów, to

prawdopodobnie również zadajemy sobie te pytania. Krótka odpowiedź na

pierwsze pytanie jest taka, że istnieje bazylion narzędzi do automatyzacji

testów. Oczywiście jest jeszcze Selenium, tak więc jest ich bazylion i jeden.

Z kolei krótką odpowiedzią na drugie pytanie jest klasyczna odpowiedź

konsultanta: „To zależy”.

Wybieranie właściwych narzędzi

Teraz już w nieco poważniejszym tonie: mimo że naprawdę dostępnych jest

wiele narzędzi do automatyzacji testów i prawie każdego dnia słyszymy

o jakimś nowym (a każde z nich obiecuje być „tym właściwym”), to istnieje

tylko kilka kategorii narzędzi przeznaczonych do różnych celów. Niektóre

narzędzia realizują więcej niż jeden cel, przy czym w większości wypadków

będziemy prawdopodobnie potrzebować kombinacji pewnych narzędzi. Aby

dowiedzieć się, które z tych narzędzi będą dla nas najbardziej odpowiednie,
powinniśmy najpierw odpowiedzieć sobie na kilka pytań. O ile pytanie

„którego narzędzia powinienem użyć” jest pytaniem typu „jak”, to pytania,

od których powinniśmy zacząć, są pytaniami typu „dlaczego” i „co”. Gdy

już sobie na nie odpowiemy, wybór właściwych narzędzi powinien być

w większości wypadków dosyć trywialny. Kategorie tych narzędzi oraz

pytania, na które powinniśmy uzyskać odpowiedź, omawiamy w dalszej

części tego rozdziału. Zanim jednak podejmiemy konkretną decyzję,

powinniśmy przynajmniej przeczytać rozdziały z części I tej książki,

ponieważ pomogą nam one uzyskać lepsze odpowiedzi na te pytania.

Podczas gdy pozostałe rozdziały z części I tej książki pomogą nam

odpowiedzieć na większość pytań typu „dlaczego” i „co”, ten rozdział

poświęcony jest jednemu istotnemu pytaniu, które jest dosyć często

pomijane. Pytanie to nie jest pytaniem typu „dlaczego”, „co” ani też „jak”,

ale raczej pytaniem typu „kto”…

Kto powinien pisać testy?

W większości przypadków klienci znają już odpowiedź na to pytanie, mimo

że nie wzięli oni pod uwagę wszystkich dostępnych alternatyw i ich

konsekwencji, gdyż po prostu nie są oni świadomi ich istnienia! Opiszmy

więc teraz dostępne opcje i związane z nimi konsekwencje. Zwróćmy

uwagę, że nie ma jednej właściwej odpowiedzi na to pytanie, a każda opcja

ma swoje własne wady i zalety, będziemy zatem sami musieli podjąć taką

decyzję, która najlepiej będzie pasowała do naszej organizacji.

Nawet jeśli mamy już w swojej organizacji zespół zajmujący się

automatyzacją, to nadal powinniśmy przeczytać ten rozdział, gdyż

pozwoli nam to lepiej zrozumieć wady i zalety sytuacji, w której teraz

jesteśmy, dzięki czemu łatwiej nam będzie się z nimi zmierzyć. Na dłuższą
metę możemy nawet sami rozważyć lub przynajmniej spróbować nakłonić

naszych menedżerów do zmiany podjętej decyzji.

Promowanie testerów manualnych lub niedoświadczonych


deweloperów do rangi deweloperów automatyzacji

Czasem testerzy manualni, którzy mają zerowe lub bardzo niewielkie

umiejętności programistyczne, dowiadują się o jednym z narzędzi

automatyzacji typu „nagraj i odtwórz” i są nim niezwykle podekscytowani.

Udają się więc do swojego szefa i mówią mu, że mogą szybko zacząć

tworzyć testy automatyczne i zaoszczędzić w ten sposób sporo czasu

i pieniędzy! Taki entuzjazm jest świetny i jako menedżerowie możemy

zechcieć go wykorzystać, ale powinniśmy pamiętać o tym, o czym

mówiliśmy na początku rozdziału 1: narzędzia typu „nagraj i odtwórz” są

łatwe na początku, ale w dłuższej perspektywie się nie sprawdzają.

Wielu testerów manualnych ma jednak pewne doświadczenie

w programowaniu. Niektórzy z nich studiowali nawet informatykę lub

podobną dziedzinę, ale na kilka lat wylądowali w zespole zapewniania

jakości (Quality Assurance, QA). Inni po prostu bawili się programowaniem

i cieszą się na myśl o pisaniu kodu. Takie osoby często postrzegane są jako

idealni kandydaci do rozpoczęcia prac związanych z automatyzacją testów.

Przydzielenie tego zadania komuś, kogo już znamy i komu ufamy, a kto

dodatkowo posiada pewne umiejętności w programowaniu i zna nasz system

oraz organizację, może być bardzo przekonujące. Nie ma potrzeby

inwestować zbyt wiele w szkolenia, a takie zadanie zwykle mocno

motywuje tę osobę! Na początku menedżer zespołu QA prawdopodobnie

zdecyduje, że osoba ta na projekt automatyzacji testów poświęcać będzie

jedynie od 20% do 50% czasu swojej pracy, natomiast przez resztę czasu

nadal będzie testować oprogramowanie ręcznie.


Oczywiście każda osoba jest inna, a my dokonaliśmy tu pewnego

uogólnienia, tak więc słowa te powinniśmy traktować trochę

z przymrużeniem oka i sami decydować o własnych sprawach. O ile jednak

niektóre z tych osób mogą nadawać się na członków zespołu automatyzacji

testów, gdy zespół taki zostanie już odpowiednio utworzony, o tyle zwykle

nie będą oni właściwymi osobami do rozpoczynania budowy

i opracowywania systemu automatyzacji testów. Jeśli im na to pozwolimy, to

prawdopodobnie wstępny etap ich pracy zakończy się sukcesem, co

zasugeruje nam, że dokonaliśmy właściwego wyboru. Jednak w miarę

upływu czasu zaczną pojawiać się problemy z utrzymaniem i stabilnością,

przez co stan tego projektu może zacząć się pogarszać.

W początkowym etapie tworzenie testów automatycznych, które

„wykonują jakąś pracę”, nie stanowi zwykle zbyt dużego wyzwania

technicznego. Niektóre narzędzia pozwalają to osiągnąć nawet osobom,

które nie mają żadnego doświadczenia w programowaniu, ale nawet pisanie

działających testów automatycznych w kodzie (np. za pomocą narzędzia

Selenium) nie wymaga zwykle dużych umiejętności programistycznych.

Takie testy zwykle będą kończyć się sukcesem i mogą nawet znaleźć jakieś

interesujące błędy.

Jednak po pewnym czasie niektóre rzeczy mogą zacząć sprawiać nam

problemy: rozwój aplikacji nie stoi przecież w miejscu. Aplikacja ewoluuje,

dodawane są nowe funkcje, niektóre istniejące funkcje i ekrany interfejsu

użytkownika ulegają zmianie, a niektóre fragmenty są przepisywane. Od

czasu do czasu taki niedoświadczony deweloper automatyzacji zauważy, że

pewne rzeczy w aplikacji zostały zmienione i będzie musiał w odpowiedni

sposób naprawić automatyzację. Jeśli będą to jedynie małe i bardzo

szczegółowe zmiany, to z pewnością będzie on w stanie sobie z nimi

poradzić. Ale gdy liczba testów zacznie się zwiększać, wtedy bez
właściwego planowania i projektowania, oraz bez odpowiednich

umiejętności debugowania, nie znajdziemy w kodzie ukrytych nieumyślne

zależności lub założeń, które sprawiają, że testy są bardziej wrażliwe i mniej

wiarygodne. Ponadto prędzej czy później deweloperzy aplikacji zmodyfikują

coś, co może mieć wpływ na dużą liczbę testów. Naprawa tego zajmie sporo

czasu, tak więc bez względu na to, czy sama aplikacja działa poprawnie czy

nie, automatyzacja przez długi czas będzie całkowicie popsuta i w tym

czasie nie będzie z niej żadnych korzyści.

Wyciągając wnioski z doświadczeń, taki deweloper automatyzacji może

poprosić deweloperów aplikacji (angażując w to również swojego

menedżera), aby informowali go zawczasu o każdej zmianie, jaką

zamierzają wprowadzić, a która może mieć potencjalny wpływ na

automatyzację, tak aby mógł się on do tego odpowiednio przygotować.

Niestety nie będzie to działać zbyt dobrze… Nawet przy najlepszych

intencjach deweloperzy nie są wystarczająco świadomi lub po prostu nie

wiedzą, które z wprowadzanych przez nich zmian mogą mieć wpływ na

automatyzację, a które nie. Z jednej strony przez cały czas dokonują oni

bardzo dużej liczby zmian, a z drugiej – nie są na tyle zaznajomieni z tym,

co i w jaki sposób jest robione w ramach automatyzacji, aby dokładnie

wiedzieć, co będzie miało wpływ na automatyzację testów, a co nie.

Kolejnym problemem, jaki może się pojawić, gdy w pobliżu nie ma

nikogo z odpowiednim doświadczeniem w automatyzacji testów, jest

sytuacja, w której jeden lub więcej testów kończy się niepowodzeniem bez

żadnego oczywistego powodu. Deweloper automatyzacji może najpierw

obarczyć winą narzędzie, sieć lub po prostu brak szczęścia i spróbować

uruchomić taki test ponownie. Alternatywnie może on podejrzewać, że jest

to problem związany z synchronizacją w czasie, i spróbować naprawić go

poprzez dodanie lub zwiększenie opóźnień pomiędzy poszczególnymi


operacjami. W każdym razie uruchomi on test ponownie, a ten zakończy się

sukcesem. Hura! Wkrótce jednak, nie licząc już tego, że testy stałyby się

bardzo powolne w wyniku wprowadzonych opóźnień, ich ogólna stabilność

będzie się pogarszać, i coraz trudniej będzie znaleźć podstawową przyczynę

tych niepowodzeń. W takim wypadku ogólna korzyść, jaką powinna nam

dostarczyć automatyzacja testów, będzie maleć, ponieważ ani deweloper

automatyzacji, ani deweloperzy aplikacji nie będą w stanie jednoznacznie

stwierdzić, czy błąd tkwi w automatyzacji, czy może w samej aplikacji.

Ostatecznie w ten sposób tracimy również zaufanie do automatyzacji testów.

Jeśli deweloper automatyzacji ma pracować nad automatyzacją testów

tylko częściowo (np. 20–50% swojego czasu pracy), a przez resztę czasu

wykonywać testy ręcznie, to możemy natknąć się na kilka dodatkowych

problemów.

Przede wszystkim poświęcanie określonej części czasu naszej pracy na

jedną czynność, a reszty czasu na inną, prawie nigdy nie jest dobrym

pomysłem. Niezwykle trudno jest poświęcić określoną liczbę dni w tygodniu

lub długich przedziałów czasowych na jedną czynność, gdy znajdujemy się

w tym samym biurze z osobami, które oczekują naszej pomocy przy jakichś

innych działaniach. Jeśli nasz menedżer lub my sami nie wyznaczymy sobie

konkretnych dni lub liczby godzin na realizację tego zadania, trudno nam

będzie dokładnie zmierzyć, ile tak naprawdę czasu poświęcamy na

automatyzację testów, a ile na wykonywanie testów manualnych, które

zawsze będą mieć wyższy priorytet. Poza tym samo pisanie testów

automatycznych stanowić będzie tylko część tej pracy, ponieważ

powinniśmy również badać uzyskiwane wyniki i naprawiać uszkodzone

testy, a to również będzie pochłaniać nasz czas. Jeśli chcemy wykonywać

testy każdej nocy, to ich wyniki trzeba będzie sprawdzać każdego ranka!

Jeśli nie będziemy uruchamiać testów co noc, wówczas między kolejnymi


uruchomieniami testów może zostać wprowadzonych zbyt wiele zmian,

przez co trudniej nam będzie dowiedzieć się, dlaczego coś się popsuło. Gdy

naprawimy lub zmienimy coś w automatyzacji, to dopiero po kilku dniach

będziemy w stanie stwierdzić, czy nasze zmiany były prawidłowe – ale czy

do tego czasu będziemy jeszcze pamiętać, co próbowaliśmy naprawić,

dlaczego i w jaki sposób? Prawdopodobnie nie… Ponadto trudno nam

będzie korzystać z czegoś, co jest niespójne, i to promować. Ludzie

(menedżerowie, deweloperzy itd) nie wiedzą, czy mogą się spodziewać

wyników automatyzacji, czy nie, tak więc ich nie oczekują, a tym samym

nie przykładają do nich zbyt dużej wagi. W rezultacie ludzie ci widzą, że nie

mogą opierać się na automatyzacji testów i ostatecznie tracą do niej

zaufanie.

Wniosek

Osoby z pewną wiedzą programistyczną, ale bez doświadczenia

w programowaniu i automatyzacji, mogą z powodzeniem być

efektywnymi członkami zespołu automatyzacji testów, ale

potrzebują dobrego przewodnika! Automatyzacja testów jest

sztuką (dyscypliną) samą w sobie, która wymaga posiadania

określonych umiejętności. Na dobre opanowanie tej sztuki

potrzebny jest czas, wysiłek i poświęcenie, tak więc nie jest to coś,

co można robić „na boku”, nawet jeśli jest się najlepszym

programistą na świecie.

Dzielenie pracy między testerów manualnych i deweloperów


automatyzacji

Kolejne typowe podejście jest takie, że kilku doświadczonych deweloperów

rozwija infrastrukturę oraz „elementy składowe”, zaś większa grupa osób


nieprogramujących lub młodszych deweloperów w prosty sposób

wykorzystuje tę infrastrukturę do tworzenia automatycznych skryptów

testowych. Podejście to ma ścisły związek z wybranym przez nas

narzędziem, którym może być albo gotowy produkt, albo narzędzie

tworzone wewnątrz naszej organizacji. Ponadto rodzaj użytego narzędzia

zwykle wskazuje, ile pracy można wykonać bez pisania kodu, a ile będzie

wymagać kodowania.

Znam wiele zespołów, które wybrały to podejście i są z niego bardzo

zadowolone. Pozwala nam ono zaangażować w projekt automatyzacji

wszystkich dotychczasowych testerów z zespołu, bez konieczności uczenia

ich programowania. Poza tym w rzeczywistości jest tak, że testerzy, którzy

nie potrafią pisać kodu, są zwykle tańsi od programistów, więc ma to

również sens pod względem finansowym.

W dalszej części tego rozdziału omawiamy bardziej szczegółowo

kategorie dostępnych narzędzi, podając przy tym również kilka przykładów

z wykorzystaniem tego podejścia. Większość z tych narzędzi wymusza na

nas pewien ograniczony sposób interakcji z testowanym systemem w zamian

za brak konieczności pisania kodu. Przykładami takich narzędzi są

Ranorex® oraz SmartBear SoapUI®. Choć Ranorex działa bardzo dobrze

z wieloma technologiami interfejsu użytkownika, to narzędzie to nie jest

zaprojektowane do obsługi innego rodzaju testów. Z kolei SoapUI

przeznaczone jest wyłącznie do testowania systemów za pośrednictwem

protokołu komunikacji sieciowej HTTP (i kilku innych). Większość z tych

narzędzi pozwala zarówno na nagrywanie (pojedynczych kroków lub całych

scenariuszy), jak i na ręczne tworzenie i edytowanie skryptów, za pomocą

intuicyjnego graficznego interfejsu użytkownika lub uproszczonego języka

skryptowego, który nie wymaga prawdziwych umiejętności

programistycznych. Ponadto narzędzia te dostarczają nam również pewne


mechanizmy pozwalające na ponowne wykorzystywanie różnych zestawów

działań, przy czym są one zwykle mniej elastyczne od prawdziwego kodu

obiektowego. Narzędzia te wymagają kodowania jedynie wtedy, gdy chcemy

zrobić coś, do czego nie były one projektowane. Zwykle są one dostarczane

są w postaci kompletnego rozwiązania do zarządzania testami, ich

wykonywania, tworzenia raportów itd.

Możliwość wielokrotnego wykorzystywania połączonych ze sobą i/lub

zakodowanych akcji, nie tylko zmniejsza ilość pracy i ułatwia

utrzymywanie, lecz ma jeszcze inną zaletę: dla każdego komponentu

wielokrotnego użytku możemy zdefiniować opisową nazwę, która definiuje

jego działanie. Pozwala nam to budować testy w sposób, który lepiej

ujawnia nasze zamiary, dzięki czemu testy są łatwiejsze w utrzymaniu.

Ponadto możemy wykorzystać tę technikę do zastosowania podejścia

testowania opartego na słowach kluczowych (Keyword Driven Testing,

KDT). W ramach tego podejścia skrypty testowe składają się wyłącznie (lub

głównie) z takich elementów składowych (lub akcji), które zamiast

szczegółowych działań technicznych opisują konkretne czynności

biznesowe. Na przykład scenariusz zakupów online można ułożyć z takich

elementów składowych jak „zaloguj”, „dodaj do koszyka” czy „zapłać”.

Elementy te mogą zwykle przyjmować argumenty ze skryptu testowego

i dzięki temu mogą być wykorzystywane w różnych miejscach, z różnymi

wartościami lub w nieco inny sposób. Dzięki możliwości nadawania

opisowych nazw automatyczne skrypty testów są bardziej czytelne oraz

łatwiejsze w pisaniu i utrzymywaniu. Więc nawet gdy na taki skrypt testowy

spojrzy jakiś nietechniczny przedsiębiorca, to będzie on mógł się

dowiedzieć, co taki skrypt robi, bez wdawania się przy tym w jakiekolwiek

szczegóły techniczne dotyczące tego, jak te działania faktycznie są

wykonywane. Nazwy tych elementów składowych nazywa się czasem

„słowami kluczowymi” (keywords), stąd też nazwa tej techniki.


Z jakiegoś powodu wiele organizacji samodzielnie opracowało na swoje

potrzeby kompletne, złożone narzędzia obsługujące to podejście. Być może

narzędzia dostępne w czasie, gdy rozpoczynali nad nimi prace, nie były dla

nich do końca odpowiednie.

Jeszcze innym wariantem tego podejścia jest pisanie wszystkiego

w kodzie, z zachowaniem podziału pracy pomiędzy osoby kodujące

i niekodujące – koderzy tworzą „elementy składowe” w postaci metod, zaś

osoby niekodujące uczone są absolutnych podstaw, które są im potrzebne do

wywoływania tych metod z poziomu testów.

Mimo że u podstaw podejścia KDT leży możliwość wielokrotnego

wykorzystywania elementów, jego podstawową wadą jest ogólny narzut

i współzależność w procesie pisania i utrzymywania zestawu automatyzacji

testów. Choć składanie skryptów testowych z predefiniowanych elementów

składowych brzmi bardzo zachęcająco, to w rzeczywistości dość często

istnieje potrzeba dodania lub zmodyfikowania istniejącego elementu

składowego, co oznacza, że rzadko możemy napisać skrypt testowy bez

uprzedniej pomocy programisty w zakresie zmiany lub dodania nowego

elementu składowego. Ale zanim programista będzie mógł utworzyć nowy

lub zaktualizować istniejący element, musi wiedzieć, w jaki sposób

zamierzamy go używać, co często wiemy dopiero wtedy, gdy zaczniemy

pisać test. Ponadto osoba niekodująca może badać niepowodzenia tylko do

pewnego stopnia, bo jeśli problem tkwi wewnątrz operacji elementu

składowego, to jego analizę będzie musiał kontynuować programista.

Ponieważ programistów piszących elementy składowe jest zazwyczaj mniej

niż testerów piszących skrypty testowe, programiści ci stają się zwykle

wąskim gardłem i mogą potencjalnie opóźniać proces pisania

i utrzymywania testów.
W takich sytuacjach kolejnym częstym problemem jest to, że w celu

uniknięcia potrzeby dokonywania zmian w elemencie składowym, element

taki projektuje się zbyt ogólnie. Objawia się to tym, że element taki

przyjmuje zbyt dużą lub zbyt małą liczbę parametrów, których wartości

obejmują duże ilości informacji (np. za pomocą listy oddzielanej

przecinkami), co może mieć wpływ na zachowanie danej akcji. Użycie

takich parametrów może faktycznie zminimalizować wymaganą liczbę

poszczególnych elementów składowych, ale jest też bardzo podatne na

błędy, zaś osoba pisząca skrypt powinna znać dokładny format danych

oczekiwanych przez taki element. Ostatecznie tego rodzaju „rozwiązania”

problemu wąskiego gardła sprawiają, że skrypty testowe stają się jeszcze

bardziej złożone i trudniejsze w pisaniu i utrzymywaniu.

Istnieje jeszcze jedna kategoria narzędzi, które dodatkowo wprowadzają

podział pomiędzy skryptami testowymi i ich wewnętrzną implementacją.

Przykładami narzędzi z tej kategorii są Cucumber oraz SpecFlow (powiemy

sobie o nich więcej w dalszej części tego rozdziału). Kategoria ta różni się

od poprzedniej tym, że należące do niej narzędzia skupiają się głównie na

czytelności testów, aby móc z nich korzystać głównie do dokumentacji.

Zwykle narzędzia te nie są powiązane z konkretnymi technologiami, tj.

możemy je łączyć z innymi narzędziami, aby dostarczać możliwości

w zakresie automatyzacji interfejsu użytkownika, API HTTP (zobacz tekst

uzupełniający w dalszej części tego rozdziału), komunikacji lub dowolnych

innych sposobów interakcji z testowanym systemem, przy czym wymagają

one większej ilości kodu niż narzędzia należące do pierwszej kategorii.

Ponieważ narzędzia z tej kategorii zapewniają również podział na skrypty

testowe, które nie wymagają pisania kodu, oraz na część implementacji,

która jest czystym kodem (i prawdopodobnie również dlatego, że większość

z tych narzędzi jest typu open source), wiele zespołów używa ich do

stosowania podejścia KDT, czyli umożliwienia osobom niekodującym


pisania scenariuszy testowych, a programistom pisanie elementów

składowych. Niestety w ten sposób nie wykorzystują oni tych narzędzi we

właściwym celu. Narzędzia te skupiają się bardziej na tym, aby scenariusze

były czytelne i mogły być wykorzystywane w formie dokumentacji, niż na

zapewnianiu możliwości ich ponownego użycia. Zwolennicy podejścia BDD

(omawianego w dalszej części tego rozdziału) twierdzą nawet, że głównym

celem tych narzędzi jest komunikowanie wymagań w sposób możliwy do

zweryfikowania, i nie postrzegają oni testowania jako głównego celu tych

narzędzi. Choć istnieje optymalny punkt, w którym wielokrotne

wykorzystywanie i czytelność idą ze sobą w parze, to jeśli za bardzo

skierujemy się w jedną stronę, zaczniemy tracić na tym drugim aspekcie.

Innymi słowy, im bardziej będziemy się starać, aby nasze elementy

składowe były wielokrotnego użytku, tym bardziej będą tracić one na

czytelności, i odwrotnie.

Korzystanie z dedykowanego zespołu automatyzacji

Prawdopodobnie najbardziej rozpowszechnionym podejściem jest

posiadanie dedykowanego zespołu (bądź też jednej lub dwóch osób

w przypadku małego projektu), który jest odpowiedzialny za całą

automatyzację testów. Zwykle wszyscy członkowie takiego zespołu piszą

kod i odpowiadają za implementowanie skryptów testowych, infrastruktury

oraz kodu wielokrotnego użytku, a także za utrzymywanie kodu, badanie

wyników oraz usprawnianie systemu automatyzacji testów w miarę upływu

czasu.

Dużą zaletą takiego zespołu jest to, że jego członkowie dzielą się między

sobą wiedzą i doświadczeniem, a także wykorzystują ponownie kod bez

żadnych ograniczeń. Jest to zwłaszcza istotne na początku, kiedy

infrastruktura i praktyki są dopiero formowane. Ponadto, jeśli zespoły


deweloperów podzielone są wzdłuż granic architektonicznych

i technologicznych – np. „zespół klienta”, „zespół serwera”, „zespół bazy

danych” itd. – wówczas bardziej sensowne wydaje się posiadanie

dedykowanego zespołu automatyzacji, który zaimplementuje testy

kompleksowe dla całego systemu. Więcej informacji na temat związków

pomiędzy automatyzacją testów, architekturą i strukturą biznesu można

znaleźć w rozdziałach 6 i 8.

Z drugiej strony, ponieważ taki zespół jest bardzo zwarty, zwykle nie

będzie on blisko współpracował z innymi deweloperami. Jedną

z konsekwencji tej sytuacji jest to, że zespół taki zwykle pisze testy

w momencie, gdy testowane przez nie funkcje są już ukończone i w miarę

stabilne (po tym, jak testerzy przynajmniej raz przetestują je ręcznie).

Zazwyczaj otrzymują oni istniejące scenariusze testów utworzone wcześniej

przez testerów manualnych i automatyzują je, ewentualnie dostosowując je

po drodze pod automatyzację. Jednak ta separacja między deweloperami

aplikacji i deweloperami automatyzacji rodzi pewne problemy:

1. Jeśli testowany kod nie był pisany pod kątem testowania, jego

automatyzacja może być bardzo trudna. Aby to zmienić, deweloper

aplikacji musiałby oderwać się od swojej bieżącej pracy i zmodyfikować

projekt funkcji, którą wcześniej zaimplementował, a która została już

nawet przetestowana ręcznie. Z tego powodu sytuacja taka będzie miała

miejsce bardzo rzadko…

2. Podobne problemy mogą wystąpić, gdy znajdziemy błąd podczas

implementowania automatycznego testu. Jeśli dana funkcja została już

przetestowana ręcznie, znaleziony przez nas błąd może nie być

krytyczny, ale może utrudnić właściwe zaimplementowanie

automatyzacji. Tu również trzeba przerwać pracę dewelopera aplikacji,


aby to naprawił, inaczej deweloper automatyzacji nie będzie mógł

kontynuować pracy nad tym testem.

Kolejną wadą tego podejścia jest to, że ponieważ odpowiedzialność za

badanie niepowodzeń spoczywa głównie na zespole automatyzacji, a nie na

zespołach programistów, może nam być niezwykle trudno ustabilizować

testy. Każda zmiana dokonywana przez deweloperów aplikacji może

sprawić, że jeden lub więcej testów będzie kończyć się niepowodzeniem,

a sami deweloperzy zwykle nie będą się tym przejmować, dopóki nie

udowodnimy im, że dzieje się tak na skutek błędu. Więcej informacji na

temat konsekwencji takich procesów biznesowych można znaleźć

w rozdziale 5.

Dedykowany deweloper automatyzacji wewnątrz każdego


zespołu

W organizacjach tworzących oprogramowanie, w których zespoły

organizowane są wokół konkretnych funkcji zamiast według

technologicznych lub architektonicznych granic, często bardziej sensowna

wydaje się obecność jednego lub dwóch deweloperów automatyzacji

w każdym takim zespole. Dotyczy to szczególnie sytuacji, gdy taka

organizacja zamierza pokryć testami automatycznymi każdą nową funkcję

(lub historyjkę użytkownika), zanim zostanie ona uznana za ukończoną.

W takim przypadku zaleca się, aby wszyscy deweloperzy automatyzacji

mieli możliwość dzielenia się ze sobą wiedzą, kodem i pomysłami, zaś jeden

starszy deweloper automatyzacji, nienależący do żadnego konkretnego

zespołu, zapewniał profesjonalne wsparcie i nadzorował prace innych

deweloperów automatyzacji

Oczywiście podejście to nie wpisuje się dobrze w sytuację, gdy

automatyzujemy istniejące, starsze manualne przypadki testowe, ponieważ


w takim wypadku nie będzie żadnej współpracy między deweloperem

automatyzacji a deweloperem aplikacji. Jeśli pełne pokrycie nie zostało

jeszcze osiągnięte, pomóc może obecność kilku deweloperów automatyzacji

pracujących nad starszymi testami, podczas gdy pozostali deweloperzy będą

pracować nad nowymi testami w zespołach tworzących funkcje. Więcej

informacji na temat sposobów przybliżania się do pełnego pokrycia przy

jednoczesnym zapewnieniu pokrycia nowych funkcji, można znaleźć

w rozdziale 4.

W małych organizacjach lub zespołach może pracować jeden lub dwóch

deweloperów automatyzacji, który współpracują z deweloperami aplikacji

przy tworzeniu nowych funkcji, uzupełniając w pozostałym czasie wszelkie

luki w testach regresji.

Największą zaletą tego podejścia jest łatwość utrzymania „zielonej”

automatyzacji, ponieważ odpowiedzialność za dostarczanie nowych funkcji

ze wszystkimi działającymi testami leży po stronie całego zespołu. Ponadto

pisanie testów podczas opracowywania danej funkcji gwarantuje łatwość

testowania aplikacji.

Dawanie deweloperom pełnej odpowiedzialności za


automatyzację

Niektóre zespoły idą o krok dalej i decydują, że zamiast dedykowanego

dewelopera automatyzacji w każdym zespole tworzącego funkcje, to

deweloperzy aplikacji będą pisać i utrzymywać testy automatyzacji.

Tradycyjnie deweloperzy dokonują tego za pomocą testów jednostkowych

(patrz rozdział 17), ale nie ma żadnego powodu, aby nie mogli oni tego robić

również za pomocą testów o szerszym zakresie. W rzeczywistości pierwsi

orędownicy podejścia tworzenia oprogramowania sterowanego testami

(Test-Driven Development, TDD) – Kent Beck i Martin Fowler – twierdzą,


że terminu „testy jednostkowe” używają nie tylko dla testów poziomu

pojedynczej klasy lub metody, ale w zasadzie dla testów o dowolnym

zakresie11.

Może to być bardzo dobre podejście, jeśli tylko wszyscy deweloperzy

(lub przynajmniej kilku z nich z każdego zespołu) dysponują

umiejętnościami wymaganymi do pisania dobrych testów. Tak jak jedni

deweloperzy specjalizują się wyłącznie w rozwoju „klientów”, a inni

wyłącznie w rozwoju „serwerów”, tak istnieją również deweloperzy

wszechstronni, za każdy z nich może mieć umiejętności wymagane przy

pisaniu dobrych testów lub ich nie mieć.

W małych organizacjach, które dysponują odpowiednimi osobami, może

to działać bardzo dobrze. Jednak menedżerowi zespołu deweloperów

w dużej organizacji nie poleciłabym tego podejścia ze wszystkimi jego

aspektami, ponieważ nie wszystkie zespoły mogą mieć osoby

o odpowiednich umiejętnościach. Taki menedżer powinien znaleźć eksperta

(pracującego wewnątrz lub poza organizacją), który przeszkoli i będzie

towarzyszył kolejno jednemu zespołowi po drugim, pomagając im

przestawić się na ten sposób myślenia i pracy. Jest to istotne, ponieważ

zwykle każdy zespół ma inne problemy i ograniczenia, tak więc podejście

„uniwersalne” może być bardzo niebezpieczne i prowadzić do niskiej

jakości testów, które nie są wiarygodne i są trudne w utrzymaniu.

Dodatkowo ważne jest, aby promować przekazywanie wiedzy między

zespołami, zarówno w celu tworzenia spójnych praktyk, jak

i optymalizowania procesów pracy, głównie poprzez dokonywanie

przeglądów kodu i programowanie w parach.

Różnorodność narzędzi
Jak już wspomnieliśmy wcześniej, wyboru odpowiedniego narzędzia należy

dokonać dopiero po przeczytaniu wszystkich rozdziałów z części I tej

książki. Ponieważ jednak wyjaśniliśmy już, że jednym z najbardziej

istotnych czynników mających wpływ na wybór narzędzi jest sposób, w jaki

ludzie będą ich używać, możemy przystąpić do przedstawienia różnego

rodzaju dostępnych narzędzi. Zwróćmy uwagę, że w większości przypadków

będziemy korzystać z kombinacji tych narzędzi, ponieważ różne narzędzia

stanowią odpowiedź na różne problemy, a razem dostarczają kompletne

rozwiązanie. W wielu przypadkach będziemy również musieli zbudować

nasze własne małe narzędzia (głównie w celu połączenia ze sobą innych

narzędzi) lub zostaniemy zmuszeni do korzystania z pewnych starszych

narzędzi, które zostały wcześniej opracowane w naszej organizacji.

W kolejnych punktach pogrupujemy te narzędzia w odrębne kategorie,

w ramach których podamy kilka przykładów i omówimy czynniki

przemawiające za ich wyborem.

Klasyfikacja narzędzi

Zanim przejdziemy dalej, musimy wyjaśnić kilka rzeczy. Po pierwsze,

klasyfikacja ta nie jest ostateczna czy bezsporna, ponieważ wielu z tych

narzędzi nie da się jednoznacznie zakwalifikować do pojedynczej kategorii.

Inne osoby mogą sklasyfikować te narzędzia w inny sposób. Mimo że część

z tych narzędzi jest do siebie podobna, każde z nich ma swoje

niepowtarzalne funkcje oraz cechy. Ponadto wiele z tych narzędzi ma

zastosowanie do więcej niż jednego problemu, dlatego mogą one należeć do

więcej niż jednej kategorii. W ogólnym przypadku większość narzędzi, które

nie wymagają od nas umiejętności programistycznych, oferuje więcej

funkcji, podczas gdy narzędzia wymagające umiejętności kodowania są


zwykle przeznaczone do bardziej konkretnego, węższego celu, ale możemy

je w prosty sposób łączyć z innymi narzędziami.

Zwróćmy uwagę, że choć w każdej takiej kategorii podajemy przykłady

najbardziej popularnych narzędzi, to w żadnym wypadku nie jest to

wyczerpująca lista narzędzi w danej kategorii. Przykłady te zostały głównie

oparte na doświadczeniu i wiedzy autora tej książki, tak więc nie są one

w żaden sposób promowane względem pozostałych narzędzi. Narzędzia

i technologie pojawiają się i znikają. Tym samym konkretne przykłady

i opisy funkcji zapewne dosyć szybko staną się nieaktualne krótko po

opublikowaniu tej książki. Jednak w ogólnym przypadku sama ta

klasyfikacja, jak i przedstawione tu główne koncepcje, pozostaną

niezmienione jeszcze przez długi czas.

IDE12 i języki programowania

Bez względu na to, czy zdecydujemy się tworzyć automatyzację poprzez

pisanie kodu, czy też skorzystamy z jakiegoś narzędzia, które jest bardziej

odpowiednie dla osób bez umiejętności programistycznych, deweloper

automatyzacji będzie wykonywać większość swojej pracy w ramach pewnej

aplikacji, która zapewnia główne środowisko pracy. Za pomocą tego

narzędzia będzie on tworzył i utrzymywał testy, a zwykle także wszelkie

inne artefakty będące częścią systemu automatyzacji testów.

Jeśli zdecydowaliśmy się skorzystać z narzędzi bardziej odpowiednich

do osób niebędących programistami, to narzędzia te zwykle składać się będą

ze swoich własnych specjalistycznych środowisk, których poznanie

i wykorzystywanie będzie łatwiejsze dla takich osób. W takim wypadku nie

mamy zwykle żadnego wyboru dotyczącego IDE, ponieważ będzie to po

prostu ta sama aplikacja, która zapewnia technologię umożliwiającą

tworzenie, edytowanie i uruchamianie automatyzacji testów. Zauważmy


jednak, że zwykle narzędzia te nie tylko generują w uniwersalnym języku

programowania kod, który deweloper automatyzacji może zmodyfikować,

ale też często pozwalają programistom na rozszerzenie automatyzacji

poprzez pisanie modułów z użyciem niestandardowego kodu. Do edycji tych

plików niektóre z tych narzędzi dostarczają swoje własne środowiska, ale

większość z nich umożliwia deweloperowi skorzystanie z zewnętrznego,

powszechnie używanego (i skierowanego raczej do programistów) IDE.

Jeśli automatyzację planujemy napisać głównie w kodzie, to musimy

również zdecydować się na konkretny język programowania. Choć wiele

IDE pozwala nam pracować w wielu językach programowania, a kod wielu

języków programowania można pisać w różnych IDE, to jednak większość

języków ma swoje własne „naturalne” środowiska. Z tego powodu, gdy już

zdecydowaliśmy się na konkretny język programowania, zwykle wybór

zintegrowanego środowiska programowania jest już dość prosty.

Jeśli chodzi o wybór języka programowania, to należy wziąć pod uwagę

kilka czynników. Po pierwsze, choć w większości języków programowania

możemy robić praktycznie wszystko to, co chcemy, to niektóre narzędzia

(przykładowo dedykowane automatyzacji interfejsu użytkownika) działają

wyłącznie z określonym językiem programowania. Na przykład Coded UI

firmy Microsoft działa wyłącznie z językami C# lub VB.NET. Nie możemy

pisać testów Coded UI w Javie lub Pythonie. Jednak niektóre narzędzia,

takie jak Selenium, są albo obsługiwane przez wiele różnych języków, albo

mają swoje alternatywy w innych językach.

Jeśli nie ogranicza nas technologia i możemy wybierać spośród wielu

różnych języków programowania, to powinniśmy wziąć pod uwagę poniższe

czynniki:

Po pierwsze, jest wysoce zalecane, aby korzystać z tego samego języka

programowania, którego używają pozostali deweloperzy w naszym


zespole. Dla testów jednostkowych decyzja ta jest oczywista, ponieważ

nie tylko jest to najprostszy sposób, ale też testy jednostkowe pisane są

zwykle przez tych samych programistów, którzy tworzą kod systemu.

Takie podejście zaleca się również w odniesieniu do pozostałych testów

automatyzacji, głównie z powodu transferu wiedzy, współpracy oraz

ponownego wykorzystywania wspólnych narzędzi przez deweloperami

automatyzacji i deweloperami produktu. W niektórych firmach zdarzało

się, że pojedynczy deweloper automatyzacji decydował się korzystać

z innego języka niż pozostali członkowie zespołu (bo przykładowo był

on z nim lepiej zaznajomiony), przez co później firma ta była zmuszona

pozostać już przy tej decyzji i stosować różne obejścia, aby zintegrować

ten język z systemem kompilacji lub innymi narzędziami, i to czasem już

na długo po tym, jak ten deweloper automatyzacji opuścił tę firmę.

Zmiana języka programowania w późniejszym etapie jest

praktycznie niemożliwa!

Popularność – w większości przypadków lepiej jest wybrać popularny,

dobrze ustabilizowany język niż jakiś język niszowy. Unikajmy

wybierania „najnowszego i najfajniejszego” języka, z którym

doświadczenie ma niewielka liczba osób (z takich samych powodów

starajmy się nie wybierać żadnego przestarzałego języka). Istnieje ku

temu kilka powodów:

Wybór popularnego języka ułatwi nam rekrutację dodatkowych

deweloperów automatyzacji, gdy będzie to konieczne.

Łatwiej nam będzie znaleźć pomoc i samouczki w Internecie, a także

wziąć udział w zajęciach wykładowych.

Będziemy mieli do dyspozycji więcej narzędzi i bibliotek.


W czasie pisania tej książki najpopularniejszymi językami

programowania były Java, C#, Python i JavaScript. Istnieje również

rozszerzenie dla języka JavaScript o nazwie TypeScript, które jest z nim

w pełni kompatybilne, ale wprowadza do tego języka wiele dodatkowych

funkcji.

Funkcje języka – choć w ogólnym przypadku w każdym języku

programowania możemy napisać dowolny program, a większość

języków oferuje podobny zestaw podstawowych konstrukcji (takich jak

instrukcje „if”, zmienne, metody itd.), to każdy język ma swoje własne

unikalne funkcje, jak również własne ograniczenia. Te funkcje

i ograniczenia mogą mieć znaczący wpływ na czytelność, możliwość

ponownego wykorzystywania i łatwość utrzymywania naszego kodu!

Niektóre funkcje języka mogą być w nim implementowane kosztem

innych korzyści, jakie oferowane są przez pozostałe języki.

W szczególności większość języków zawiera funkcje, które pozwalają

programistom ograniczyć popełniane przez nich błędy! Choć funkcje te

czasem wprawiają w zakłopotanie młodszych programistów, to jednak

pomagając nam popełniać mniejszą ilość błędów, pomagają zwiększyć

niezawodność kodu i uczynić go bardziej solidnym. Przykłady takich

funkcji języka podano poniżej.

Porównanie funkcji języka

Choć nie jest to wyczerpujące porównanie funkcji języków

programowania, to powinno ono dać nam dobry pogląd na to, jakie

funkcje są dostępne w różnych językach programowania i jakie

przynoszą one korzyści. Zwróćmy uwagę, że „funkcje języka” nie są

tym samym, co funkcje podstawowych bibliotek języka. Choć każdy

język zwykle ma swój własny zestaw podstawowych bibliotek, które


dostarczają pewne podstawie usługi, takie jak operacje matematyczne,

listy, powszechne struktury danych, drukowanie, operacje na plikach,

data i godzina itd., to funkcje języka są bardziej ogólnym konstruktem

syntaktycznym rozpoznawanym przez kompilator, który możemy

wykorzystać do ustrukturyzowania naszego kodu, bez względu na to,

co ten kod tak naprawdę robi.

Silne typowanie kontra typowanie dynamiczne. Silne typowanie

oznacza, że typ zmiennej lub parametru musi być jawnie

zadeklarowany, tak aby kompilator mógł sprawdzić poprawność

jego użycia już na etapie kompilacji. W niektórych językach

możemy łączyć silne typowanie z typowaniem dynamicznym. Java

obsługuje wyłącznie silne typowanie. C# jest głównie silnie

typowanym językiem, ale obsługuje również typowanie

dynamiczne (za pośrednictwem słowa kluczowego dynamic).


Python i JavaScript obsługują jedynie typowanie dynamiczne, zaś

TypeScript dodatkowo typowanie silne.

Enkapsulacja – Zdolność do kontroli zakresu dostępności zmiennej

lub metody, zwykle poprzez zadeklarowanie członków klasy jako

członków publicznych lub prywatnych. Wszystkie języki

obiektowe, w tym Java i C#, obsługują tę funkcję. To samo dotyczy

języka TypeScript. JavaScript pozwala to osiągnąć na swój własny

sposób, który polega na deklarowaniu zagnieżdżonych funkcji,

a następnie deklarowaniu zmiennych lokalnych w tych funkcjach

wewnętrznych. Funkcja ta nie jest dostępna w Pythonie.

Polimorfizm lub funkcje wywołań zwrotnych – Choć polimorfizm

jest uważany za typowy element świata obiektowego, a funkcje

wywołań zwrotnych już nie, tak naprawdę oferują one mniej więcej
takie same korzyści. Krótko mówiąc, umożliwiają one zmiennym

na odwoływanie się nie tylko do danych, ale również do

funkcjonalności, z możliwością przekazywania ich do metod oraz

uzyskiwania ich z metod. Pozwala nam to w prosty sposób

rozszerzyć zachowanie kodu bez konieczności modyfikowania jego

podstawowej logiki. Wszystkie popularne języki oferują

przynajmniej jedną z tych funkcji, jednak niektóre języki

skryptowe, zwłaszcza te projektowane z myślą o konkretnym celu

lub narzędziu, są tych funkcji pozbawione.

Wyrażenia lambda i domknięcia – Jest to zdolność do definiowania

metody wewnątrz innej metody, a następnie odwoływania się do

zmiennych lokalnych metody zewnętrznej z poziomu metody

wewnętrznej. Wyrażenia lambda zwykle pozwalają nam osiągnąć to

samo za pomocą krótszej i bardziej eleganckiej składni.

Domknięcia są w pełni wspierane przez języki C#, JavaScript

i TypeScript. Ponadto C#, TypeScript i JavaScript ES6 obsługują

dodatkowo składnię wyrażeń lambda. Java i Python również

obsługują składnię wyrażeń lambda, ale ich implementacje

domknięć są w pewnym stopniu ograniczone.

Wielowątkowość – Zdolność do równoległego wykonywania kodu.

Choć pisanie solidnego i niezawodnego kodu wielowątkowego jest

bardzo trudne – przez co powinni go unikać nawet doświadczeni

programiści, jeśli tylko mają jakiś inny wybór – to nadal możemy

używać zewnętrznych bibliotek, które go wykorzystują.

Wielowątkowość jest obsługiwana przez języki Java, C# i Python,

ale nie przez JavaScript. Choć JavaScript oferuje pewne

mechanizmy, które zapewniają współbieżność (w ramach koncepcji

nazywanych „obietnicami” (promises), to nie mamy tu do czynienia


z pełną wielowątkowością. Z tego powodu, jeśli korzystamy

z Selenium w kodzie JavaScript (WebDriverJS), to nasz kod stanie

się znacznie bardziej skomplikowany i problematyczny, a także

trudniejszy w debugowaniu niż w przypadku innych języków.

Słowa kluczowe async i await, dostępne są w językach TypeScript

i JavaScript ES2017, stanowią pewne rozwiązane tego problemu,

ale nadal kod Selenium w językach JavaScript i TypeScript nie jest

tak czytelny i łatwy w debugowaniu jak w przypadku pozostałych

języków.

Choć Python stał się w ostatnim czasie dosyć popularny w obszarze

automatyzacji testów – prawdopodobnie z powodu jego uproszczonej

składni i łatwości nauki, co pozwala na jego szybkie przyswojenie

przez osoby niebędące programistami – to zwykle nie polecam

stosowania tego języka w automatyzacji testów z powodu

wspominanych wyżej ograniczeń, przy czym inne czynniki opisane

powyżej mogą przechylić szalę na jego korzyść. Moim własnym

ulubionym językiem programowania jest C#, dlatego też używam go

w przykładach prezentowanych w części II tej książki, ale przyznaję, że

jest to głównie kwestia przyzwyczajenia…

Biblioteki testowania (jednostkowego)

Jeśli piszemy nasze testy za pomocą kodu, potrzebujemy narzędzia, które

pozwolą nam je uruchamiać. Co prawda można by napisać wszystkie testy

w formie jednego prostego programu wiersza polecenia, który wykonuje je

sekwencyjnie i wyświetla ich wyniki, jednak znacznie łatwiej będzie nam

pisać i uruchamiać indywidualne testy z użyciem jakiejś biblioteki

testowania. Wtedy, z poziomu wiersza polecenia lub narzędzia z interfejsem

graficznym, biblioteka taka pozwoli nam podejrzeć listę dostępnych testów,


uruchomić je i zobaczyć, które z nich zakończyły się sukcesem, a które

niepowodzeniem. Zwykle możemy zdecydować, które z tych testów chcemy

uruchomić – wszystkie lub tylko wybrane testy, lub też wyłącznie testy,

które mają określone cechy.

Ponadto narzędzia te umożliwiają nam definiowanie specjalnych metod,

które są wykonywane przed oraz po każdym teście, przed i po grupie testów,

a także przed i po wszystkich testach, co pozwala zagwarantować, że testy

nie będą ze sobą kolidowały. Niektóre biblioteki pozwalają nam również

określać zależności między testami, mimo że płynąca z tego korzyść jest

wątpliwa, ponieważ w ten sposób testy służą dwóm różnym celom –

inicjalizowaniu i testowaniu, które nie idą ze sobą w parze i utrudniają ich

utrzymywanie. Dodatkowo uniemożliwia to równoległe uruchamianie

zależnych testów.

Biblioteki testowania są zwykle projektowane pod kątem testów

jednostkowych, ale nadają się one równie dobrze do testów integracyjnych

i testów systemu. Nie panikujmy więc, gdy widzimy termin „biblioteka

testów jednostkowych”, jaki jest zwykle stosowany do opisu tych narzędzi.

Przykładami takich bibliotek są chociażby: JUnit i TestNG dla Javy

i MSTest, NUnit i xUnit dla .NET. Dla języka Python mamy wbudowane

biblioteki unittest oraz py.test, zaś w przypadku języka JavaScript

najbardziej popularne są Jasmine i Mocha.

Wszystkie popularne biblioteki testów jednostkowych są albo częścią

jakiegoś środowiska IDE, częścią jakiegoś zestawu narzędzi dla danego

języka, albo też darmowymi projektami open source. Tym samym ich cena

nie powinna stanowić dla nas problemu…

Uwaga
Biblioteki testowania nazywane są czasem jarzmem testowym

(test harness).

Biblioteki asercji

W większości takich bibliotek uznaje się, że test zakończył się

powodzeniem, jeśli nie zgłasza on żadnego wyjątku (tj. podczas jego

wykonywania nie wystąpił żaden błąd). Dobrą praktyką jest jednak

wykonanie jakiejś weryfikacji na końcu każdego testu, zwykle poprzez

porównanie faktycznego rezultatu testowanej operacji z oczekiwanym

wynikiem. Z tego powodu większość bibliotek testowania pozwala nam na

wykonywanie takich weryfikacji przy użyciu prostego mechanizmu

nazywanego asercjami (assertions). Typowa asercja pozwala nam porównać

uzyskany rezultat z rezultatem oczekiwanym. Jeśli obie te wartości różnią

się od siebie, to zgłaszany jest odpowiedni wyjątek i efekcie cały test kończy

się niepowodzeniem. Choć większość bibliotek testowania ma własne

metodami asercji, to istnieją dedykowane biblioteki asercji, które oferują

dodatkowe korzyści. Niektóre z tych bibliotek dostarczają bardziej

szczegółowe metody asercji, przykładowo do sprawdzania poprawności

komunikatów odpowiedzi HTTP. Inne są bardziej elastyczne i pozwalają

nam definiować nasze własne asercje, zwykle w bardzo czytelny i „płynny”

sposób.

Uwaga

Wiele bibliotek testowania, w tym również bibliotek dostawców

zewnętrznych, oferuje mechanizmy do tworzenia atrap dla

obiektów13. Ponieważ jednak mechanizmy te przydają się


wyłącznie do czystych testów jednostkowych, nie będziemy ich

tutaj omawiać. Więcej informacji na ich temat można znaleźć

w rozdziale 17.

Biblioteki w stylu BDD

Tworzenie oprogramowania sterowane zachowaniem (Behavior-Driven

Development, BDD) jest metodyką, która wywodzi z TDD (Test Driven

Development) (patrz rozdział 17), ale kładzie większy nacisk na

zmniejszanie luki pomiędzy opisem zachowania jakiejś funkcji

(znajdującym się w formalnych specyfikacjach) w języku naturalnym

a testami, które weryfikują, czy dana funkcja faktycznie zachowuje się

zgodnie z tym opisem. Z tego powodu opisy te nazywane są przez

niektórych wykonywalnymi specyfikacjami lub żywą dokumentacją.

Kolejnym aspektem tej metodyki jest to, że testy wykorzystywane są jako

kryteria akceptacji dla historyjki użytkownika, dlatego jest ona również

nazywana tworzeniem oprogramowania sterowanym testami akceptacyjnymi

(Acceptance Test-Driven Development, ATDD). Metodyka ta jest omawiana

szczegółowo w rozdziale 16.

Zastosowanie metodyki BDD sprowadza się do użycia narzędzi, które

pozwalają nam pisać testy za pomocą zdań w języku naturalnym i powiązać

każde takie zdanie z metodą, która wykonuje operacje opisywane przez to

zdanie. Zdania ta, wraz odpowiadającymi im metodami implementacji,

mogą być wykorzystywane ponownie, dzięki czemu dokumentacja i testy są

bardziej spójne.

Krótko mówiąc, narzędzia obsługujące BDD pozwalają nam tłumaczyć

czytelne dla człowieka specyfikacje na wykonywalny kod. Najbardziej

popularnym narzędziem BDD jest Cucumber, opracowany pierwotnie dla


języka Ruby, a później przeniesiony do innych języków, w tym do Javy i C#

(gdzie występuje on pod nazwą SpecFlow). Cucumber wykorzystuje

specjalny język o nazwie Gherkin, składający się z bardzo niewielu słów

kluczowych, po których wprowadzane są zdania w języku naturalnym.

Poniżej znajduje się przykład scenariusza napisanego w języku Gherkin:

Scenario: Cash withdrawal charges commission


Given the commission for cash withdrawal is $1
And I have a bank account with balance of $50
When I withdraw $30
Then the ATM should push out $30
And the new balance should be $19
And the charged commission should be $1

W powyższym przykładzie wytłuszczone słowa (Scenario, Given,


And, When oraz Then) są słowami kluczowymi języka Gherkin, zaś

pozostały tekst jest w języku naturalnym. Metody zostają powiązane z tymi

zdaniami za pomocą wyrażeń regularnych, co pozwala nam określać

dodatkowe parametry, jak choćby wartości wyrażające ilości, które

w powyższym przykładzie zostały oznaczone kursywą.

Większość bibliotek BDD generuje w tle kod dla szkieletu testu

jednostkowego, wykorzystując przy tym jedną z istniejących bibliotek

testów jednostkowych oraz jeden z popularnych języków programowania.

Wygenerowany w ten sposób szkielet testu jednostkowego wywołuje pewne

metody, które musimy zaimplementować, a każda taka metoda powiązana

jest zwykle z jednym zdaniem w języku naturalnym w scenariuszu Gherkin

w celu wykonania rzeczywistej pracy.

Kolejnym popularnym narzędziem z tej kategorii jest Robot Framework.

Choć Robot Framework również obsługuje język Gherkin, to posługiwanie


się w nim tym językiem nie jest wymagane. Robot Framework dostarczany

jest z pewnymi wbudowanymi bibliotekami do wykonywania typowych

akcji, operacji czy walidacji, a dodatkowo zawiera większy zestaw bibliotek

zewnętrznych, zapewniających różnorodne sposoby oddziaływania

z testowanym systemem (zobacz kolejny podrozdział). Oczywiście możemy

również napisać nasze własne biblioteki za pomocą języków Python lub

Java.

W niektórych z tych narzędzi przyjęto nieco inne podejście, aby

dostarczyć nam możliwość zawierania dokumentacji w obrębie samego kodu

testu. Przykładami takich narzędzi są RSpec dla języka Ruby, Spectrum dla

Javy, MSpec dla .NET oraz Jasmin i Mocha dla języka JavaScript, które są

również bibliotekami testowania.

Technologie zapewniające interakcję z testowanym


systemem

Bez względu na to, czy piszemy testy bezpośrednio w kodzie, czy za

pomocą jakiegoś innego narzędzia, test musi w jakiś sposób komunikować

się z testowanym systemem. Najbardziej oczywistym sposobem osiągnięcia

tego jest zasymulowanie pracy użytkownika w interfejsie systemu. Nie

zawsze jednak jest to najlepsze rozwiązanie (więcej informacji o wadach

i zaletach testowania za pośrednictwem interfejsu użytkownika można

znaleźć w rozdziale 6). Czasem lepsza może okazać się interakcja

z testowanym systemem za pomocą protokołu HTTP, TCP/IP lub jakiegoś

innego protokołu komunikacji, za pośrednictwem bazy danych, poprzez

tworzenie, modyfikowanie lub odczytywanie z plików wykorzystywanych

przez testowany system, poprzez uruchamianie komend z wiersza polecenia

itd. Większość z tych działań możemy wykonywać z poziomu kodu przy


użyciu standardowych interfejsów programowania aplikacji (API)

i bibliotek.

Jednak większość technologii interfejsu użytkownika nie dostarcza

prostego w użyciu API do symulowania czynności wykonywanych przez

użytkownika, ponieważ interfejs użytkownika powinien być

wykorzystywany przez użytkownika, a nie przez inną aplikację… Czasem

technologie te dostarczają takie API, ale są to zwykle niskopoziomowe

interfejsy, których bezpośrednie wykorzystywanie w testach

automatycznych nie jest wcale takie proste. Do symulowania działań

użytkownika wykonywanych za pośrednictwem interfejsu użytkownika

zwykle potrzebne jest dedykowane narzędzie, które pozwoli na

zautomatyzowanie interfejsu użytkownika dla konkretnej technologii.

Najbardziej znanym przykładem takiego narzędzia jest Selenium, które

automatyzuje interfejsy aplikacji sieci Web.

Jeśli planujemy wchodzić w interakcję z testowanym systemem za

pomocą protokołu HTTP i zamierzamy napisać automatyzację w kodzie, to

możemy po prostu napisać nasz własny kod, który będzie wysyłał żądania

i będzie przetwarzał odpowiedzi jak każdy inny klient. W ten sposób

zapewnimy sobie maksymalną elastyczność, a przy okazji dowiemy się, co

jest potrzebne do napisania takiej aplikacji klienta. Z uwagi na to, że

protokół HTTP jest bardzo popularnym sposobem interakcji z testowanym

systemem, powstało kilka narzędzi i bibliotek, których celem jest ułatwienie

nam tej pracy. Niektóre z tych narzędzi były przeznaczone do użycia

z poziomu kodu (np. RestAssured dla Javy), a niektóre są samodzielnymi

narzędziami (np. SoapUI firmy SmartBear). Istnieją także narzędzia,

których głównym zadaniem jest ułatwianie nam wysyłania i/lub

monitorowania żądań oraz podglądanie uzyskiwanych odpowiedzi za

pośrednictwem interfejsu użytkownika, dając nam jednocześnie możliwość


tworzenia makr lub testów automatycznych. Ponieważ jednak automatyzacja

testów nie jest ich głównym celem, zwykle nie stanowią one najlepszego

wyboru dla kompletnego systemu automatyzacji testów. Przykładami takich

narzędzi są Fiddler i Postman.

INTERFEJS PROGRAMOWANIA APLIKACJI (API)

Podczas gdy większość aplikacji projektuje się tak, aby były one

kontrolowane przez użytkowników za pośrednictwem interfejsu

użytkownika, niektóre aplikacje i komponenty oprogramowania są

projektowane w taki sposób, aby były kontrolowane przez inne

aplikacje (lub komponenty oprogramowania). Ponadto wiele aplikacji

może być kontrolowanych zarówno przez użytkowników, jak i przez

inne programy. Aby jakaś aplikacja mogła być kontrolowana przez inne

aplikacje, powinna udostępniać interfejs programowania aplikacji

(Application Programming Interface, API), tak aby inne programy,

będące klientami lub konsumentami takiego API, mogły za jego

pomocą ją kontrolować. Z technicznego punktu widzenia API mogą

być dostarczane w wielu różnych formach lub kształtach, ale od strony

pojęciowej wszystkie API definiują zbiór operacji, które klient może

wywoływać, wraz z odpowiadającymi im parametrami, strukturami

danych, wynikami itd. API powinny być zwykle dobrze

udokumentowane, aby deweloperzy aplikacji mogli z nich łatwo

korzystać i wiedzieli, czego można oczekiwać od każdej takiej operacji.

Technologie pozwalające aplikacjom na udostępnianie własnego API

można podzielić na trzy grupy:

1. Bezpośrednie wywołania metod – aplikacja (lub częściej komponent

oprogramowania) dostarcza zestaw metod (i klas, w większości

nowoczesnych technologii), które aplikacja klienta może


bezpośrednio wywoływać w obrębie tego samego procesu,

podobnie jak klient wywołuje swoje własne metody.

2. Protokół komunikacji sieciowej – aplikacja definiuje zestaw

komunikatów, które może wymieniać z klientem, a także ich

dokładny format. Aplikacja udostępniająca API zwykle działa jako

osobny proces, często na osobnej maszynie, i może obsługiwać

wielu klientów jednocześnie. Obecnie najbardziej powszechnie

wykorzystywanym protokołem podstawowym, za pomocą którego

aplikacje udostępniają swoje API, jest HTTP (lub bardziej

precyzyjnie HTTPS). API te zwykle definiują format komunikatów

wykorzystywanych dla żądań i odpowiedzi, zgodnie ze stylem

architektonicznym o nazwie REST (skrót od Representational State

Transfer). Ponadto zwykle wykorzystują one notację JSON

(JavaScript Object Notation) jako składnię dla formatów struktur

danych i komunikatów. Nieco starszym stylem dla API HTTP, który

nadal jest dosyć popularny, jest SOAP (Simple Object Access

Protocol), oparty na języku XML (Extensible Markup Language).

3. Zdalne wywoływanie procedur (Remote Procedure Call, RPC) – ten

typ technologii jest w zasadzie kombinacją dwóch poprzednich.

Dzięki RPC operacje udostępniane poprzez API aplikacji są

definiowane jako zestaw metod (procedur) i klas, podobnie jak ma

to miejsce przy bezpośrednim wywoływaniu metod. Jednak

w przeciwieństwie do bezpośredniego wywoływania metod, RPC

jest wykorzystywane do wywoływania tych metod z klientów

zdalnych, w innych procesach i maszynach. Wewnętrzna

technologia RPC generuje metody zastępcze, które klient może

wykorzystywać lokalnie. Metody te mają taka samą sygnaturę

(nazwy metod i parametry) jak metody na serwerze, który


udostępnia interfejs API. Te metody zastępcze szeregują nazwę (lub

inny identyfikator) metody wraz z wartościami jej argumentów do

postaci komunikatu i wysyłają go do serwera za pośrednictwem

protokołu komunikacji sieciowej (np. HTTP). Następnie serwer

analizuje składnię komunikatu i wywołuje odpowiednią metodę

wraz z jej argumentami. Zgodnie ze stylem RPC może być na

przykład wykorzystywana technologia Windows Communication

Foundation (WCF). Z kolei Google oferuje technologię gRPC, zaś

wiele usług udostępniających API REST dostarcza również

powiązanie z językiem dla popularnych języków programowania,

będące czymś w rodzaju wywołań RPC wyłącznie po stronie

klienta.

Te trzy kategorie są jedynie kategoriami głównymi. Aplikacja może

udostępniać API również w inny, mniej standardowy sposób, na

przykład poprzez odczytywanie i zapisywanie ze współdzielonego

pliku, z bazy danych, lub za pomocą jakiegoś sposobu komunikacji

z aplikacjami.

Interfejsy API mogą być wykorzystywane w różnych celach:

1. Systemy operacyjne udostępniają bogaty zestaw API dla

hostowanych w nich aplikacji. Interfejsy te mogą być używane do

pracy z plikami, procesami, sprzętem, interfejsem użytkownika itd.

2. Komponenty programowe lub biblioteki wielokrotnego użytku

udostępniają interfejsy API, z których mogą korzystać aplikacje.

Zwykle taki interfejs jest jedynym sposobem wykorzystywania tych

bibliotek. Biblioteki te mogą być przykładowo używane do

wykonywania skomplikowanych operacji matematycznych lub do

sterowania konkretnymi urządzeniami.


3. Wtyczki (plug-ins) – niektóre aplikacje mogą być rozszerzane przez

zewnętrznych dostawców oprogramowania za pomocą wtyczek,

w celu dostarczenia dodatkowych funkcji dla tej aplikacji lub

zintegrowania jej z innymi aplikacjami. Na przykład edytor

tekstowy może udostępniać API, które może być wykorzystywane

przez wtyczki w różnych celach, takich jak sprawdzanie pisowni,

integracja z systemami kontroli wersji, integracja z aplikacjami

poczty e-mail itd. Czasem to samo API może być używane przez

użytkowników do tworzenia makr, jak ma to miejsce w przypadku

aplikacji pakietu Microsoft Office.

4. Usługi sieci Web udostępniają API (zwykle API REST), aby

umożliwić innym aplikacjom ich wykorzystywanie. Na przykład

witryna z prognozą pogody może udostępniać API, które może być

wykorzystywane przez dostawców aplikacji w celu zintegrowania

jej z tą usługą.

Narzędzia do nagrywania, edytowania i odtwarzania kontra


biblioteki kodu

Generalnie narzędzia do automatyzacji interfejsu użytkownika można

sklasyfikować albo jako narzędzia do „nagrywania i odtwarzania”, albo też

jako zwykłe biblioteki, które możemy wykorzystywać w naszym własnym

kodzie. Jednak w rzeczywistości mamy tu czynienia raczej z kontinuum,

którego nie da się go jednoznacznie sprowadzić do dwóch osobnych

kategorii. Z jednej strony tego kontinuum znajdziemy bardzo „głupie”

narzędzia, które nagrywają ruchy i kliknięcia myszą lub wciśnięcia klawiszy,

zapisują je i pozwalają nam je odtwarzać ponownie. Starsze osoby mogą

pamiętać narzędzie Macro Recorder dostarczane swego czasu w systemie

Windows 3.1… Na szczęście narzędzie to nie jest już częścią systemu


Windows, a podobne programy nie są już tak popularne. Nie trzeba

dodawać, że takie naiwne narzędzia są wysoce podatne na błędy, ponieważ

na ślepo odtwarzają one akcje myszy i klawiatury, nie mając żadnej wiedzy

o tym, czy coś się poruszyło, zmieniło itd.

Po drugiej stronie tego spektrum system operacyjny dostarcza

niskopoziomowe API, które pozwalają nam odpytywać istniejące elementy

(lub nawet piksele) wyświetlane w ramach interfejsu użytkownika i wysyłać

do nich komunikaty, jak gdyby były one wysyłane przez mysz i klawiaturę.

Jednak między tymi dwoma rodzajami narzędzi istnieje jeszcze wiele

innych: przede wszystkim większość narzędzi rejestrujących kliknięcia

myszą nie rejestruje jedynie współrzędnych X i Y tych kliknięć lub ruchów,

ale starają się one raczej identyfikować elementy interfejsu użytkownika za

pomocą jednej lub więcej ich właściwości, najlepiej jakiegoś unikalnego

identyfikatora. Ponadto generują one kod w popularnym języku

programowania, który można potem edytować, dostosować i utrzymywać

według własnych potrzeb, lub też generują bardziej ogólny skrypt

przeznaczony raczej dla osób bez doświadczenia programistycznego, który

można edytować za pomocą dedykowanego edytora.

Zwróćmy uwagę, że prawie wszystkie narzędzia do automatyzacji

interfejsu użytkownika są albo dostarczane z narzędziem, które pozwala nam

badać elementy w interfejsie użytkownika naszej aplikacji (w tym również

ich właściwości), albo są projektowane pod kątem ich wykorzystania

w połączeniu z innym istniejącym narzędziem, dostarczanym zwykle

w ramach pakietu SDK odpowiedniego systemu operacyjnego, co pozwala

nam osiągnąć ten sam cel. Poniżej znajdują się opisy niektórych

popularnych narzędzi do automatyzacji interfejsu użytkownika.

Selenium
Jest to prawdopodobnie najbardziej popularne narzędzie automatyzacji

testów, które jest wykorzystywane głównie do automatyzacji interfejsu

użytkownika w aplikacjach sieci Web. Jak wszystkie narzędzia do

automatyzacji interfejsu użytkownika, Selenium pozwala nam wykonywać

w imieniu użytkownika akcje myszy i klawiatury oraz pobierać wyświetlane

dane. Selenium ma kilka istotnych zalet, które sprawiają, że narzędzie to jest

tak bardzo popularne:

Jest narzędziem open source (dzięki czemu jest darmowy).

Obsługuje wiele różnych przeglądarek internetowych.

Jest dostępny w wielu językach programowania.

Podstawową wadą narzędzia Selenium jest to, że było ono projektowane

z myślą o przeglądarkach, przez co obsługa innych technologii interfejsu

użytkownika jest w nim mocno ograniczona i dostępna za pośrednictwem

zewnętrznych rozszerzeń. Ponadto narzędzie to zostało zaprojektowane

w taki sposób, aby było wykorzystywane głównie z poziomu kodu.

Aby zapewnić uniwersalność przeglądarek oraz języków

programowania, narzędzie Selenium składa się z dwóch części, które

możemy swobodnie wymieniać. Są to:

1. Powiązanie z językiem.

2. Sterownik przeglądarki.

Powiązanie z językiem jest biblioteką kodu, która dostarcza klasy

i metody, z jakich możemy korzystać w kodzie naszych testów. Biblioteki te

są albo skompilowane, jak w przypadku języków Java lub C#, albo są

bibliotekami w czystym kodzie źródłowym, jak w przypadku języków

Python lub JavaScript. Dla każdego obsługiwanego języka programowania

stosuje się inne powiązanie z językiem14. Ta część komunikuje się ze


sterownikiem przeglądarki za pomocą dedykowanego protokołu łączącego

JSON.

Sterownik przeglądarki otrzymuje żądania od powiązania z językiem

i wywołuje odpowiednie operacje w przeglądarce. Każdy typ przeglądarki

ma swój własny sterownik. Ponieważ jednak wszystkie sterowniki

„rozumieją” ten sam protokół łączący JSON, ten sam test może zostać użyty

z innym sterownikiem i odpowiadającą mu przeglądarką.

Uwaga

Mimo że powiązanie z językiem komunikuje się ze sterownikiem

za pośrednictwem protokołu HTTP, komunikacja ta nie ma

żadnego związku z komunikacją między przeglądarką a serwerem

aplikacji sieci Web.

Komponent powiązania z językiem dostępny w narzędziu

Selenium nie jest biblioteką testowania, ale raczej zwykłą

biblioteką kodu. Z tego powodu możemy używać go z dowolnego

rodzaju aplikacji, mimo że jest on zwykle wykorzystywany

z poziomu jakiejś biblioteki testów jednostkowych.

Elastyczna architektura narzędzia Selenium pozwala na

zintegrowanie go z innymi specjalistycznymi narzędziami, łącznie

z Selenium Grid, które umożliwia lokalne testowanie w różnych

przeglądarkach, jak również z różnymi dostawcami testowania

opartego na chmurze, takimi jak BrowserStack i SauceLabs.

Zalety tej elastycznej architektury wykorzystuje dodatkowo

narzędzie Appium, które umożliwia testowanie mobilne za

pomocą API narzędzia Selenium.


Na rysunku 3.1 pokazano typową architekturę testu z użyciem Selenium.

Rysunek 3.1. Typowa architektura automatyzacji testów z użyciem

Selenium

CZYM JEST SELENIUM WEBDRIVER?

Bez wdawania się w szczegóły, Selenium 1.0, nazywane również

Selenium RC (lub Selenium Remote Control), było oryginalną

technologią do automatyzowania interfejsu użytkownika w sieci Web.

W wersji 2.0 narzędzie to zostało połączone z inną technologią

o nazwie WebDriver, tworząc wspólnie narzędzie „Selenium

WebDriver” będące popularną technologią, szeroko wykorzystywaną

w ostatnich latach. Zwróćmy uwagę, że terminy „Selenium” oraz

„WebDriver” często używane są zamiennie.

Jak wspomnieliśmy wcześniej, narzędzia do automatyzacji interfejsu

użytkownika zwykle dostarczane są z narzędziem inspekcji do


identyfikowania elementów interfejsu i ich właściwości. Selenium nie

oferuje takiego narzędzia, ponieważ jest ono wbudowane we wszystkie

nowoczesne przeglądarki internetowe. Wszystkie nowsze przeglądarki mają

wbudowane narzędzia dla deweloperów (przeważnie otwierane klawiszem

F12). Narzędzia te zawierają zwykle eksplorator DOM15, który umożliwia

nam identyfikowanie elementów i ich właściwości. Rysunek 3.2 pokazuje

eksplorator DOM w przeglądarce Chrome.

Rysunek 3.2. Eksplorator DOM w przeglądarce Chrome

Selenium IDE

Selenium oferuje również dedykowaną wtyczkę dla przeglądarki Firefox,

o nazwie Selenium IDE. Wtyczka ta umożliwia nagrywanie przypadków


testowych oraz proste zarządzanie i edytowanie tych przypadków bez

konieczności pisania kodu. Ponadto pozwala ona również na eksportowanie

testów do postaci kodu w językach Ruby, Java lub C#, z wykorzystaniem

różnych popularnych bibliotek testowania. Mimo że te funkcje są bardzo

przystępne, to jednak narzędzie to rzadko uznawane jest za profesjonalne

rozwiązanie do automatyzacji testów. W sierpniu 2017 roku zespół Selenium

ogłosił na swoim blogu16, że Firefox w wersji 55 nie będzie już obsługiwać

narzędzia Selenium IDE, tak więc – przynajmniej na razie – nie będzie ono

dłużej rozwijane ani utrzymywane. Na rysunku 3.3 pokazano interfejs

użytkownika wtyczki Selenium IDE.


Rysunek 3.3. Selenium IDE

Appium

Appium jest rozszerzeniem dla narzędzia Selenium WebDriver, które

pozwala na automatyzację interfejsu użytkownika dla aplikacji mobilnych

działających na systemach Android, iOS oraz Windows 10. W przypadku

tych ostatnich, rozszerzenie to obsługuje zarówno aplikacje uniwersalne

UWP, jak i klasyczne aplikacje Win32. Podobnie jak Selenium, narzędzie

Appium zostało zaprojektowane do wykorzystywania bezpośrednio

z poziomu kodu. Appium wspiera zarówno natywne aplikacje mobilne, jak

również mobilne aplikacje sieci Web i aplikacje hybrydowe. Jeśli tylko

aplikacja działa podobnie na różnych platformach, to kod testów Appium

może być na nich ponownie wykorzystywany. Z Appium można korzystać

za pośrednictwem istniejących API WebDriver, ale dodatkowo rozszerza je

ono o pewne możliwości mobilne, takie jak gesty dotykowe, orientacja

i obracanie ekranu itd. Appium może działać zarówno na prawdziwych

urządzeniach, jak i na emulatorach.

Samo Appium może działać w systemach Windows, Linux oraz Mac

i dostarczane jest z wbudowanym narzędziem inspekcji dla aplikacji iOS

i Android. Zwróćmy uwagę na to, że aby móc używać Appium do

testowania aplikacji iOS, narzędzie to musi być uruchomione w systemie

macOS, do którego podłączone jest urządzenie (lub emulator) iOS. Możemy

jednak uruchomić test na innej maszynie i zdalnie podłączyć się do usługi

Appium działającej na komputerze typu Mac. Czynniki te są również istotne,

jeśli planujemy uruchamiać testy w systemie iOS w ramach ciągłej integracji

lub kompilacji nocnych.

Ranorex
Ranorex jest kompletnym narzędziem do automatyzacji interfejsu

użytkownika, w którego skład wchodzi zintegrowane środowisko

programowania, biblioteka testowania, komponent uruchamiania,

raportowanie i wiele innych. Ranorex umożliwia rejestrowanie kompletnych

przypadków testowych, jak również bardziej niepodzielnych kroków

testowania, oraz ich edytowanie za pośrednictwem intuicyjnego interfejsu

użytkownika (bez potrzeby pisania czy edytowania kodu). Jednak w tle

tworzy on kod C#, umożliwiając nam pisanie w tym języku

niestandardowych funkcji dla operacji, które nie są obsługiwane przez to

narzędzie, takich jak operacje na bazach danych czy innych technologiach

niebędących interfejsami użytkownika. Ranorex wspiera szeroki zakres

technologii interfejsów użytkownika, poczynając od starszych, takich jak

PowerBuilder i Microsoft Visual FoxPro, aż po te najbardziej nowoczesne,

w tym natywne i hybrydowe interfejsy dla Androida i iOS, a także UWP.

Jedną z największych zalet narzędzia Ranorex jest to, że umożliwia

gładkie przejście z prostego nagrywania całego przypadku testowego na

bardziej złożoną, ręcznie kodowaną automatyzację testów. Jest to możliwe

dzięki temu, że Ranorex umożliwia łatwą modyfikację i refaktoryzację

nagranych skryptów, dzielenie ich na moduły wielokrotnego użytku,

zapewnienie jeszcze lepszego wykorzystania tych modułów za pomocą

zmiennych, a w razie potrzeby także przekształcanie małych fragmentów do

postaci kodu. Możemy nawet używać narzędzia Ranorex jako API, które

będziemy w stanie wykorzystywać w dowolnym języku platformy .NET (np.

C#) i połączyć z inną biblioteką testowania, którą sobie wybierzemy.

Ranorex zawiera wbudowane narzędzie inspekcji, które oferuje również

przyjazną abstrakcję dla różnych technologii interfejsu użytkownika.

Narzędzie to działa w połączeniu z funkcją Object Repository

(Repozytorium obiektów), która pozwala zarządzać wszystkimi elementami


interfejsu użytkownika w porządku hierarchicznym. Repozytorium obiektów

wykorzystuje specjalny wariant składni XPath17, o nazwie RXPath,

i pozwala nam na edytowanie go w inteligentny i interaktywny sposób.

Choć Ranorex od samego początku obsługuje automatyzację interfejsu

użytkownika dla przeglądarek, to dopiero od wersji 7.0 obsługuje on

również narzędzie Selenium WebDriver jako odrębną technologię interfejsu

użytkownika, pozwalając nam na wykorzystanie bogatego ekosystemu

narzędzia Selenium, w tym także Selenium Grid, oraz różnych dostawców

testowania w chmurze, takich jak SauceLabs i BrowserStack.

Microsoft Coded UI

Microsoft Coded UI jest zbiorem technologii, które dostarczają

automatyzację interfejsu użytkownika głównie dla aplikacji systemu

Windows oraz dla różnych technologii interfejsu użytkownika firmy

Microsoft, takich jak Win32, Windows Presentation Foundation (WPF),

Silverlight, Microsoft Store Apps, Universal Windows Platform (UWP) itd.

Narzędzie to obsługuje również aplikacje sieci Web, jednak w tym

przypadku nie daje nam ono żadnych dodatkowych korzyści w porównaniu

do narzędzia Selenium. Prawdopodobnie największa zaletą Coded UI jest

jego integracja z produktami Visual Studio i Microsoft Team Foundation

Server (TFS). Tak naprawdę Microsoft Coded UI jest częścią produktu

Visual Studio Enterprise, a nie samodzielnym produktem.

Podobnie jak Ranorex, Coded UI pozwala nam pracować w różnych

stylach, poczynając od samego nagrywania, aż po samo pisanie kodu.

Niestety, w przeciwieństwie do narzędzia Ranorex nie zapewnia ono

gładkiego przejścia między różnymi stylami. Jest tak głównie dlatego, że

możliwości edytowania nagrań bez konieczności kodowania są tutaj dosyć

ograniczone. Jeśli jednak musimy zautomatyzować aplikację Windows


w języku C# lub VB.NET i jesteśmy skłonni napisać automatyzację

w kodzie, wówczas narzędzie to będzie mogło stanowić realną alternatywę.

Mimo że API dostarczane przez Coded UI nie jest zbyt intuicyjne

i przyjazne, to jednak zapewnia dosyć dobrą kontrolę. Z tego powodu

utworzyłem narzędzie opakowujące Coded UI, o nazwie

TestAutomationEssentials.CodedUI (patrz dodatek C), które ma bardziej

wygodny API. Jest ono dostępne w witrynie GitHub oraz jako pakiet NuGet.

Biblioteka testowania narzędzia Coded UI jest oparta na bibliotece

MSTest. Podczas budowania nowego projektu Coded UI z poziomu Visual

Studio, tworzony jest szkielet klasy testowej, a także plik o nazwie

UIMap.uitest. Plik UIMap.uitest przechowuje nagrane czynności oraz

elementy, które identyfikujemy za pomocą wbudowanego narzędzia Coded

UI Test Builder. Projektant pliku UIMap.uitest jest pokazany na rysunku 3.4.

Za pomocą tego projektanta możemy edytować nagrane czynności, a także

wprowadzać pewne podstawowe modyfikacje dotyczące sposobu

identyfikowania elementów. Tak naprawdę za kulisami plik ten

przechowywany jest jako plik XML, zaś każda zmiana dokonana w nim za

pomocą tego projektanta generuje również plik UIMap.Designer.cs (w

języku C#). Ponieważ ten plik C# jest generowany ponownie po każdej

zmianie dokonanej w projektancie, nie powinniśmy edytować go

samodzielnie. Projektant pozwala nam jednak przenieść kompletne nagrania

do oddzielnego pliku (UIMap.cs), tak aby nie były one przez niego

nadpisywane. Niestety jest to operacja nieodwracalna: od tej chwili nagrania

nie będą widoczne w projektancie, tak więc będzie je można edytować

wyłącznie za pomocą edytora kodu C#.

Uwaga
Aby ułatwić utrzymywanie dużych projektów w programie Coded

UI, w ramach pojedynczego projektu Coded UI można utworzyć

wiele plików UIMap.

Rysunek 3.4. Projektant pliku UIMap.uitest

Jeśli piszemy test za pomocą kodu, to zamiast Coded UI Test Builder

możemy użyć narzędzia Inspect.exe, dostarczanego w ramach pakietu

Windows SDK. Narzędzie to jest nieco bardziej precyzyjne i czasem może

dostarczyć bardziej dokładne informacje na temat właściwości elementów

interfejsu użytkownika.

Microsoft Visual Studio Test Professional i Coded UI

Kolejnym interesującym przypadkiem użycia dostarczanym przez Microsoft

jest nagrywanie akcji będących częścią testu manualnego, z wykorzystaniem

programu Microsoft Visual Studio Test Professional (znanym również jako


Microsoft Test Manager lub MTM). Microsoft Visual Studio Test

Professional pozwala nam nagrać kroki manualnego przypadku testowego,

a następnie odtworzyć je przy kolejnym wykonaniu testu. Jeśli później

utworzymy projekt testowy Coded UI, będziemy mogli zaimportować te

nagrania do pliku UIMap i kontynuować ich edycję z poziomu Visual

Studio. Jednak operacja ta również jest jednokierunkowa: choć możemy

zastąpić istniejące nagranie nowym i użyć je do ponownego wygenerowania

nagrania w UIMap, to edytując to nagranie za pomocą projektanta UIMap,

nie aktualizujemy nagrania wykorzystywanego przez Microsoft Visual

Studio Test Professional. Ponieważ jednak nie mamy zbyt dużej kontroli nad

nagraniami generowanymi przez Microsoft Visual Studio Test Professional,

w większości przypadków nie będzie ono wiarygodnym i pożytecznym

narzędziem do automatyzacji testów, a może jedynie zaoszczędzić trochę

czasu testerom manualnym, pod warunkiem, że nagrania pozostają stabilne

bez żadnej interwencji. W większości nowoczesnych i dynamicznych

aplikacji jest to jednak dosyć rzadka sytuacja.

Unified Functional Testing (UFT)

UFT, znane wcześniej jako QuickTest Professional (QTP), jest

prawdopodobnie najstarszym dostępnym na rynku narzędziem do

automatyzacji interfejsu użytkownika. QTP zostało po raz pierwszy wydane

przez firmę Mercury Interactive w maju 1998 roku, po czym w roku 2006

zostało wykupione przez HP. Firma HP zmieniła jego nazwę na UFT w roku

2012. Ponieważ jeszcze do niedawna produkt ten dominował na rynku, był

on stosunkowo drogi i mogły sobie na niego pozwolić w zasadzie tylko

większe firmy.

UTF umożliwia nagrywanie i edytowanie operacji interfejsu

użytkownika wielu różnych aplikacji i technologii, m.in. WPF, Javy, SAP


czy emulatorów terminali komputerów mainframe. Narzędzie to dostarcza

„widok słów kluczowych”, w którym możemy edytować nagrany skrypt

w widoku podobnym do siatki, bez konieczności pisania kodu. Za kulisami

UTF generuje również kod VBScript, który możemy edytować bezpośrednio

w „widoku eksperta”. Wszystkie zmiany dokonane w tym widoku są

synchronizowane wstecznie z widokiem słów kluczowych.

W 2015 roku firma HP wydała nowy, bardziej nowoczesny produkt

o nazwie LeanFT. Jest on skierowany raczej do programistów

i profesjonalnych deweloperów automatyzacji, ponieważ pozwala

dodatkowo na pisanie testów w językach Java i C# z wykorzystaniem

wszystkich powszechnie stosowanych bibliotek testowania. LeanFT oferuje

lepszą integrację z systemami kontroli wersji i powszechnie stosowanymi

systemami kompilacji, a przy tym oferowany jest w znacznie bardziej

przystępnej cenie.

SoapUI

W przeciwieństwie do wszystkich wyżej wymienionych narzędzi, SoapUI

firmy SmartBear nie jest narzędziem do automatyzacji interfejsu

użytkownika, ale raczej do automatyzacji API HTTP. SoapUI obsługuje

nagrywanie, edytowanie i odtwarzanie komunikacji HTTP (REST lub

SOAP). Istnieją dwie wersje tego narzędzia: (darmowa) wersja open source

oraz wersja Pro, będąca również częścią pakietu ReadyAPI. Wersja Pro

wprowadza wiele dodatkowych ulepszeń w zakresie produktywności,

refaktoryzację, dodatkowe protokoły, biblioteki i wiele innych funkcji.

SoapUI wykorzystuje język programowania Groovy, który jest językiem

dynamicznym, kompatybilnym z Javą. Poza wysyłaniem i odbieraniem

komunikatów HTTP, SoapUI może także tworzyć atrapy dla usług sieci Web

(znane również jako symulatory; więcej informacji na ich temat można


znaleźć w rozdziale 6) i obsługuje testowanie obciążenia (omawiane

w rozdziale 18).

Pakiety do zarządzania testami

Kategoria tych narzędzi odnosi się nie tyle do samych testów

automatycznych, co raczej ogólnie do czynności i procesów związanych

z testowaniem. Narzędzia te często jednak oferują pewne funkcje związane

z automatyzacją testów, a same testy automatyczne mogą być z nimi

integrowane i zarządzane z ich poziomu.

Narzędzia te zazwyczaj pozwalają nam zarządzać zestawami testów,

planować testy, testować rezultaty, a także dostarczać raporty, wykresy

i trendy dla zarządu. Testy automatyczne można zwykle dołączać do

przypadku testowego lub je z nim wiązać, a ich rezultaty mogą być

automatycznie raportowane do tych narzędzi. Często narzędzia te

bezpośrednio zarządzają lub ściśle integrują się z innymi narzędziami do

zarządzania błędami lub innymi artefaktami zarządzania cyklem życia

aplikacji (Application Lifecycle Management, ALM), takimi jak

wymagania, wersje, kamienie milowe itd. Najbardziej popularnymi

narzędziami z tej kategorii są Microsoft Visual Studio Test Professional

(będące częścią pakietu Microsoft TFS) oraz Quality Center firmy HP.

Narzędzia kompilacji oraz potoki ciągłej integracji lub


ciągłego dostarczania

Ta ostatnia kategoria narzędzi w zasadzie sama w sobie stanowi odrębną

dziedzinę, która wykracza poza ramy tej książki. Narzędzia te odgrywają

jednak bardzo ważną rolę w obszarze automatyzacji testów. Są one

wykorzystywane do uruchamiania testów w scentralizowany sposób, zwykle

po skompilowaniu i wdrożeniu produktu. Scentralizowane uruchamianie


testów (w przeciwieństwie do uruchamiania ich na maszynie dowolnego

z deweloperów) gwarantuje, że testy będą kończyć się sukcesem w czystym

środowisku, pozbawionym jakichkolwiek zależności czy domniemań, jakie

mogą potencjalnie dotyczyć tej konkretnej maszyny dewelopera. Zwykle

testy automatyczne uruchamiane są na zaplanowanej kompilacji nocnej

(nightly build) bądź też na kompilacji ciągłej integracji (Continuous

Integration, CI), która jest wyzwalana przez każdą operację check-in

każdego dewelopera, co potencjalnie zapobiega przedostaniu się

uszkodzonego kodu do repozytorium kontroli wersji.

Dzisiaj większość z tych narzędzi pozwala na zdefiniowanie potoku

o dużo szerszym zakresie. Potok taki zawiera czasem kroki manualne,

w ramach których kompilacja przechodzi przez różne testy i ewentualnie

ręczne procesy zatwierdzania, aż do opublikowania w środowisku

produkcyjnym. Podejście to znane jest jako ciągłe dostarczanie (Continuous

Delivery, CD). To samo podejście, ale już bez ręcznego zatwierdzania,

nazywane jest ciągłym wdrażaniem (Continuous Deployment). Więcej

informacji na temat ciągłej integracji i ciągłego dostarczania można znaleźć

w rozdziałach 5 i 15.

Obecnie najbardziej popularnymi narzędziami należącymi do tej

kategorii są Jenkins, Microsoft Team Foundation Server (i jego wersja

online Team Foundation Services), TeamCity firmy JetBrain oraz Bamboo

firmy Atlassian.

Inne czynniki mające znaczenie przy wybieraniu narzędzi

Oczywiście pierwszą rzeczą, jaką należy zrobić podczas wybierania

narzędzia do automatyzacji, jest upewnienie się, że jest ono w stanie

komunikować się testowanym przez nas systemem. Ponadto na początku

tego rozdziału mówiliśmy o tym, jak istotne jest dopasowanie takiego


narzędzia do umiejętności dewelopera, oraz jak duże znaczenie ma sposób,

w jaki zamierzamy z niego korzystać w naszej organizacji. Zapewne

będziemy również chcieli, aby wszystkie wybrane przez nas narzędzia

dobrze ze sobą współpracowały. Jest jednak jeszcze jeden istotny aspekt,

który trzeba wziąć pod uwagę: cena i licencjonowanie.

Jeśli chodzi o ceny, to w zasadzie nie ma o czym mówić, ponieważ

wszyscy wolimy płacić mniej niż więcej… Nie będziemy tutaj wyjaśniać, że

„tanio” w ostatecznym rozrachunku niejednokrotnie oznacza „drogo”, lub

podawać innych podobnych frazesów, bo nie o to tu chodzi.

W rzeczywistości najważniejsza cena, jaką przyjdzie nam zapłacić za projekt

automatyzacji testów, podyktowana jest w większym stopniu jakością pracy

deweloperów automatyzacji niż jakimkolwiek innym czynnikiem. Istnieje

jednak jeden ważny aspekt dotyczący cen i licencjonowania, którego

mogliśmy nie wziąć pod uwagę na początku tworzenia projektu

automatyzacji testów, a który później może mieć znaczący wpływ na sposób,

w jaki z niego korzystamy.

Otóż, jeśli automatyzację opracowuje dla nas tylko jedna lub kilka osób,

to cena narzędzi, z których te osoby korzystają, nie będzie odgrywać

znaczącej roli. Ale jak wspomnieliśmy w rozdziale 1, automatyzacja

wykorzystywana jest najlepiej wtedy, gdy umożliwimy deweloperom

uruchamianie testów przed ewidencjonowaniem dokonywanych przez nich

zmian. Jeśli prędzej czy później zamierzamy to osiągnąć, to musimy

sprawić, aby wszyscy deweloperzy korzystali z narzędzia do automatyzacji!

Z tego względu powinniśmy poważnie rozważyć korzystanie z tanich

narzędzi, a przynajmniej takich, których struktura licencji nie będzie nas

ograniczać, gdy zaczniemy je wykorzystywać na szeroką skalę.


Rozdział 4. Osiąganie pełnego
pokrycia

Zanim przejdziemy do bardziej konkretnych i interesujących rzeczy na temat

dobrych praktyk w zakresie tworzenia niezawodnej i łatwiej w utrzymaniu

automatyzacji testów, pozwolę sobie zacząć od omówienia tematu, który jest

celem wielu osób: osiąganie pełnego pokrycia.

Często osoby, które nie mają żadnego doświadczenia w zakresie

automatyzacji testów, myślą, że w ciągu kilku tygodni lub miesięcy będą

w stanie zautomatyzować wszystkie swoje przypadki testów manualnych,

pokrywając testami pełną funkcjonalność swojego względnie

skomplikowanego produktu. Oczywiście każdy produkt ma inną złożoność

i w pewnych przypadkach osiągnięcie tego celu jest możliwe. Jednak

zazwyczaj tuż po zaimplementowaniu kilku przypadków testowych osoby te

zdają sobie sprawę, że czas potrzebny na zbudowanie pojedynczego

automatycznego przypadku testowego jest dużo dłuższy niż zakładali, a co

za tym idzie, osiągnięcie pełnego pokrycia staje się dla nich dużo trudniejsze

niż pierwotnie przypuszczali. Istnieje wiele powodów, dla których pierwsze

testy mogą zająć sporą ilość czasu, w tym brak odpowiednich umiejętności

czy inwestowanie większej ilości czasu w podstawy, ale również to, że jak

w przypadku każdego oprogramowania, istnieje wiele drobnych szczegółów,


które trzeba dopieścić, aby mogło to działać. Oczywiście korzystanie

z technik nagrywania może ten proces przyspieszyć, ale jak już

powiedzieliśmy wcześniej, za tak zaoszczędzony czas przyjdzie nam

prawdopodobnie zapłacić w formie wiarygodności i łatwości w utrzymaniu

testów. Jeśli jednak automatyzacja tworzona będzie mądrze, to szybkość

dodawania pierwszych testów prawdopodobnie będzie dosyć niska i mimo

że po jakimś czasie znacznie wzrośnie, to cel w postaci osiągnięcia pełnego

pokrycia nadal będzie daleki od zakładanego planu. W przypadku wielu

projektów osiągnięcie pełnego pokrycia może zająć całe lata! Ta przepaść

w oczekiwaniach może powodować sporą frustrację zarządu, który może

anulować cały projekt automatyzacji, jeśli zabraknie na niego pieniędzy

w budżecie lub nie będzie można uzasadnić jego kosztów.

Ale ta przepaść w oczekiwaniach nie jest jedynym problemem. W tym

długim okresie, jaki jest potrzeby do osiągnięcie pełnego pokrycia, zespół

tworzący oprogramowanie nie czeka z założonymi rękami. Dodają oni coraz

więcej funkcji, a co za tym idzie – coraz więcej przypadków testowych,

które trzeba zautomatyzować, aby pokryć te nowe funkcje! Nasuwa się więc

pytanie: czy dodawanie nowych testów automatycznych zajmuje więcej czy

mniej czasu niż dodawanie nowych funkcji do produktu? Oczywiście

odpowiedź będzie się różnić w zależności od konkretnych funkcji, ale trzeba

spojrzeć na to z nieco szerszej perspektywy. W wielu przypadkach projekty

automatyzacji testów startują przy dużo mniejszej liczbie deweloperów

automatyzacji niż deweloperów aplikacji, tak więc wydaje się, że tempo

automatyzacji będzie mniejsze. Ponadto, gdy rozpoczyna się projekt

automatyzacji, w wielu przypadkach deweloperzy automatyzacji należą już

do innego zespołu niż deweloperzy aplikacji – zwykle deweloperzy

automatyzacji należą do zespołu zapewniania jakości (Quality Assurance,

QA), a nie do zespołu rozwojowego. Ale dlaczego porównywanie ze sobą

ich szybkości w ogóle ma znaczenie?


Powód jest następujący: jeśli szybkość dodawania nowych testów

automatycznych jest mniejsza od tempa dodawania w jakim deweloperzy

produktu dodają nowych funkcje, przepaść ta staje się z czasem coraz

większa. Jeśli więc przykładowo nasze pokrycie wynosi 10% (pomijając

tutaj sposób jego pomiaru), to po roku, mimo zwiększania liczby testów, ta

wartość procentowa będzie mniejsza niż 10%, a w każdym kolejnym roku

będzie maleć. Oznacza to, że względna wartość automatyzacji testów

zamiast rosnąć, będzie się z czasem zmniejszać. Dlatego, jeśli nie bierzemy

po uwagę zniwelowania tej przepaści poprzez zatrudnienie większej liczby

osób do zwiększenia wysiłków przy automatyzacji, wówczas powinniśmy

przede wszystkim ponownie przeanalizować nakład pracy wkładany

w automatyzację testów!

Może to wyglądać na dużą inwestycję, która zaowocuje dopiero za kilka

lat, gdy automatyzacja ta będzie w stanie zastąpić wszystkie nasze manualne

testy regresji. Dobra wiadomość jest taka, że w dalszej części tego rozdziału

pokazujemy, w jaki sposób możemy zyskać na automatyzacji praktycznie

już na samym początku projektu, stopniowo i powoli niwelując tę przepaść.

Jeśli skupimy się na dostarczaniu tej wartości już na początku,

usprawiedliwienie tej inwestycji stanie się prostsze, a nawet będzie

oczywiste.

W jaki sposób mierzymy pokrycie?

Tak czy inaczej, jeśli chcemy osiągnąć 100% pokrycia, musimy najpierw

zadać sobie pytanie, w jaki sposób możemy to pokrycie zmierzyć. Bo 100%

czego? Trzy najczęściej stosowane metryki, które starają się udzielić

odpowiedzi na to pytanie, to:

1. Procent przypadków testów manualnych pokrytych przez automatyzację


2. Procent pokrytych funkcji

3. Procent pokrycia kodu

Przeanalizujmy teraz prawdziwe znaczenie każdej z tych metryk.

Procent przypadków testów manualnych pokrytych przez


automatyzację

Jeśli dokonamy bezpośredniej konwersji testów manualnych na testy

automatyczne, możemy w zasadzie powiedzieć, że osiągnęliśmy

stuprocentowe pokrycie. Jednak w przypadku tej metryki 50% nie oznacza,

że wykonaliśmy 50% pracy, ponieważ niektóre testy mogą być znacznie

prostsze i krótsze od innych. Ponadto, jak powiedzieliśmy w rozdziale 2,

testy manualne rzadko dają się bezpośrednio zautomatyzować i wymagają

zazwyczaj podziału na kilka odrębnych testów, wprowadzenia pewnych

modyfikacji, a także okazjonalnego scalenia, aby można je było

przekształcić w poprawnie utrzymywane i wiarygodne testy automatyczne.

Ponadto niektóre testy, jak te weryfikujące, czy doświadczenie użytkownika

jest odpowiednie, nie nadają się do automatyzacji i powinny pozostać

testami manualnymi.

Ale pomijając już to wszystko, mamy tutaj do czynienia z pewnym

ważnym założeniem. Aby móc w ogóle skorzystać z tej metryki, musimy

założyć, że scenariusze testów manualnych na pewno pokrywają 100% tego,

co powinno być pokryte. Nie zawsze jest to prawdą, zwłaszcza jeśli chodzi

o przestarzałe aplikacje. To prowadzi nas do drugiej metryki.

Procent pokrytych funkcji

Bez względu na to, czy mówimy o testach manualnych, czy

automatycznych, w jaki sposób możemy zmierzyć, jak dużą część

funkcjonalności systemu one pokrywają? Cóż, wszystko zależy od tego, co


dokładnie uznajemy za „funkcje” i jak mierzymy stopień pokrycia

pojedynczej funkcji. Załóżmy, że mamy listę ukończonych funkcji (np.

w TFS, Jira, Excelu lub jakimś innym programie) wraz z ich opisem, a także

listę przypadków testowych, które powiązane są z tymi funkcjami (możliwe

nawet, że zarządzaną za pomocą tego samego narzędzia). W takim wypadku

łatwo jest powiedzieć, że każda funkcja, która nie jest powiązana z żadnym

testem, nie została pokryta. Czy oznacza to jednak, że te funkcje, z którymi

powiązany jest co najmniej jeden test są pokryte? Prawdopodobnie nie…

Może się zdarzyć, że jedna prosta funkcja może mieć 10 wyczerpujących

testów, natomiast inna, bardzo złożona funkcja, może mieć tylko jeden

minimalistyczny test…

Ponadto takie listy funkcji (bez względu na to, jak będą one zarządzane)

rzadko kiedy dokładnie odzwierciedlają prawdziwą funkcjonalność systemu.

Są one zwykle pisane jeszcze przed zaimplementowaniem tych funkcji

i dokumentują jedynie zamiar klienta lub menedżera produktu, bądź też

pisane są jakiś czas po implementacji w celu udokumentowania wykonanej

pracy. W tym pierwszym wypadku możliwe jest, że podczas

implementowania funkcji zostały podjęte pewne decyzje o zmianie

niektórych rzeczy w oryginalnym planie z powodu zaistniałych konfliktów

lub napotkanych przeszkód. Jeśli tylko nie pracujemy w branży medycznej

lub innej branży o zaostrzonych regulacjach, istnieje spora szansa, że

decyzje te zostały podjęte ustnie i że dokument ten nie został odpowiednio

zaktualizowany (być może nawet to sam programista pozwolił sobie na

podjęcie takiej decyzji). Drugi przypadek, gdzie dokumentacja zostaje

napisana już po zaimplementowaniu funkcji, ma miejsce zwykle wtedy, gdy

proces tworzenia oprogramowania rozpoczął się bez właściwego

zarządzania jakąkolwiek dokumentacją i któregoś dnia ktoś uznał, że taka

dokumentacja jest potrzebna. Wtedy dla takiego projektu zostaje

sporządzona dokumentacja, która ma na celu wyjaśnienie sposobu działania


systemu. W tym przypadku dokumentacja będzie prawdopodobnie

odzwierciedlać faktyczny stan systemu, ale nie ma żadnej gwarancji, że

naprawdę obejmuje ona wszystko to, co zostało zaimplementowane.

Możliwe, że będą istnieć pewne „ukryte” funkcje, których osoba pisząca

dokumentację nie jest świadoma lub po prostu o nich zapomniała, natomiast

sami użytkownicy nadal z niej korzystają.

Spójrzmy więc teraz na znacznie bardziej obiektywny sposób

dokonywania pomiarów: pokrycie kodu.

Procent pokrycia kodu

Obecnie prawie każdy język programowania lub środowisko

uruchomieniowe (.NET, JVM itd.) zawiera funkcje pozwalające na

przechwycenie niektórych fragmentów wykonywalnego kodu (pliki DLL,

JAR, EXE itd.), aby móc w czasie uruchamiania wykryć, czy każdy

z wykonywalnych wierszy lub segmentów kodu został wykonany. Po

odpowiedniej instrumentacji wykonywalnego kodu możemy uruchomić testy

i uzyskać raport na temat tego, które i jak wiele wierszy kodu zostało lub nie

zostało wykonanych. Technika ta nazywana jest pokryciem kodu i jest

bardzo obiektywną i dokładną metodą pomiaru. Większość osób myśli, że

pokrycie kodu sprawdza się wyłącznie w przypadku testów jednostkowych,

ponieważ bezpośrednio wywołują one instrumentowane moduły. Ale

większość narzędzi pokrycia kodu może być używana do dowolnego rodzaju

testów, w tym również testów manualnych! Zwykle dla modułu

instrumentowanego nie ma znaczenia to, z jakiego miejsca jest wywoływany

i jaki proces go udostępnia.

Dokonywanie pomiaru pokrycia kodu jest świetną metryką, ale bardziej

istotna jest analiza niepokrytych obszarów. Mówiąc ogólnie, po odkryciu

niepokrytego wiersza lub obszaru w kodzie, powinniśmy albo dodać nowy


test, który go pokryje, albo usunąć ten kod, jeśli zdamy sobie sprawę, że nie

jest on wykorzystywany (co nazywamy „martwym kodem”). Choćby

niewielkie zbliżenie się do stuprocentowego pokrycia kodu może zwiększyć

nasze przekonanie o tym, że kod jest wolny od większości błędów!

Posiadanie kodu o wysokim stopniu pokrycia stanowi wygodną podstawę

dla procesu refaktoryzacji i poprawy wewnętrznej jakości kodu.

Niestety, pokrycie kodu ma również pewne wady:

1. Choć ten sposób dokonywania pomiarów jest dosyć dokładny, a przy tym

łatwo jest podejrzeć procent pokrycia, to znacznie trudniej uzyskać

prawdziwe dane na temat niepokrytych obszarów. Aby zrozumieć, jaka

funkcjonalność jest pokryta testami, a jaka nie, musimy mocno zagłębić

się w kodzie, a nie jest to coś, co menedżerowie będą mogli zrozumieć.

2. Istnieje kilka technik, które są stosowane w narzędziach do pokrywania

kodu w celu dokonania pomiaru pokrycia kodu, a każda z nim mierzy

inne rzeczy: niektóre po prostu wykrywają, czy dany wiersz kodu został

wykonany, czy nie. Czasem jednak pojedynczy wiersz może zawierać

kilka segmentów kodu, z których każdy może być wywoływany

niezależnie. Oto prosty przykład takiego kodu:

if (x > 10) DoOneThing() else DoAnotherThing();

Z tego powodu kolejna technika dokonywania pomiarów zlicza

rozgałęzienia przepływu sterowania zamiast poszczególnych wierszy

kodu. Istnieje jeszcze kilka innych technik i szczegółów stosowanych

w tego rodzaju narzędziach i trzeba zdawać sobie sprawę w tego, że

w zależności od techniki stosowanej przez dane narzędzie, znaczenie

dokładnego „procentowego pokrycia kodu” może być nieco inne.

3. Załóżmy, że udało nam się uzyskać 100% pokrycia kodu i wszystkie testy

kończą się sukcesem. Nadal nie oznacza to, że nie mamy żadnych
błędów. Żeby nie było nieporozumień: to naprawdę świetny wynik!

Szanse na znalezienie błędów w takiej sytuacji są niezmiernie niskie, ale

nie zerowe! Fakt, że wszystkie wiersze zostały wykonane, nie oznacza,

że zostały one wykonane we wszystkich możliwych sekwencjach

i ścieżkach. Listing 4.1 pokazuje uproszczony przykład takiego

przypadku. Jak łatwo wywnioskować z tego przykładu, testy Test1


i Test2 pokrywają wszystkie wiersze (i gałęzie) klasy

ClassUnderTest, ponieważ oba sprawdzają zarówno część then


instrukcji if w metodzie ClassUnderTest.Foo, jak też instrukcję

return 1, która jest wykonywana, gdy warunek if nie jest spełniony.


Ponadto oba te testy powinny zakończyć się pomyślnie. Jeśli jednak

dodamy test, który wywołuje metodę Foo(1), zostanie wówczas

zgłoszony wyjątek DivideByZeroException. Jeśli nie jest to coś,

czego spodziewa się użytkownik, to będziemy mieć wówczas błąd,

pomimo stuprocentowego pokrycia kodu. Innym, nawet prostszym

przykładem, jest sytuacja, w której testy sprawdzają 100% obszaru kodu,

ale część z nich nie weryfikuje właściwej rzeczy lub nie weryfikuje tak

naprawdę niczego! (tj. sprawdzają one złą rzecz lub wcale nie zawierają

instrukcji Assert).
Listing 4.1. Kod ze stuprocentowym pokryciem, który nadal zawiera błąd
4. Czasem programista napisze jakieś wiersze kodu, które mogą zostać

wykonane jedynie w niezwykle rzadkich przypadkach, takich jak

zewnętrzne błędy, które mogą być bardzo trudne do zasymulowania

w środowisku testowym. Istnieją ponadto sytuacje (co prawda rzadkie,

ale jednak istnieją), w których narzędzie pokrycia kodu uznaje wiersz

kodu za niepokryty, mimo że nie jest on osiągalny, ale nie może zostać

usunięty. Na przykład niektóre narzędzia będą raportować jako kod

niepokryty ostatni wiersz w metodzie ClassUnderTest.Foo oraz


zamykającą klamrę bloku try w metodzie

CheckIfFooThrewException z listingu 4.2. Z tego powodu

osiągnięcie 100% pokrycia kodu jest zazwyczaj niemożliwe.


Listing 4.2. Niepokryte wiersze, których nie można usunąć

DOWODZENIE POPRAWNOŚCI

Jak napisał sławny informatyk Edsger Dijkstra w roku 1970: „Poprzez

testowanie programów można wykazać istnienie błędów, ale nie ich

brak!”18. Jest tak dlatego, że testowanie może jedynie dowieść, iż

określone przykłady działają poprawnie, a nie że program działa

poprawnie we wszystkich możliwych przypadkach.

Z moich zajęć z informatyki pamiętam, że uczono nas dowodzenia

poprawności algorytmów lub fragmentów kodu. Na przykład uczyliśmy

się udowadniać, że algorytm Merge Sort poprawnie sortuje dowolną

tablicę o dowolnej długości, której elementami są dowolne liczby (przy

pewnych założeniach). W przeciwieństwie do testowania, tego rodzaju

dowód jest prawdziwy dla każdych poprawnie podanych liczb!

Teoretycznie moglibyśmy udowodnić poprawność całego kodu naszego

systemu i w ten sposób trzeba by w ogóle przeprowadzać testów!

Oczywiście w przypadku rzeczywistych aplikacji jest to całkowicie

niepraktyczne. Co więcej, dowód taki ograniczony jest do konkretnej

implementacji. Załóżmy więc, że dowiedliśmy poprawności jednej

z wersji naszej aplikacji. Jeśli teraz wprowadzimy jakąkolwiek zmianę

w naszym kodzie, będziemy musieli ponownie dowieść poprawności

tej aplikacji (przynajmniej w zakresie modułu, który został zmieniony),

co jest zupełnie niepraktyczne.

Podsumowując: nie istnieje jeden właściwy sposób pomiaru pokrycia

testów, a każda metryka ma swoje wady. Jeśli jednak do dowolnej z tych

metryk podejdziemy z głową, będziemy mogli uzyskać szacunkowe dane


o postępie, jaki poczyniliśmy w pokrywaniu aplikacji automatycznymi

testami.

Uzyskiwanie korzyści przed osiągnięciem


pełnego pokrycia

Jak na razie możemy się zgodzić, że:

1. Niwelowanie luki i osiąganie pełnego pokrycia zajmie dużą ilość czasu.

2. Mimo że może to potrwać bardzo długo, ważne jest, aby w miarę upływu

czasu minimalizować lukę między nowymi i pokrytymi funkcjami, a nie

ją powiększać.

Aby zminimalizować tę lukę, będziemy prawdopodobnie potrzebować

większej liczby osób do pisania testów, a to wymaga dodatkowych nakładów

finansowych, których nasz szef prawdopodobnie nie będzie chciał zapewnić.

Przynajmniej nie na tym etapie. Ale nie martwmy się: możemy czerpać

korzyści z automatyzacji na długo przed zniwelowaniem tej luki, a jeśli

tylko korzyści te będą zauważalne dla naszego szefa, nie będzie większego

problemu z przekonaniem go do wyłożenia dodatkowych pieniędzy. Aby

zrozumieć, co możemy zyskać dzięki automatyzacji przy jedynie

częściowym pokryciu kodu, spróbujmy najpierw zrozumieć, co zyskamy po

osiągnięciu pełnego pokrycia.

Co robimy po osiągnięciu pełnego pokrycia?

Załóżmy na chwilę, że uzyskaliśmy stuprocentowe pokrycie kodu

i wszystkie nasze testy kończą się sukcesem! Co robimy następnego dnia


rano, po tym, jak otworzyliśmy szampana, aby uczcić to wydarzenie (i po

tym jak minął nam kac)?

Jeśli na tym etapie uznamy projekt automatyzacji za „ukończony”

i zwolnimy wszystkich deweloperów automatyzacji, to gdy tylko do naszego

produktu zostaną dodane jakieś nowe funkcje, nasze pokrycie kodu znów

spadnie poniżej 100%. Nie dlatego, że mamy teraz mniej automatyzacji, ale

dlatego, że mamy teraz więcej funkcji! Okazuje się więc, że tak naprawdę

wcale nie zakończyliśmy naszej pracy. Co więcej, jeśli jeden lub więcej

testów przestanie działać z powodu zmian dokonanych w aplikacji, musimy

upewnić się, że zostaną one jak najszybciej naprawione. A zatem tego

poranka będziemy tak naprawdę chcieli mieć pewność, że wszystkie nasze

testy nadal kończą się sukcesem. W rzeczywistości jest to idealny stan, który

opisaliśmy w rozdziale 2, w części zatytułowanej „Uzyskiwanie maksimum

korzyści z automatyzacji testów”.

Utrzymywanie stuprocentowego pokrycia kodu oznacza, że:

1. Dla każdej nowej funkcji muszą zostać opracowane nowe testy.

2. Każda zmiana, która psuje istniejące testy, musi zostać jak najszybciej

naprawiona.

Ponadto, aby utrzymać stan, w którym wszystkie testy kończą się

sukcesem, trzeba jak najszybciej naprawiać błędy, gdy któryś z testów

zakończy się niepowodzeniem z ich powodu. W takim stanie będzie to dosyć

proste do zrobienia: w końcu błąd ten został spowodowany przez ostatnie

operacje ewidencjonowania kodu (check-in). Na tym etapie deweloperzy

mają jeszcze na świeżo ten kod w swoich głowach. Co więcej, niemądre

byłoby uznanie historyjki użytkownika za ukończoną, gdy właśnie popsuła

ona coś, co wcześniej działało. Z tego powodu jedynym sensownym

rozwiązaniem będzie jak najszybsze jej naprawienie.


Tak czy inaczej, w kontekście przepustowości, po osiągnięciu

stuprocentowego pokrycia musimy być w stanie dodawać testy szybciej niż

dodajemy nowe funkcje, aby móc utrzymać to pokrycie na takim poziomie.

Jeśli jednak po osiągnięciu stuprocentowego pokrycia musimy być w stanie

dodawać testy szybciej niż dodajemy nowe funkcje, to musimy to robić

także na długo przed osiągnięciem tego stanu!

W jaki sposób uzyskać 100% pokrycia?

Zróbmy teraz jeden krok wstecz. Załóżmy, że rozpoczęliśmy nasz projekt

automatyzacji rok temu i w tamtym czasie mieliśmy do pokrycia 100

przypadków testowych (bez względu na to, w jaki sposób to mierzymy).

Teraz, a więc rok później, zdołaliśmy pokryć wszystkie 100 testów

i wszystkie one kończą się sukcesem. Czy to oznacza, że mamy teraz

stuprocentowe pokrycie? Tylko jeśli 90% deweloperów aplikacji poszło na

roczne wakacje… (brzmi świetnie!), a pozostałe 10% zostało w firmie po to,

by naprawiać błędy (niesprawiedliwe!). Co tak naprawdę się stało? Przez ten

cały rok do produktu dodawane były nowe funkcje, więc teraz mamy X

dodatkowych przypadków testowych do pokrycia. Jeśli X jest większe niż

100 (powiedzmy wynosi 150), wówczas pokrycie ich zajmie nam dodatkowe

1,5 roku, ale zanim zdołamy to osiągnąć, będziemy mieli 225 niepokrytych

testów… W tym miejscu luka robi się coraz większa. Te sytuacje

przedstawiono na rysunku 4.1. Jeśli X wynosi dokładnie 100 (tj. dokładnie

tyle, ile liczba przypadków testowych, które udało nam się zautomatyzować

przez ostatni rok), to w następnym roku ukończymy pokrywanie tych

nowych 100 przypadków, co da nam łącznie 200 zautomatyzowanych

przypadków testowych, ale do tego czasu będziemy mieć już

prawdopodobnie 100 dodatkowych testów, co oznacza, że ta luka będzie


cały czas na stałym poziomie. Taka sytuacja może się utrzymywać

w nieskończoność, niczym potok, ale nigdy nie będziemy w stanie osiągnąć

100% pokrycia. Sytuację tę pokazano na rysunku 4.2. Tak więc raz jeszcze,

jeśli chcemy uzyskać stuprocentowe pokrycie, musimy szybkość

opracowywania i stabilizowania testów automatycznych musi być większa

od tempa dodawania nowych funkcji, jak to pokazano na rysunku 4.3.

Rysunek 4.1. Powiększająca się luka pomiędzy funkcjami i pokryciem


Rysunek 4.2. Stała luka pomiędzy funkcjami i pokryciem

Rysunek 4.3. Zmniejszanie luki pomiędzy funkcjami i pokryciem

Odwracanie koła
Należy tutaj podkreślić, że im później testy automatyczne tworzone są po

opracowaniu danej funkcjonalności, tym trudniejszy i mniej wydajny jest ich

rozwój. Osoba, która opracowała daną funkcjonalność, mogła opuścić firmę,

przenieść się do innej pracy lub po prostu zapomnieć większość szczegółów.

W najlepszym wypadku będzie ona po prostu zbyt zajęta innym zadaniem,

aby mogła nam pomóc w pewnych „drobiazgach”, które są nam potrzebne

do zaimplementowania naszego zautomatyzowanego testu. Jeśli na przykład

chcemy, aby osoba taka wprowadziła kilka zmian, których opracowanie

potrwa kilka godzin – bo chcemy, żeby ta funkcjonalność była lepiej

testowana przez automatyzację – wówczas prawdopodobnie minie trochę

czasu, zanim ta osoba ta będzie mogła znaleźć odpowiedni moment i nam

w tym pomóc. Czasem możemy nawet znaleźć błędy uniemożliwiające nam

ukończenie automatycznego testu, ale ponieważ z perspektywy użytkownika

nie są to błędy krytyczne, może minąć kilka tygodni lub nawet miesięcy,

zanim ktoś je naprawi. Problem ten – wraz z jego typowymi rozwiązaniami

– omawiamy bardziej szczegółowo w kolejnym rozdziale.

Natomiast gdy test automatyczny jest opracowywany równolegle

z testowaną przez niego funkcją, wówczas współpraca pomiędzy

deweloperem aplikacji a deweloperem automatyzacji będzie znacznie lepsza,

dając w rezultacie lepszą automatyzację w znacznie krótszym czasie! Ale ma

to również inne, bardziej istotne korzyści:

1. Ponieważ znacznie łatwiej testować mniejsze i prostsze komponenty niż

monolityczny kod „spaghetti”, projektowanie pod kątem możliwości

testowania oznacza projektowanie pod modułowość, rozszerzalność

i wielokrotne wykorzystywanie. Opracowywanie testów razem

z funkcjami wymusza takie cechy projektowania, co ostatecznie

pozytywnie wpływa na całokształt projektu systemu.


2. Konieczność zaprojektowania i zaimplementowania automatycznego

testu wymaga, aby wszystkie istotne szczegóły były dobrze

zdefiniowane i przejrzyste. Często rodzi to pewne pytania i problemy

dotyczące oczekiwanego zachowania funkcji i pozwala nawet znaleźć

błędy, zanim jeszcze funkcja zostanie zaimplementowana!

3. Większość błędów w implementacji nowej funkcji zostanie wyłapana

i naprawiona przez dewelopera aplikacji, zanim jeszcze zaewidencjonuje

on swój kod! Choć jest wysoce zalecane, aby tester manualny

przynajmniej raz zweryfikował poprawność działania danej

funkcjonalności (dostarczając jeszcze jedno zabezpieczenie, aby

upewnić się, że test automatyczny jest poprawny!), szanse wykrycia

przez niego prostych błędów są bardzo małe, co pozwala zaoszczędzić

cenny czas oraz niedogodności związane z typowym rytuałem

wyszukiwania błędów, raportowaniem, badaniem, oceną stanu,

naprawianiem i ich ponownym weryfikowaniem. W rzeczywistości

tester manualny może skupić się bardziej na testowaniu eksploracyjnym,

które pozwoli mu znaleźć mniej oczywiste błędy.

Uwaga

Oczywiście opracowywanie automatycznego testu przy

jednoczesnym rozwijaniu testowanej funkcjonalności może być

nawet łatwiejsze, jeśli ten sam deweloper implementuje zarówno

samą funkcjonalność, jak i dedykowany jej test automatyczny.

W ten sposób proces ten będzie jeszcze bardziej wydajny!

Oczywiście w takim wypadku będziemy mieć mniejszą kontrolę

i mniej informacji zwrotnych dotyczących pracy dewelopera, ale

problem ten można rozwiązać za pomocą innych technik, takich


jak programowanie w parze, przeglądy kodu, przeglądanie

przypadku testowego przez testera manualnego przed

zaimplementowaniem automatycznego testu itd. Ogólnie rzecz

biorąc, efektywność takiego podejścia zależy głównie od

konkretnych zaangażowanych osób oraz kultury danej organizacji.

Więcej szczegółowych informacji na temat wad i zalet

poszczególnych wzorców organizacyjnych można znaleźć

w poprzednim rozdziale.

Jeśli zatem znacznie bardziej efektywne i cenne jest opracowywanie

testów automatycznych wraz z testowaną funkcjonalnością, lub

przynajmniej rozwijanie ich blisko siebie, to po co czekać z tym do

osiągnięcia pełnego pokrycia?! Powróćmy na chwilę do przypadku,

w którym szybkość tworzenia nowych funkcji jest dokładnie taka sama, jak

szybkość dodawania nowych automatycznych przypadków testowych

(przypadek przedstawiony na rysunku 4.2). Ponieważ zawsze będziemy

mieć lukę na poziomie 100 przypadków testowych, możemy zdecydować się

w dowolnym momencie na pominięcie pozostałych 100 przypadków

testowych i przeskoczyć bezpośrednio do tych, które są w danym momencie

tworzone. Od tego momentu będziemy zachowywać się dokładnie tak, jak

byśmy to robili przy osiągnięciu pełnego pokrycia, z wyjątkiem tego, że

zawsze będziemy mieć 100 niezautomatyzowanych przypadków testowych,

które trzeba będzie testować ręcznie. Jeśli jednak luka ta byłaby zawsze na

poziomie 100 przypadków testowych, to tak czy inaczej musielibyśmy to

zrobić. Różnica polega jedynie na tym, że zamiast ręcznie testować 100

ostatnich przypadków testowych, teraz zawsze będziemy ręcznie testować

100 tych samych przypadków testowych i stale automatyzować nowe. Jest to

znacznie lepsze, bo jak wspomnieliśmy wcześniej, tworzenie testów


automatycznych w połączeniu z testowanymi funkcjami jest znacznie

bardziej wydajne.

Uwaga

Opracowywanie i uruchamianie testów przed lub wraz z testowaną

funkcjonalnością nazywane jest testowaniem progresji,

w przeciwieństwie do testowania regresji, które zwyczajowo

wykonujemy, gdy testy tworzymy i uruchamiamy testy po

ukończeniu testowanej funkcji. Po tym jak testy te (czasami

nazywane również testami akceptacyjnymi) zakończą się

sukcesem w najnowszej kompilacji, dołączają one do zestawu

testów regresji w kolejnych kompilacjach i wersjach danego

produktu.

Jeśli opracowywanie nowych automatycznych przypadków testowych

jest szybsze od tworzenia funkcjonalności (co przedstawia rysunek 4.3),

możemy w dowolnym momencie zdecydować o rozpoczęciu tworzenia

testów progresji i ukończyć pozostałe testy regresji w późniejszym czasie,

między opracowywaniem kolejnych testów progresji. Ponieważ wiemy, że

testy opracowujemy szybciej niż tworzymy funkcjonalność produktu,

możemy być pewni, że między tworzeniem kolejnych testów progresji

będziemy mieć dość czasu na domknięcie luki w testach regresji.

Teoretycznie powinniśmy dotrzeć do punktu pełnego pokrycia w dokładnie

takim samym czasie, jak w przypadku opracowywania wszystkich testy

w sposób regresyjny, ponieważ zmieniamy jedynie kolejność ich tworzenia!

Co więcej, ponieważ opracowywanie testów progresji jest wydajniejsze od

tworzenia testów regresji, prawdopodobnie osiągniemy ten punkt znacznie


wcześniej! Na rysunkach 4.4 i 4.5 pokazano, że kolejność opracowywania

testów nie zmienia czasu potrzebnego na osiągnięcie pełnego pokrycia.

Rysunek 4.4. Opracowywanie testów regresji jako pierwszych

Rysunek 4.5. Opracowywanie testów progresji jako pierwszych


Mapa drogowa prowadząca do pomyślnego
projektu automatyzacji

Doszliśmy do punktu, w którym możemy wyciągnąć wniosek, że lepszym

podejściem wydaje się rozpoczęcie prac nad progresyjną automatyzacją

testów przed osiągnięciem pełnego pokrycia w regresji. Nasuwają się jednak

dwa pytania:

1. W którym dokładnie momencie należy rozpocząć pracę nad testami

progresji?

2. W jaki sposób nadawać priorytety pracy nad testami regresji?

Kiedy rozpocząć pracę nad progresją?

Sądząc po tym, co tej pory zostało powiedziane, można by pomyśleć, że

zachęcamy do rozpoczęcia prac nad progresją już od pierwszego dnia,

jeszcze przed rozpoczęciem tworzenia testów regresji. Cóż, jest to możliwe,

ale w praktyce zwykle tego nie robimy ani nie zalecamy. Na początku

nowego projektu automatyzacji testów koszt inwestycji przewyższa jej

zwrot. Aby pierwsze testy mogły działać, musimy zbudować dużą część

infrastruktury, co zapewne zajmie więcej czasu niż potrzebne jest na

zaimplementowanie funkcjonalności z historyjki użytkownika. Dotyczy to

zwłaszcza sytuacji, gdy historyjka użytkownika jest tylko małym

usprawnieniem istniejącej funkcji. W takim wypadku nadal będziemy

musieli zbudować większość infrastruktury potrzebnej do przetestowania

całej historyjki, mimo że historyjka użytkownika dotyczy jedynie małego

usprawnienia. Z tego powodu zalecamy rozpocząć pracę od zestawu testów

poprawności (sanity tests), które weryfikują podstawową funkcjonalność

systemu i jego głównych funkcji. Utworzenie takiego zestawu pomoże nam

zbudować i ustabilizować infrastrukturę głównych funkcji w aplikacji.


W tym czasie zyskamy również pewne doświadczenie i wiedzę nie tylko

w ogólnym zakresie automatyzacji testów, ale również w zakresie

automatyzacji testów w kontekście naszej konkretnej aplikacji. Ponadto

powinniśmy wykorzystać ten czas na zintegrowanie testów automatycznych

z narzędziami ciągłej integracji oraz procesami rozwojowymi. Więcej

informacji na temat integrowania testów z procesem ciągłej integracji można

znaleźć w rozdziale 15.

Podczas tworzenia testów poprawności musimy stale się upewniać, że

wszystkie testy kończą się sukcesem (o ile w naszej aplikacji nie ma

żadnego błędu, który miałby wpływ na nasze testy). Jeśli to konieczne,

wprowadźmy zmiany w naszych testach i/lub infrastrukturze, aby

dostosować je do wszelkich zmian w produkcie. W przypadku gdy test

kończy się niepowodzeniem z powodu błędu w aplikacji, postarajmy się

o jego jak najszybsze naprawienie i nie poprzestawajmy na samym

zgłoszeniu tego błędu. Jeśli nie uda nam się tego osiągnąć, po prostu

wysyłajmy dzienne raporty dotyczące niepowodzeń testów oraz

powiązanych z nimi błędów, ale kładźmy nacisk na czas, jaki zajmuje nam

rutynowe badanie tej samej porażki dzień po dniu. Ponadto upewnijmy się,

że testy działają na dowolnej maszynie, aby dać deweloperom możliwość

uruchamiania ich na ich własnych komputerach. Powinniśmy się upewnić,

że diagnozowanie niepowodzeń jest wystarczająco łatwe, i oczywiście starać

się, aby nasze testy były łatwe w utrzymaniu. Więcej informacji na temat

pisania łatwych w utrzymaniu testów i infrastruktury można znaleźć

w części II tej książki. Z kolei rozdział 13 zawiera informacje

o diagnozowaniu niepowodzeń, zaś w rozdziale można znaleźć 15 porady

dotyczące stabilizowania testów.

Kiedy zestaw testów poprawności jest gotowy, stabilny i jest częścią

ciągłej integracji, możemy przystąpić do rozpoczęcia prac nad progresją.


Jeśli nadal są jakieś testy, które kończą się niepowodzeniem z powodu

znanych błędów w produkcie, wówczas najpierw trzeba naprawić te błędy.

Oczywiście musimy mieć zgodę na ten ruch od odpowiednich interesariuszy

w naszej organizacji (zwłaszcza od liderów i menedżera deweloperów)

i trzeba to zrobić w odpowiedni sposób i we właściwym czasie.

W szczególności, jeśli cykl wydawniczy to nadal tylko kilka miesięcy,

ważne jest znalezienie właściwego momentu, aby uniknąć stresujących

okresów, w których menedżerowie są mniej tolerancyjni wobec

eksperymentów i porażek. Jeśli to my jesteśmy menedżerami zespołu

deweloperów, wówczas decyzja ta należeć będzie do nas. Jeśli nie,

prawdopodobnie będziemy musieli wykonać pewne prace przygotowawcze,

zanim otrzymamy zgodę od liderów zespołu deweloperów. Jednym

z prostszych rozwiązań może być rozpoczęcie od opracowania testu dla

każdego błędu, który jest naprawiany, a dopiero później przystąpienie do

właściwych testów progresji. Więcej informacji o tym, jak stopniowo

zmieniać kulturę, aby wspierać tworzenia testów w połączeniu z rozwojem

produktu, można znaleźć w rozdziale 15.

Jak tylko zaczniemy tworzenie testów dla nowych historyjek

użytkownika, powinno być to realizowane we współpracy z deweloperami

implementującymi taki scenariusz, zaś sam scenariusz powinien być

uznawany za „zakończony” tylko wtedy, gdy wszystkie testy (łącznie z tymi

nowymi) będą kończyć się sukcesem. Więcej informacji na temat metodyki

ATDD, która kładzie nacisk na implementowanie testów przed

implementacją testowanej funkcjonalności, można znaleźć w rozdziale 16.

Nadawanie priorytetu pracy w celu zlikwidowania luki


w regresji
Od tej chwili utrzymywanie testów w stabilnym stanie powinno być

stosunkowo proste. Zestaw testów poprawności gwarantuje, że jeśli

deweloper zepsuł coś istotnego, to zostanie to jak najszybciej naprawione,

zanim jeszcze testerzy manualni zaczną korzystać z tej wersji. Testy

progresji (która stopniowo stają się regresją) gwarantują, że nowo

opracowywane funkcje będą nadal działać.

Teraz musimy skupić się na pozostałej części testów regresji. I jest tego

sporo… W jaki zatem sposób mamy zdecydować o tym, które z nich

powinniśmy zautomatyzować jako pierwsze? Naszą ogólną radą jest

uporządkowanie ich według wartości i ryzyka, jakie niesie ze sobą ich

rzadkie testowanie. Oto kilka wskazówek:

1. Przede wszystkim należy skupić się na tych funkcjach, które stanowią

największą wartość dla biznesu i klientów. Wyszukiwanie błędów i ich

zapobieganie w tych funkcjach bezpośrednio przekłada się na zyski

z produktu. Jednak same te wytyczne to za mało. Powinniśmy porównać

je z kolejnymi wskazówkami, aby ostatecznie zdecydować o priorytecie

pokrywania tej funkcji.

2. Jeśli jakiś komponent (lub funkcja) ma zostać wkrótce zastąpiony poprzez

zmodyfikowanie całego jego działania, wówczas na tym etapie nie ma

sensu tworzyć dla niego testów. Po prostu poczekajmy na opracowanie

nowego działania, a potem wraz z nim utworzymy nowe testy.

3. Jeśli funkcja jest bardzo stabilna i nie planujemy jej zmieniać, to

pokrywanie jej automatyzacją również nie będzie zbyt opłacalne. Istnieje

bardzo małe ryzyko, że coś w jakiś sposób będzie miało na nią wpływ.

Dotyczy to szczególnie starszych komponentów, których nikt nie

odważy się dotknąć, nawet jeśli są bardzo istotnymi elementami

systemu. Znalezienie czegoś o wyższym poziomie ryzyka szybciej

przyniesie nam większą korzyść.


4. Jeśli funkcja jest ukończona i działa poprawnie, ale my nie planujemy

zmieniać jej podstawowej technologii ani dokonywać rozległej

refaktoryzacji jej struktury wewnętrznej, to funkcja taka jest świetnym

kandydatem do pokrycia jej automatyzacją testów. Testy nie powinny

być zmieniane, gdy zmienia się podstawowa lub wewnętrzna struktura

i po takiej zmianie nadal powinny kończyć się sukcesem, gwarantując

utrzymanie istniejącego działania.

5. Podobnie jak powyżej, usprawnianie wydajności danej funkcji polega

zwykle na zmianie jej wewnętrznej struktury przy jednoczesnym

zachowaniu zewnętrznej funkcjonalności. Również i w tym przypadku

po zmianach tych testy powinny jak do tej pory kończyć się sukcesem.

6. Należy rozważyć pokrycie funkcji, która często się psuje. Zwróćmy

jednak uwagę, że często psująca się funkcja jest oznaką

problematycznego projektu. Z tego powodu może ona być dobrym

kandydatem do refaktoryzacji (w takim przypadku automatyzacja będzie

bardzo cenna) lub do całkowitego przepisania (a wtedy automatyzacja

również może wymagać ponownego napisania…).

Poza pokrywaniem istniejących funkcji, przez naprawą każdego błędu,

który został znaleziony nie przy użyciu automatyzacji, należy napisać test

automatyczny, mający na celu odtworzenie tego błędu. Dopiero gdy

automatyczny test pomyślnie zdoła go odtworzyć, można naprawić ten błąd.

W ten sposób gwarantujemy, że żaden błąd nigdy nie zostanie wykryty

dwukrotnie.

Jeśli zastosujemy się do tych wskazówek, będziemy mogli efektywnie

zarządzać ryzykiem i naszym postępem. Pozwoli nam to łatwo utrzymywać

nasze wyniki automatyzacji w „zielonym” stanie i wcześnie wyłapywać

większość trywialnych błędów. Oprócz tego stopniowo pokrywamy coraz to

więcej obszarów testów regresji. Jak powiedzieliśmy w rozdziale 2,


automatyzacja testów nie zastąpi nam całkowicie testerów manualnych, ale

pierwszym warunkiem do powstrzymania się od wykonywania uciążliwych

manualnych testów regresji dla danej funkcji jest niezawodność

automatyzacji testów tej funkcji! Dopóki automatyzacja będzie

niewiarygodna, nie będzie mogła zastąpić testów manualnych. Jeśli

spróbujemy dojść do 100% regresji przed przejściem do ciągłej integracji,

będzie nam ciężko ustabilizować testy i im zaufać. Opisana tutaj mapa

drogowa ułatwia utrzymywanie stabilnych testów od samego początku,

czyniąc je przez to znacznie bardziej wiarygodnymi. Z tego powodu

możemy stopniowo zmniejszać wysiłki w zakresie ręcznego testowania

regresji pokrytych funkcji, umożliwiając testerom manualnym wykonywanie

bardziej wartościowego testowania eksploracyjnego.


Rozdział 5. Procesy biznesowe

Jak mogliśmy wywnioskować z poprzednich rozdziałów, projekt

automatyzacji testów nie może być niezależny. Jego cykl życia jest ściśle

powiązany z cyklem życia testowanej przez niego aplikacji. Podobnie jak

w przypadku każdego innego projektu, jego istnienie jest uzasadnione tylko

wtedy, gdy ktoś z niego korzysta. W przeciwnym razie projekt taki jest

całkowicie bezużyteczny. W przypadku automatyzacji testów

„użytkownikiem” jest tak naprawdę cały zespół tworzący oprogramowanie.

Może się wydawać, że głównym użytkownikiem jest menedżer zespołu

zapewniania jakości lub menedżer zespołu tworzenia aplikacji, ale

w rzeczywistości nie wykorzystują go oni do swoich własnych celów. Mogą

wymagać wymyślnych raportów i mówić nam, co mamy robić (a czasem

i nawet, w jaki sposób mamy to robić…), ale jak wspomnieliśmy

w rozdziale 1, jedną z głównych zalet automatyzacji testów jest to, że

pozwala ona zespołowi dużo szybciej wykrywać i naprawiać błędy. Aby

jednak było to możliwe, zespół musi nauczyć się z niej efektywnie

korzystać, co zwykle wymaga pewnego procesu roboczego. Procesy te

mogą być nieco mniej rygorystyczne, jeśli zespół jest mały i każdy jest

w stanie dostrzec ich zalety. Jednak bez względu na to, czy procesy te są

rygorystycznie wymuszane przez zarząd, czy też zespół dostrzega ich


wartość, trzeba się do nich stosować, aby zapewnić właściwą współpracę

i czerpać maksymalne korzyści z automatyzacji.

Regularne uruchamianie testów

Jak wspomnieliśmy wcześniej, jeśli rzadko będziemy testować kod

tworzony przez deweloperów, prawdopodobnie wiele testów będzie się

kończyć niepowodzeniem z powodu prostych zmian w produkcie. W takim

wypadku zbadanie tych niepowodzeniem w celu ustalenia, które z nich

wynikają z błędów w aplikacji, a które są powodowane przez dozwolone

zmiany, zajmie nam długi czas. Co więcej, gdy wiele testów kończy się

niepowodzeniem z powodu dozwolonych zmian, tworzy to wrażenie, że

zarówno uzyskiwane rezultaty, jak i automatyzacja testów jako całość, są

niewiarygodne.

Z tego powodu pierwszą rzeczą, o jaką powinniśmy zadbać, jest to, aby

testy wykonywane były regularnie, zaś porażki powstające w wyniku

dozwolonych zmian były jak najszybciej obsługiwane.

Najprostsze podejście

Jeszcze zanim przygotujemy formalny proces kompilacji, który będzie

uruchamiać testy automatycznie i raportować uzyskane wyniki,

powinniśmy – jeśli mamy już kilka gotowych testów – uruchamiać je

przynajmniej raz dziennie, aby upewnić się, że są stabilne. Jeśli nową

kompilację otrzymujemy od deweloperów rzadziej niż raz dziennie, to

między poszczególnymi kompilacjami powinniśmy oczekiwać mniejszej

liczby niepowodzeń. Jednak testy mogą nadal kończyć się niepowodzeniem

z powodu:
błędu w teście,

problemu środowiskowego, takiego jak problem z siecią, sprzętem,

systemem operacyjnym lub inną infrastrukturą,

błędu w produkcie, którego nie da się odtworzyć za każdym razem.

Bez względu na to, jaki jest powód danego niepowodzenia, należy

zbadać i obsłużyć je szybko i gruntownie, aby zagwarantować stabilność

i wiarygodność automatyzacji (więcej informacji na temat badania

niepowodzeń testów można znaleźć w rozdziale 13). Jeśli możliwość

naprawienia problemu leży poza naszym zasięgiem (bo przykładowo mamy

do czynienia z błędem w produkcie lub problemem środowiskowym) i nie

możemy liczyć na jego szybkie usunięcie, wówczas powinniśmy

przynajmniej zgłosić ten problem i podkreślić jego istotność dla stabilności

automatyzacji. W dalszej części tego rozdziału podajemy bardziej

szczegółowe zalecenia i praktyki do obsługi tych przypadków

z perspektywy procesu.

Jeśli jednak nową kompilację otrzymujemy częściej niż raz dziennie

(gdy nadal nie mamy automatycznego procesu kompilacji, możemy

uzyskiwać zmiany dokonywane przez programistów poprzez

synchronizowanie i kompilowanie ich na naszej maszynie lokalnej),

wówczas – poza powyższymi powodami niepowodzeń – porażki te mogą

się również pojawiać w wyniku:

Błędu regresji w produkcie: tj. programista zaewidencjonował kod

zawierający błąd w przepływie, który działał wcześniej prawidłowo

i nie powinien był się zmienić.

Dozwolonej zmiany w produkcie, która zmieniła działanie oczekiwane

przez jeden lub więcej testów: czyli z powodu wprowadzenia tej zmiany
testy stały się nieaktualne.

Tu także błędy te powinny zostać zbadane i obsłużone tak szybko, jak

to tylko możliwe. W przypadku wprowadzenia dozwolonej zmiany musimy

zaktualizować nasze testy, aby ją odzwierciedlały.

Testowanie nocne

Samodzielne uruchamianie testów na naszej maszynie lokalnej raz dziennie

jest przydatne, ale też podatne na błędy:

Musimy pamiętać o tym, aby uruchamiać testy każdego dnia.

Możemy być w trakcie dokonywania pewnych zmian lub tworzenia

nowego testu, przez co nasz kod nie będzie się kompilował lub działał

poprawnie.

Na naszej maszynie lokalnej mogą powstawać specyficzne warunki,

inne od tych, jakie panują na innych maszynach.

Z tego powodu powinniśmy również zautomatyzować proces

codziennego uruchamiania testów. Bardzo powszechnym podejściem jest

automatyczne uruchamianie testów każdej nocy za pomocą

zautomatyzowanego procesu kompilacji. Głównym powodem uruchamiania

testów w nocy jest chęć zmaksymalizowania ilości czasu potrzebnego na

badanie niepowodzeń przed kolejnym uruchomieniem, zwłaszcza gdy

całkowity czas uruchamiania testów wynosi kilka godzin. Podobnie jak

w poprzednim podejściu, następnego ranka ktoś musi zbadać te

niepowodzenia i odpowiednio je obsłużyć. Naprawa jakichkolwiek

problemów powodowanych przez automatyzację oraz przez wprowadzone

do produktu dozwolone zmiany powinna być dokonywana przez

dewelopera automatyzacji tak szybko, jak to tylko możliwe. Jeśli chodzi


o błędy w produkcie, to powinniśmy przynajmniej zgłaszać je w naszym

systemie śledzenia błędów (np. TFS, Jira).

Menedżerowie zespołów zapewniania jakości lub tworzenia

oprogramowania często żądają automatycznych raportów zawierających

wyniki nocnych testów. Choć jest do dosyć powszechne, menedżerowie ci

rzadko kiedy robią z nimi coś pożytecznego, ponieważ informacje

o niepowodzeniach są zwykle zbyt techniczne i dlatego wiedzę na temat

przyczyny danego niepowodzenia, jego poziomu ważności oraz sposobów

jego rozwiązania można uzyskać dopiero po przeprowadzeniu pewnych

badań. Z kolei raport manualny, utworzony po zbadaniu wyników i dojściu

do pewnych bardziej znaczących wniosków jest dla nich znacznie

cenniejszy. Więcej informacji na ten temat można znaleźć w rozdziale 15.

Zamiast uruchamiać testy tylko raz każdej nocy, zakładając, że

wykonanie całego testu nie trwa zbyt długo, możemy sprawić, że testy

automatyczne wykonywać się będą przy każdej operacji ewidencjonowania

zmian (check-in), co czyni kompilację ciągłej integracji znacznie

cenniejszą. Choć wygląda to na bardzo małą zmianą w porównaniu do

nocnego uruchamiania (i z technicznego punktu widzenia faktycznie tak

jest), to podejście to może być naprawdę efektywne, ale wymaga również

pewnych istotnych zmian w procesie roboczym. Zmiany te omawiamy

w dalszej części tego rozdziału, po zbudowaniu dla nich podstaw z użyciem

pewnych bardziej powszechnych czynników i podejść.

Obsługiwanie błędów wykrywanych przez


automatyzację

Tradycyjnie, gdy tester napotyka jakiś błąd, zgłasza go w systemie

śledzenia błędów (np. TFS lub Jira) i pozostawia go tam do chwili, aż ktoś
zdecyduje, że jego priorytet jest wystarczająco wysoki, aby mógł on zostać

naprawiony. Po naprawnieniu tego błędu tester musi już tylko upewnić się,

że faktycznie błąd już nie występuje. Z powodu różnic pomiędzy testami

manualnymi i automatyzacją testów (omawianych w rozdziale 2), nie

stanowi to większego problemu dla błędów znajdywanych w testach

manualnych, ale będzie już problematyczne w przypadku testów

automatycznych. Oto dlaczego.

Jednym z głównych problemów automatyzacji testów, który zostaje

zwykle przeoczony, jest problem związany z błędami o średnim stopniu

ważności. Co więcej, większość ludzi nie postrzega ich nawet jako

problemu. Jednak w naszej opinii osłabia to proces budowania zaufania do

automatyzacji, marnuje cenny czas i ogólnie obniża wartość automatyzacji.

Dlaczego ograniczamy się tu jedynie do błędów o średnim stopniu

ważności? Czy błędy o wysokim stopniu ważności nie są przypadkiem

bardziej istotne? Chodzi o to, że bardzo poważne błędy są zwykle bardzo

szybko naprawiane. Zazwyczaj zostają one naprawione jeszcze przed

uruchomieniem kolejnego testu nocnego. Jednak gdy to błędy o średnim

stopniu ważności powodują, że automatyczne testy kończą się

niepowodzeniem, mogą minąć tygodnie, miesiące, a czasem nawet cała

wieczność, zanim zostaną one naprawione. W tym czasie z automatycznymi

testami możemy uporać się stosując jedno z trzech typowych podejść.

Każde z nich ma jednak pewne wady…

Zachowywanie testów kończących się niepowodzeniem

Pierwszą i najbardziej powszechną opcją jest zachowywanie testów

kończących się niepowodzeniem z powodu znanych błędów, dopóki nie

zostaną one naprawione. Zaletą tego rozwiązania jest to, że rezultaty takich
testów będą przedstawiać prawdziwy obraz dotyczący jakości naszego

oprogramowania. Ma ono jednak również pewne wady:

Powoduje to, że każdego ranka ktoś (zwykle deweloper automatyzacji)

będzie badał w kółko to samo niepowodzenie. Fakt, że konkretny test

wczoraj i dziś zakończył się niepowodzeniem, nie oznacza wcale, że

przyczyna obu tych niepowodzeń jest taka sama. Jeśli test obejmuje

pięć kroków i wczoraj zakończył się on niepowodzeniem przy kroku 3,

to może się zdarzyć, że dzisiaj, z powodu jakiegoś innego błędu (lub

nawet wprowadzenia dozwolonej zmiany), niepowodzenie to będzie

związane z krokiem 1 lub 2 tego testu. Jeśli nie będziemy codziennie

badać tych niepowodzeń, możemy nieumyślnie przeoczyć jakiś błąd!

Jeśli nawarstwi nam się kilka takich błędów, to czas potrzebny na

codzienne badanie wszystkich tych niepowodzeń może stać się dla nas

sporym problemem i zabrać nam czas przeznaczony na pisanie

kolejnych testów.

Uwaga

Jeśli czas potrzebny na zbadanie wszystkich niepowodzeń

przekracza jeden dzień roboczy, to nie ma sensu uruchamiać

kolejnego testu nocnego, gdyż stajemy się wąskim gardłem tego

procesu! Jeśli sytuacja ta dość często się powtarza, może to

wskazywać na wiele innych ważnych problemów, takich jak złe

raporty, niestabilne środowiska, nieodpowiedni projekt, złe

zarządzanie czasem itd…

Dopóki wszystkie te błędy nie zostaną naprawione, co jest dosyć mało

prawdopodobne, końcowy rezultat każdego wykonania testu zawsze


będzie „niepowodzeniem”. W większości przypadków przyczyną

niepowodzeń testów jest zwykle więcej niż jeden błąd i dla wielu osób

posiadanie od 10% do 20% testów kończących się niepowodzeniem jest

czymś zupełnie normalnym. W taki wypadku bardzo trudno jest

odróżnić nowy błąd od starych. Różnica między 11 a 10 testami

kończącymi się niepowodzeniem jest znacznie mniej zauważalna niż

różnica między jednym testem kończącym się niepowodzeniem

a brakiem takich testów.

Często błąd powoduje, że test kończy się niepowodzeniem nie

w miejscu jego ostatniej weryfikacji (asercji), która jest głównym celem

testu, lecz w jednym z jego wcześniejszych kroków. Oznacza to, że test

taki nie sprawdza nawet tego, co powinien, niemniej jednak nie

będziemy mogli użyć tego testu, dopóki nie naprawimy tego błędu.

Gdyby to tester wykonywał ten test ręcznie, prawdopodobnie byłby on

w stanie ominąć ten błąd i kontynuować sprawdzanie głównej rzeczy,

ale testy automatyczne po prostu nie potrafią tego robić. Oznacza to, że

w ten sposób automatyzacji mogą umknąć pewne dodatkowe błędy,

których nie będziemy w stanie wykryć, aż nie naprawimy tego

pierwszego błędu.

Gdy pierwotny błąd zostanie w końcu naprawiony, test może nadal

kończyć się niepowodzeniem z powodu wprowadzenia dozwolonych

zmian w czasie, gdy błąd ten był aktywny. Ponieważ mogło minąć

sporo czasu, przeanalizowanie niepowodzenia i znalezienie sposobu

jego naprawy może być trudniejsze niż w przypadku zidentyfikowania

tych zmian zaraz po ich wprowadzeniu. Z tego powodu często mówi

się, że kod, który nie jest uruchamiany przez długi czas, po prostu

zaczyna się „psuć”.


Poza powyższymi problemami często ten sam błąd jest przyczyną

niepowodzenia kilku innych testów. Z jednej strony, jeśli przyczynia się

on do porażki zbyt wielu testów, powinniśmy być w stanie nadać mu na

tyle wysoki priorytet, aby został on szybko naprawiony. Z drugiej

strony, jeśli przyczynia się on do porażki tylko kilku testów, to może on

być ignorowany jeszcze przez długi czas. Oznacza to, że wszystkie

powyższe wady należy przemnożyć nie tylko przez każdy błąd, ale też

przez każdy test, na który ten błąd ma wpływ.

Wykluczanie testów kończących się niepowodzeniem

Drugie podejście polega na tym, że testy kończące się niepowodzeniem

z powodu znanych błędów wykluczamy z regularnego testowania do czasu

naprawnienia tych błędów. Pozwoli nam to łatwiej zauważać nowe błędy

i zniesie konieczność ponownego badania wszystkich rezultatów każdego

dnia. Tak czy inaczej, błędy zarządzane są w systemie śledzenia błędów, tak

więc nie musimy się tym przejmować każdego dnia. Podejście to ma jednak

kilka wad wcześniejszej metody, plus kilka własnych:

Podobnie jak w przypadku pierwszego podejścia, jeśli błąd powoduje

zakończenie testu niepowodzeniem w jednym z jego środkowych

kroków, zamiast na etapie końcowej weryfikacji testu, możemy

pominąć jakieś błędy.

Również jak w przypadku pierwszego podejścia, jeśli błąd dotyczy

więcej niż jednego testu, wówczas będziemy musieli wykluczyć

wszystkie te testy.

Podczas gdy w pierwszym podejściu trudno nam było zauważyć, czy

powód niepowodzenia zmienił się między dniem wczorajszym

a dzisiejszym (np. wczoraj test kończył się w kroku 3, a dziś kończy się
w kroku 2), w tym podejściu nie możemy nawet tego zobaczyć,

ponieważ test w ogóle się nie uruchamia, co oznacza, że może nam

umknąć jeszcze więcej błędów!

Podczas gdy w pierwszym podejściu kroki następujące po kroku

prowadzącym do niepowodzenia mogą zacząć się psuć, w tym

podejściu zepsuć się może cały test.

Od strony praktycznej trudno jest śledzić, które testy są wykluczone i z

powodu jakiego błędu. Musimy także pamiętać o tym, aby dołączyć je

z powrotem po naprawieniu błędu. Automatyzacja tego procesu jest

możliwa, ale ponieważ test może się „zepsuć”, dołączanie go

automatycznie do uruchamianych testów bez uprzedniego upewnienia

się, że kończy się pomyślnie, nie jest zalecane. Ponadto nie są nam

znane żadne komercyjne narzędzia śledzenia błędów, które tego

dokonują.

Tworzenie obejść w teście

Trzecie podejście ma zastosowanie głównie wtedy, gdy błąd wpływa na

krok pośredni w teście, a nie na całą istotę tego testu. Zwykle jest ono

stosowane tylko wtedy, gdy pojedynczy błąd o niskim priorytecie wpływa

na wiele testów. W ramach tego podejścia w teście wykorzystywane jest

obejście, aby umożliwić kontynuację jego wykonywania i zweryfikowanie

głównej istoty tego testu.

Podejście to rozwiązuje wiele wcześniejszych problemów:

Przekazywany jest rezultat końcowy, tak więc łatwo jest wykrywać

i badać nowe niepowodzenia.

Zachowuje ono główną istotę każdego testu i obniża szanse pominięcia

błędów.
Jeśli problem ma wpływ na wiele testów, a automatyzacja jest

prawidłowo zaprojektowana, obejście to powinno znajdować się

w jednym miejscu.

Chroni ono każdy kod przed zepsuciem.

Oczywiście ma ono również pewne wady:

Ukrywa ono fakt istnienia problemu! Istnieje prawdopodobnie co

najmniej jeden test (lub przynajmniej powinien), którego istotą jest

zweryfikowanie problematycznego kroku. Jeśli obejście zostanie

zastosowanie globalnie, wówczas test ten zakończy się sukcesem tylko

z powodu tego obejścia, bez faktycznego przetestowania tego, co

powinno zostać przetestowane.

Obejścia sprawiają zwykle, że kod automatyzacji jest bardziej

skomplikowany i trudniejszy w utrzymaniu.

Jeśli zarządzanie wykluczonymi testami i pamiętanie o przywróceniu

ich do cyklu jest trudne, to śledzenie obejść i pamiętanie o ich usuwaniu

po naprawie błędu jest praktycznie niemożliwe!

Testy kończące się niepowodzeniem z powodu błędów w testowanej

przez nie funkcjonalności są przeważnie uznawane za wartościowe.

Zwykle jednak testy, które kończą się niepowodzeniem z powodu

błędów o średnim lub niskim poziomie ważności w kroku, który jedynie

konfiguruje warunki wstępne dla testu, postrzegane są jako błędy

pierwszego rodzaju. W takim przypadku najbardziej naturalnym

rozwiązaniem byłoby utworzenie obejścia zamiast „marnowanie” czasu

potrzebnego na raportowanie i zarządzanie błędami, które nie wpływają

na jawną istotę testów. Z tego powodu wiele błędów jest całkowicie

ignorowanych.
OPTYMALNE PODEJŚCIE?

Kiedyś starałem się opracować optymalne podejście, które by

rozwiązało większość wad trzech opisanych powyżej podejść.

Podejście to powinno działać w następujący sposób:

Błąd mający wpływ na testy automatyczne, powinien być

zgłaszany w powiązaniu z testami kończącymi się

niepowodzeniem, wraz z wycinkiem tekstu komunikatu

niepowodzenia lub śladem stosu, który służyć będzie za

„diagnostykę różnicową” dla tego konkretnego niepowodzenia.

Przy kolejnych uruchomieniach wycinek ten zostanie

wykorzystany do automatycznego sprawdzenia, czy test zakończył

się niepowodzeniem przy tym samym, czy jakimś innym

problemie. Jeśli na przykład niepowodzenie objawia się

komunikatem typu: „Błąd: Nie można znaleźć pliku

Temp\152243423\Data.Info” (gdzie 152243423 jest liczbą, która

może się zmieniać przy każdym uruchomieniu), wówczas wycinek

tekstu „Nie można znaleźć pliku Data.Info” będzie

prawdopodobnie poprawną diagnostyką różnicową dla takiego

niepowodzenia, natomiast wycinek „Błąd”, „152243423” lub

kompletny komunikat nie będą dobrze realizować tego celu,

ponieważ komunikaty te są albo zbyt ogólne, albo zbyt

szczegółowe w kontekście tego konkretnego wystąpienia.

Na końcu każdego uruchomienia automatycznie generowany jest

raport. Generator raportu odpytuje system śledzenia błędów oraz

wycinki tekstu komunikatów, które są powiązane z tymi błędami.

Jeśli zidentyfikuje on odpowiedni wycinek w komunikacie błędu,

zaznacza ten test na żółto zamiast na czerwono w celu wskazania,


że jest to znany błąd. Dzięki temu łatwo jest odróżnić regresje

(kolor czerwony) od znanych błędów (kolor żółty).

Jeśli test, który powiązany jest z jakiś błędem, zakończy się

sukcesem, zostanie on oznaczony innym kolorem (niebieskim),

aby wskazać, że błąd prawdopodobnie został naprawiony.

Uwaga: technikę tę możemy dodatkowo usprawnić za pomocą

wyrażeń regularnych zamiast wycinków tekstu, przy czym będzie ona

trudniejsza w użyciu.

Tak czy inaczej, nie znam żadnego komercyjnego produktu, który

pomagałby nam to osiągnąć. Raz udało mi się zaimplementować

podobne rozwiązanie (dostosowane do klienta), ale niestety większość

osób nie potrafiła z niego poprawnie korzystać. Być może była to po

prostu kwestia braku odpowiedniego szkolenia…

Tak czy inaczej, nadal wierzę, że traktowanie dowolnego błędu

automatyzacji jako błędu krytycznego będzie lepszym podejściem,

o czym za chwilę sobie powiemy.

Traktowanie wszystkich niepowodzeń automatyzacji jako


błędów krytycznych

Choć każde z powyższych podejść ma swoje zalety i dla każdego z nich

istnieją przypadki najbardziej odpowiednie do ich zastosowania, to

z powodu wspomnianych wad zalecam ogólnie korzystanie z czwartego

podejścia. Polega ono na tym, że każdy błąd, który doprowadził do

niepowodzenia automatycznego testu, traktujemy jako błąd krytyczny,

nawet jeśli ma on jedynie średni lub niski wpływ na końcowego

użytkownika. Oznacza to, że każdy taki błąd musi zostać naprawiony


jeszcze przed następnym cyklem testowania (np. następnym cyklem

nocnym). Jest to jedyny sposób na to, aby zestaw automatyzacji

przejrzyście i niezwłocznie ostrzegał nas, gdy zostaną wprowadzone

nowe błędy regresji (bez naruszania przy tym pokrycia). Na początku

podejście to może się wydawać bardzo ekstremalne i kosztowne, ale jeśli

przyjrzymy się mu pod innym kątem, to zobaczymy, że jest ono bardzo

realistyczne:

Biorąc pod uwagę, że w kilku poprzednich uruchomieniach wszystkie

testy kończyły się powodzeniem, nowe niepowodzenie może być

związane jedynie z jakąś ostatnią zmianą. Ponieważ pomiędzy

kolejnymi uruchomieniami powinien istnieć jedynie ograniczony

zestaw tych zmian, zidentyfikowanie zmiany, które spowodowała to

niepowodzenie, powinno być bardzo łatwe.

Znacznie łatwiej i szybciej jest naprawić zmiany, które zostały

wprowadzone niedawno, niż naprawić błędy powiązane ze starymi

zmianami. Jeśli tak czy inaczej w pewnym momencie będą one musiały

zostać naprawione, to znacznie taniej będzie naprawić je wcześniej.

Czas marnowany na ponowne badanie ciągle tych samym niepowodzeń

lub oddzielanie znanych niepowodzeń od nowych również jest cenny.

W najgorszym wypadku cofnięcie tylko ostatnich zmian z pewnością

naprawi istniejący problem. Większość użytkowników byłaby

prawdopodobnie bardziej poirytowana, gdyby istniejąca funkcja, którą

znają i lubią, nagle przestała działać w oczekiwany przez nich sposób,

niż gdyby dostarczenie nowej funkcji miało zostać nieco opóźnione.

Jednak mówiąc szczerze, sytuacja taka powinna mieć miejsce bardzo

rzadko – w większości przypadków deweloperzy będą w stanie szybko

naprawić błąd.
Uruchamianie testów co noc oraz angażowanie deweloperów i zarządu

w proces utrzymywania wszystkich testów w kolorze zielonym poprzez

naprawę każdego problemu jeszcze tego samego dnia jest świetnym

rozwiązaniem. To naprawdę pozwala nam podnieść jakość produktu,

zwiększyć stopień pokrycia i zachęcić do refaktoryzacji, aby sprawić, że

system będzie łatwy w utrzymaniu przez długi czas.

Ale uruchamianie testów co 24 godziny również nie jest najlepszą

opcją. Może to oznaczać wiele operacji check-in, zwłaszcza jeśli zespół jest

duży. Ponadto 24 godziny po zaewidencjonowaniu danej zmiany

programista będzie zwykle już pochłonięty całkowicie innym zadaniem.

Przestawienie się z powrotem na kontekst poprzedniej zmiany może być

czasochłonne i dezorientujące, zwłaszcza w weekendy, kiedy to „24

godziny” oznacza zwykle 72 godziny… Nie mówiąc już o sytuacji, w której

deweloper odpowiedzialny za zepsuty test udał się właśnie na tygodniowe

wakacje…

Ciągła integracja

Większości deweloperów zna jest dobrze znane pojęcie „ciągłej integracji”

(Continuous Integration, w skrócie CI). Jak wspomnieliśmy w rozdziale 2,

termin ten oznacza, że przed każdą operacją ewidencjonowania (check-in),

kod jest automatycznie kompilowany i weryfikowany przez automatyczne

testy. Zmiany ewidencjonowane są tylko wtedy, gdy wszystkie testy kończą

się sukcesem. Proces, który kompiluje kod i uruchamia testy, zwykle działa

na jednym lub kilku dedykowanych serwerach kompilacji, a nie na

maszynie dewelopera. Pozwala to na scentralizowane sterowanie tym

procesem, a także umożliwia wykonywanie innych zadań na maszynie

dewelopera w czasie działania tego procesu.


POMNIEJSZE WARIANTY CIĄGŁEJ INTEGRACJI

Choć powyższy opis ciągłej integracji (CI) jest obecnie najbardziej

typowy, istnieją również inne warianty tego pojęcia. Warianty te były

częściej stosowane w przeszłości, przy czym dzisiaj nadal są one

dosyć powszechne. Ponieważ są one również prostsze, czasem

korzysta się z nich w mniejszych zespołach.

1. Zamiast kompilować kod i uruchamiać testy przed wprowadzeniem

zmian do głównego repozytorium kontroli kodu, procesy te

wykonywane są po zakończeniu tej operacji. Ponieważ w takim

wypadku niemożliwe jest powstrzymanie operacji check-in, proces

kompilacji (który obejmuje kompilację i testy) raportuje jedynie,

czy proces zakończył się sukcesem, czy niepowodzeniem. Często

rezultaty te są również wysyłane automatycznie do odpowiednich

osób poprzez e-mail, a głównie do dewelopera, który dokonał

operacji check-in. Jeśli kompilacja zakończy się niepowodzeniem,

dobrą praktyką jest, aby deweloper jak najszybciej naprawił ten

błąd, zanim ktokolwiek inny będzie mógł zaewidencjonować inne

zmiany.

2. Drugi wariant stosowany jest zwykle w bardzo małych zespołach

lub gdy nikt nie posiada umiejętności pozwalających na

zainstalowanie i skonfigurowanie serwera kompilacji. Choć

wariant ten jest zwykle mniej preferowany, gdyż opiera się on

bardziej na samodyscyplinie niż na narzędziach, to nadal wyraża

on istotę i ideę stojącą za pojęciem CI. W wariancie tym każdy

programista pobiera najnowszy kod, kompiluje i uruchamia testy

lokalnie, i wykonuje operacje check-in tylko wtedy, gdy wszystkie

testy kończą się sukcesem.


Przejście z kompilacji nocnych do CI może stanowić pewne wyzwanie.

Ale ostatecznie uzyskiwane w ten sposób korzyści przewyższają te

problemy. Więcej informacji na temat właściwego sposobu dokonania tego

przejścia można znaleźć w rozdziale 15.

Tworzenie oprogramowania sterowane testami


akceptacyjnymi

Choć ciągła integracja pozwala odpowiedzieć na pytania dotyczące tego

kto, kiedy i jak powinien uruchamiać testy, nie daje ona odpowiedzi na

pytania: kto, kiedy i jak powinien pisać testy. W rozdziale 3 udzieliliśmy

odpowiedzi na pytanie „kto powinien implementować testy”. Ponadto

w rozdziałach 2 i 3 mówiliśmy o tym, dlaczego lepiej jest pisać testy

podczas opracowywania funkcji, a nie po ich ukończeniu. W rozdziale 16

omawiamy ten temat znacznie bardziej szczegółowo. Ponieważ jednak

temat ten powiązany jest z procesami biznesowymi, musimy tutaj

przedstawić przynajmniej jego zarys.

Tworzenie oprogramowania sterowane testami akceptacyjnymi

(Acceptance Test Driven Development, ATDD) – dla którego istnieje kilka

pomniejszych wariantów, np. tworzenie oprogramowania sterowane

zachowaniem (Behavior Driven Development, BDD) lub specyfikacja na

przykładach (Specification by Example, SbE) – jest metodyką bazującą na

następujących koncepcjach:

1. Dla każdej historyjki użytkownika zespół, wraz z właścicielem produktu,

definiuje jeden lub kilka scenariuszy, które przedstawiają jego

zamierzone użycie po zaimplementowaniu. Scenariusze te stają się

zarówno kryteriami akceptacji dla historyjki użytkownika, jak również

przepływami testów, które będą weryfikować tę implementację.


2. Testy implementowane są przed implementacją kodu produktu.

Implementowanie testów może nam nasunąć dodatkowe pytania

i ujawnić niedociągnięcia w definicji historyjki użytkownika. Ponadto

zmusza to zespół do rozpoczęcia planowania kodu produktu w sposób

łatwiejszy do przetestowania. Oczywiście na tym etapie testy nie mogą

kończyć się sukcesem (taka sytuacja oznaczałaby problem w teście!)

3. Deweloperzy implementują kod w taki sposób, aby testy kończyły się

sukcesem. Nie powinni oni opracowywać żadnej funkcjonalności, która

wykracza poza zakres tych testów. Ponadto muszą uruchamiać

wszystkie istniejące testy, aby upewnić się, że niczego nie zepsuli.

4. Historyjka użytkownika uważana jest za „ukończoną” tylko wtedy, gdy

wszystkie testy kończą się sukcesem. Na tym etapie taką historyjkę

można zaprezentować właścicielowi produktu, klientowi lub nawet

skierować ją do produkcji.

Zaletą tej techniki jest to, że gwarantuje ona zaangażowanie się testerów

i deweloperów automatyzacji na wczesnym etapie i dzięki temu mają oni

wpływ na jakość produktu. Ponadto, jeśli proces ten realizowany jest na

samym początku projektu, oznacza to, że testy pokrywają wszystkie

zdefiniowane funkcje (jakie powinny być testowane przez automatyzację)

i wszystkie kończą się sukcesem! Jak wspomnieliśmy wcześniej, pozwala

to deweloperom refaktoryzować kod produktu tak często i w takim stopniu,

w jaki tylko uznają za stosowne, ponieważ w łatwy sposób mogą się

upewnić, że niczego nie zepsuli. W rezultacie zwiększamy w ten sposób

wewnętrzną jakość kodu i pozwalamy na szybsze i bezpieczniejsze

dodawanie nowych funkcji.

W przypadku wprowadzania tego podejścia w trakcie realizacji

projektu, wiele z jego zalet będzie mniej oczywistych, ale na dłuższą metę
nadal nam się to opłaci. Rozdział 16 zawiera porady, które pomagają nam

wprowadzić to podejście do już istniejącego projektu.

Ciągłe dostarczanie i ciągłe wdrażanie

Temat ciągłej integracji, o którym mówiliśmy wcześniej, nie byłby

kompletny bez rozszerzenia tego tematu o ciągłe dostarczanie (continuous

delivery) i ciągłe wdrażanie (continuous deployment).

Jeszcze jakieś 10 lat temu dostarczanie nowych wersji komercyjnego

oprogramowania wymagało dużego nakładu pracy. Polegało to na

produkowaniu fizycznych nośników CD ROM dostarczanych w eleganckim

pudełku z wydrukowaną okładką, czasem również z drukowanym

podręcznikiem użytkownika. Każda próba dodania nowej funkcji

w ostatnim momencie, tuż przed wydaniem produktu na rynek, mogła

oznaczać konieczność ponownego drukowania materiałów i tłoczenia

nośników. Nie wspominając już o narzucie związanym z zarządzaniem

łańcuchem dostaw…

Dzisiaj większość komercyjnego oprogramowania możemy pobrać

z Internetu, wraz z podręcznikiem użytkownika udostępnianym w postaci

zbioru stron HTML lub w innej formie, takiej jak plik PDF. To, wraz

z testami automatycznymi, usuwa większość przeszkód uniemożliwiających

szybkie dostarczanie nowych zmian. Naturalnie aplikacje sieci Web

aktualizuje się jeszcze łatwiej. Większość wewnętrznych projektów

oprogramowania również jest aplikacjami sieci Web lub są one

aktualizowane za pośrednictwem scentralizowanego systemu wdrażania

i dystrybucji.

Aby jednak jeszcze bardziej uprościć proces wdrażania, również i on

musi zostać zautomatyzowany. Ręczne działania w procesie wdrażania nie


tylko zwiększają ryzyko pomyłek, ale też wydłużają czas potrzebny na jego

ukończenie. Jeśli planujemy uruchamiać naszą automatyzację testów

w izolowanych środowiskach, co należy robić również w przypadku ciągłej

integracji, to będziemy również zmuszeni zautomatyzować proces

wdrażania, który może wtedy zostać użyty przy wdrażaniu nowych wersji

w środowisku produkcyjnym. Więcej informacji na temat izolacji można

znaleźć w rozdziale 7, zaś temat integrowania testów z CI omawiany jest

w rozdziale 15. Automatyzowanie całego procesu wdrażania nazywane jest

ciągłym wdrażaniem.

Choć z technicznego punktu widzenia możliwe jest zautomatyzowanie

całego procesu wdrażania, wiele firm woli zachować końcową decyzję

dotyczącą tego, co i kiedy wejdzie do produkcji, w formie ręcznie

podejmowanej decyzji biznesowej. W takim wypadku zwykle chcą one, aby

nowa wersja została najpierw wdrożona do środowiska imitującego

środowisko produkcyjne w celu przeprowadzenia dodatkowych testów

manualnych i weryfikacji. Wówczas cały proces jest zautomatyzowany, ale

końcowy krok wymaga ręcznej interwencji. Jest to nazywane ciągłym

dostarczaniem.

Ciągłe wdrażanie jest bardziej odpowiednie dla firm, które dostarczają

SaaS19 oraz inne aplikacje sieci Web, które nie są uznawane za krytyczne

(przykładowo Facebook). Firmy te mogą pozwolić sobie na małe

niedociągnięcia widoczne dla niewielkiej grupy użytkowników, jeśli tylko

błędy te mogą zostać szybko naprawione. Ale aplikacje o charakterze

krytycznym, takie jak aplikacje medyczne, w których nie można sobie

pozwolić nawet na najdrobniejsze niedociągnięcia, lub też pewne dziedziny,

w których zastosowanie poprawki może zająć długi czas, jak choćby

w przypadku zintegrowanych systemów awionicznych, gdzie zwykle

preferuje się ciągłe dostarczanie.


Kolejne podejście, które zostało zapoczątkowane przez największe

organizacje świadczące usługi w sieci Web, ale stopniowo zaczęło być

stosowane na szerszą skalę, polega na tym, że poszczególne funkcje

weryfikowane są na etapie produkcji. Każda funkcja jest najpierw wdrażana

tylko na niewielką skalę, po czym stopniowo, w miarę jak udowadnia ona

swoją wartość i stabilność, jest ona dostarczana do pozostałych klientów.

Podejście to nazywane jest wydaniem kanarkowym (canary release) lub

wprowadzaniem stopniowym (gradual rollout).

Wydania kanarkowe

Wysoce skalowalne i szeroko dostępne aplikacje sieci Web (np. Facebook,

Twitter, Google, ale także produkty mniejszych firm) są z natury

rozproszone i nie mogą być od razu wdrażane w całości. Ponieważ za

modułem równoważenia obciążenia istnieje wiele serwerów (często

określanych węzłami), które uruchamiają tę samą aplikację, każdy z nich

musi być aktualizowany niezależnie od pozostałych. Jeśli aplikacja nie jest

krytyczna, może ona najpierw zostać wdrożona tylko na jednym węźle

i kierować do siebie tylko niewielką porcję ruchu, zanim nawet jeszcze

zostanie ona w pełni przetestowana. Ten nowo zaktualizowany węzeł

powinien być agresywnie monitorowany, aby zobaczyć, czy nie występują

żadne problemy lub anomalie wskazujące na istnienie jakiegoś problemu.

Jeśli coś pójdzie nie tak, wówczas taki węzeł może zostać odłączony do

czasu naprawy tego błędu. Jeśli wszystko działa poprawnie, możliwe jest

dalsze stopniowe wdrażanie nowej wersji na coraz to większej liczbie

węzłów. W rzeczywistości, ponieważ podejście to stosowane jest zwykle

wtedy, gdy węzły są maszynami wirtualnymi lub kontenerami (które są

czymś w rodzaju super lekkich i modułowych maszyn wirtualnych), to

zamiast aktualizować istniejące maszyny wirtualne, stopniowo tworzone są


nowe maszyny wirtualne, które podłączane są do modułu równoważenia

obciążenia, zaś stare maszyny są stopniowo niszczone.

Ponadto, zamiast korzystać z prostego modułu równoważenia

obciążenia do losowego wybierania klientów, którzy otrzymają nową

wersję, możliwe jest dostarczenie jednego adresu URL standardowym

klientom publicznym, innego adresu URL klientom wersji beta, a jeszcze

innego dla użytkowników wewnętrznych. Każdy adres URL jest kierowany

do innego modułu równoważenia obciążenia. Gdy nowa wersja zostaje po

raz pierwszy wdrożona na nowej maszynie wirtualnej, ta nowa maszyna

dodawana jest najpierw do modułu równoważenia obciążenia

dedykowanego użytkownikom wewnętrznym. Po nabraniu odrobiny

pewności co do tych zmian (na przykład po wykonaniu pewnych testów

manualnych), maszyna ta jest odłączana od pierwszego modułu

równoważenia obciążenia i dodawana do drugiego, który obsługuje

klientów programu beta. Jeśli po jakimś czasie nie zostaną wykryte żadne

istotne problemy, maszynę taką można przenieść dalej do modułu

równoważenia obciążenia, który obsługuje klientów publicznych.

Aby możliwe było oddzielne wdrażanie indywidualnych funkcji,

architektura aplikacji powinna składać się z dużej liczby bardzo małych

komponentów, które zwykle współdziałają ze sobą w sposób

asynchroniczny. Ten rodzaj architektury nazywany jest architekturą

mikrousług20. Więcej informacji na temat związków między automatyzacją

testów i architekturą testowanego systemu można znaleźć w kolejnym

rozdziale.

Testowanie A/B
Kolejnym pojęciem, które często wykorzystywane jest przez dostawców

dużych aplikacji sieci Web jest testowanie A/B. „Testowanie A/B” jest

terminem zapożyczonym z marketingu i analityki biznesowej. Tego rodzaju

testowanie polega na tym, że jednej grupie potencjalnych klientów

udostępnia się jedną wersję produktu (wariant „A”), a drugiej grupie

klientów udostępnia się inny wariant tego samego produktu, który różni

jedynie jedna właściwość (wariant „B”). Uzyskane w ten sposób dane

marketingowe są następnie poddawane analizie w celu ustalenia, czy

właściwość ta ma wpływ na wzrost sprzedaży produktu, czy też nie.

Podobną koncepcję można stosować do aplikacji sieci Web: aby

sprawdzić, czy jeden wariant danej funkcji jest lepszy od drugiego,

opracowywane są oba te warianty, które następnie wdrażane są w różnych

zestawach węzłów. Następnie obydwa warianty są monitorowane

i porównywane ze sobą, aby dowiedzieć się, którego z nich użytkownicy

używają częściej, i czy ma on pozytywny wpływ na pewne kluczowe

wskaźniki efektywności biznesu. W przypadku witryn handlu

elektronicznego (e-commerce) lub oprogramowania SaaS, zazwyczaj

przekłada się to bezpośrednio na zwiększone przychody!

Podsumowanie

Jak widzimy, automatyzacja testów nie może funkcjonować samodzielnie.

Jej wartość pochodzi ze sposobu, w jaki jest ona wykorzystywana. Jeśli

wspiera testowanie jedynie w tradycyjny sposób, to jej wartość jest dosyć

ograniczona. Jeśli jednak jest ona wykorzystywana jako część całego

procesu rozwojowego i ogólnych procesów biznesowych, wówczas może

mieć ona nawet bezpośredni wpływ na uzyskiwane przychody. Pamiętajmy,

że nie da się realizować testowania A/B bez ciągłego dostarczania lub


przynajmniej ciągłego wdrażania, nie można realizować ciągłego

dostarczania bez ciągłej integracji, a ciągłej integracji bez automatyzacji

testów.
Rozdział 6. Automatyzacja
i architektura testów

Ponieważ zdecydowana większość testów manualnych jest wykonywana za

pośrednictwem interfejsu użytkownika i w kompletnym systemie, który

usiłuje naśladować środowisko produkcyjne tak dokładnie, jak to tylko

możliwe, ludzie często zakładają, że jest to również właściwe podejście dla

testów automatycznych. Jak już jednak powiedzieliśmy w rozdziale 2, testy

manualne i automatyczne różnią się od siebie pod wieloma względami.

W tym rozdziale omawiamy pewne strategiczne aspekty dotyczące

architektury automatyzacji testów i pokazujemy, że są one ściśle powiązane

z architekturą testowanego systemu.

Założenia dotyczące architektury testów

Podobnie jak każdy inny projekt oprogramowania, automatyzacja testów

powinna mieć określoną architekturę. Architektura systemu

oprogramowania zwykle odzwierciedla ogólne decyzje, które mają wpływ

na cały system i które trudno jest już potem zmienić. W przypadku systemu

automatyzacji testów decyzje te wpływają zwykle na to, w jaki sposób testy

są pisane, jak są one uruchamiane, co mogą robić, a czego nie, itd. Jednak
architektura automatyzacji testów powinna również brać pod uwagę

architekturę testowanego systemu. Te decyzje dotyczące architektury

wpływają również na izolację testów (o czym mówimy w kolejnym

rozdziale), co z kolei znacząco wpływa na ich niezawodność. Oto kilka

ogólnych czynników, które powinniśmy wziąć pod uwagę podczas

projektowania architektury dla rozwiązania automatyzacji testów:

1. Kto powinien napisać test i jakie umiejętności powinny mieć takie osoby?

2. Kto i kiedy powinien uruchamiać testy?

3. Które fragmenty testowanego systemu chcemy przetestować? (Lub

inaczej, które fragmenty testów są dla nas bardziej istotne?)

4. Które fragmenty testowanego systemu możemy przetestować w sposób

wiarygodny?

5. Jak długo testy będą się wykonywały?

6. Jak łatwo będzie napisać nowe testy?

7. Jak łatwe będzie utrzymanie istniejących testów?

8. Jak łatwe będzie badanie testów kończących się niepowodzeniem?

Pierwsze dwa czynniki omówiliśmy już w poprzednich rozdziałach.

W tym rozdziale skupiamy się główne na punktach 3–5, a pozostałe

omawiane są w dalszych rozdziałach.

Poznawanie architektury testowanego systemu

Większość osób na pytanie „które komponenty testowanego systemu chcesz

przetestować” odpowiada po prostu „wszystkie”. Jednak w większości

przypadków testowanie całego systemu od początku do końca może sprawić,

że testy przestaną być wiarygodne, będą trudne w utrzymaniu, a czasem

nawet niemożliwe do wykonania. Z tego względu musimy najpierw poznać

architekturę testowanego systemu, aby podjąć odpowiednią decyzję.


Powrót do podstaw: czym jest system komputerowy?

Aby zrozumieć architekturę testowanego systemu oraz jej wpływ na

automatyzację testów, cofnijmy się najpierw do pierwszej lekcji informatyki

i odpowiedzmy sobie na pytanie: „Czym jest system komputerowy?”.

Standardowa odpowiedź opisuje go zwykle jako system, który przyjmuje

jakieś dane, przetwarza te dane, po czym wypluwa jakieś wartości

wyjściowe, jak to pokazano na rysunku 6.1.

Rysunek 6.1. Ogólne wyjaśnienie działania systemu komputerowego

Jedną z istotnych właściwości każdego istniejącego systemu

komputerowego, która wynika z powyższego opisu systemu, jest to, że

wyniki generowane przez taki system zależą wyłącznie od sekwencji

dostarczonych do niego danych. Nawet jeśli komputer generuje liczby

losowe, to liczby te są tak naprawdę pseudolosowe, ponieważ do ich

obliczenia komputer wykorzystuje zegar systemowy, będący urządzeniem

wejściowym.

Uwaga
Niektórzy ludzie myślą, że uczenie maszynowe oraz inne

technologie „sztucznej inteligencji”, które zyskują ostatnio na

popularności, nie spełniają powyższego stwierdzenia, ponieważ

naśladują one sposób myślenia człowieka, który jest

niedeterministyczny. Cóż, prawda jest taka, że za żadną z tych

technologii nie stoi żadna magia. Główną rzeczą, która je

wyróżnia jest to, że są one zależne od obszernych ilości danych

oraz złożonego przetwarzania tych danych, jednak sama zasada

ich działania jest dokładnie taka sama. Jak już wspomnieliśmy,

algorytmy z użyciem liczb losowe wykorzystują tak naprawdę

pseudolosowe ciągi uzależnione od zegara systemowego, który

również dostarcza dane.

Czym jest test automatyczny?

Chociaż w rozdziale 1 podaliśmy ogólną definicję testu automatycznego,

jednak biorąc pod uwagę powyższą definicję systemu komputerowego,

możemy zdefiniować (funkcjonalny) test automatyczny jako program

komputerowy, który przesyła dane do innego systemu komputerowego

(testowanego systemu), a następnie uzyskany ciąg wyjściowy lub jego

fragment porównuje z pewnym predefiniowanym oczekiwanym rezultatem,

po czym generuje wynik tego porównania. Na rysunku 6.2 pokazano ten

opis testu automatycznego.


Rysunek 6.2. Opis testu automatycznego

Rzeczywiste systemy komputerowe

Choć powyższy opis systemu komputerowego teoretycznie jest prawdziwy,

to jednak większość systemów komputerowych składa się z mniejszych

systemów (nazywanych często usługami), komunikuje się z zewnętrznymi

systemami, przyjmuje dane z wielu różnych źródeł i generuje dużą ilość

różnego rodzaju wartości wyjściowych. Diagramy rzeczywistego

oprogramowania zwykle zbliżone są do rysunku 6.3.


Rysunek 6.3. Typowy diagram architektury systemu

Co więcej, obecnie niewiele systemów działa w sposób „samodzielny”

lub jest „czarnymi skrzynkami” i nie jest uzależnionych od żadnego innego

systemu. Działanie większości dzisiejszych systemów uzależnione jest

zwykle od pewnych usług lub komponentów, nad których rozwojem lub

zachowaniem nie mamy pełnej kontroli. Może to stanowić pewne wyzwanie:

z jednej strony naszych klientów nie powinno obchodzić to, że jesteśmy

zależni od usług innych dostawców. Jednak z drugiej strony problemy

w tych usługach nie są pod kontrolą naszego zespołu deweloperów. Z tego

powodu trudno jest jednoznacznie wytyczyć granicę pomiędzy miejscem,


w którym nasz system się kończy, a zaczyna kolejny, ani też podjąć decyzję

odnośnie tego, które komponenty lub usługi powinniśmy testować.

Dla testerów manualnych stanowi to mniejszy problem. Testerzy

manualni komunikują się z systemem poprzez interfejs użytkownika,

podobnie jak robią to użytkownicy końcowi, i sprawdzają, czy to co widzą

ma sens. Jeśli testerzy manualni napotkają ostrzeżenie, że jakaś zewnętrzna

usługa nie jest dostępna i że należy spróbować skorzystać z niej później,

mogą oni zweryfikować, czy ta usługa rzeczywiście jest w tej chwili

niedostępna. Jeśli faktycznie tak jest, to nie ma potrzeby zgłaszać tego

incydentu jako błędu. Co więcej, jeśli nawet usługa ta jest aktywna, to często

wartości wyjściowe naszego systemu zależą od danych otrzymanych od tej

usługi zewnętrznej, tak więc tester manualny może określić, czy są one

poprawne, czy nie, przy czym nie może on wcześniej przewidzieć, jakie

powinny być te wartości wyjściowe. Ale jak już wiemy, dla automatyzacji

testów to, czy coś ma sens, nie jest żadną opcją, ponieważ musimy być

w stanie zdefiniować deterministyczny wynik oczekiwany. W tym celu

musimy kontrolować wszystkie dane wprowadzone do systemu, które

mogą mieć wpływ na wartości wyjściowe, jakie chcemy zweryfikować,

łącznie z danymi z tych zewnętrznych systemów, od których jesteśmy

zależni! W ten sposób znów wracamy do podstawowych definicji systemu

komputerowego i testu automatycznego, ale teraz musimy zastosować je do

rzeczywistego, złożonego systemu.

Podczas gdy w podstawowej definicji mówiliśmy o pojedynczym ciągu

danych, w rzeczywistych systemach ciąg ten złożony jest z wielu różnych

i niezależnych źródeł danych, które uznajemy zwykle za osobne ciągi lub

strumienie. To samo dotyczy wartości wyjściowych: typowy system

generuje wiele różnych ciągów wartości wyjściowych, które przeznaczone

są dla różnych celów. Co więcej, dane i wartości wyjściowe często są ze


sobą tak ściśle powiązane, że trudno jest je od siebie odróżnić. Na przykład

gdy poruszamy myszą, generujemy dane, ale w rezultacie, i to w całkowitej

synchronizacji z naszymi ruchami, komputer przesuwa kursor myszy na

ekranie, co jest generowanym przez niego wyjściem (w rzeczywistości

komputer zmienia tylko wartości kolorów poszczególnych pikseli, co tworzy

złudzenie, że kursor się „porusza”). Podobna rzecz ma miejsce podczas

wpisywania treści do pola tekstowego: wciskane przez nas klawisze

generują dane i w rezultacie system wyświetla glif odpowiedniej litery we

właściwym miejscu na ekranie! Ale klawiatura, mysz i ekran nie są

jedynymi źródłami operacji wejścia i wyjścia (input/output, I/O) w naszym

komputerze. Większość systemów wykorzystuje magazyn danych na dysku

(w formie plików lub rekordów bazy danych), komunikację sieciową itd.

Niektóre systemy oddziałują z określonym sprzętem i wykonują w tym celu

dodatkowe, unikalne operacje we/wy. Kolejnym istotnym źródłem danych,

od którego uzależnionych jest wiele systemów, jest zegar systemowy.

Choć to szczegółowe spojrzenie na operacje wejścia/wyjścia pomogło

nam zrozumieć, jak taki teoretyczny model systemu komputerowego

przekłada się na rzeczywisty system, to nadal nie nam wystarczy, aby

zrozumieć architekturę testowanego systemu i odpowiednio zaplanować

naszą automatyzację. Jeśli jednak spojrzymy na nasz diagram blokowy, taki

jak ten przedstawiony na rysunku 6.3, i oddzielimy komponenty naszego

systemu od systemów, usług i źródeł, które uznajemy za zewnętrzne dla

testowanego systemu, to będziemy w stanie zrozumieć, które wejścia tego

systemu mogą mieć potencjalny wpływ na jego konkretne wartości

wyjściowe. Rysunek 6.4 pokazuje przykład tego, w jaki sposób można

poprowadzić taką linię podziału. Ponieważ dla tej operacji nie istnieje żaden

standardowy termin, taki wybór komponentów, które uznawane są za część

testowanego systemu nazywać będziemy „zakresem testowania”.


Rysunek 6.4. Nasz zakres testowania jest zdefiniowany poprzez

narysowanie linii między testowanym systemem a źródłami zewnętrznymi

Gdybyśmy mieli kontrolę nad wszystkimi tymi źródłami danych,

moglibyśmy zdefiniować testy z użyciem deterministycznych oczekiwanych

rezultatów. Kontrolowanie danych uzyskiwanych z zewnętrznych systemów

może się nam teraz wydawać niemożliwe, ale w dalszej części tego

rozdziału pokazujemy, w jaki sposób możemy przynajmniej naśladować te

dane. Oczywiście takim źródłem danych mogą być również pliki, urządzenia

zewnętrzne i czynności wykonywane przez użytkownika. Wniosek jest taki,

że test musi kontrolować wszystko to, co uznajemy za dane dla testowanego

systemu, i co może mieć wpływ na rezultat, który chcemy zweryfikować.


Zwróćmy uwagę na to, że środki magazynowania, takie jak pliki czy

bazy danych, zwykle są czymś, co systemy wykorzystują wewnętrznie do

swoich własnych celów, ale niektóre systemy używają ich też do

komunikacji z innymi systemami. Jeśli na przykład nasza aplikacja zapisuje

dane do bazy danych, która może potem zostać użyta przez inny system do

tworzenia raportów, wówczas możemy uznać taką bazę danych za docelową

wartość wyjściową. I odwrotnie, jeśli nasz system jest usługą raportowania,

powinien on tworzyć raporty zgodnie z danymi, które zewnętrzny system

zapisuje do bazy danych. W takim wypadku powinniśmy traktować tę bazę

danych jako źródło danych, które powinno być kontrolowane przez test.

W ogólnym przypadku, jeśli nasz system wykorzystuje bazę danych lub

pliki wyłącznie wewnętrznie, to nie należy się tym przejmować i po prostu

postrzegać je jako część zakresu testowania. Ponieważ jednak rozpoczynanie

od czystej bazy danych w każdym teście (lub w ogóle!) jest zazwyczaj

niemożliwe, przez co istniejące dane mogą potencjalnie wpływać na

uzyskiwane rezultaty, powinniśmy rozważyć skorzystanie z technik izolacji

opisywanych w rozdziale 7.

Alternatywy i założenia w architekturze


warstwowej

Każdy system jest inny i ma inną architekturę. Jak wspomnieliśmy

wcześniej, większość nowoczesnych systemów składa się z usług, które

komunikują się ze sobą (tzw. architektura mikrousług), ale mimo to

pomocne może być omówienie najpierw nieco bardziej tradycyjnej

i prostszej architektury warstwowej typu klient/serwer oraz czynników

wpływających na wybór właściwych komponentów do zakresu testowania.

Mimo wszystko większość z tych czynników odnosi się również do bardziej


nowoczesnych systemów, a poza tym nadal w użyciu jest wiele systemów

tradycyjnych. W przykładzie tym będziemy mówić o samodzielnej aplikacji

biznesowej, pozbawionej jakichkolwiek istotnych zależności od systemów

zewnętrznych. Jednak nawet i w takim wypadku istnieje wiele alternatyw

i czynników, które zaraz sobie omówimy, podając przy tym wady i zalety

każdego z nich. Wiele z tych alternatyw i czynników zachowuje ważność

w architekturze mikrousług oraz w większości innych architektur systemów,

jakie są obecnie wykorzystywane. Na rysunku 6.5 pokazano typową

architekturę warstwową, o której będzie mowa.

Rysunek 6.5. Typowa architektura warstwowa


Związki między zakresem a testem

Zanim omówimy przeznaczenie każdej z tych warstw oraz dostępne

alternatywy dla zakresu testowania, chcę wyjaśnić związki pomiędzy

zakresem testowania a testowanymi scenariuszami. W rzeczywistości zakres

testowania, który definiuje testowane komponenty zawarte w testowanym

systemie, może być w większości przypadków niezależny od scenariusza,

który definiuje cel oraz kroki konkretnego testu. Innymi słowy, za pomocą

dowolnego zakresu testowania możemy zaimplementować dowolny

scenariusz (test). Jest to możliwe, gdy komponent implementujący

podstawową logikę weryfikowaną przez test jest zawarty w zakresie

testowania, który zwykle stanowi warstwa logiki biznesowej. Dodatkowo,

aby możliwe było użycie dowolnego zakresu testowania z dowolnym

scenariuszem, scenariusz trzeba zaplanować i opisać przy użyciu ogólnych

terminów biznesowych, zamiast pełnych i precyzyjnych szczegółów

technicznych, takich jak opis tego, które dokładnie przyciski kliknąć, aby

wykonać pewną czynność biznesową. Zauważmy, że chociaż bardzo

dokładne opisywanie scenariuszy testowych do automatyzacji może

wydawać się dobrym pomysłem, tak naprawdę sprawia, że testy stają się

trudniejsze w utrzymaniu (omówimy to bardziej szczegółowo w części II tej

książki). Tak więc scenariusze testowe, które są opisywane na poziomie

szczegółowości najlepiej dopasowanym do automatyzacji, nie tylko są

łatwiejsze w utrzymaniu, ale są też niezależne od zakresu testowania. Oto

przykład scenariusza dla witryny księgarni internetowej, opisywanego za

pomocą ogólnych terminów biznesowych:

1. Jako administrator dodaj poniższe książki do katalogu i przypisz je do

kategorii „Test automation”:

• „Growing Object-Oriented Software Guided by Tests” autorstwa Steve’a

Freemana i Nata Pryce’a: 54,99 dolara


• „xUnit Test Patterns” autorstwa Gerarda Meszarosa: 74,99 dolara

• „The Complete Guide to Test Automation” autorstwa Arnona Axelroda:

39,99 dolara

• „Specification by Example” autorstwa Gojko Adzica: 49,99 dolara

2. Jako administrator zdefiniuj promocję, w ramach której, po zakupieniu 3

książek z kategorii „Test automation”, klient otrzyma zniżkę

w wysokości 10 dolarów

3. Jako użytkownik końcowy dodaj poniższe książki do koszyka:

• „Growing Object-Oriented Software Guided by Tests”

• „xUnit Test Patterns”

• „The Complete Guide to Test Automation”

4. Sprawdź, czy została przyznana zniżka w wysokości 10 dolarów i czy

całkowita kwota do zapłaty wynosi 154,97 dolara (74,99 + 49,99 + 39,99

– 10).

Zwróćmy uwagę, że opis ten nie zawiera wszystkich kliknięć i naciśnięć

klawiszy, jakie są potrzebne w celu dodania książki do katalogu, utworzenia

promocji czy dodania książek do koszyka zakupowego. Dzięki temu test ten

możemy teraz przykładowo zaimplementować w kompleksowym zakresie

testowania za pomocą narzędzia Selenium, aby wykonać odpowiednie akcje

w przeglądarce (podłączonej do całego zaplecza i bazy danych) i w niej

również zweryfikować oczekiwany rezultat. Możemy też wybrać mniejszy

zakres testowania, taki jak wysyłanie żądań bezpośrednio do serwera w celu

ukończenia tych czynności biznesowych, a nawet zawęzić to do testu

jednostkowego pojedynczej klasy zawierającej logikę promocji. Oczywiście

istnieją również opcje pośrednie, z których każda ma swoje zalety i wady,

o czym przekonamy się za chwilę. Ponadto wyjaśnimy również, w jaki

sposób możemy łączyć i dopasowywać różne zakresy testowania, aby

skorzystać z więcej niż jednej takiej opcji, oraz jaki niesie to ze sobą koszt.
Choć w powyższym typowym przykładzie scenariusz można

zaimplementować przy użyciu różnych zakresów testowania, to czasem

będziemy chcieli, aby test weryfikował szczegóły specyficzne dla konkretnej

warstwy. Gdy na przykład chcemy sprawdzić, czy określony przycisk został

wyłączony po ukończeniu transakcji przez użytkownika, musimy uwzględnić

w teście warstwę interfejsu użytkownika. W podobny sposób, jeśli chcemy

zweryfikować, czy dane wprowadzane przez użytkownika przetrwają

ponowne uruchomienie systemu, to w zakresie testowania musimy

uwzględnić zarówno interfejs użytkownika, jak i bazę danych. Jednak

niektóre testy wymagają tak naprawdę tylko jednej warstwy, gdy na

przykład chcemy zweryfikować, czy przycisk Save (Zapisz) jest wyłączony,

jeśli użytkownik nie wypełnił wymaganego pola, co wymaga jedynie

warstwy modelu widoku.

Omówienie warstw

Nasza stereotypowa aplikacja jest klasyczną architekturą trójwarstwową:

warstwa górna jest wzbogaconą aplikacją klienta (tj. aplikacją systemu

Windows), która komunikuje się z serwerem poprzez jakiś własny protokół

oparty na HTTP. Warstwa środkowa jest „sercem” systemu, w którym

znajduje się logika biznesowa. Warstwa dolna jest relacyjną bazą danych

(SQL), która głównie przechowuje i pobiera dane, ale zawiera również

pewne procedury składowane, które wykonują złożone zapytania w celu

zwiększenia wydajności. Każda warstwa jest oddzielnym procesem

i potencjalnie może zostać wdrożona na innej maszynie. Jednak każda z tych

warstw zawiera własne komponenty wewnętrzne (np. pliki DLL, archiwa

JAR itd., zależnie od użytej technologii). Szczegóły tej architektury podano

poniżej.
Warstwa klienta

Warstwa klienta składa się z następujących warstw logicznych:

1. Warstwa interfejsu użytkownika – ta warstwa odpowiedzialna jest za

graficzny układ i wygląd interfejsu użytkownika. Zwykle jest ona

tworzona za pomocą edytora WYSIWYG21 lub jakiegoś deklaracyjnego

języka znaczników, takiego jak HTML, XML lub XAML. Jeśli warstwa

ta zawiera kod, powinien on być bardzo uproszczony i obsługiwać

wyłącznie układ i wygląd interfejsu użytkownika.

2. Model widoku – ta warstwa jest odpowiedzialna za dostarczanie danych,

które powinny być wyświetlane w warstwie interfejsu użytkownika,

a także za przesyłanie zdarzeń użytkownika (np. kliknięcia przycisku) do

odpowiednich obiektów w warstwie logiki klienta.

3. Logika klienta – ta warstwa jest odpowiedzialna za logikę i przepływ

w aplikacji klienta. W przeciwieństwie do warstwy „logiki biznesowej”

w serwerze, zasadniczo nie obsługuje ona logiki biznesowej, ale raczej

schemat przechodzenia pomiędzy ekranami, i wiąże komunikację

z serwerem z działaniem interfejsu użytkownika. Na przykład, gdy

w interfejsie użytkownika zostanie kliknięty przycisk, a model widoku

przekaże go do warstwy logiki klienta, warstwa ta może przełączyć

użytkownika na widok, który pozwoli mu określić większą liczbę

szczegółów. Gdy użytkownik kliknie OK w tym widoku, kod klienta

poprosi komponent serwera proxy o wysłanie informacji do serwera.

W zależności od odpowiedzi uzyskanej z serwera, warstwa ta może

zdecydować o tym, który widok wyświetlić.

4. Serwer proxy – ta warstwa jest warstwą techniczną, która zapewnia

wygodne API do wykorzystania przez kod klienta (w formie obiektów

i metod), i po prostu pakuje parametry tych metod i wysyła do serwera

jako komunikat żądania. Dane uzyskane w ramach odpowiedzi są


następnie tłumaczone z powrotem na obiekty zwracane przez te metody.

Niektóre technologie dostarczają tę warstwę jako komponent do użycia

bez konieczności dodatkowej konfiguracji lub pisania kodu.

(Środkowa) warstwa serwera

Warstwa środkowa, czyli warstwa serwera, jest złożona z:

1. Warstwy usług – jest to odpowiednik warstwy serwera proxy w kliencie.

Przekształca ona komunikaty wysyłane przez klienta na zdarzenia, które

wywołują metody w kodzie.

2. Warstwa logiki biznesowej – stanowi „mózg” całego systemu. W tej

warstwie wykonywana jest cała podstawowa logika i wszystkie

obliczenia. Gdy warstwa ta musi pobrać pewne dane lub je zachować

w bazie danych, wykorzystuje ona do tego warstwę dostępu do danych.

3. Warstwa dostępu do danych – ta warstwa dostarcza warstwie logiki

biznesowej wygodne API do uzyskiwania dostępu do bazy danych. Choć

leżąca pod nią warstwa mapowania obiektowo-relacyjnego wykonuje

automatycznie całą ciężką pracę, czasem istnieje potrzeba dostarczenia

interfejsu API, który jest bardziej naturalny, bardziej abstrakcyjny (tj.

agnostyczny technologicznie) i łatwiejszy do używania przez warstwę

biznesową.

4. Warstwa mapowania obiektowo-relacyjnego – jest to zwykle zewnętrzna

technologia, która tłumaczy obiekty i właściwości na wyrażenia SQL do

odczytujące dane z relacyjnych tabel i je w nich zapisujące. Zwykle jest

ona oparta głównie na konfiguracji i nie wykorzystuje niestandardowego

kodu.

Warstwa bazy danych


W naszym przykładzie warstwa bazy danych nie ma zbyt wiele kodu,

z wyjątkiem kilku procedur składowanych do zwiększenia wydajności. Ale

pomijając już te procedury i to, że silnik bazy danych sam w sobie jest

produktem komercyjnym, to nadal zawiera ona pewne artefakty

produkowane przez zespół deweloperów: schemat (strukturę) tabel, indeksy,

widoki, ograniczenia itd.

Alternatywne zakresy testowania

Skoro poznaliśmy już architekturę, spójrzmy, jakie mamy dostępne opcje,

jeśli chodzi o wybór zakresu testowania dla automatyzacji i jakie będą

konsekwencje stosowania każdej z nich.

Kompleksowy zakres testowania

Pierwszą i najbardziej oczywistą opcją dla zakresu testowania w przypadku

systemu, który nie ma zewnętrznych zależności, jest zakres kompleksowy

(end-to-end). Opcję tę przedstawia rysunek 6.6. Największą zaletą tej opcji

jest to, że jest ona najbardziej zbliżona do tego, co robią użytkownicy i nie

wpływa ona na testowanie żadnego komponentu ani na integrację pomiędzy

komponentami. Jednak testy te są naturalnie wolniejsze, trudniejsze do

stabilizacji i utrzymania z powodu częstych zmian w testowanym systemie,

a ponadto utrudniają badanie niepowodzeń.


Rysunek 6.6. Kompleksowy zakres testowania

W tym podejściu testy komunikują się jedynie z interfejsem

użytkownika, ale wykonują zwykle akcje, które wymagają zaangażowania

wszystkich warstw i ich komponentów składowych.

WYJAŚNIANIE NIEJASNOŚCI TERMINU „KOMPLEKSOWE”


Gdy niektórzy ludzie mówią o testach „kompleksowych”, mają oni na

myśli bardzo rozwlekłe scenariusze, naśladujące zwykle złożone

rzeczywiste historyjki użytkownika. Przykładowo test kompleksowy

dla witryny internetowej handlu elektronicznego mógłby polegać na

rejestracji użytkownika, wyszukiwaniu różnych produktów w katalogu,

dodawaniu wielu produktów do koszyka zakupowego, usuwaniu

jakichś produktów z koszyka, modyfikowaniu ilości niektórych

produktów w koszyku, przechodzeniu do procesu płatności, dzieleniu

płatności na dwie różne metody, korzystaniu z kuponu rabatowego,

kończeniu transakcji itd. Choć scenariusze te są przydatne do

sprawdzania poprawnego działania historyjek, to jednak słabo nadają

się one do automatyzacji testów, ponieważ ich utrzymanie zwykle staje

się uciążliwe. Jeśli program ewoluuje (jeśli nie, to automatyzacja

testów tak czy inaczej nie będzie stanowić dla nas zbyt dużej wartości,

o czym mówiliśmy w rozdziale 2), wówczas te scenariusze zmieniałyby

się cały czas i często kończyłyby się niepowodzeniem z powodu

uprawnionych zmian. Badanie tych niepowodzeń zajmować będzie

sporą ilość czasu z powodu złożoności takiego scenariusza. Posiadanie

kilku takich testów, które uruchamiane są tylko w przypadku, gdy

wszystkie inne testy kończą się sukcesem, może okazać się cenne, ale

opieranie się głównie na tego rodzaju testach nie jest zalecane.

Gdy z kolei inni ludzie mówią o „testach kompleksowych” lub bardziej

precyzyjnie o „kompleksowym zakresie testowania”, mają oni na myśli

to, że testy komunikują się z kompletnym systemem jako „czarną

skrzynką”, a nie tylko z jego częścią. Innymi słowy, zakres testowania

zawiera wszystkie warstwy i komponenty systemu. Nie oznacza to

jednak, że scenariusze powinny być długie i rozwlekłe. Scenariusz

może na przykład po prostu opisywać użytkownika dodającego do


koszyka zakupowego tylko jeden produkt, przy czym nadal robi się to

na kompletnym systemie.

Kompleksowe testowanie jednokierunkowe (interfejs do bazy


danych lub baza danych do interfejsu)

W tym podejściu, pokazanym na rysunku 6.7, zakresem testowania również

jest cały system, tak więc z technicznego punktu widzenia jest to taki sam

zakres jak w poprzednim przypadku. Ale w przeciwieństwie do poprzedniej

opcji, tutaj test komunikuje się zarówno z interfejsem użytkownika, jak i z

bazą danych, a nie tylko z samym interfejsem. Opcja ta ma mniej więcej

takie same zalety i wady jak opcja zakresu kompleksowego, ale istnieją

pomiędzy nimi pewne kluczowe różnice. Po pierwsze, gdy manipulujemy

danymi lub sprawdzamy je za pośrednictwem bazy danych, nie testujemy

systemu w sposób, w jaki używałby go użytkownik. Ma to dwie wady: po

pierwsze, możemy pominąć błędy, jakie może napotkać użytkownik, a po

drugie nasze testy mogą kończyć się niepowodzeniem z powodu problemów,

które nie są prawdziwymi błędami. Czasem jednak szybciej i prościej jest

użyć bazy danych zamiast naśladować wszystkie czynności użytkownika,

które wymagane są do utworzenia lub zweryfikowania pewnych danych.

Zwróćmy uwagę, że szanse na niepowodzenie z powodu problemów,

które nie są prawdziwymi błędami, niekoniecznie muszą być większe, niż

gdybyśmy robili wszystko za pośrednictwem interfejsu użytkownika, ale

między tymi dwoma przypadkami nadal jest zasadnicza różnica: jeśli

interfejs się zmienia, prawdopodobnie jest to ściśle związane ze zmianą

w wymaganiach systemu. Ale baza danych jest szczegółem implementacji,

który deweloperzy mogą zmieniać według własnego uznania.

Wprowadzanie danych lub ich pobieranie bezpośrednio z bazy danych

często pomija mechanizmy sprawdzania i reguły biznesowe, które serwer


powinien wymuszać, co może wprowadzić system w stan, pod kątem

którego nigdy nie był projektowany i który nigdy nie będzie miał miejsca

w produkcji.

Kolejnym ryzykiem, jakie należy wziąć pod uwagę, jeśli opieramy się na

bazie danych, jest to, że wprowadzamy zależność od jeszcze jednej

technologii, która może się w całości zmienić. Gdy nasze testy wchodzą

w interakcję z testowanym systemem poprzez interfejs użytkownika, to

w przypadku zmiany technologii interfejsu (np. z Windows Forms na WPF),

trzeba będzie poświęcić sporo pracy na ponowne dopasowanie testów do tej

nowej technologii. Gdy nasze testy komunikują się z testowanym systemem

zarówno przez interfejs użytkownika, jak i za pośrednictwem bazy danych,

wówczas podwajamy to ryzyko, ponieważ zarówno technologia interfejsu

użytkownika, jak i technologia bazy danych mogą pewnego dnia zostać

zamienione na coś nowszego. Mimo że tego rodzaju okazje zdarzają się

dosyć rzadko, to jeśli planujemy długi czas życia dla automatyzacji testów,

wówczas w ciągu kilku lat faktycznie może do tego dojść. Na przykład

dzisiaj wiele zespołów zastępuje swoje silniki baz relacyjnych (SQL), takie

jak Microsoft SQL Server czy Oracle, jakimiś alternatywami „nie SQL-

owymi”, takimi jak MongoDB lub inne, bardziej skalowalne rozwiązania.

Istnieją jednak pewne przypadki, w których takie podejście przyniesie

większe korzyści niż podejście kompleksowe:

Niektóre systemy projektowane są w taki sposób, że klienci mogą

komunikować się z bazą danych bezpośrednio i/lub mogą pisać

aplikacje, które w bezpośredni sposób wchodzą z nią w interakcję z bazą

danych. W takim wypadku ważne jest, aby upewnić się, że to, co robią

użytkownicy, działa tak, jak powinno.

Inne systemy korzystają z istniejącej bazy danych, która zarządzana jest

przez inną aplikację. Nawet jeśli aplikacja ta jest opracowywana przez


inny zespół w tej samej firmie, to nadal może ona być uznawana za

aplikację „zewnętrzną”, jeśli tylko praca obydwu tych zespołów jest od

siebie niezależna, a ich harmonogramy wydania nie są ze sobą

zsynchronizowane. W takim wypadku bezpośrednia interakcja z bazą

danych zamiast za pośrednictwem takiej zewnętrznej aplikacji może

wydawać się sensowna.

Szybkość: tworzenie danych bezpośrednio w bazie danych może być

dużo szybsze niż za pośrednictwem interfejsu użytkownika. Jeśli celem

większości testów jest korzystanie z istniejących danych, które znajdują

się już w bazie danych, a nie testowanie możliwości tworzenia tych

danych, ale nadal wolimy nie opierać się na danych współdzielonych

(patrz kolejny rozdział dotyczący izolacji), to prawdopodobnie znacznie

szybsze będzie utworzenie tych danych bezpośrednio w bazie. Zwróćmy

uwagę, że zamiast korzystać bezpośrednio z bazy danych, dane możemy

również utworzyć poprzez odwoływanie się do serwera (przez warstwę

serwera proxy lub wysłanie żądań HTTP, bądź też przy wykorzystaniu

dowolnej innej warstwy).

Niezawodność i łatwość utrzymania: mimo że wcześniej schemat bazy

danych nazwaliśmy szczegółem implementacji, to w przypadkach,

w których schemat ten w najbliższym czasie nie będzie raczej

modyfikowany (w przeciwieństwie do interfejsu użytkownika),

korzystanie z bazy danych może być bardziej niezawodne i łatwiejsze

w utrzymaniu. Zwłaszcza gdy schemat tej bazy, lub przynajmniej ten

jego fragment, z którym musimy się komunikować, będzie

wystarczająco prosty.
Rysunek 6.7. Zakres kompleksowego testu jednokierunkowego

Tylko serwer (testowanie dwukierunkowe)

To podejście, przedstawione na rysunku 6.8, również jest dosyć powszechne

i ma pewne istotne zalety w porównaniu z kompleksowym zakresem


testowania. Wśród jego zalet znajdują się większa szybkość i niezawodność.

Ma ono szczególne zastosowanie w następujących sytuacjach:

Klient stanowi tylko cienką warstwę nad serwerem.

Klient zmienia się znacznie częściej niż API lub protokół, za pomocą

którego klient komunikuje się z serwerem.

Serwer udostępnia publiczne API, którego klienci mogą używać

bezpośrednio (patrz tekst uzupełniający: „API, wsteczna kompatybilność

i utrzymywanie automatyzacji testów”).

System ma wiele rodzajów aplikacji klienta (np. dla różnych systemów

operacyjnych, dla sieci Web, mobilne itd.) i wszystkie oferują takie same

funkcjonalności. W takim wypadku testy serwera mogą być łączone

z oddzielnymi testami samego klienta (patrz dalej) przeznaczonymi dla

każdego z tych klientów oraz kilkoma prostymi testami kompleksowymi

dla każdego klienta.

W tym podejściu, zamiast dostosowywać i weryfikować rezultaty na

kliencie, test komunikuje się bezpośrednio z serwerem za pomocą tego

samego protokołu, jaki jest wykorzystywany przez aplikacje klienta do

komunikowania się z serwerem (zwykle będzie to HTTP/HTTPS). Fakt, że

test nie manipuluje interfejsem użytkownika, nie oznacza, że każdy test

powinien weryfikować tylko jedną parę „żądanie/odpowiedź”.

W rzeczywistości prawie wszystkie scenariusze mogą opisywać faktyczne

historyjkę użytkownika, jak zostało to omówione wcześniej.


Rysunek 6.8. Zakres testowania samego serwera

API, WSTECZNA KOMPATYBILNOŚĆ I UTRZYMYWANIE

AUTOMATYZACJI TESTÓW

Gdy testowana aplikacja udostępnia publiczne API klientom lub

dostawcom zewnętrznym, życie deweloperów automatyzacji staje się

prostsze niż zwykle, ponieważ nie muszą się oni tak bardzo

przejmować łatwością utrzymania. Jeśli klienci lub zewnętrzni


dostawcy korzystają z API naszej aplikacji, to raczej oczekują, że nasza

firma będzie utrzymywać i dostarczać wsteczną kompatybilność

pomiędzy kolejnymi wersjami tego API. To daje gwarancję, że każdy

test, który zakończył się sukcesem w jednej wersji oprogramowania,

nadal będzie działał w nowszych wersjach. W przeciwnym wypadku

będziemy mieli do czynienia z błędem zrywającym kompatybilność.

Innymi słowy, kod działającego wcześniej testu rzadko powinien się

zmieniać.

Załóżmy na przykład, że nasz zespół tworzy witrynę pozwalającą na

prowadzenie blogów. Poza umożliwieniem użytkownikom

publikowania nowych wpisów za pośrednictwem interfejsu tej witryny,

pozwala ona programistom na pisanie aplikacji, które komunikują się

z tą witryną i publikują w niej nowe wpisy za pomocą API REST. Tak

więc klient może przykładowo opracować swoje własne narzędzie,

które odczytuje pogodę z kilku różnych witryn, wylicza własną

uśrednioną prognozę i wykorzystuje API naszej witryny do

automatycznego publikowania tej prognozy na swoim blogu. Inny

klient może użyć tego API do powiadamiania go o nowych wpisach lub

do wysyłania ich automatycznie na skrzynki mailowe odpowiednich

odbiorców, zgodnie z kategoriami i znacznikami powiązanymi

z każdym wpisem.

Utrzymywanie wstecznej kompatybilności oznacza, że te aplikacje,

które zostały napisane przez klienta i które korzystają z API pierwszej

wersji, muszą działać bezproblemowo i dokładnie tak samo

w nowszych wersjach witryny silnika blogowego, bez konieczności

modyfikowania czegokolwiek w tych aplikacjach. Do witryny silnika

mogą zostać dodane nowe funkcje i rozwiązania, ale nic nie powinno

zostać usunięte, jak również nie powinno być żadnych zmian, które
psułyby dotychczasową funkcjonalność. Zwróćmy uwagę, że samo

utrzymywanie struktury komunikatów (lub publicznych interfejsów

klas i metod) w niezmienionym stanie jest niewystarczające do

zachowania kompatybilności. Konieczne jest również, aby zachowanie

zewnętrzne pozostało takie samo.

Załóżmy na przykład, że w pewnym momencie menedżer produktu

żąda, aby każdy nowy wpis blogowy zawierał krótki abstrakt

wyjaśniający jego tematykę, tak aby można je było zamieścić na liście

wykazu wpisów, oraz by w ten sposób zachęcić czytelników do

czytania interesujących ich wpisów. Chce on również narzucić, aby

każdy taki abstrakt składał się z co najmniej 100 znaków, żeby można

go było wypełnić jakąś istotną teścią. Tak długo, jak będzie to mieć

zastosowanie jedynie do wpisów blogowych, które tworzone są ręcznie

za pośrednictwem naszej witryny, wymaganie to nie będzie stanowić

żadnego problemu. Jeśli jednak wymusimy to nowe ograniczenie

również w API, wówczas nasi klienci, którzy z niego korzystają, będą

się na nas złościć, ponieważ teraz napisane przez nich oprogramowanie

(np. aplikacja publikująca prognozę pogody) nie będzie już działać!

Oczywiście modyfikowanie ich oprogramowania nie będzie wcale takie

proste i wymagać będzie dodatkowego czasu i pieniędzy, a jeśli na

dodatek programista będący autorem tego oprogramowania opuścił już

firmę, problem może się jeszcze pogłębić… Możliwym rozwiązaniem

dla tego przykładu jest rezygnacja z wymuszania tej reguły dla postów

blogowych generowanych za pośrednictwem API albo automatyczne

tworzenie miejsca zastępczego dla abstraktu, które będzie informować,

że jest to automatyczny wpis blogowy.

Kontynuując nasz przykład, kolejnym wymaganiem menedżera

produktu jest to, aby automatyczne wpisy generowane przez API


zawierały w swoim tytule prefiks „AUTO:”. Na pierwszy rzut oka

może się wydawać, że ta nowa funkcja nie powinna mieć wpływu na

API i że nie będziemy mieć problemu ze wsteczną kompatybilnością.

Klient nadal będzie mógł używać tego samego komunikatu API do

tworzenia nowych wpisów blogowych. Będzie mógł również używać

tego samego komunikatu do uzyskiwania wszystkich wpisów

blogowych filtrowanych na podstawie daty, jak miało to miejsce do tej

pory. Jeśli jednak aplikacja klienta utworzy nowy wpis blogowy,

a następnie będzie wyszukiwać utworzone przez siebie wpisy na

podstawie ich tytułów, to może nie być w stanie ich odnaleźć, ponieważ

tytuły tych wpisów zawierają teraz prefiks „AUTO” i nie zgadzają się

dokładnie z tym, co utworzyła aplikacja. To właśnie dlatego ważne (i

znacznie trudniejsze!) jest upewnienie się, że zachowujemy wsteczną

kompatybilność dla działania, a nie tylko dla struktury komunikatów

API. Ale jeśli my musimy to zrobić dla naszych klientów, to

i programiści automatyzacji również mogą się z tego cieszyć.

Zmiany psujące kod

Choć w teorii 100% testów, które kończą się sukcesem w jednej wersji,

powinno działać prawidłowo również w nowszych wersjach,

w rzeczywistości sprawa jest nieco bardziej skomplikowana. Jeśli

pracujemy dla jednej z dużych firm, której miliony klientów korzystają

z interfejsu API, wówczas zerwanie wstecznej kompatybilności będzie

sporym problemem. Z tego powodu w nowszych wersjach często

pozostawiane są pewne błędy, ponieważ ich usunięcie może zaszkodzić

działaniu istniejącego oprogramowania klienta (dotyczy to zwłaszcza

dostawców kompilatorów i powiązanych technologii, które stanowią

technologiczną podstawę dla wielu aplikacji). Jeśli jednak produkt

naszego zespołu ma tylko kilku klientów korzystających z API,


wówczas akceptowalne może być zerwanie kompatybilności w kilku

miejscach, w celu naprawienia błędów lub dokonania pewnych

usprawnień, które klient może docenić.

Jednym z obszarów, w których błędy mają zwykle wyższy priorytet niż

kompatybilność wsteczna, jest bezpieczeństwo. Oznacza to, że jeśli

w naszej aplikacji zostało naruszone bezpieczeństwo, a jedyna możliwa

naprawa wymaga zerwania wstecznej kompatybilności API, to zwykle

warto zapłacić tę cenę. Ale z drugiej strony, mogą zostać znalezione

pewne kreatywne rozwiązania pozwalające na usunięcie konkretnego

problemu bez konieczności zrywania wstecznej kompatybilności, lub

przynajmniej minimalizujące ryzyko dla starych klientów i całkowicie

eliminujące go dla klientów, którzy są skłonni zaktualizować swój kod.

Zakres testowania samego serwera (testowanie


jednokierunkowe)

Jest to po prostu połączenie jednokierunkowego podejścia kompleksowego

z metodą dwukierunkową z samym serwerem. Tak jak w podejściu z samym

serwerem, test komunikuje się z serwerem poprzez jego API, ale podobnie

jak w jednokierunkowym podejściu kompleksowym komunikuje się również

bezpośrednio z bazą danych w celu wprowadzenia danych jako danych dla

testowanego systemu lub sprawdzenia danych zapisanych przez testowany

system. Czynniki, jakie należy rozważyć przy tym podejściu, są praktycznie

takie same, jak w wypadku połączenia podejścia jednokierunkowego

z metodą z samym serwerem. Możemy to na przykład wykorzystać do

przetestowania, czy serwer zapisuje istotne dane do bazy danych przy

żądaniu „aktualizacji”, lub do wstrzyknięcia wstępnie wymaganych danych

dla scenariusza implementowanego z użyciem API publicznego serwera.

Rysunek 6.9 pokazuje architekturę tego podejścia.


Rysunek 6.9. Jednokierunkowe testowanie samego serwera

CZYM DOKŁADNIE SĄ TESTY INTEGRACYJNE?

Mimo że termin „testy integracyjne” jest używany na szeroką skalę, nie

ma żadnej zwięzłej definicji. Chyba najbardziej trafnym porównaniem,

jakim możemy się posłużyć do zdefiniowania tego pojęcia, będzie:

każdy zakres testowania, który jest większy od testu jednostkowego (lub

testu komponentu), a jednocześnie mniejszy od testu kompleksowego.


Tym terminem zwykle określa się podejście typ „sam serwer”, ale nie

zawsze. Niektórzy nawet używają go do opisywania testów

pokrywających integrację między wieloma kompletnymi systemami

(przykładowo kilka połączonych ze sobą kompleksowych zakresów

różnych systemów). Mówiąc ogólnie, testy integracyjne są dowolnego

rodzaju testami sprawdzającymi integrację między dwoma lub więcej

komponentami.

Zakres testowania samego klienta

W niektórych sytuacjach ważniejsze jest przetestowanie klienta niż serwera.

Na przykład:

W aplikacji, gdzie większość kodu znajduje się w kliencie, a serwer

wykorzystywany jest tylko sporadycznie.

Gdy serwer jest starszym lub zewnętrznym systemem, który nie będzie

się zmieniał, podczas gdy nowy, bardziej rozbudowany klient jest na

etapie rozwoju.

Gdy serwer zawiera złożone algorytmy, których rezultaty są trudne do

przewidzenia i kontrolowania: chodzi zwłaszcza o takie algorytmy, które

wykorzystują liczby losowe, lub serwery, których działanie jest

uzależnione od trudnych do kontrolowania zdarzeń i warunków. W takiej

sytuacji możemy zechcieć przetestować serwer niezależnie od klienta

i wydzielić złożoność serwera z zakresu testowania klienta (tj. traktować

serwer niczym system zewnętrzny).

Gdy klient jest tylko jednym z wielu klientów, a serwer testowany jest

oddzielnie (patrz podejście z „samym serwerem”). W takim wypadku

każdy klient i serwer testowane będą oddzielnie, a w celu sprawdzenia


integracji dla każdego klienta powinno zostać napisanych tylko kilka

prostych testów kompleksowych.

W takich przypadkach korzystne może być całkowite odizolowanie

serwera od testów, aby uczynić testy szybszymi, bardziej wiarygodnymi

i łatwiejszymi we wdrażaniu. W tym celu musimy najpierw utworzyć

symulator dla serwera. Symulator ten powinien naśladować protokół

używany przez rzeczywisty serwer, ale już dokładne treści emitowane do

klienta powinna kontrolować infrastruktura. Ponadto test może weryfikować

to, co wysyła do niego klient. Symulatory omawiane są w dalszej części tego

rozdziału. Opcja ta została przedstawiona na rysunku 6.10.


Rysunek 6.10. Zakres testowania samego klienta

Powierzchowny zakres testowania

Czasem testowanie za pośrednictwem interfejsu użytkownika nie jest

możliwe, ponieważ technologia interfejsu użytkownika nie dostarcza

dobrego interfejsu automatyzacji lub po prostu nie jest dość wiarygodna dla

automatyzacji. Jeśli nadal chcemy mieć zakres testowania najbliższy

zakresowi kompleksowemu, możemy przetestować aplikację

„powierzchownie”, jak to pokazano na rysunku 6.11. To podejście jest


bardzo podobne do podejścia kompleksowego, ale zamiast naprawdę

naśladować ruchy myszy i wciśnięcia na klawiaturze, automatyzacja pomija

warstwę interfejsu użytkownika i rozmawia bezpośrednio z kodem

znajdującym pod spodem, nazywanym warstwą modelu widoku.

Rysunek 6.11. „Powierzchowny” zakres testowania

Chociaż podejście to może wydawać się bardzo logiczne, to jednak rodzi

ono pewne poważne problemy. Jeśli możemy je rozwiązać, wtedy metoda ta


może być prawidłowa, ale zdecydowanie powinniśmy zrobić to już na

samym początku, aby ocenić możliwość jej realizacji, a także wycenić

koszty potencjalnych rozwiązań, ponieważ problemy te mogą nam

uniemożliwić skorzystanie z tego zakresu testowania. Zwróćmy uwagę, że

w większości przypadków wyzwania te dotyczą refaktoryzacji aplikacji

klienta i są one następujące:

1. Przede wszystkim warstwa widoku powinna być łatwa do oddzielenia od

pozostałych warstw. Jeśli zostanie prawidłowo zastosowany jeden

z wzorców MV*22, wówczas powinno to być dosyć proste. Ale często

rzeczywista architektura nieco (w mniejszym lub większym stopniu)

odbiega od architektury planowanej. W takim wypadku powinniśmy

najpierw ocenić, czy możliwe jest zrefaktoryzowanie kodu z powrotem

do planowanej architektury MV*.

2. Inicjalizacja – każdy uruchamiany program rozpoczyna swoje działanie

od wykonania metody o nazwie „main”. Metoda ta zwykle wczytuje

wszystkie zasoby, których aplikacja potrzebuje do działania, i na koniec

otwiera okno główne. Na tym etapie proces przechodzi w stan

bezczynności w oczekiwaniu na jakieś dane (głównie zdarzenia myszy

lub klawiatury), a gdy te się pojawią, aplikacja wywołuje odpowiedni

kod obsługi konkretnego zdarzenia. Gdy zdarzenie zostanie obsłużone,

aplikacja wraca do stanu bezczynności, oczekując na dalsze zdarzenia.

Aplikacja przechodzi na koniec metody „main” i kończy swoje działanie

dopiero wtedy, gdy użytkownik zamknie główne okno aplikacji lub

wybierze jakąś inną opcję, która to realizuje. Jednak testy zachowują się

nieco inaczej. Biblioteki testowania (np. JUnit, NUnit, MSTest itd.)

implementują za nas metodę „main” i pozwalają nam definiować różne

testy, z których każdy zachowuje się jak mały oddzielny program.

Ponadto biblioteki te pozwalają nam uruchamiać kod zarówno przed, jak


i po wykonaniu wszystkich testów. Gdyby po prostu wywołać metodę

„main” testowanego systemu z poziomu kodu inicjalizującego takiej

biblioteki lub jednego z testów, wyświetlony zostanie interfejs

użytkownika i aplikacja będzie czekać na dane od użytkownika. Dopóki

prawdziwy użytkownik nie zamknie tego okna, nie nastąpi powrót

z wywołania metody main, a sam test nie będzie mógł być

kontynuowany! (większość bibliotek po jakimś czasie zgłasza wyjątek

przekroczenia dozwolonego czasu, co spowoduje, że test zakończy się

niepowodzeniem). Z tego powodu musimy utworzyć nasz własny kod

inicjalizacji, który z jednej strony zainicjalizuje wszystkie istotne zasoby

(np. otworzy połączenie z serwerem), ale z drugiej strony nie wyświetli

interfejsu użytkownika, lub przynajmniej nie wejdzie do pętli, która

oczekuje na dane od użytkownika. Napisanie takiej metody

inicjalizującej i oddzielenie inicjalizacji widoku od inicjalizacji innych

komponentów może wymagać ogromnej refaktoryzacji, w zależności od

konkretnego projektu i złożoności systemu. Jeśli tu również rzeczywista

architektura podąża za dobrze ustrukturyzowanym wzorcem MV*, to

będzie to znacznie łatwiejsze.

3. Okna dialogowe i wyskakujące komunikaty – znów, jeśli podział

odpowiedzialności architektury MV* jest ściśle utrzymywany, to

powinno to być znacznie łatwiejsze. Ale okna dialogowe i wyskakujące

komunikaty często utrudniają poprawną implementację takiego wzorca,

przez co stanowią one spore wyzwanie dla automatyzacji. Jeśli w pewnej

sytuacji aplikacja musi wyświetlić komunikat dla użytkownika lub musi

poprosić go o dodatkowe dane, wówczas może ona otworzyć okno

komunikatu lub modalne okno dialogowe. Jeśli kod ten wywołany

zostanie z poziomu testu, takie okno dialogowe pojawi się podczas

działania tego testu (!), a przejście do kolejnej instrukcji nie nastąpi,

dopóki prawdziwy użytkownik nie zamknie tego okna. Jeśli wzorzec ten
zaimplementowany jest poprawnie, wówczas obsługa zdarzeń nie

powinna otworzyć tego okna bezpośrednio, lecz użyć w tym celu obiektu

abstrakcyjnej fabryki. Jeśli test jest w stanie zamienić tę fabrykę na inną,

która zwraca sztuczne obiekty okien dialogowych, wówczas problem ten

będzie rozwiązany. Te sztuczne obiekty okien dialogowych nie będą

prawdziwymi oknami dialogowymi z interfejsem użytkownika, lecz

czystymi obiektami, które zaimplementują ten sam interfejs co okno

dialogowe i natychmiast powrócą do miejsca wywołania wraz

z „wejściem”, które test dostarcza do aplikacji w miejsce danych

użytkownika.

Zwróćmy uwagę, że podejście to oznacza, iż większość kodu klienta jest

wczytywana do pamięci procesu testu.

Zakres testowania czystej logiki

Co prawda podejście to nie jest to zbyt popularne, jednak wydaje się ono na

tyle interesujące, że warto o nim wspomnieć, choćby po to, aby zachęcić do

„nieszablonowego” myślenia. Jeśli logika biznesowa jest rozproszona

zarówno w kliencie, jak i w serwerze (lub innych komponentach) i chcemy

je przetestować razem, ale jednocześnie chcemy, aby testy były szybkie

i uruchamiały się bez żadnego specjalnego wdrażania czy konfiguracji,

wówczas podejście to może być dla nas przydatne.

W podejściu tym wybieramy wyłącznie te komponenty zawierające

logikę biznesową i łączymy je ze sobą, omijając i pozorując przy tym

wszystkie bardziej techniczne warstwy, jak to pokazuje rysunek 6.12.

Pozorowanie23 jest koncepcyjnie bardzo zbliżone do symulowania, ale

zamiast komunikowania się z testowanym systemem przy użyciu jakiegoś

kanału komunikacji, odbywa się to za pomocą bezpośrednich wywołań

metod, zwykle w ramach implementacji jakiegoś interfejsu. Pozorowanie


jest stosowane głównie w testowaniu jednostkowym, ale w tym podejściu

również jest ono przydatne.

W tej opcji test komunikuje się bezpośrednio z warstwą modelu widoku,

podobnie jak w podejściu „powierzchownym”. Jednak tutaj klient i serwer,

zamiast komunikować się przy użyciu rzeczywistego kanału

komunikacyjnego (za pośrednictwem warstwy serwera proxy w kliencie

i warstwy usług w serwerze), zostają ze sobą połączone za pomocą obiektu

pozornego, który zachowuje się jak prosty mostek: kieruje wszystkie

wywołania z klienta bezpośrednio do wywołań metod na serwerze

i odpowiednio zwraca rezultaty, w ramach tego samego procesu i wątku. Na

koniec pozorujemy warstwę dostępu do danych (data access layer, DAL),

aby zasymulować dowolną komunikację z bazą danych. Zwykle prawdziwe

zachowanie bazy danych będziemy naśladować poprzez zapisywanie

i odczytywanie danych z pamięci zamiast rzeczywistej bazy danych.


Rysunek 6.12. Zakres testowy czystej logiki

Testy komponentów

Testy komponentów testują pojedynczy komponent (np. komponent logiki

biznesowej lub komponent dostępu do danych) oddzielnie od pozostałej

części systemu. Komponentem jest zazwyczaj pojedynczy plik DLL lub

JAR. Może on mieć zależności zewnętrzne w postaci plików, baz danych

itd., jednak w większości przypadków, jeśli testowany komponent zależy od


innego komponentu będącego częścią tworzonej aplikacji, to nasz test

dostarczać będzie obiekty pozorne (atrapy) symulujące ten komponent

zależny.

Zwróćmy uwagę, że jeśli utworzymy osobny test dla każdego

komponentu w architekturze warstwowej, wówczas – poza warstwą logiki

biznesowej – większość testów nie będzie odzwierciedlać scenariuszy

z perspektywy użytkownika, lecz bardziej techniczne użycie jego API. Choć

może nie jest to najbardziej interesująca rzecz do weryfikowania

z perspektywy użytkownika końcowego, to jednak może to być ciekawe od

strony architektury i projektowania. Poza weryfikacją poprawności kodu,

test komponentu gwarantuje również, że przetestowana funkcjonalność

faktycznie jest zaimplementowana w odpowiednim komponencie i że

zamierzony projekt systemu jest utrzymywany. Jeśli testy komponentów

zostaną zaimplementowane na wczesnym etapie rozwoju aplikacji, to proces

projektowania testów pomoże nam również w kształtowaniu projektu

komponentów, a także sprawi, że API będą prostsze w użyciu i utrzymaniu.

Jest to szczególnie korzystne dla tych komponentów, które powinny być

ponownie wykorzystywane w innych aplikacjach. Dotyczy to komponentów

wielokrotnego użytku, stosowanych w wielu aplikacjach tworzonych przez

tę samą firmę w celu wewnętrznej optymalizacji, ale jest to jeszcze bardziej

istotne i przydatne dla tych komponentów opracowywanych przez firmę,

które powinny być wielokrotnie wykorzystywane przez jej klientów.

Przykładowo, aplikacja używana do kontrolowania pewnych urządzeń

elektronicznych produkowanych przez daną firmę może zawierać

komponent, który komunikuje się z tym urządzeniem. Komponent ten, poza

tym że jest wykorzystywany przez samą aplikację, może zostać

udostępniony klientom, którzy chcą komunikować się z takim urządzeniem

z poziomu ich własnych aplikacji. Ponieważ pisanie testów dla komponentu


przypomina używanie go z poziomu innego klienta, pisanie ich na

wczesnym etapie rozwoju pomaga ukształtować API takiego komponentu

w taki sposób, aby był on łatwy w użyciu.

Testy jednostkowe

Podczas gdy testy komponentów sprawdzają pojedynczy komponent, testy

jednostkowe testują jeszcze mniejsze fragmenty, jakimi są zwykle

pojedyncze klasy lub nawet metody. Testy jednostkowe przeznaczone są do

testowania najmniejszych możliwych do przetestowania funkcjonalności.

Ponieważ testy te są tak bardzo związane z implementacją, zwykle

deweloperzy piszący kod produktu piszą również testy jednostkowe, które

weryfikują napisany przez nich kod. W rozdziale 17 mówimy nieco więcej

na temat testów jednostkowych oraz metodyki tworzenia oprogramowania

sterowanego testami (test-driven development, TDD), która pomaga pisać te

testy w bardziej efektywny sposób.

Rzeczywista architektura

Choć wspomniana wcześniej architektura warstwowa jest nadal dość

powszechna w wielu tradycyjnych aplikacjach biznesowych, to każda

aplikacja jest inna i dzisiaj większość systemów jest od niej dużo bardziej

skomplikowanych. Po omówieniu kilku wzorców, w dalszej części tego

rozdziału prezentujemy kilka prawdziwych przykładów architektur aplikacji

oraz wybranych dla nich architektur automatyzacji.

Architektura planowana kontra architektura rzeczywista


Większość projektów rozpoczyna się od ładnie wyglądającego diagramu

architektury (jak w przypadku wspomnianej wcześniej architektury

warstwowej). Jednak często po wykonaniu pewnych prac, rzeczywista

architektura staje się mniej przejrzysta i brzydsza niż ta przedstawiona na

schematach, zaś niektóre komponenty zaczynają „zawłaszczać”

odpowiedzialności, które powinny znajdować w innym komponencie, przez

co architektura staje się bardziej niechlujna. W takich sytuacjach niektóre

z powyższych alternatyw mogą nie być właściwe lub być trudniejsze

w implementacji. Często jest tak, że gdy prace nad rozwojem automatyzacji

testów rozpoczynane są w późnym etapie projektowania oprogramowania,

różnice te doprowadzają do powstania istotnych przeszkód. Próba ominięcia

tych przeszkód zwykle sprawia, że kod testu staje się bardziej złożony,

trudny w utrzymaniu i mniej wiarygodny. Często jednak musimy godzić się

na pewne kompromisy między tymi wadami a ceną refaktoryzacji kodu

w celu ich wyeliminowania. Ponieważ przeszkody te napotykamy zwykle

podczas implementowania testów, oznacza to, że prawdopodobnie nadal nie

mamy wystarczającego pokrycia, aby bezpiecznie zrefaktoryzować ten

kod…

Typowe warianty

Zanim omówimy bardziej złożone architektury, pomówmy najpierw

o typowych wariantach architektury warstwowej:

1. Dzisiaj większość aplikacji opartych jest na sieci Web i wykorzystuje

potencjalnie klienta mobilnego (smartfon) zamiast klasycznej aplikacji

Windows. Technologie dedykowane klientom opartym na sieci Web

także różnią się od siebie, głównie tym, jak wiele logiki znajduje się po

stronie klienta (przeglądarka), a jak wiele na serwerze. W przypadku

aplikacji mobilnych istnieje kilka typowych wariantów technologii


i koncepcji interfejsu użytkownika: aplikacja może korzystać

bezpośrednio z interfejsu użytkownika systemu operacyjnego („aplikacja

natywna”), z interfejsu użytkownika sieci Web (interfejs dostosowany do

użytkownika mobilnego) lub z interfejsu hybrydowego, który jest

głównie przeglądarką internetową zagnieżdżoną wewnątrz natywnej

aplikacji.

2. Wiele aplikacji obsługuje więcej niż jeden rodzaj klienta. Mogą one mieć

tradycyjną aplikację, witrynę sieci Web lub aplikację mobilną, które

w większości robią to samo, ale każda z nich jest w lepszym stopniu

przystosowana do technologii i formatu maszyny, na której działają.

W przypadku automatyzacji testów mamy do czynienia z interesującym

wyzwaniem: czy chcemy implementować każdy scenariusz z użyciem

każdej technologii? Tę sytuację omawiamy w dalszej części tego

rozdziału.

3. Ponieważ przeglądarki mogą wyręczać nas w renderowaniu interfejsu

użytkownika, a także w komunikacji z serwerem sieci Web, w przypadku

tradycyjnych aplikacji sieci Web serwer często przesyła do przeglądarki

głównie statyczne strony HTML, zaś większość „logiki klienta” i „logiki

biznesowej” obsługuje w tej samej warstwie, a może i nawet w tym

samym komponencie (w formie jednej warstwy monolitycznej). Jeśli

przykładowo użytkownik kliknie nagłówek kolumny w widoku siatki,

aby posortować dane według tej kolumny, przeglądarka wysyła żądanie

do serwera, który następnie zaserwuje jej nową stronę z nowym

porządkiem sortowania. Wiele tradycyjnych, ale bardziej złożonych

systemów, dokonuje podziału serwera sieci Web i umieszcza „logikę

interfejsu użytkownika” w warstwie serwera sieci Web, która

komunikuje się z warstwą (serwerem lub „usługą”) zawierającą logikę

biznesową i/lub dostęp do bazy danych. Jednak w większości


nowoczesnych aplikacji sieci Web strona klienta zawiera złożony kod

JavaScript obejmujący logikę klienta. Zwykle ten kod JavaScript

również złożony jest z różnych komponentów i wykorzystuje jedną

z wielu bibliotek JavaScript, spośród których obecnie najpopularniejsze

są Angular i React. Z perspektywy automatyzacji testów może to być

przydatne dla testów komponentów lub testów jednostkowych po stronie

klienta, ale może być również wykorzystywane w szerszych zakresach

testowania poprzez wywoływanie funkcji JavaScript z narzędzia

Selenium.

4. Czasem aplikacja ma dwa lub więcej rodzajów klientów przeznaczonych

dla różnych osób: przykładowo główną aplikację klienta dla

użytkownika końcowego oraz kolejną aplikację klienta w sieci Web dla

administratora i/lub dyrektorów.

5. W wielu nowoczesnych aplikacjach stosuje się architekturę zorientowaną

na usługi (SOA) lub nawet podejście oparte na mikrousługach, w którym

prawie każdy komponent jest osobną usługą działającą w oddzielnym

procesie i może zostać wdrożony na osobne maszyny w celu

zapewnienia lepszej skalowalności.

6. Po drugiej stronie tego spektrum znajduje się wiele starszych systemów,

w których większość logiki biznesowej ma postać procedur

składowanych w warstwie bazy danych, zamiast w oddzielnej warstwie.

7. Niektóre (w większości nowoczesne) aplikacje mają dwie oddzielne bazy

danych: jedną do szybkiego obsługiwania transakcji, a drugą

zoptymalizowaną do obsługi zapytań. Po zapisaniu danych

w transakcyjnej bazie danych, zostają one przeniesione za pomocą

jakiegoś asynchronicznego mechanizmu kolejki do usługi, która

przekształca je na strukturę zoptymalizowaną pod odpytywanie

i zapisuje je tam. Ten wzorzec architektoniczny nazywany jest


„podziałem odpowiedzialności polecenia i zapytania” (Command and

Query Responsibility Segregation, CQRS). Niektóre aplikacje mogą

mieć nawet więcej baz danych przeznaczonych do różnych celów lub

nawet różne technologie baz danych, aby lepiej dopasować je do swoich

potrzeb (np. relacyjna baza danych „SQL”, baza danych dokumentów,

grafowa baza danych itd.).

Łączenie testów

Mimo że możemy wybrać tylko jeden zakres testowania i użyć go do

wszystkich naszych testów, istnieją co najmniej dwa sposoby, w ramach

których możemy wykorzystać więcej niż jedno podejście.

Mieszanie i dopasowywanie

Ponieważ każda opcja zakresu testowania ma swoje własne wady i zalety,

często najbardziej efektywną strategią jest utworzenie z nich pewnej

mieszanki. Możemy zdecydować o tym, jaki zakres zastosować dla danej

porcji testów lub określić kryteria wyboru zakresu dla każdego testu.

Możemy przykładowo zdecydować, że jeden reprezentatywny scenariusz

z każdej funkcji zostanie zaimplementowany w formie testów

kompleksowych, podczas gdy wszystkie pozostałe testy będę jedynie testami

samego serwera. Ponadto można ustalić, że to deweloperzy powinni pisać

testy jednostkowe do pokrycia logiki biznesowej dla każdej nowej pisanej

przez nich funkcji. Piramida testów Mike’a Cohna24 jest klasycznym

przykładem takiej mieszanki. Istnieją oczywiście również inne prawidłowe

podejścia, ale ostatecznie to my powinniśmy wybrać to, co będzie dla nas

najlepsze. Choć w rzeczywistości w przypadku pewnych optymalnych

warunków nasz wynik końcowy będzie zbliżony do piramidy testów, to

jednak nie powinniśmy w nią celować, decydując się na procentowy udział


każdego zakresu testowania. Ten najbardziej odpowiedni zakres dla każdego

testu należy wybrać na podstawie wad i zalet każdego z tych zakresów i na

tej podstawie uzyskać ostateczne wartości procentowe, nawet jeśli nie

zdołamy osiągnąć w ten sposób „piramidy”.

Lepiej pozwolić zespołowi wybrać właściwy zakres dla każdego testu.

Choć nie jest to jedyne wyjście, jednak wydaje się być odpowiednie

w przypadku korzystania z metodyki ATDD (patrz rozdział 16), ponieważ

zachęca to zespół do wspólnej pracy nad testami i definiowania ich zgodnie

z ich wartością biznesową. Chociaż jest to zalecane podejście, to ma ono

również swoje wady: używanie zbyt wielu rodzajów zakresów testowania

może okazać się trudne w zarządzaniu i utrzymaniu. Ponadto nie istnieje

żadna przejrzysta reguła, na podstawie której można by zdecydować, który

zakres będzie lepiej pasował do konkretnego testu, tak więc mogą pojawić

się pewne różnice zdań. Wybrane wskazówki pomagające wybrać właściwy

zakres dla każdego testu podsumowano w dalszej części tego rozdziału.

Abstrakcyjne zakresy testów

Ponieważ zakresy testów są niezależne od scenariuszy, czasem pożądane jest

ponowne wykorzystywanie scenariuszy testowych i możliwość

uruchamiania ich z użyciem innego zakresu: na przykład węższego zakresu

do uzyskiwania opinii zwrotnych oraz szerszego zakresu do weryfikowania

integracji ze wszystkimi warstwami. Główna idea polega na tym, że istotne

czynności biznesowe wykonywane przez test są implementowane

w warstwie, która może zostać wstrzyknięta do klasy testowej lub nadpisana

przez klasy pochodne. W takim wypadku ta warstwa biznesowa służy za

adapter między testem a testowanym systemem. W ten sposób sam test nie

ulega zmianie, ale możemy dostarczyć różne implementacje odpowiednich

czynności, aby obsługiwać pożądane zakresy testowania. Na rysunku 6.13


pokazano, w jaki sposób test może używać różnych adapterów do

komunikacji z aplikacją korzystającą z różnych zakresów testowania. Na

listingu 6.1 pokazano pseudokod takiego testu. Na listingu tym metoda

InitializeSut tworzy instancję wybranego adaptera, który jest następnie

używany w obrębie testu (za pomocą elementu członkowskiego _sut) do

wykonywania różnych działań za pośrednictwem tego adaptera.


Listing 6.1. Abstrakcyjny zakres testowania
Rysunek 6.13. Abstrakcyjny zakres testowania

Podsumowanie czynników

Omówiliśmy wiele różnych zakresów testowania oraz sposób ich łączenia,

ale kiedy przyjdzie nam wybrać tylko jeden z nich, to nadal możemy mieć

z tym kłopot. Aby nieco ułatwić ten wybór, poniżej zamieszczono

podsumowanie czynników, które należy wziąć pod uwagę.


Cel

Zanim zaczniemy projektować naszą automatyzację testów, musimy

najpierw jasno zdefiniować sobie nasz cel: jakie są najważniejsze

scenariusze, które planujemy pokryć i dlaczego? Zgodnie z tym celem

musimy mieć pewność, że wzięliśmy pod uwagę wszystkie istotne

komponenty, które biorą udział w tym scenariuszu. Jeśli na przykład

zdecydujemy, że naszym zakresem testowania będzie testowanie „samego

klienta” (chociażby z powodu pewnych trudności technicznych), ale naszym

celem jest przetestowanie logiki biznesowej, to w takim przypadku

z pewnością z nim się miniemy.

Osiągalność

Jeśli nasza aplikacja wykorzystuje technologię interfejsu użytkownika, dla

której nie ma wiarygodnej i przystępnej cenowo technologii automatyzacji,

wówczas eliminuje to opcję automatyzacji za pośrednictwem interfejsu

użytkownika. Dotyczy to nie tylko interfejsu użytkownika, lecz może

również dotyczyć sytuacji, w której nasza aplikacja otrzymuje dane z innych

źródeł, a my tych źródeł nie możemy bezpośrednio kontrolować. Może to

być jakieś urządzenie fizyczne, usługa zewnętrzna itd.

Stopień ważności kontra ryzyko

Nawet jeśli możemy uwzględnić jakiś komponent w naszym zakresie

testowania, nie oznacza to, że powinniśmy to zrobić. Podczas gdy większość

pozostałych czynników dotyczy „ceny” lub ryzyka uwzględnienia

komponentu w zakresie testowania, ten czynnik dotyczy drugiej strony

równania. Czynnik ten sprowadza się do zadania pytania: „Jak ważne jest,

aby uwzględnić ten komponent?” lub „Jakie jest ryzyko w przypadku


niewzględnienia tego komponentu?”. Na przykład, jeśli warstwa interfejsu

użytkownika jest bardzo cienka, nie zawiera żadnej logiki i jest edytowana

za pomocą edytora WYSIWYG, to szanse na to, że w interfejsie

użytkownika pojawi się błąd, który automatyzacja testów będzie w stanie

wykryć, będą znacznie mniejsze niż w przypadku, gdy interfejs użytkownika

zostanie celowo zmieniony i wówczas trzeba będzie po prostu aktualizować

nasz test za każdym razem, gdy tak się stanie. Oczywiście ten czynnik

również nie ogranicza się jedynie do interfejsu użytkownika. To samo

dotyczy każdego komponentu, który nie zawiera w sobie zbyt wiele logiki,

lub który został już przetestowany i nie jest już nigdzie modyfikowany.

Szybkość

Jeśli testy są zaplanowane do uruchamiania wyłącznie w nocy, wówczas

czynnik ten może nie być tak bardzo istotny, chyba że proces ich

wykonywania zaczyna przeciągać się do rana. Jeśli jednak planujemy

uruchamiać nasz test w ramach ciągłej integracji lub oczekujemy, że

deweloperzy będą uruchamiać testy przed ewidencjonowaniem swoich

zmian, wówczas nie możemy oczekiwać, że będą oni czekać kilka godzin na

ukończenie tych testów.

Poza cyklem przyczynowo-skutkowym zapewnianym deweloperom

aplikacji przez automatyzację, szybkość testów bezpośrednio wpływa

również na cykl przyczynowo-skutkowy deweloperów automatyzacji, co

przekłada się na ich produktywność i jakość wykonywanej przez nich pracy.

Gdy tworzymy, debugujemy, naprawiamy lub refaktoryzujemy konkretny

test, czas potrzebny na uruchomienie tego konkretnego testu może mieć

ogromne znaczenie, ponieważ może nam zająć największą część czasu

poświęcanego na takie rzeczy. Tworzenie, debugowanie i naprawianie jest

zazwyczaj niezbędne, więc tak czy inaczej będziemy je wykonywać, jednak


zbyt długi czas wykonywania pojedynczego testu może być frustrujący do

tego stopnia, że będziemy unikać refaktoryzacji kodu testu i ostatecznie jego

dalsze utrzymywanie stanie się niemożliwe.

Istnieje wiele technik, które mogą przyspieszyć wykonywanie naszych

testów – są one opisane w rozdziale 15. Zasadniczo automatyzacja interfejsu

użytkownika, komunikacja między maszynami i uzyskiwanie dostępu do

olbrzymich ilości danych może mieć duży wpływ na szybkość naszych

testów. Czyste testy jednostkowe są prawie zawsze wiele rzędów wielkości

szybsze od testów kompleksowych (przy czym one również mają swoje

wady i ograniczenia).

Chociaż szybkość testu jest istotna, to nie powinniśmy przeceniać jej

znaczenia, gdyż inne czynniki mogą być od niej dużo ważniejsze. Ponadto

nie zakładajmy niczego w zakresie szybkości testów, zanim nie dokonamy

stosownych pomiarów! Czasem prawidłowo zaprojektowane testy

kompleksowe lub integracyjne mogą być bardzo szybkie, jeśli nie opieramy

się na ogromnych ilościach danych, a serwer znajduje się na tej samej

maszynie.

Co może się zmienić?

Dowolna zmiana w interfejsie między testem a testowanym systemem

oznacza, że musimy zaktualizować testy – bez względu na to, czy tym

interfejsem jest interfejs użytkownika, API, schemat bazy danych itd.

Niektóre z tych interfejsów zmieniają się częściej od innych, generując

większy koszt związany z utrzymaniem testów. W rzeczywistości istnieją

dwa rodzaje takich zmian: stopniowe, stałe zmiany oraz jednorazowe

wymiany. Jeśli interfejs zmienia się stopniowo w sposób ciągły, wówczas

nasze testy również musimy stale aktualizować. Jeśli jednak cały interfejs

zostaje wymieniony, to będziemy musieli ponownie przepisać sporą część


automatyzacji testów lub może nawet wszystko napisać od nowa! Na

przykład w wielu sytuacjach ciągłym zmianom może ulegać interfejs

użytkownika. Jest to stopniowa, stale dokonywana zmiana. Jeśli jednak

w pewnym momencie cała technologia interfejsu użytkownika zostanie

całkowicie wymieniona, to będziemy musieli przepisać cały kod

automatyzacji, który oddziałuje bezpośrednio z tym interfejsem

użytkownika. To samo może dotyczyć API, przy czym w przypadku

publicznego API, który nasza firma zobowiązana jest utrzymywać w celu

zachowania wstecznej kompatybilności, jest to dużo mniej prawdopodobne.

Mimo że jest to bardzo istotna kwestia, którą należy brać pod uwagę,

jednak często nie jesteśmy stanie przewidzieć przyszłości. Może dzisiaj

wewnętrzne API zmienia się częściej niż interfejs użytkownika, ale

w pewnym momencie cała technologia interfejsu użytkownika zostanie

całkowicie wymieniona. Równie dobrze może się zdarzyć sytuacja

odwrotna, tak więc zwykle nie jesteśmy w stanie przewidzieć tego, co

przyniesie nam przyszłość.

Jednak, mimo że nie można przewidywać przyszłości, dostosowywanie

scenariuszy testowych do funkcjonalności biznesowych oraz tworzenie

modułowej infrastruktury, która abstrahuje wszystkie szczegóły

technologiczne wewnątrz możliwych do wymienienia modułów, jest

najlepszym rozwiązaniem (więcej szczegółów na ten temat można znaleźć

w kilku pierwszych rozdziałach II części tej książki). Ponadto często

jesteśmy w stanie trafnie wytypować to, które elementy zostaną zamienione,

a które nie, a także co może się częściej zmieniać stopniowo.

Ograniczone zasoby

Jeśli zaprojektujemy naszą automatyzację testów pod kątem

wykorzystywania pewnych kosztownych lub ograniczonych zasobów,


wówczas może to ograniczyć naszą zdolność do rozszerzenia wykorzystania

naszych testów. Takim zasobem może być urządzenie, licencja

oprogramowania lub jakaś płatna usługa. Jeśli przykładowo do

uruchomienia testów potrzebne nam jest drogie oprogramowanie

zainstalowane na tej maszynie, to prawdopodobnie trudno nam będzie

usprawiedliwić przed zarządem fakt, że wszyscy deweloperzy powinni

uruchamiać testy przed ewidencjonowaniem kodu. Jeśli licencja wymagana

jest tylko w celu opracowania testu, to będzie można poprosić deweloperów

o uruchamianie testów przed wykonywaniem operacji ewidencjonowania,

ale nie będą oni w stanie naprawić lub tworzyć nowych testów – co

oczywiście stanowi nawiązanie do omówienia z rozdziału 3, dotyczącego

ludzi i narzędzi.

Obsługa rozszerzeń i dostosowywanie

Jeśli nasza aplikacja daje możliwość jej dostosowywania, oznacza to, że

zwykli użytkownicy mogą zmieniać jej domyślne działanie, nie mając przy

tym umiejętności pisania kodu. Zmiany te są zwykle dosyć ograniczone

w stosunku do tego, co użytkownik może osiągnąć dzięki obsłudze

rozszerzeń, ale nadal wprowadza to pewną złożoność do naszej macierzy

testowej. W większości przypadków aplikacja dostarczana jest

z konfiguracją domyślną, którą użytkownik może zmienić (dostosować).

Zwróćmy uwagę, że te opcje dostosowywania, w tym konfiguracja

domyślna, są tak naprawdę danymi dla systemu. Z tego względu

prawdopodobnie nie będziemy chcieli używać konfiguracji domyślnej

w naszych testach, ale raczej korzystać w nich z tej możliwości

dostosowywania, aby uprościć sobie testowanie. Przykładowo, jeśli

użytkownik może dostosować jakiś formularz poprzez dodanie do niego

dodatkowych pól lub usunięcie pewnych pól domyślnych, to możemy


sprawić, że test dostosuje tę aplikację w taki sposób, aby w większości

testów dostępny był jedynie niewielki podzbiór tych pól, zaś w pozostałych

testach, przeznaczonych do testowania samej funkcji dostosowywania,

tworzone były pola różnego rodzaju.

Jeśli nasza aplikacja projektowana jest pod kątem obsługi rozszerzeń,

oznacza to, że klienci mogą tworzyć swoje własne rozszerzenia, które

zastąpią lub rozbudują domyślne zachowanie aplikacji. Również i w tym

wypadku aplikacja może być dostarczana z pewnymi rozszerzeniami

domyślnymi, ale w naszych testach nie powinniśmy ich uwzględniać.

Należy oddzielić testowanie poprawności kodu od rozszerzeń, ponieważ te

domyślne rozszerzenia mogą, ale nie muszą, być używane przez klienta.

Ponadto przydatne może być utworzenie specjalnego rozszerzenia na

potrzeby różnych testów, ale głównie w celu przetestowania, czy punkty

rozszerzania aplikacji wywoływane są wtedy, gdy powinny.

Co poza architekturą warstwową?

Powyższe omówienie różnych opcji testowania architektury warstwowej

powinno nam pomóc lepiej poznać możliwości w zakresie projektowania

testów dla tej klasycznej architektury. Jednak jak wspomnieliśmy na

początku tego rozdziału (i przedstawiliśmy na rysunku 6.3), wiele systemów

jest dużo bardziej skomplikowanych lub po prostu mają one inną

architekturę. Choć liczba możliwych architektur jest nieskończona, to

podstawowe składniki architektury warstwowej istnieją praktycznie we

wszystkich ich rodzajach: duże systemy złożone są z mniejszych

podsystemów, które komunikują się ze sobą. Podsystemy te często zapisują

i pobierają dane, i zwykle budowane są z mniejszych komponentów.

Wreszcie, większość systemów zawiera jakiś interfejs użytkownika.

Ponieważ wszystkie te składniki napotkaliśmy już w architekturze


warstwowej, powinniśmy być w stanie zastosować większość z tych idei

i czynników do architektury dowolnego systemu, dla którego planujemy

naszą automatyzację testów.

Aby dowiedzieć się, w jaki sposób możemy zastosować te idee

w rzeczywistych aplikacjach, możemy zapoznać się z informacjami

przedstawionymi w dodatku A. Zanim to jednak zrobimy, jest jeszcze jedno

dodatkowe pojęcie, o którym już krótko wspomnieliśmy, ale musimy

omówić je bardziej szczegółowo. Chodzi o symulatory. Ponieważ opisana

wcześniej architektura warstwowa dotyczyła samodzielnej aplikacji, która

nie zależy od żadnego innego systemu, nie potrzebowaliśmy w niej żadnych

symulatorów. Jednak w większości rzeczywistych systemów, o czym

możemy przekonać się we wspomnianym dodatku, symulatory są

niezbędnym wzorcem, z którego my również prawdopodobnie będziemy

musieli skorzystać.

Symulatory

W kontekście automatyzacji testów symulator jest komponentem tworzonym

specjalnie na potrzeby testów w celu zasymulowania innego komponentu, od

którego jest uzależniony i z którym komunikuje się testowany system, przy

czym nie chcemy testować tego komponentu. Symulatory mają kilka zalet:

Ponieważ to test kontroluje symulator, może on zasymulować sytuacje,

które w inny sposób trudno jest uzyskać.

Dzięki nim nasze testy są bardziej niezawodne, ponieważ unikamy wielu

nieoczekiwanych sytuacji, których nie możemy przewidzieć. Zwykle

będziemy preferować symulowanie zewnętrznych usług, których nie

możemy kontrolować.
Mogą być używane do weryfikowania, czy testowany system wysyła

prawidłowe komunikaty do symulowanego komponentu.

W przypadku gdy usługa, którą chcemy zasymulować, jest

ograniczonym zasobem, symulator pozwala nam testować wcześniej

i znacznie częściej (co skraca cykl przyczynowo-skutkowy). Przykładem

takiego ograniczonego zasobu może być starszy system typu Mainframe,

płatna usługa lub jakiegoś rodzaju urządzenie.

Załóżmy na przykład, że nasz system komunikuje się z zewnętrzną lub

starszą usługą, która prognozuje pogodę na podstawie informacji

przekazywanych przez pewne czujniki, i korzystając z takiej prognozy,

system ten podejmuje określone decyzje. Jeśli spróbujemy kompleksowo

przetestować taki system, będzie nam bardzo trudno zweryfikować, czy

system ten podejmuje odpowiednie decyzje, jeśli nie możemy kontrolować

prognozowanej pogody. Możemy spróbować kontrolować prognozowaną

pogodę poprzez fizyczną kontrolę ciepła, wilgotności oraz wiatru w pobliżu

czujników, ale to byłoby bardzo trudne i niezwykle kosztowne, zwłaszcza

gdy potrzebujemy wielu środowisk testowych. Ponadto trudno nam będzie

ocenić to, jak kontrolowane przez nas parametry fizyczne powinny wpływać

na prognozowaną pogodę. Jeśli jednak w całości zasymulujemy taką usługę

prognozowania pogody, będziemy mogli napisać test, który powie jej

wprost, aby prognozowała burzliwą lub spokojną pogodę. Wówczas taką

właśnie prognozę zobaczy nasz system, dzięki czemu będziemy mogli łatwo

sprawdzić, czy podejmuje on odpowiednie decyzje w zależności od rodzaju

pogody, którą kazaliśmy raportować symulowanej usłudze.

Oznacza to, że symulator ma zwykle dwa interfejsy. Pierwszy

komunikuje się z testowanym systemem tak, jak gdyby była to prawdziwa

usługa. Drugi jest interfejsem, który pozwala testowi kontrolować to, co

symulator zgłasza do testowanego systemu i/lub pozyskiwać informacje


o komunikatach, które testowany system przesyła do usługi. Zwróćmy

uwagę, że o ile podczas tworzenia takiego symulatora możemy

zaprojektować interfejs dla testu właściwie w dowolny sposób, o tyle

interfejs dla testowanego systemu musi być dokładnie taki jak w przypadku

rzeczywistej usługi. Możemy jednak (i powinniśmy) ograniczyć do

niezbędnego minimum to, co symulator wysyła do testowanego systemu,

aby uprościć kontrolowanie i utrzymanie. Trzeba ponadto zdecydować, czy

symulator ma znajdować się wewnątrz procesu testu, czy też w oddzielnym

procesie. Jeśli zdecydujemy się zaimplementować go jako część procesu

testu, wówczas test ten będzie mógł oddziaływać z symulatorem poprzez

bezpośredni dostęp do współdzielonych danych w pamięci (np. statyczna

lista obiektów). Jeśli zdecydujemy się na oddzielny proces, będziemy

musieli utworzyć dodatkowy kanał komunikacji między testami

i symulatorem, aby testy te mogły kontrolować ten symulator. Kontynuując

nasz poprzedni przykład, test powinien wykorzystywać ten kanał do

powiadamiania symulatora, aby ten „przewidywał” burzową pogodę. Bez

względu na to, w jakim procesie zaimplementujemy ten symulator,

konieczne może być synchronizowanie dostępu do danych (czy to w pamięci

procesu testu, czy też w pamięci tego oddzielnego procesu symulatora), aby

uniknąć sytuacji wyścigu25 (race conditions), jeśli testowany system

wchodzi z interakcję z symulatorem z poziomu testu w sposób

asynchroniczny.

Istnieją dwa powszechne błędne założenia dotyczące symulatorów.

Pierwszym jest to, że powinny one odtwarzać rzeczywiste dane. Dla

niektórych ludzi łatwiejsze wydaje się nagranie ruchu komunikacji i jego

późniejsze odtworzenie przez symulator. Jednak takie „bezpośrednie”

odtwarzanie komunikatów zwykle nie będzie działać, ponieważ niektóre ich

fragmenty mogą być zależne od daty i godziny, kolejności, danych

otrzymanych w żądaniu (na które odpowiada symulowana usługa),


niepowtarzalności itd. Jeśli spróbujemy sprawić, aby symulator odpowiednio

dostosowywał te komunikaty, to tylko go skomplikujemy i przez to będzie

on mniej wiarygodny i trudniejszy w utrzymaniu. W takim wypadku

określenie oczekiwanego rezultatu w sposób deterministyczny będzie bardzo

trudne, a dodatkowo uniemożliwi nam to testowanie przypadków, których

nagrywanie nie zdołało przechwycić, ponieważ są one rzadkie i mogą nie

być obecne w nagraniu.

Drugim błędnym założeniem jest to, że symulator powinien być

autonomiczny. Symulator autonomiczny to symulator, który zawsze

odpowiada przy użyciu tej samej odpowiedzi lub który zawiera w sobie

wewnętrzną logikę, pozwalającą mu odpowiadać za pomocą „właściwej”

odpowiedzi, ale nie może być kontrolowany przez test. Co prawda istnieją

przypadki, w których symulator taki ma zastosowanie, jednak jego główną

wadą jest to, że nie mamy tak naprawdę kontroli nad danymi dostarczanymi

przez symulator do testowanego systemu, przez co nie możemy symulować

wszystkich potrzebnych nam przypadków. Ponadto kod takiego symulatora

zaczyna z czasem się komplikować i może zawierać błędy, ponieważ jego

wewnętrzna logika staje się coraz bardziej złożona, gdy staramy się, aby

lepiej odzwierciedlał zachowanie oryginalnej usługi i podawał „właściwą”

odpowiedź na wszystkie możliwe permutacje żądania.

Preferowanym podejściem jest zaimplementowanie tak zubożonego

symulatora, jak to tylko możliwe, i pozwolenie testom na jego bezpośrednio

kontrolowanie. Ponadto, jeśli chcemy również weryfikować komunikaty,

które testowany system przesyła do symulatora, to powinniśmy również

dodać do symulatora funkcję pobierania wysłanych do niego danych, tak aby

można je było zbadać w samym teście.

POKONYWANIE BARIERY PSYCHOLOGICZNEJ


Z jakiegoś powodu w większości przypadków, w których sugerowałem

klientom implementację takiego symulatora, wstępnie odpowiadali oni,

że choć jest to całkiem fajny pomysł, to jednak nie jest on realistyczny,

a przynajmniej nie w najbliższej przyszłości. Takiej odpowiedzi

udzielali głównie menedżerowie testów, ale czasem również

menedżerowie zespołów deweloperów. Wydaje mi się, że taka reakcja

wynikała z dużej zmiany koncepcji w stosunku do testów manualnych

i dlatego rozwiązanie to uchodzi za dużo bardziej skomplikowane

i ryzykowne. Jednak w większości przypadków, gdy uparcie pytałem,

co musiałoby się stać, aby zbudowali oni taki symulator, a następnie

wyjaśniałem jego korzyści i ryzyko niestabilności w przypadku jego

braku, okazywało się, że jego opracowanie jest znacznie prostsze, niż

wszyscy początkowo myśleli. W wielu tych przypadkach w danej

firmie był jeden deweloper, który napisał konkretną usługę lub

komponent, który się z nią komunikował. Po znalezieniu takiej osoby

i odbyciu z nią rozmowy, dostawałem zwykle wszystkie szczegóły

techniczne potrzebne do zbudowania symulatora.

W niektórych przypadkach musiałem jednak ręcznie odtwarzać

protokół. Było to co prawda bardziej czasochłonne, ale nadal

wykonalne. Zwróćmy uwagę, że gdy będziemy stosować się do

procedury pisania jednego testu naraz, która będzie opisana w II części

tej książki, wtedy nie musimy ręcznie odtwarzać i implementować

całego protokołu naraz, lecz tylko jego minimalną część, niezbędną dla

tego konkretnego testu. Gdy napiszemy już jeden test z użyciem

takiego symulatora i każdy będzie mógł się przekonać, że to działa,

wtedy nikt nie będzie nas już zatrzymywać!

Symulowanie daty i godziny


Wiele systemów wykonuje prace wsadowe lub generuje pewne zdarzenia

w określonych przedziałach czasowych lub w określonym dniu i godzinie.

W takich sytuacjach wielu scenariuszy nie da się efektywnie przetestować

bez pewnego rodzaju obejść. Majstrowanie przy zegarze systemowym nie

jest zalecane, ponieważ wpływa to na wszystkie procesy na danej maszynie

(zwłaszcza gdy używamy klienta poczty Outlook). Czasem możliwe jest

pomajstrowanie przy danych w bazie danych w celu wcześniejszego

wygenerowania jakiegoś zdarzenia, ale nie zawsze jest to wykonalne i może

sprawiać problemy z integralnością danych.

Ponieważ data i godzina są niczym innym jak danymi dla systemu, to

jeśli chcemy je kontrolować w teście, musimy je zasymulować. Jednak data

i godzina nie są zwykle uzyskiwane z „usługi”, ale dostarczane bezpośrednio

przez system operacyjny. Ponieważ zasymulowanie samego systemu

operacyjnego nie jest możliwe, sztuczka polega na utworzeniu warstwy

abstrakcji między zegarem systemu operacyjnego a aplikacją. Jeśli system

wykorzystuje mechanizm wstrzykiwania zależności (dependency injection,

DI), to może on już zawierać taką warstwę abstrakcji. W przeciwnym razie

kod aplikacji powinien zostać zrefaktoryzowany w celu wprowadzenia takiej

warstwy. Jeśli w kodzie istnieje wiele miejsc, w których uzyskiwany jest

dostęp do zegara systemowego, wówczas refaktoryzacja taka może być

ryzykowna. W przeciwnym wypadku nie powinno to być zbyt trudne. Wtedy

możemy zaimplementować dwie klasy implementujące tę abstrakcję: jedna

wykorzystuje rzeczywisty zegar systemowy – jest to klasa wykorzystywana

w produkcji, a druga klasa jest symulatorem (atrapą), który test może

kontrolować. Powinniśmy utworzyć oddzielny obiekt docelowy kompilacji

(poza standardowymi „debug” i „release”), który korzysta z symulatora daty

i godziny, lub wyposażyć się w jakiegoś rodzaju mechanizm do

wstrzykiwania tej klasy w czasie uruchamiania.


Zwróćmy uwagę, że żaden system nie jest projektowany pod kątem

obsługi przypadku, w którym czas płynie wstecz, więc symulator powinien

zezwalać testowi na przeskakiwanie do symulowanego czasu wyłącznie

w przód. Należy również wspomnieć, że jeśli kilka podsystemów lub usług

opiera się na zegarze systemowym, wówczas ważne jest, aby wszystkie one

wykorzystywały ten sam symulator, aby zapewnić ich synchronizację.

Rzeczywista aplikacja implementująca to rozwiązanie przedstawiona jest

w trzecim przykładzie w dodatku A.

Podsumowanie: dokonywanie własnych


wyborów

Gdy znamy już główne techniki i podejścia do projektowania sposobu

interakcji automatyzacji testów z testowanym systemem, a także powiązane

z nimi kwestie do rozważenia, możemy zapoznać się z dodatkiem

A przedstawiającym kilka rzeczywistych przykładów. Jednak bez względu

na to, czy przeczytamy ten dodatek, czy nie, powinniśmy teraz dysponować

wszystkimi narzędziami, jakie potrzebne są do zaplanowania architektury

automatyzacji testów dla naszego systemu. Zauważmy jednak, że przy

pierwszej próbie zastosowania tej wiedzy w praktyce możemy napotkać

pewne problemy. Jest tak, ponieważ zwykle istnieje więcej niż jeden

właściwy sposób na osiągnięcie tego celu. Może istnieć kilka właściwych

alternatyw, choć każda z nich ma inne wady i zalety. Ostatecznie, po

zapoznaniu się ze wszystkimi dostępnymi opcjami, powinniśmy wybrać tę,

która naszym zdaniem będzie najlepsza dla nas i dla naszej organizacji.

Z czasem będziemy poszerzać swoją wiedzę i odpowiednio korygować

nasze wybory, a może nawet zdecydujemy się na zmianę dotychczasowego

kierunku, ale przynajmniej zyskamy cenne doświadczenie.


Rozdział 7. Izolacja i środowiska
testowe

W poprzednim rozdziale dotyczącym architektury wspomnieliśmy, że

wyniki generowane przez każdy system komputerowy zależą wyłącznie od

sekwencji dostarczonych do niego danych. Zgodnie z tym stwierdziliśmy,

że aby móc ustalić, jakie wyniki powinniśmy uzyskać w konkretnym teście,

musimy być w stanie kontrolować wszystkie dane. Jednak twierdzenie to

oznacza również, że przed każdym kolejnym testem musimy ponownie

zainicjalizować system!

Aby lepiej zrozumieć, co tak naprawdę to oznacza, rozpocznijmy od

bardzo prostego przykładu: jeśli testujemy aplikację Kalkulator w systemie

Windows i naciśniemy kolejno przyciski „1”, „+”, „1” oraz „=”, to

spodziewamy się uzyskać w polu wyniku liczbę „2”, prawda? Będzie to

jednak miało miejsce tylko wtedy, gdy aplikacja kalkulatora została przed

chwilą uruchomiona i znajduje się w początkowym stanie lub gdy

nacisnęliśmy wcześniej przycisk „C”, który kasuje dotychczasowe

obliczenia. Jeśli przed rozpoczęciem naszych testów nacisnęliśmy jakieś

inne przyciski, na przykład przycisk „1”, to uzyskanym wynikiem będzie

liczba „12” (ta początkowa jedynka zostanie złączona z jedynką, którą

nacisnęliśmy jako pierwszą w naszym teście, co spowoduje powstanie


liczby „11”, która po naciśnięciu kolejnych przycisków „+”, „1” i „=” da

nam w rezultacie „12”).

Stan

Choć Kalkulator jest prostą aplikacją i możemy dosyć szybko zrestartować

ją przed każdym kolejnym testem, to w przypadku innych aplikacji nie

zawsze będzie to takie proste. Na szczęście powyższe twierdzenie jest zbyt

rygorystyczne, a jego łagodniejsza wersja nadal jest prawdziwa:

„produkowane przez system wyniki zależą od sekwencji dostarczonych do

niego danych oraz stanu początkowego tego systemu”. Możemy to

również sparafrazować w ten sposób: „wyniki systemu komputerowego są

w całości określane przez dostarczone do niego dane oraz jego bieżący

stan”. Oznacza to, że aby nasze testy były wiarygodne, musimy mieć pełną

kontrolę nie tylko nad samymi danymi, ale także nad stanem testowanej

aplikacji. Choć wewnętrzny stan danej aplikacji kontrolowany jest głównie

bezpośrednio przez tę aplikację, a nie przez konkretny test, to jednak test

może wprowadzić ją w większość pożądanych stanów poprzez

uruchomienie jej we wcześniejszym stanie i zastosowanie do niej

wymaganych danych. W naszym przykładzie z kalkulatorem, zamiast

restartować aplikację kalkulatora przed rozpoczęciem każdego testu,

wystarczy wcisnąć przycisk „C”, aby test był poprawny we wszystkich

przypadkach.

Zauważmy, że stan może być dowolną rzeczą, jaką system pamięta,

przechowywaną w pamięci dowolnego rodzaju. Zaliczamy do tego (między

innymi):

1. Rejestry procesora. W tym również licznik rozkazów, który wskazuje

procesorowi adres kolejnej instrukcji do wykonania.


2. Pamięć RAM. W pamięci tej przechowywane są zwykle zmienne i dane

programu w czasie jego wykonywania, które nie są zapisywane na

dysku.

3. Lokalne dyski twarde. Dane przechowywane na dyskach twardych

w formie plików i folderów (system plików) lub w postaci bazy danych

(pod którą zwykle wykorzystywany jest system plików). Kolejną formą

danych przechowywanych na dysku twardym jest rejestr systemu

Windows.

4. W przypadku maszyny wirtualnej, zarówno host, jak i maszyna gościa

mają swoje własne rejestry procesora, pamięć i lokalne dyski twarde.

Oczywiście maszyna gościa wykorzystuje wyłącznie zasoby

dostarczane jej przez host, ale w większości zastosowań wyglądają

i zachowują się one jak dwie różne maszyny.

5. Zdalne dyski twarde. Działają podobnie jak dyski lokalne, ale dyski te

podłączone są do innego komputera w sieci. Dostęp do tych dysków

w sieci uzyskiwany jest zwykle poprzez sieciowy system plików lub

usługę bazy danych.

6. Stan maszyn zdalnych i usług, łącznie z usługami w chmurze. Obecnie

większość komputerów wykorzystuje usługi w chmurze lub usługi

działające na maszynach zdalnych. Jeśli potraktujemy takie usługi jak

część naszego systemu, wówczas stan tych systemów również powinien

być uważany za część stanu naszej aplikacji. Na przykład, jeśli nasza

aplikacja wykorzystuje usługę indeksowania działającą w chmurze, to

stan tej usługi może mieć wpływ na wyniki naszego systemu.

7. Pamięć podręczna i ciasteczka przeglądarki. Choć dane te są tak

naprawdę przechowywane w pamięci RAM i/lub na lokalnych dyskach

twardych, to w aplikacjach sieci Web mają one szczególne znaczenie,


ponieważ aplikacje te uzyskują do nich dostęp w inny sposób niż do

zwykłych danych w pamięci RAM i na dyskach twardych.

Hipotetycznie jakakolwiek zewnętrzna zmiana dokonana na dowolnym

fragmencie informacji przechowywanych w tych lokalizacjach może mieć

potencjalny wpływ na stan aplikacji i zaburzyć rezultaty testów. Na

szczęście większość z tych form magazynowania jest kontrolowana

i zarządzana przez fragmenty oprogramowania, które ograniczają

programowi dostęp do tych magazynów wyłącznie do przypisanych lub

konkretnie dozwolonych mu porcji. Ponadto, im bardziej modułowa jest

aplikacja, tym bardziej możemy rozważać stan każdego modułu osobno

i zagwarantować, że określone wyniki będą mogły być modyfikowane

wyłącznie przez określone podsystemy. Dzięki tym ograniczeniom

i gwarancjom możemy sprawić, że nasz system automatyzacji testów

będzie bardziej wiarygodny i przewidywalny za sprawą takiej lepszej

kontroli nad stanem systemu. Wykorzystywanie tych gwarancji do realizacji

naszych potrzeb nazywane jest izolacją.

Jeśli jednak gwarancje te nie zostaną wykorzystane w odpowiedni

sposób, może to doprowadzić do powstania poważnych problemów

z automatyzacją testów i negatywnie wpłynąć na jej wiarygodność. Często

mówi się, że problemy te powodowane są przez brak izolacji.

Problemy z izolacją i ich rozwiązania

Rozważmy najpierw pewne typowe problemy powodowane przez brak

izolacji, a następnie pomówmy o odpowiednich technikach izolacji, za

pomocą których będziemy mogli te problemy rozwiązać. Potem omówimy

pewne inne korzystne „efekty uboczne”, które możemy uzyskać dzięki

odpowiedniej izolacji.
Problem 1 – testy manualne i test automatyczny
wykonywane w różnym czasie

Weźmy pod uwagę aplikację handlu elektronicznego (e-commerce), za

pośrednictwem której sprzedawany jest sprzęt audio. Jednym

z deweloperów automatyzacji, z najdłuższym doświadczeniem w zespole,

jest Kathy. Pierwszym napisanym przez nią testem był test poprawności

usiłujący dodać do koszyka zakupowego produkt „Słuchawki”, którego

cena wynosi 100 dolarów, oraz produkt „Mikrofon”, którego cena wynosi

50 dolarów. Zadaniem tego testu jest sprawdzenie, czy całkowita suma cen

produktów w koszyku wynosi 150 dolarów. Test ten był bardzo stabilny

i przez długi czas był wykonywany jako część zestawu testów poprawności

uruchamianych co noc w środowisku zapewniania jakości (Quality

Assurance, QA) i kończył się niepowodzeniem tylko w przypadku jakiegoś

większego problemu lub błędu.

Pewnego ranka menedżer zespołu QA poprosił Johna, jednego

z testerów manualnych, o sprawdzenie, czy gdy administrator dokona

zmian w cenach produktów, to czy istniejące faktury nadal będą pokazywać

oryginalną cenę. John wykonał ten test i z zadowoleniem zgłosił swojemu

szefowi, że aplikacja zadziałała poprawnie (a może nie był on aż tak

zadowolony, bo czuje większą satysfakcję w przypadku znalezienia

jakiegoś błędu?).

Tak czy inaczej, następnego ranka, gdy Kathy prześledziła rezultaty

nocnych automatycznych testów poprawności, zaskoczyło ją, że ten jej

stary i niezawodny do tej pory test zakończył się niepowodzeniem.

Przeprowadzone przez nią śledztwo wykazało, że faktyczna całkowita cena

produktów w koszyku zamiast oczekiwanych 150 wynosi 170 dolarów. Po

głębszym zbadaniu okazało się, że ktoś zmienił cenę słuchawek na 120

dolarów, i nietrudno się domyślić, kim była ta osoba…


Takie scenariusze są dosyć częste. Zwykle kiedy projekt automatyzacji

jest młody i nie ma w nim zbyt wielu testów, częstotliwość takich zdarzeń

jest dosyć niska i mogą one zostać naprawione zaraz po ich wystąpieniu.

Kiedy jednak automatyzacja zaczyna się rozrastać i coraz więcej testów

opiera się na wielu różnorodnych danych ulokowanych w udostępnionej

bazie danych, zdarzenia takie występują dużo częściej i może to

niekorzystnie wpłynąć na wiarygodność rezultatów automatyzacji.

Problem 2 – testy manualne i automatyczne wykonywane


jednocześnie

Inny test napisany przez Kathy weryfikuje, czy po złożeniu przez klienta

zamówienia na trzy słuchawki, zapasy zostaną odpowiednio

zaktualizowane. Mówiąc dokładniej, po uruchomieniu test odczytuje

najpierw liczbę słuchawek dostępnych w magazynie, następnie wykonuje

transakcję związaną z zakupem trzech słuchawek, a na końcu odczytuje raz

jeszcze stan zapasów i weryfikuje, czy liczba sztuk tego produktu została

zredukowana o trzy w stosunku do oryginalnego odczytu.

Test ten działał bardzo dobrze w czasie nocnego uruchamiania, ale

okazjonalnie, gdy był uruchamiany za dnia, zwłaszcza w okresie

wzmożonej aktywności przed wydaniami, test ten kończył się

niepowodzeniem. Powodem było to, że John (oraz inni testerzy manualni)

wykonywał dodatkowe transakcje w ciągu dnia i okazjonalnie kupował

słuchawki dokładnie między czasem rozpoczęcia i zakończenia tego testu.

Test automatyczny kończył się niepowodzeniem, ponieważ oczekiwał on,

że zapasy tego produktu zostaną pomniejszone o 3 w stosunku do wartości

początkowej, ale ponieważ testerzy manualni wykonywali w tym czasie

inne transakcje, liczba dostępnych produktów była na końcu mniejsza.


Problem 3 – kolejność ma znaczenie

W wyniku poprzednich doświadczeń Kathy zaimplementowała pewien

mechanizm izolacji: zamiast używać istniejących produktów z bazy danych

(takich jak „Słuchawki” czy „Mikrofon”), infrastruktura automatyzacji

testów przed uruchomieniem każdego testu tworzyła specjalne produkty

(„test1”, „test2” itd.), które po zakończeniu testu były usuwane. Od tej pory,

zamiast produktów dostępnych w bazie danych, w testach używane były

wyłącznie te produkty tymczasowe.

Pewnego dnia do zespołu automatyzacji zarządzanego przez Kathy

dołączył nowy deweloper automatyzacji o imieniu Bob. Pierwszym

zadaniem, które przypisał mu menedżer zespołu QA, było

zautomatyzowanie testu wykonywanego do tej pory przez Johna. Test ten

sprawdza, czy jeśli administrator zmieni cenę produktów, to istniejące

faktury nadal pokazywać będą ceny oryginalne (jest to ten sam test,

o którym wspomniano w problemie nr 1).

Bob pomyślnie zaimplementował ten test i wykonał go kilka razy, aby

upewnić się, że jest stabilny. Ponadto menedżer zespołu QA ostrzegł go, że

może napotkać konflikt z testem poprawności, jak zostało to przedstawione

w problemie nr 1. Z tego powodu Bob uruchomił kilka razy test

poprawności i upewnił się, że i on nadal działa poprawnie.

Przez kilka nocy oba te testy działały bez żadnych problemów. Pewnego

dnia Kathy zauważyła, że nazwa testu napisanego przez Boba nie jest zbyt

czytelna, więc postanowiła ją zmienić. Ku zaskoczeniu wszystkich,

następnej nocy test poprawności zakończył się niepowodzeniem. Podobnie

jak w poprzednim przypadku, test zakończył się porażką, ponieważ

uzyskanym wynikiem było 170 dolarów zamiast spodziewanych 120. Gdy

Kathy uruchamiała ten test oddzielnie, wykonywał się on poprawnie.

Wszystkie testy manualne również wyglądały na poprawne. Kathy była


zdumiona i ostatecznie uznała, że jest to tylko jednorazowy problem (może

jakiś błąd, który został już rano naprawiony?) i że niepowodzenia tego nie

będzie można już powtórzyć.

Ku jeszcze większemu zaskoczeniu Kathy, następnej nocy test

ponownie zakończył się niepowodzeniem. Zdumiona tym Kathy

zdecydowała się na bardziej dokładne zbadanie przyczyny tego problemu.

Ostatecznie zdała sobie sprawę, że zmiana nazwy testu spowodowała

zmianę kolejności wykonywania testów. Od tej pory test Boba uruchamiał

się przed testem poprawności, podczas gdy przed zmianą tej nazwy test

Boba wykonywał się po nim. Test Boba modyfikował cenę produktu

„test1”, który wykorzystywany był również przez test poprawności,

podobnie jak miało to miejsce w przypadku, gdy John uruchamiał ten test

ręcznie.

Jednak Bob nadal nie rozumiał, dlaczego testy nie kończyły się

niepowodzeniem, gdy uruchamiał je zaraz po ich napisaniu. Postanowił on

zwrócić się do Kathy o pomoc, aby dowiedzieć się, co zrobił źle. Po

wyjaśnieniu jej sposobu, w jaki uruchamiał on te testy, Kathy

odpowiedziała mu, że zaimplementowany przez nią mechanizm izolacji

ponownie tworzył dane testowe przy każdym uruchomieniu zestawu testów.

Bob faktycznie uruchamiał swój test przed uruchomieniem testu

poprawności, ale wykonywał on je oddzielnie (w przeciwieństwie do

uruchamiania ich razem w postaci jednego zestawu testów), co

powodowało ponowne tworzenie danych pomiędzy testami i w rezultacie

doprowadzało do „ukrycia” problemu.

Problem 4 – testy automatyczne uruchamiane jednocześnie

Po tym jak zestaw testów automatycznych znacznie się rozrósł, a czas

potrzebny na ich wykonywanie stał się zbyt długi, Kathy zdecydowała się
na podzielenie tych testów na cztery grupy i uruchamianie każdej z nich

równolegle na innej maszynie. Czas uruchamiania znacząco się skrócił, ale

okazjonalnie testy, które były do tej pory stabilne, zaczęły kończyć się

niepowodzeniem bez żadnego konkretnego powodu. Ponowne

uruchamianie takiego testu oddzielnie nie odtwarzało wcześniejszego

niepowodzenia. Sytuacja ta dotyczyła głównie wspomnianego wcześniej

testu sprawdzającego stan zapasów – czasami test ten kończył się

niepowodzeniem, mimo że testy uruchamiane były w nocy, gdy nikt ręcznie

nie komunikował się z systemem.

Oczywiście powód tych niepowodzeń jest bardzo podobny do powodu

podanego w problemie nr 2, jednak tutaj to nie testerzy manualni wpływali

na wyniki testów automatycznych, ale kolidowały ze sobą różne testy

automatyczne, które uruchamiane były równolegle.

Techniki izolacji

W teorii najlepszą izolację możemy osiągnąć wtedy, gdy każdy przypadek

testowy rozpoczniemy od „dziewiczego” środowiska, w którym aplikacja

nigdy nie była instalowana. Podczas inicjalizacji testu instalowane

i uruchamiane są wszystkie niezbędne komponenty aplikacji i dopiero

wtedy wykonywany jest test. W rzeczywistości jednak rzadko jest to

wykonalne. Prześledźmy więc teraz techniki, które są bardziej możliwe do

realizacji. Niektóre z nich uzupełniają się nawzajem i można je łączyć.

Korzystanie z oddzielnych kont

Jeśli aplikacja dostarcza usługę do indywidualnych użytkowników lub

klientów i definiuje pojęcie „kont”, które nie powinny wzajemnie widzieć


swoich danych, wówczas w kontekście izolacji będzie to „nisko zwisający

owoc”. Najpierw możemy utworzyć jedno konto, które będzie dedykowane

systemowi automatyzacji testów, co uniemożliwi interwencję testerów

manualnych. Następnie możemy (a nawet powinniśmy) przypisać po

jednym koncie dla każdego dewelopera automatyzacji i testowego

środowiska wykonawczego (w którym przykładowo uruchamiane są testy

nocne), aby wyeliminować kolizje między testami uruchamianymi

jednocześnie przez różnych deweloperów.

Osobne bazy danych dla testów manualnych i automatyzacji


testów

Wiele zespołów ma ograniczoną liczbę środowisk, które obsługują różne

fazy cyklu rozwojowego. Zwykle są to odpowiednio środowiska:

tworzenia, testowania (lub zapewniania jakości), przedprodukcyjne

(również przejściowe lub akceptacyjne) i produkcyjne, przy czym

w różnych zespołach liczba, nazwy oraz cele tych środowisk mogą być

inne. Każde takie środowisko zawiera zwykle swoją własną kopię bazy

danych.

Jeśli nasz zespół korzysta z takich środowisk, to kolejną po stosowaniu

osobnych kont zalecaną techniką izolacji jest utworzenie tylko jednego

nowego środowiska z jego własną kopią bazy danych, wyłącznie na

potrzeby automatyzacji. Technika ta staje się najbardziej cenna

w przypadku utworzenia automatycznej kompilacji, która uruchamia testy.

Kompilacja ta powinna najpierw utworzyć lub zaktualizować środowisko

automatyzacji, a następnie uruchomić w nim wszystkie testy. Wymaga to od

nas również zautomatyzowania wdrożenia środowiska (jeśli jeszcze tego

nie zrobiliśmy). Dobra wiadomość jest taka, że gdy zrozumiemy już

wszystkie zawiłości dotyczące tworzenia tego automatycznego skryptu


wdrażania, będziemy mogli używać go do tworzenia lub aktualizowania na

żądanie dowolnego innego środowiska (np. testowego lub produkcyjnego)

i to w znacznie prostszy i bezpieczniejszy sposób niż w przypadku ręcznego

wdrażania! Więcej informacji na ten temat można znaleźć w rozdziale 15.

Jeśli środowisko to używane jest wyłącznie do uruchamiania

scentralizowanych cyklów testowania, a jego użycie jest zarządzane

i synchronizowane przez system kompilacji (lub nawet ręcznie, jeśli liczba

korzystających z niego osób jest bardzo mała), wówczas ta technika izolacji

gwarantuje, że automatyzacja ma pełną kontrolę nad tym środowiskiem

i eliminuje prawie wszystkie usprawiedliwienia niewyjaśnionych

niepowodzeń.

Oddzielne środowisko dla każdego członka zespołu

Założenie poprzedniej techniki, że tylko jeden użytkownik może używać

środowiska w danej chwili, może być poprawne przy małej liczbie

jednocześnie korzystających z niego osób, (np. członków zespołu

automatyzacji) i krótkim czasie potrzebnym na uruchomienie cyklu. Ale

założenie to może bardzo szybko stać się błędne, a utworzone przez nas

środowisko stanie się wówczas wąskim gardłem. Choć deweloperzy

automatyzacji dodają nowe lub zmieniają istniejące testy, muszą je

uruchamiać, aby je weryfikować, a czasem debugować w celu ich

przetestowania. Jeśli istnieje tylko jedno dedykowane środowisko

automatyzacji testów, to prawdopodobnie będą oni testować i debugować te

testy w jakimś innym, mniej sterylnym środowisku. Przede wszystkim

jednak środowiska te mogą się od siebie różnić, co oznacza, że to, co

przetestują w takim środowisku, niekoniecznie będzie odzwierciedlać to, co

nastąpi w „formalnym”, scentralizowanym cyklu. Poza tym mogą oni


wykluczyć wszelkie nieoczywiste niepowodzenia jako rezultat niesterylnej

natury środowiska.

Z tego powodu kolejnym logicznym krokiem jest utworzenie wielu

środowisk automatyzacji, a nawet posiadanie oddzielnych środowisk dla

każdego dewelopera automatyzacji. Ale co z deweloperami aplikacji? Jeśli

chcemy zachęcić ich (lub wymusić na nich) do uruchamiania pewnych

testów przed ewidencjonowaniem kodu, to również i oni będą potrzebować

oddzielnego środowiska.

Zwykle największą przeszkodą, która może stanąć nam na drodze, jest

to, że system jest zbyt duży, aby można go było umieścić na maszynach

każdego z deweloperów (a wyposażenie każdego dewelopera w dodatkowy

komputer jest oczywiście zbyt drogie). Ale w większości przypadków,

nawet jeśli w produkcji system wykorzystuje kilka dedykowanych

serwerów, to umieszczenie ich wszystkich na jednej maszynie nie powinno

stanowić problemu. Jedyną rzeczą, która zazwyczaj przybiera duże

rozmiary, jest baza danych. Ale tak naprawdę zwykle automatyzacja nie

wymaga od nas przechowywania tam wszystkich danych. Możemy więc

przechowywać w bazie tylko niezbędne minimum danych, które używane

są przez testy automatyczne (następna technika stanowi uzupełnienie tego

podejścia).

Niektóre osoby obawiają się, że takie zminimalizowane środowiska nie

będą dobrze odzwierciedlać obciążenia i skali rzeczywistego systemu. Cóż,

to prawda, ale nie jest to przecież celem funkcjonalnych testów

automatycznych. Dopiero testy obciążeniowe, które omawiane są

w rozdziale 18, wymagają swojego dedykowanego środowiska, które może

zawierać sporą ilość danych i powinno być bardziej podobne do faktycznej

topologii środowiska produkcyjnego. Jednak środowisko przeznaczone do


testów obciążeniowych, jak również same te testy, powinny być całkowicie

oddzielne i odmienne od zwykłych testów funkcjonalnych i ich środowisk.

Kolejną często podnoszoną kwestią jest to, że każde dodawane przez

nas środowisko zwiększa koszt utrzymania i zarządzania. Dotyczy to

szczególnie sytuacji, w której niektóre z kroków wdrożenia wykonywane są

ręcznie. Aktualizowanie schematu bazy danych jest często wykonywane

ręcznie, co jest bardzo ryzykowne. Jeśli pominęliśmy poprzednią technikę,

to będziemy teraz musieli w pełni zautomatyzować również proces

wdrażania! Gdy proces ten zostanie już w pełni zautomatyzowany,

tworzenie nowych środowisk będzie bardzo proste, zwłaszcza gdy

będziemy używać maszyn wirtualnych lub kontenerów (patrz niżej).

Równoległe uruchamianie testów

Jeśli pokonaliśmy wyzwania dotyczące tworzenia wielu oddzielnych

środowisk, możemy utworzyć kilka środowisk dla głównych cyklów

testowania (np. ciągłej integracji lub kompilacji nocnych) i rozdzielić testy

pomiędzy te środowiska. Każda porcja testów uruchamiana będzie w innym

środowisku, równolegle do pozostałych i dzięki temu całkowity czas

uruchamiania testów zostanie znacząco skrócony. Upewnijmy się jedynie,

że całkowity czas wykonywania każdej porcji jest dość zbliżony do czasu

wykonywania pozostałych, a wtedy całkowity czas wykonywania całego

cyklu testowania zostanie podzielony przez liczbę dostępnych środowisk.

Resetowanie środowiska przed każdym cyklem testowania

Choć realizacja wspomnianego wcześniej podejścia teoretycznego,

w którym każdy przypadek testowy rozpoczynamy od czystego środowiska,

nie jest zwykle możliwa, to często możliwe jest wykonanie tego raz przed
każdym cyklem testowania (np. przed rozpoczęciem testów nocnych lub

kompilacji CI). Prawdziwe znaczenie słów „wyczyść środowisko” może się

różnić, ale ogólnie oznacza to jakiś sposób przywracania stanu środowiska

do pewnego znanego stanu początkowego.

W zależności od architektury oraz technologii używanych przez

aplikację, możemy to osiągnąć na kilka sposobów. Oto kilka przykładów:

przywracanie bazy danych ze znanej „podstawowej” kopii zapasowej;

usuwanie wszelkich plików, które tworzone są przez aplikację podczas

jej działania, lub zamienianie tych plików na ich oryginalne wersje;

odinstalowanie lub ponowne zainstalowanie aplikacji za pomocą

programu instalacyjnego/dezinstalacyjnego, jeśli aplikacja ma taki

program.

Jednym z pytań, które nasuwają się przy rozważaniu tych rozwiązań jest

to, czy resetowanie stanu powinno mieć miejsce przed czy po wykonaniu

testów? W większości przypadków preferowaną odpowiedzią jest: przed.

Gwarantuje to, że środowisko rozpocznie się od świeżego stanu nawet

wtedy, gdy poprzedni cykl został w jakiś sposób przerwany przed

osiągnięciem fazy czyszczenia. Ponadto, jeśli testy zakończą się

niepowodzeniem i będziemy chcieli dowiedzieć się, dlaczego tak się stało,

to utrzymywanie środowiska w nienaruszonym stanie pozwoli nam uzyskać

więcej informacji (np. pliki dziennika, wpisy w bazie danych itd.)

w zakresie przyczyny tego niepowodzenia.

Kolejną powszechną i bardzo potężną techniką resetowania środowiska

przed każdym cyklem testowania jest korzystanie z maszyn wirtualnych lub

kontenerów, również za pośrednictwem usługi działającej w chmurze.


Tworzenie i przywracanie kopii zapasowej bazy danych

Zakładając, że aplikacja korzysta z pojedynczej bazy danych i mamy już

odrębne środowisko dla automatyzacji, lub nawet kilka takich oddzielnych

środowisk, możemy rozważyć rozpoczynanie każdego cyklu testowania lub

nawet każdego zestawu testów, od czystego stanu poprzez przywrócenie

bazy danych z przygotowanej wcześniej kopii zapasowej. Przy każdym

uruchomieniu cyklu testowania będzie on przywracał bazę danych z tej

kopii zapasowej.

Jeśli musimy zaktualizować schemat bazy danych lub dane w pliku

kopii zapasowej z powodu zmian w produkcie i/lub testach, musimy

utworzyć taką kopię ponownie. W tym celu przywracamy poprzednią kopię

zapasową, stosujemy do niej wymagane zmiany, a następnie ponownie

tworzymy kopię zapasową. Dobrze byłoby przechowywać te kopie

zapasowe w systemie kontroli wersji, aby były one synchronizowane ze

zmianami w testowanym systemie. Niestety jedną z wad przechowywania

plików kopii zapasowej w takim systemie jest to, że pliki kopii zapasowej

są zwykle plikami binarnymi, a narzędzia kontroli wersji zazwyczaj nie

mogą przechowywać danych przyrostowych dla plików binarnych, tak więc

będą one zapisywać pełne pliki dla każdej pojedynczej zmiany. Oznacza to

również, że nie możemy porównywać różnych wersji takiego pliku. Z tego

powodu lepszym podejściem wydaje się przechowywanie skryptów, które

zamiast tworzyć nową kopię zapasową bazy danych, tworzą ponownie

samą bazę. W ten sposób, zamiast przywracać bazę danych z kopii

zapasowej, infrastruktura testów będzie uruchamiać ten skrypt w celu

utworzenia czystego środowiska.

Cofanie transakcji
Kolejną techniką izolacji, która wykorzystuje bazę danych i podpada pod

kategorię resetowania środowiska, jest rozpoczynanie każdego testu od

nowej transakcji bazy danych i kończenie go (pomyślnie lub nie) operacją

cofania tej transakcji. To gwarantuje, że każda zmiana dokonana w bazie

danych przez test zostanie wycofana.

To podejście jest bardziej przydatne dla testów komponentów niż dla

testów kompleksowych, ponieważ wymaga ono, aby testowany system

wykorzystywał tę samą transakcję, którą rozpoczął test. Ponadto bardziej

adekwatną techniką jest izolowanie poszczególnych testów między sobą niż

izolowanie cyklów testowania, ponieważ czas cofania transakcji jest dosyć

krótki.

Jeśli taką technikę będziemy stosować w teście komponentu, to

testowany komponent powinien być w stanie przyjąć (zwykle poprzez swój

konstruktor) istniejące połączenie bazy danych, poprzez które komunikuje

się z bazą danych, tak więc test powinien otworzyć połączenie, rozpocząć

nową transakcję i podać to połączenie do komponentu. Gdy test się

zakończy, cofa on transakcję lub po prostu zamyka połączenie bez

zatwierdzania, co powoduje jej odrzucenie, a tym samym cofnięcie

wszystkich zawartych w niej operacji.

W przypadku testów kompleksowych zwykle jest to możliwe tylko

wtedy, gdy aplikacja zapewnia pewnego rodzaju punkt zaczepienia lub

„tylną furtkę” umożliwiające rozpoczęcie nowej transakcji i jej wycofanie,

wyłącznie w celu rozwiązania tego konkretnego problemu, co może

doprowadzić do powstania istotnej luki w zabezpieczeniach. Inną wadą

tego podejścia w testach kompleksowych jest to, że wprowadza ono wysoki

stopień powiązania oraz szereg założeń między testem a szczegółami

implementacji testowanego systemu. Przykładowo, rodzaj i nazwa bazy

danych są szczegółami implementacji, a nie określonymi wymaganiami.


Choć rodzaj lub instancja bazy danych nie jest czymś, co ulega częstym

zmianom, to czasem konieczność przeprojektowania architektury jakiejś

funkcji może taką zmianę wymusić (np. przeniesienie kilku tabel

z relacyjnej bazy danych do bazy „NoSQL”), a w takim wypadku zmiany

niezbędne do wykonania w testach byłyby ogromne. Co więcej, interwencja

testu w transakcjach bazy danych może powodować, że testowany system

w czasie testowania będzie zachowywał się inaczej niż w produkcji, przez

co będzie on mniej wiarygodny. Może się to zdarzyć, gdy testowany system

zakłada, że transakcja została zatwierdzona, co byłoby prawdą

w środowisku produkcyjnym, ale test cofnął tę transakcję.

Korzystanie z maszyn wirtualnych, kontenerów i chmury

Oto małe powtórzenie dla tych, którzy nie są zaznajomieni z maszynami

wirtualnymi: maszyna wirtualna jest jak komputer znajdujący się wewnątrz

innego komputera. Mówiąc dokładniej, jest to system operacyjny

udostępniany wewnątrz innego systemu operacyjnego. System operacyjny

będący hostem przydziela część swoich zasobów – takich jak czas

procesora, pamięć, przestrzeń dyskowa, przepustowość sieci itd. –

maszynie wirtualnej, przy czym maszyna wirtualna nie jest „świadoma”

tego, że jest hostowana i zachowuje się jak kompletny i zwykły system

operacyjny. Jako użytkownicy możemy komunikować się z tą maszyną

wirtualną za pośrednictwem specjalnej aplikacji działającej na hoście lub za

pomocą pulpitu zdalnego. Jednak w wielu przypadkach maszyny wirtualne

wykorzystywane są do uruchamiania usług, z którymi oddziałują wyłącznie

inne aplikacje lub do których dostęp możemy uzyskać wyłącznie za

pośrednictwem przeglądarki internetowej. Jeden host może udostępniać

wiele maszyn wirtualnych, dlatego też w tym celu często stosuje się

dedykowane, potężne maszyny.


Z kolei kontenery można przyrównać do bardzo uproszczonych maszyn

wirtualnych. Typowa maszyna wirtualna zajmuje duże ilości zasobów,

a czas potrzebny na jej włączenie jest podobny do czasu, jaki potrzebny jest

do uruchomienia standardowego systemu operacyjnego (dla większości

wersji systemu Windows jest to kilka minut). W przypadku kontenerów

host zwykle współdzieli część siebie i swoich zasobów z kontenerem,

w przeciwieństwie do przydzielania swoich zasobów, dzięki czemu zużywa

ich mniej. Nakłada to jednak pewne ograniczenia na kontener, którym nie

może być dowolny system operacyjny, jak w przypadku maszyny

wirtualnej. Z tego względu kontenery są dużo bardziej ograniczone i nie

zawierają graficznego interfejsu użytkownika, za to ładują się niemal

natychmiast i wykorzystują one znacznie mniej zasobów pamięci, dysku

itd. Ponadto zapewniają większą elastyczność i łatwość zarządzania,

dostarczając przy tym podobną izolację, jak w przypadku maszyn

wirtualnych.

Istnieją dwie główne funkcje, które czynią z maszyn wirtualnych

i kontenerów interesujące narzędzia do izolacji:

Migawki: ponieważ „dysk twardy” maszyny wirtualnej nie jest

prawdziwym dyskiem, a jedynie plikiem na komputerze hosta, możliwe

jest zapisywanie specjalnych kopii zapasowych (nazywanych

migawkami) maszyny wirtualnej i ich późniejsze przywracanie.

W rzeczywistości migawka może nawet zawierać stan pamięci maszyny

wirtualnej, a nie tylko jej dysku twardego, tak więc można ją zrobić

również podczas działania maszyny wirtualnej i przywrócić ją

dokładnie do tego samego stanu. Ponadto technologia wirtualizacji

zwykle pozwala nam zapisywać w nich jedynie różnice w stosunku do

obrazu bazowego maszyny wirtualnej i dzięki temu nie zajmują one

zbyt wiele miejsca, a ich tworzenie i przywracanie trwa bardzo krótko.


Automatyzacja testów może użyć tej funkcji do przywracania systemu

do stanu z migawki, która została wcześniej zrobiona i zawiera

predefiniowany stan początkowy dla naszych testów.

Szablony: w podobny sposób można sklonować obraz maszyny

wirtualnej, aby utworzyć wiele instancji tej samej maszyny wirtualnej.

Jest to nieco bardziej skomplikowane od zwykłego kopiowania obrazu,

ponieważ każda maszyna musi mieć inną nazwę, inny adres IP, inny

adres MAC itd., aby zapobiec kolizjom w sieci i konfliktom. Na

szczęście host może wyręczyć nas w zarządzaniu tymi różnicami, więc

nadal jest to możliwe. Zdolność do tworzenia wielu instancji maszyn

wirtualnych z tego samego obrazu ułatwia skalowanie aplikacji

w poziomie (patrz tekst uzupełniający poniżej). W podobny sposób, na

potrzeby automatyzacji testów, możemy utworzyć wiele podobnych

środowisk, które mogą testować równolegle z odpowiednią izolacją.

Niektórzy główni gracze internetowi, jak Google, Microsoft czy

Amazon, utrzymują olbrzymie centra danych rozlokowane po całym

świecie i każdemu, kto tego potrzebuje, wynajmują dostępne w nich zasoby

obliczeniowe, głównie w postaci maszyn wirtualnych i kontenerów. Ten typ

usługi nazywany jest chmurą. Jej podstawową zaletą w porównaniu do

korzystania z naszych własnych maszyn wirtualnych jest elastyczność

w zakresie zwiększania i zmniejszania konsumowanych przez nas zasobów,

przy czym płacimy tylko za to, co wykorzystaliśmy. Usługi te uwalniają nas

również od konieczności utrzymywania kosztownego sprzętu. Istnieje wiele

innych korzyści i opcji przemawiających za korzystaniem z chmury, ale

temat ten wybiega poza zakres tego omówienia.

Wniosek jest taki, że migawki i szablony maszyn wirtualnych oraz

kontenerów są świetnym sposobem na osiągnięcie izolacji i równoległości,


a do tego pomagają nam tworzyć i zarządzać dużą liczbą środowisk.

Skalowanie w górę kontra skalowanie w poziomie

Tradycyjnie serwer, który używany był do uruchamiania wymagającej

aplikacji serwerowej, był pojedynczym komputerem o dużej mocy

obliczeniowej. Serwery uruchamiające aplikacje krytyczne często

wykorzystywały pary sąsiadujących ze sobą maszyn nazywanych

klastrami, z których jedna była maszyną podstawową, a druga

maszyną pomocniczą. Gdy w takiej konfiguracji serwer podstawowy

ulegał awarii, serwer pomocniczy natychmiast przejmował jego pracę

i kontynuował obsługę żądań, dając nam czas na naprawę serwera

podstawowego. Jeśli obciążenie na serwerze z czasem się zwiększało,

zwykle dodawanie większej ilości pamięci lub szybszego procesora

rozwiązywało ten problem. Technika ta znana jest powszechnie jako

skalowanie w górę. Problemem jednak było to, że sprzęt dla tych

wysokiej klasy maszyn był bardzo drogi, więc zaktualizowanie takiego

komputera nie było wcale proste. Ponadto, mimo że taka praca

awaryjna zapewniała pewną nadmiarowość, to była ona w dalszym

ciągu ograniczona, ponieważ dwie maszyny znajdowały się fizycznie

blisko siebie. W takim przypadku jakakolwiek katastrofa mogła

dotknąć obu tych komputerów. Istniały co prawda pewne rozwiązania

tego problemu, ale były one również bardzo drogie i skomplikowane.

W erze „boomu dot-comów” firmy zaczęły wykorzystywać dużą

liczbę zwykłych komputerów PC w celu zapewnienia nadmiarowości

i skalowalności. Aplikacja powinna być odpowiednio zaprojektowana,

aby to obsłużyć, ale jeśli jest, to firmy mogą zwiększać dostępne ilości

zasobów obliczeniowych po prostu wdrażając tę aplikację na jeszcze

jednej maszynie. Jest to nazywane skalowaniem w poziomie. W ten


sposób możemy dodawać nowe komputery w znacznie krótszym

czasie, niż gdyby przyszło nam zamówić i zainstalować drogi

i specjalistyczny sprzęt. W ostatnich latach, dzięki rozwojowi

w obszarze maszyn wirtualnych i chmury, technika ta stała się jeszcze

bardziej popularna i dzisiaj projektowanie „monolitycznego” systemu,

który nie obsługuje skalowania w poziomie jest niemal złą praktyką.

Tworzenie niepowtarzalnych danych dla każdego testu

Większość ze wspomnianych do tej pory technik izolacji dotyczy izolacji

między środowiskami testowania i izolacji między cyklami testowania. Ale

co z izolacją między testami w tym samym cyklu i środowisku?

Przypomnijmy problem nr 3, który omówiliśmy wcześniej w tym rozdziale.

Choć zwykle nie jest możliwe utworzenie kompletnej izolacji pomiędzy

indywidualnymi testami w tym samym cyklu i środowisku (z wyjątkiem

testów jednostkowych), to jednak można zmniejszyć szanse wystąpienia

kolizji poprzez zastosowanie konkretnych technik projektowych. Wszystkie

te techniki polegają na unikaniu lub uniemożliwianiu udostępniania

mutowalnych danych między testami.

Jedną z takich technik unikania współdzielenia danych między testami

jest po prostu utworzenie niepowtarzalnego zestawu danych dla każdego

testu. Jeśli każdy test tworzy i wykorzystuje własne dane, to jeden test nie

będzie mógł wpływać na dane innego testu. Zwróćmy uwagę, że

stwierdzenie, że to test tworzy dane, nie oznacza, że uzyskuje on do nich

dostęp i wstawia je bezpośrednio do bazy danych, ale to, że test wywołuje

operacje na testowanym systemie, który tworzy te dane. Jeśli na przykład

test musi zmienić cenę jakiegoś produktu, to powinien najpierw utworzyć

produkt za pośrednictwem aplikacji, a nie bezpośrednio z użyciem bazy

danych. Dzięki temu dane są zawsze spójne i poprawne.


Jednak tworzenie wszystkiego poprzez interfejs użytkownika nie

zawsze jest właściwe ze względu na optymalizację. Jeśli aplikacja

udostępnia API do tworzenia tych danych, to powinniśmy z niego

skorzystać. Jeśli nie, wówczas do tworzenia tych danych należy rozważyć

ponowne wykorzystanie komponentów warstwy dostępu do danych

testowanego systemu. Bezpośrednie wprowadzanie danych do bazy

powinno być traktowane wyłącznie jako ostatnia deska ratunku, aby

uniknąć możliwych niespójności, a także zmniejszyć powiązanie pomiędzy

testami i schematem bazy danych, który jest zwykle szczegółem

implementacyjnym testowanego systemu.

Istotnym problemem w koncepcji tworzenia danych na potrzeby testu

jest ustalenie tego, które dane mogą być współdzielone, a które nie

powinny. Jest dosyć oczywiste, że dane przechodnie, które zmieniane są

wyłącznie przez test, powinny być tworzone. Ale rozważmy następujący

przypadek (jako dalszy ciąg problematycznych scenariuszy z Kathy,

Johnem i Bobem): opracowana została nowa funkcja, która pozwala

menedżerowi definiować promocje. W ramach takich promocji klienci

otrzymują zniżkę na dowolny produkt z grupy produktów objętych

promocją. Na przykład zakup głośnika, słuchawek lub mikrofonu uprawnia

klienta do otrzymania 10-procentowej zniżki. Bob pisze test, który tworzy

taką promocję i wiąże go z istniejącymi produktami, „Słuchawki”,

„Głośniki” i „Mikrofon”, istniejącymi w testowej bazie danych. Dane

powiązane z samymi produktami nie są modyfikowane, a jedynie

wykorzystywane przez nową promocję. Bob uruchamia swój nowy test

i test ten kończy się sukcesem, ale w nocnej kompilacji sławne już testy

poprawności opracowane przez Kathy kończą się niepowodzeniem,

ponieważ wartość całkowita była teraz o 10% mniejsza niż oczekiwano.

Zwróćmy uwagę, że Bob przestrzegał wytycznych i utworzył nową


promocję, która jest jedyną jednostką, jaka została przez niego

zmodyfikowana. Ale mimo że same jednostki produktów nie zmieniły się,

to jednostka promocji miała pewien wpływ na te produkty.

Nie wystarczy zatem, że każdy test będzie tworzył jedynie te dane,

które modyfikuje. Powinien również utworzyć wszelkie dane, które

wykorzystuje. Uważajmy jednak, aby nie posunąć się z tą regułą zbyt

daleko: większość aplikacji używa zestawu danych, który bardzo rzadko się

zmienia i określany jest często jako dane referencyjne. Przykładem takich

danych może być lista państw i walut. We wspomnianym przykładzie

w cenach produktów używa się pewnej domyślnej waluty, które

zdefiniowana jest gdzieś w bazie danych. Tworzenie oddzielnej waluty dla

każdego indywidualnego testu byłoby przesadą. Istnieje pewna „szara

strefa” między danymi referencyjnymi a danymi, które rzadko się

zmieniają. W naszym przykładzie lista produktów również zmienia się

dosyć rzadko, niemniej jednak lepiej będzie utworzyć nowy produkt dla

każdego testu.

Istnieją dwie podstawowe reguły ułatwiające podjęcie decyzji, czy dany

fragment informacji powinien być tworzony przez każdy test lub może być

traktowany jako dane referencyjne:

Czy wiele testów używa bezpośrednio tego rodzaju danych? Z jednej

strony w naszym przykładzie to, które produkty klient kupuje, jest

istotne dla wielu testów. Z tego powodu dane te powinny być

prawdopodobnie tworzone dla każdego testu. Z drugiej strony, jeśli

jednostka jest głównie wykorzystywana pośrednio, tak jak waluta

w przykładzie powyżej, to dane te mogą być prawdopodobnie wspólne

dla większości testów. Jednak testy wykorzystujące lub weryfikujące

pewne cechy blisko związane z pojęciem waluty powinny zapewne


dodawać swoją własną jednostkę waluty dla swoich szczególnych

zastosowań.

Czy większość testów może działać poprawnie, jeśli istnieje tylko jedna

domyślna instancja tego rodzaju jednostki w bazie danych? Mimo że

rzeczywista baza danych będzie definiować około 196 krajów, to

zdecydowana większość naszych testów może pracować z jednym

krajem domyślnym. Możemy zatem przechowywać ten pojedynczy

rekord w referencyjnej bazie danych i używać go w dowolnym celu,

który nie wymaga specjalnej właściwości kraju. Również i tutaj, jeśli

test wymaga bliżej interakcji z jednostką kraju, wówczas powinien

raczej utworzyć nową jednostkę. Jednak określanie jednego

konkretnego produktu w testowej bazie danych mianem „produktu

domyślnego” jest raczej mało adekwatne, ponieważ każdy produkt jest

niepowtarzalny i wiele testów potrzebuje więcej niż jednego takiego

produktu.

Zgodnie z powyższymi regułami, powinniśmy raczej mieć bardzo

cienką bazę danych, zawierającą wyłącznie jedną jednostkę referencyjnych

tabel danych, bez żadnych danych w pozostałych tabelach. Dzięki temu

tworzenie nowego środowiska jest dużo szybsze i sprawniejsze (tj. wymaga

ono mniej przestrzeni magazynowania, a tym samym jest szybsze do

skopiowania lub ponownego utworzenia).

Preferujmy również korzystanie z fikcyjnych danych referencyjnych,

które są celowo inne od prawdziwych danych (np. „Fikcyjny kraj1”,

„Fikcyjny kraj2”, zamiast prawdziwej listy krajów). W ten sposób możemy

dowiedzieć się, czy w stosunku do tych rzeczywistych wartości istnieją inne

założenia w systemie. Jeśli wszystko działa poprawnie z fikcyjnymi

danymi, pozostawiamy je w takiej postaci. Jeśli jednak napotkamy


zależność od prawdziwych wartości, to zadajemy pytanie, czy jest ona

naprawdę konieczna, czy też nie. Jeśli jest, zamieniamy dane fikcyjne na

rzeczywiste. W przeciwnym wypadku zgłaszamy błąd i staramy się usunąć

zbędną zależność. Mimo że znaczenie usuwania takich zależności nie jest

od razu oczywiste, jednak dzięki temu kod testowanego systemu jest na

dłuższą metę bardziej uniwersalny i łatwiejszy w utrzymaniu.

Definiowanie tego minimalnego zestawu danych może stanowić spore

wyzwanie, ponieważ często schemat bazy danych oraz dane wymagane do

właściwego funkcjonowania aplikacji są słabo udokumentowane i nikt tak

naprawdę nie zna wszystkich szczegółów. Znalezienie rozwiązania może

zająć trochę czasu, ale nie jest to bardzo skomplikowane. Jeśli się to uda, to

odzyskamy przy tym bezcenną wiedzę odnośnie prawdziwych wymagań

wstępnych i struktury naszej aplikacji – wiedzę, która została utracona

podczas tworzenia aplikacji, a będzie zapewne znów niezbędna

w przyszłości. Wiedza ta okaże się bardzo cenna, gdy pewne fragmenty

systemu trzeba będzie zrefaktoryzować lub napisać od nowa. Ponadto

korzystanie z minimalnego zestawu danych zamiast z pełnoprawnej bazy

danych często ma miły efekt uboczny w postaci szybszego uruchamiania

systemu i testów.

Krótko mówiąc, rozwiązanie to sprowadza się do ręcznego odtworzenia

i debugowania systemu. Wystarczy zacząć od jednego testu poprawności,

który nie opiera się na istniejących danych (przynajmniej tych, których

jesteśmy świadomi) i spróbować uruchomić go przy użyciu środowiska

z pustą bazą danych. Test prawdopodobnie zakończy się niepowodzeniem.

Jest nawet wysoce prawdopodobne, że na początku system ten nawet się nie

uruchomi! Jednak bez względu na to, jak wygląda to niepowodzenie,

powinniśmy znaleźć brakujące dane, które do niego doprowadziły,

naprawić ten błąd i spróbować ponownie uruchomić test. Aby znaleźć


brakujące dane, spróbujmy wywołać lub nawet debugować operację, która

zakończyła się niepowodzeniem, zarówno w przypadku pełnej bazy

danych, jak i nowej, cienkiej bazy danych, a następnie porównać ze sobą

uzyskane wyniki. Kontynuujemy ten proces, dopóki nie zostaną usunięte

wszystkie niepowodzenia tego testu i będzie się on kończyć sukcesem. Gdy

zdołamy wykonać to dla pierwszego testu, to powtórzenie tego dla

kolejnego testu powinno już być dużo prostsze.

Istnieją jednak pewne szczegóły, które mogą się różnić w zależności od

rodzaju aplikacji:

Jeśli aplikacja jest gotowym produktem, to zakładając, że tworzymy

dane za pośrednictwem interfejsu użytkownika lub poprzez publiczne

API, w przypadku dowolnego niepowodzenia aplikacja powinna

dostarczyć jawny komunikat błędu użytkownikowi. Jeśli tak jest,

dodajemy brakujące dane jako część kodu testu. Jeśli nie, to należy

debugować system, dopóki nie znajdziemy głównej przyczyny.

Następnie sami naprawiamy komunikat błędu, a jeśli nie możemy sami

modyfikować kodu źródłowego, to prosimy o to dewelopera lub

zgłaszamy błąd. Jeśli możemy sami naprawić kod, to powinniśmy to

zrobić, ponieważ przed dodaniem brakujących danych będzie można

zweryfikować, czy komunikat błędu jest czytelny.

Jeśli aplikacja dostarczana jest w formie oprogramowania jako usługi

(Software as a Service, SaaS) i używamy oddzielnego konta dla testów,

jak to zasugerowaliśmy przy pierwszej technice, wówczas każde konto

powinno być uważane za odpowiednik gotowego produktu.

Prawdopodobnie jednak istnieć będą pewne dane referencyjne, które są

wspólne dla wszystkich kont (np. kraje, waluty itd.), a które mogą

modyfikować wyłącznie administratorzy, osoby z marketingu czy

dowolny inny personel wewnętrzny. Jeśli brakuje tego rodzaju danych,


to podanie ładnego komunikatu błędu może być przydatne, ale nie jest

wymagane, ponieważ użytkownik końcowy nigdy nie powinien

napotkać takiego błędu. Nadal jedna zaleca się dostarczenie

przejrzystego komunikatu dla takiego błędu w formie wpisu do

dziennika. Tak czy inaczej, ponieważ brakujące dane referencyjne są

wspólne dla większości testów, to zamiast tworzyć je jako część

każdego testu, robimy to na początku całego zestawu testów albo też

dodajemy je bezpośrednio do migawki bazy danych (jak to opisano

wcześniej w ramach techniki „Resetowanie środowiska przed każdym

cyklem testowania”).

Jeśli aplikacja jest witryną (aplikacją publiczną lub wewnętrzną), ale nie

korzysta z kont i nie wymaga rejestracji, to wszystkie dane i cała

konfiguracja są prawdopodobnie wspólne dla wszystkich

użytkowników. Jest to prawdopodobnie najtrudniejszy przypadek,

w którym musimy zdecydować, które dane są prawdziwymi danymi

referencyjnymi i nie powinny nigdy się zmieniać, a które należy uznać

za dane stanowiące wejście. W niektórych przypadkach nie jest to takie

trudne, ponieważ wszystko, co może być edytowane przez dowolnego

użytkownika (wliczając w to administratora) stanowi coś, co powinien

utworzyć test, zaś dane, które mogą być modyfikowane wyłącznie przez

kogoś z zespołu deweloperów powinny być zawarte w migawce bazy

danych będącej punktem startowym dla automatyzacji. Jednak w innych

przypadkach rozróżnienie to nie jest już takie wyraźne. W obecnym

podejściu DevOps (jak również w pewnych mniej dojrzałych

organizacjach) nie ma jasnego rozróżnienia między deweloperami,

administratorami i biznesmenami. Z tego powodu tworzy są narzędzia

wewnętrzne, aby pomóc odpowiednim osobom dodawać lub

aktualizować różne fragmenty danych. Dla niektórych fragmentów


danych na początku projektu tworzone są odpowiednie narzędzia,

traktowane jako rzecz jednorazowa, umożliwiająca wprowadzenie

danych do bazy danych. Ponieważ są to narzędzia wewnętrzne, są one

często dosyć „nieporadne” i nie jest jasne, czy dane te naprawdę

powinny być edytowalne. Ponadto niektóre fragmenty danych mogą nie

pochodzić od użytkownika, lecz od systemów zewnętrznych. Dane te

mogły również zostać zaimportowane z systemu zewnętrznego raz na

początku projektu i oczekuje się, że nie będą się one zmieniać

w przyszłości. W takich niejasnych przypadkach decyzję dotyczące

tego, które dane powinniśmy traktować jako dane referencyjne, a które

nie, podejmujemy na podstawie własnego osądu, ale starajmy się pisać

testy w sposób, który ułatwi nam ich utrzymanie, jeśli pewnego dnia

zdecydujemy się na zmianę tej decyzji.

W złożonych, monolitycznych i słabo udokumentowanych systemach,

podejście polegające na rozpoczynaniu od pustej bazy danych

i wyszukiwaniu brakujących rzeczy może nie popłacić. W takim

przypadku powinniśmy spróbować zrobić to na zasadzie test po teście

lub przynajmniej funkcja po funkcji. Zamiast rozpoczynać od pustej

bazy danych, zaczynajmy od istniejącej (pełnej) bazy, ale identyfikujmy

te dane, na których opierają się nasze testy. Upewnijmy się, że nasz test

tworzy dane, których potrzebuje, zamiast opierać się na danych

istniejących. Po upływie pewnego czasu dokonujemy oceny, które dane

są prawdopodobnie niepotrzebne i usuwajmy je (po wcześniejszym

utworzeniu kopii zapasowej). Jeśli test nadal działa poprawnie, oznacza

to, że dane te faktycznie nie były już potrzebne. Jeśli jednak test kończy

się niepowodzeniem z powodu braku danych, to albo naprawiamy test

w taki sposób, że nie używa on dłużej tych danych, albo przywracamy

starą bazę danych z kopii zapasowej, bądź też dodajemy brakujące


dane, których test lub system potrzebuje. W ten sposób stopniowo

możemy usuwać nieistotne dane z konkretnych tabel i sprawić, że nasza

testowa baza danych będzie mniejsza i prostsza.

Każdy test czyści wszystko, co utworzył

Podejście, w którym każdy test tworzy potrzebne mu dane, rozwiązuje

większość konfliktów. Istnieją jednak przypadki, gdy test musi zmienić

pewien globalny stan lub gdy jakaś jednostka tworzona przez jeden test

może mieć wpływ na inne testy, mimo że te testy nie korzystają z niej

bezpośrednio. Na przykład jeden test może utworzyć promocję, która

każdej sprzedaży o wartości powyżej 100 dolarów przyznaje 10-

procentową zniżkę, lub inną promocję, która przyznaje zniżkę każdej

sprzedaży dokonanej w piątki między godziną 17:00 i 18:00 (tzw. happy

hours), natomiast kolejny test może utworzyć sprzedaż o wartości

całkowitej większej niż 100 dolarów lub która przypadkiem następuje

w piątek o 17:24, nie wiedząc przy tym o istnieniu promocji utworzonych

przez pierwszy test. Z tego powodu zaleca się również, aby każdy test

usuwał lub cofał wykonane przez siebie zmiany.

Większość powszechnie stosowanych bibliotek testowania

jednostkowego (np. JUnit, NUnit, MSTest itd.) oferuje sposób na

zdefiniowanie specjalnych metod, które wykonywane są przed każdym

testem w klasie testowej, oraz metod wykonywanych po każdej metodzie

testowej. Na przykład biblioteka JUnit do identyfikowania tych metod

wykorzystuje adnotacje @Before i @After (opis bibliotek testowania

jednostkowego można znaleźć w rozdziale 3). W różnych bibliotekach

metody te mają różne nazwy, np. SetUp/TearDown,


TestInitialize/TestCleanup itd. W celu zachowania

przejrzystości metody te będziemy odpowiednio nazywać metodami


inicjalizującymi i metodami oczyszczającymi. Głównym celem metod

oczyszczających jest zapewnienie prostego sposobu oczyszczania testów.

Okazuje się jednak, że sposób ich działania niezbyt dobrze nadaje się do

wielu przypadków i napisanie naprawdę solidnego kodu oczyszczającego

jest bardzo trudne.

Jest tak z kilku powodów:

1. Klasa testowa może zawierać więcej niż jeden test. Mimo że testy w tej

samej klasie powinny być ze sobą powiązane, to nie zawsze wszystkie

testy będą wymagać takiego samego oczyszczania.

2. Ogólnie te metody oczyszczające są wykonywane tylko wtedy, gdy

odpowiadająca im metoda inicjalizująca zakończy się sukcesem (tj. nie

zgłosi wyjątku), jednak bez względu na to, czy sam test zakończy się

powodzeniem, czy też nie. W większości przypadków ma to sens,

ponieważ jeśli nie udało nam się czegoś zainicjalizować, to nie ma

potrzeby tego oczyszczać. Z drugiej strony, jeśli test zakończył się

niepowodzeniem, to nadal musimy po nim posprzątać. Często jednak

metoda inicjalizująca tworzy więcej niż jedną jednostkę, po której

należałoby posprzątać, ale jest dosyć prawdopodobne, że utworzenie

pierwszej jednostki powiedzie się, ale drugiej już nie. W takim wypadku

metoda oczyszczająca nie zostanie wywołana, przez co ta pierwsza

jednostka nie zostanie właściwie usunięta.

3. Często jest tak, że to sam test tworzy jakąś jednostkę. Nie ma z tym

żadnego problemu, ponieważ kod oczyszczający nadal może ją usunąć.

Może się jednak zdarzyć, że test zakończy się niepowodzeniem przed

utworzenia takiej jednostki lub w czasie jej tworzenia i w rezultacie

metoda oczyszczająca również zakończy się niepowodzeniem, gdy

będzie próbować usunąć jednostkę, która nie została utworzona.


4. Test (lub metoda inicjalizująca) może utworzyć dwie jednostki, z których

jedna zależy od drugiej. Na przykład test może utworzyć nowego

klienta i zamówienie od tego klienta. Jeśli w metodzie oczyszczającej

spróbujemy usunąć tego klienta przed anulowaniem lub usunięciem

zamówienia, to zostanie zgłoszony wyjątek.

5. Połączenie powodów 3 i 4 (tj. test tworzy wiele jednostek z relacjami

pomiędzy nimi) sprawia, że pisanie kodu oczyszczającego, który działa

poprawnie we wszystkich sytuacjach niepowodzeń, jest bardzo trudne.

Ponadto bardzo trudno jest zasymulować takie niepowodzenia, co

sprawia, że testowanie kodu oczyszczającego jest prawie niemożliwe!

Rozwiązaniem jest utrzymywanie listy poleceń, które mają zostać

wykonane w fazie oczyszczania. Gdy tylko test wykona jakieś działanie,

które wymaga późniejszego oczyszczania, dodaje on do listy odpowiednie

polecenie czyszczące. Jednak polecenie to nie jest uruchamianie w chwili

jego dodania. Dopiero gdy test zakończy się sukcesem lub

niepowodzeniem, wszystkie te polecenia wykonywane są w odwrotnej

kolejności (aby rozwiązać problem nr 4). Naturalnie, gdy test kończy się

niepowodzeniem, przeskakuje on bezpośrednio do kodu oczyszczającego,

pomijając całą resztę testu. To gwarantuje czyszczenie tylko tych działań,

które zostały faktycznie wykonane (problem nr 3). Dodatek B zawiera

szczegółową implementację i wyjaśnienie dotyczące sposobu zastosowania

takiego mechanizmu. Pełną implementację tego mechanizmu zawiera

również zestaw narzędzi Test Automation Essentials (dodatek C).

Współdzielone dane tylko do odczytu

Na koniec warto wspomnieć, że jeśli aplikacja korzysta z bazy danych, ale

wyłącznie do odczytywania danych wytworzonych przez inny system, to

dane te nie powinny być traktowane jako stan, ale jako dane stanowiące
wejście. Z tego powodu nie musimy tak naprawdę izolować od siebie

instancji wykorzystujących te same dane!

Jednak w wielu przypadkach oznacza to, że testowany system nie

stanowi całego systemu i że prawdopodobnie należy przetestować

integrację między systemem wytwarzającym dane a systemem, który z tych

danych korzysta. Możemy jednak zdecydować się na kilka testów systemu,

które testować będą podsystem wytwarzający dane wraz z podsystemem je

wykorzystującym, utrzymując przy tym oddzielnie większość pozostałych

testów dla każdego podsystemu. Więcej opcji i aspektów dostosowywania

testów do architektury testowanego systemu można znaleźć w rozdziale 6.

W wielu z tych przypadkach zazwyczaj używa się kopii istniejącej

produkcyjnej bazy danych i wykorzystuje się ją do testów. Zanim jednak

zdecydujemy się obrać ten kierunek, powinniśmy zadać sobie następujące

pytania:

Czy używane przez nas dane różnią się wystarczająco i reprezentują

wszystkie przypadki, które chcemy przetestować? Jeśli przykładowo

dane uzyskiwane są od jednego klienta, podczas gdy inni klienci mogą

wykorzystywać ten system nieco inaczej, wówczas możemy nie być

w stanie pokryć wszystkich przypadków potrzebnych dla innych

klientów.

Czy schemat lub znaczenie pewnych danych mogą się zmieniać

w przyszłych wersjach? Jeśli tak, to wówczas będziemy musieli wziąć

inną kopię bazy danych, która będzie prawdopodobnie zawierać w sobie

inne dane. W ten sposób sprawimy, że wszystkie oczekiwane rezultaty,

jak również wiele innych założeń co do naszych testów, nie będą już

poprawne! W niektórych przypadkach może to być prawdziwą

katastrofą dla automatyzacji testów, ponieważ niemal wszystko trzeba

będzie napisać od początku…


Czy łatwo jest zrozumieć relację między tym, co dany test robi, a jego

spodziewanymi rezultatami? Innymi słowy, jeśli piszemy nowy test, to

czy możemy wskazać oczekiwane wyniki bez patrzenia na faktyczne

wartości wyjściowe systemu? Jeśli nie, to w jaki sposób możemy

stwierdzić, że to, co robi dziś nasz system, jest poprawne? Moglibyśmy

powiedzieć, że to, co widzimy dziś, było takie przez wiele lat i nikt jak

dotąd nie narzekał, tak więc można to uznać za poprawne. Zgoda, ale

jeśli w przyszłości jakakolwiek kod działający w oparciu o te dane

ulegnie dozwolonej zmianie, trudno nam będzie powiedzieć, czy ten

kod jest poprawny, czy nie (a jeśli kod nie będzie modyfikowany, to

jego testowanie i tak nie ma zbyt dużej wartości).

Alternatywą dla używania kopii produkcyjnych danych byłoby

utworzenie syntetycznej bazy danych, która zawierałaby dane umieszczone

w niej na potrzeby testów. W rzeczywistości proces tworzenia tych

syntetycznych danych jest identyczny jak proces ręcznego odtwarzania

i debugowania opisanego wcześniej pod tematem „Tworzenie

niepowtarzalnych danych dla każdego testu”, poświęconym tworzeniu

minimalnego zestawu danych referencyjnych.

Podsumowanie

W celu zapewnienia niezawodności i spójności, architektura automatyzacji

testów powinna kontrolować nie tylko dane wybranego zakresu testowania,

ale również jego stan. W tym rozdziale omówiliśmy różne techniki izolacji

do kontrolowania stanu testowanego systemu i unikania niespójnych

wyników w testach. Poza tworzeniem bardziej niezawodnych testów,

niektóre z tych technik mają przyjazny efekt uboczny w postaci


umożliwienia równoległego uruchamiania testów, lepszego zrozumienia

prawdziwego działania systemu i szybszego wykonywania testów.


Rozdział 8. Szersza perspektywa

W rozdziale 5 mówiliśmy o związkach między automatyzacją testów

i procesami biznesowymi. W rozdziale 6 omawialiśmy relacje między

automatyzacją testów i architekturą systemu. W tym rozdziale spoglądamy

na to z nieco szerszej perspektywy i omawiamy silną korelacji między

strukturą biznesu i architekturą, a także między procesami biznesowymi

i kulturą. Oczywiście wyjaśniamy również, w jaki sposób automatyzacja

testów jest z nimi połączona oraz jak to wszystko jest ze sobą powiązane.

Relacje między architekturą oprogramowania


i strukturą biznesu

W 1967 roku informatyk Melvin Conway opublikował pracę naukową

zatytułowaną „How Do Committees Invent”26. W trzecim od końca

akapicie tej pracy zamieścił on pewną maksymę, która później stała się

powszechnie znana jako prawo Conwaya.

Prawo Conwaya

Prawo Conwaya mówi, że organizacje, które projektują systemy (…),

ograniczone są do wytwarzania projektów będących kopiami ich struktur


komunikacyjnych. Choć prawo to nie ogranicza się jedynie do

oprogramowania, to jednak jest ono najbardziej oczywiste i najlepiej

rozpoznawane w tej dziedzinie.

Obserwacja ta wskazuje, że struktura biznesu, kultura, a także

nieformalne wzorce komunikacji mają swoje odzwierciedlenie

w architekturze systemu i odwrotnie. Ludzie pracujący w tym samym

zespole zwykle komunikują się ze sobą częściej, a fragmenty ich pracy (np.

wiersze kodu) są ze sobą lepiej poprzeplatane, tworząc w rezultacie dobrze

zdefiniowany moduł. Jeśli wytwarzany przez nich moduł powinien

komunikować się z modułem wytwarzanym przez inny zespół, to

członkowie tych dwóch zespołów muszą komunikować się ze sobą, aby

zdefiniować to połączenie. Nie jest to jednak ograniczone wyłącznie do

struktury formalnej: jeśli dwie osoby z tego samego zespołu lub różnych

zespołów nie komunikują się ze sobą zbyt dobrze, to integracja między

fragmentami ich kodu będzie zapewne dosyć niezdarna. Z kolei samotni

programiści mogą tworzyć kod, który będą w stanie zrozumieć tylko oni.

Bliscy przyjaciele z różnych zespołów mogą tworzyć „nieporadne”

interfejsy, które będą zrozumiałe tylko dla nich i tak dalej.

Prawo Conwaya działa w obu kierunkach: z jednej strony, jeśli architekt

zaprojektuje pożądaną architekturę, a menedżer zdecyduje się zorganizować

zespoły w sposób, który nie jej z nią zgodny, to w rezultacie architektura

zbudowanego oprogramowania będzie odpowiadać faktycznej strukturze

biznesowej, a nie architekturze przewidzianej pierwotnie przez architekta.

Z drugiej strony, gdy planowana jest duża restrukturyzacja

oprogramowania, mądry menedżer może w tym celu wykorzystać prawo

Conwaya i w odpowiedni sposób przeorganizować zespoły.

Zespoły pionowe kontra zespoły poziome


Tradycyjnie większość systemów biznesowych była projektowana

w architekturze warstwowej. Zwykle najwyższą warstwą jest interfejs

użytkownika, a pod nim znajduje się warstwa biznesowa. Niżej znajduje się

warstwa dostępu do danych (data-access layer, DAL), która komunikuje się

z bazą danych, zaś na samym dole – sama baza danych. Każda z tych

warstw opracowywana była zwykle przez osobny zespół. Zaletą takiego

podejścia jest to, że każda z tych warstw wymaga zazwyczaj innego

zestawu umiejętności i narzędzi, więc sensowne było umieszczenie w tym

samym zespole deweloperów o podobnych umiejętnościach, aby wspierać

w ten sposób dzielenie się wiedzą i praktykami.

Z kolei wadą tej metody jest to, że prawie każda funkcja jest

uzależniona od wszystkich tych warstw. Jeśli tylko wszystko zostanie

dobrze zaprojektowane z góry, to nie powinno to stanowić większego

problemu. Jeśli jednak klient zażąda wprowadzenia nawet najdrobniejszej

zmiany (choćby dodania najprostszej funkcji) lub też zostanie znaleziony

jakiś błąd, to prawie zawsze implementacja takiej zmiany wymagać będzie

udziału wszystkich zespołów. Ponadto, z powodu istnienia łańcucha

zależności, zwykle zespoły do wyższych warstw nie mogą rozpocząć

swojej pracy, dopóki zespoły do niższych warstw nie zakończą swojej

pracy. W rezultacie taka sztywna architektura i struktura biznesu jest bardzo

oporna na żądania wprowadzenia zmian, naprawy błędów, dodawania

nowych funkcji itd.

Z tego powodu wiele w dużych projektach przyjęto architekturę

i strukturę biznesu, która zamiast opierać się na warstwach technicznych

odpowiada „pionowym” funkcjom organizacji. W tym podejściu każdy

zespół składa się z osób z różnymi umiejętnościami i doświadczeniem (np.

interfejs użytkownika, bazy danych, logika biznesowa itd.), ale wszyscy oni

są oddelegowani do pracy nad tą samą funkcją lub dziedziną biznesową. Co


więcej, niektóre zespoły składają się głównie z osób o wszechstronnych

umiejętnościach (tak zwanych full stack developers). Poza różnicami

w doświadczeniu deweloperów, w większości przypadków każdy zespół

w takiej strukturze organizacyjnej ma również dedykowanych testerów

i menedżerów produktu. Prawdopodobnie najbardziej wpływową książką

propagującą to podejście jest Domain-Driven Design. Zapanuj nad

złożonym systemem informatycznym27 Erica Evana. Wada tego podejścia

jest jednocześnie zaletą podejścia warstwowego (dzielenie się wiedzą

z ludźmi o tym samym doświadczeniu). Jednak w większości przypadków

jego zalety, które sprawią, że oprogramowanie jest łatwiejsze w utrzymaniu

i lepiej przyjmuje zmiany, znacząco przewyższają jego wady. Rysunek 8.1

pokazuje różnicę pomiędzy podziałem pionowym i poziomym.

Rysunek 8.1. Podział pionowy kontra podział poziomy


W praktyce wiele złożonych projektów ma swoją własną

niepowtarzalną architekturę i strukturę zespołów, ale możemy w nich

w dość prosty sposób zidentyfikować współzależność między strukturą

organizacyjną i architekturą systemu i jasno zidentyfikować, w którym

miejscu linie podziału odpowiedzialności między zespołami są pionowe,

a w którym są poziome. Na przykład w jednym z największych projektów,

nad którym pracowałem, organizacja składała się z jednego zespołu

pracującego nad stroną klienta (miał to być „klient zubożony”, stąd też

tylko jeden zespół) oraz wielu zespołów projektujących stronę serwerową,

dedykowanych różnym obszarom biznesowym. Zespół testowania był

zespołem poziomym, pokrywającym scenariusze wykorzystujące wiele

funkcji.

Zależności między architekturą


oprogramowania i strukturą organizacyjną
z automatyzacją testów

Naturalnie członkowie jednego zespołu częściej i bardziej swobodnie

komunikują się ze swoimi kolegami niż z członkami innych zespołów. Ale

tworzenie, implementowanie i utrzymywanie testów automatycznych

również wymaga częstej komunikacji pomiędzy deweloperami pracującymi

w różnych zespołach. Ma to także swoje implikacje w zależnościach

między strukturą organizacyjną i strukturą automatyzacji testów.

Dedykowany zespół automatyzacji

Jeśli istnieje jeden dedykowany zespół automatyzacji, to prawdopodobnie

będzie on w pewnym stopniu odłączony od zespołu deweloperów. Z jednej


strony to dobrze, bo to oznacza, że będzie on w całości odpowiadał za

automatyzację i podobnie jak w przypadku warstwowej architektury

i struktury organizacyjnej usprawni to dzielenie się wiedzą

i doświadczeniem między programistami automatyzacji. Ponadto, ponieważ

zespół automatyzacji jest odpowiedzialny za automatyzację całej aplikacji,

jest bardziej prawdopodobne, że utworzy on zestaw testów automatycznych

pokrywający całą aplikację, podobnie jak zakres kompleksowy opisany

w rozdziale 6.

Z drugiej strony jednak zespół automatyzacji nie będzie ściśle

współpracował z zespołem deweloperów, co jest niezbędne do utrzymania

stabilnej i solidnej automatyzacji (zobacz część „Obsługiwanie błędów

wykrywanych przez automatyzację” w rozdziale 5). W takiej strukturze

zintegrowanie testów automatycznych z procesem ciągłej integracji może

być trudne (patrz rozdział 15), jak również przekonanie deweloperów

automatyzacji i deweloperów aplikacji, że to deweloperzy aplikacji powinni

naprawiać błędy pojawiające się w ramach automatyzacji.

Deweloperzy automatyzacji w zespołach poziomych

Zwykle gdy deweloperzy automatyzacji są członkami zespołów poziomych

lub gdy to członkowie zespołów poziomych zajmują się automatyzacją,

będą się skłaniać ku implementacji testów automatycznych pokrywających

jedynie ich własną warstwę. Nie chcą zajmować się błędami, które nie

powstają z ich winy. Oczywiste jest, że zakorzeni to jedynie efekty prawa

Conwaya i prawdopodobnie spowoduje powstanie większej liczby

problemów integracyjnych pomiędzy zespołami. Problemy te mogą

objawiać się jako błędy, jako opóźnienia w harmonogramie projektu,

konflikty między pracownikami lub zespołami itd.


Obwinianie kontra kultura współpracy

Zjawisko to nie jest ograniczone jedynie do zespołów poziomych, gdyż

może mieć ono zastosowanie do wszystkich zespołów, które są od siebie

wzajemnie zależne i między którymi brakuje komunikacji. Ale ponieważ

zespoły poziome są z natury zależne od zespołów projektujących niższe

warstwy, w tej sytuacji jest to bardzo powszechne. Kwintesencją tego

zjawiska jest to, że gdy coś pójdzie nie tak, zespoły zaczynają obwiniać się

wzajemnie za niepowodzenie. Pewnego razu zostałem poproszony przez

jeden taki zespół (nazwijmy go zespołem „A”) o opracowanie testów dla

komponentów tworzonych przez inny zespół (zespół „B”) w taki sposób, że

jeśli coś nie będzie działać, to zespół A będzie w stanie udowodnić, iż wina

leży po stronie zespołu B… Odmówiłem grzecznie, wyjaśniając przy tym,

że pisanie testów dla innego zespołu nie jest efektywne, ponieważ testy

automatyczne wymagają ciągłego utrzymania. Zamiast tego zasugerowałem

napisanie testów integracyjnych, które pokryją zarówno kod zespołu A, jak

i kod zespołu B. W ten sposób będzie można uzyskać znacznie większe

korzyści. Zamiast udowadniać swoją „rację”, deweloperzy będą mogli

upewnić się, że oprogramowanie, które przekazują swoim klientom, działa

poprawnie. W przypadku, gdy nie będzie, powinni oni być w stanie

prześledzić i przeanalizować główną przyczynę dostatecznie szybko

(techniki do śledzenia i analizowania testów kończących się

niepowodzeniem omawiane są w rozdziale 13), a jeśli wina faktycznie leży

po stronie zespołu B, powinni być oni w stanie to udowodnić na podstawie

dowodów zebranych w swoich testach. Dodatkowo zachęci to zespół A do

współpracy z zespołem B nad zapewnieniem prawidłowego działania

testów i sprzyja zaufaniu między tymi zespołami, gdyż będą one w stanie

przekazywać sobie fakty i dowody, a nie przypuszczenia i subiektywne

terminy.
W ogólnym przypadku dowolna technika, narzędzie i praktyka

oferująca transparentność i zachęcająca do współpracy może mieć wpływ –

zwykle pozytywny – na kulturę organizacji. Systemy kontroli kodu,

kompilacje ciągłej integracji, testowanie automatyczne i monitorowanie

wytwarzania to narzędzia i techniki, które oferują taki rodzaj

transparentności i mają pewien wpływ na kulturę. Powiedzieliśmy, że

wpływ ten jest „zazwyczaj pozytywny”, ponieważ każde takie narzędzie

może zostać nadużyte i wykorzystane w niepoprawny sposób, który

przyniesie odwrotne rezultaty. Rozdział 15 zawiera pewne wskazówki

związane z wykorzystywaniem automatyzacji testów w celu stopniowej

zmiany kultury pod kątem lepszej współpracy.

Deweloperzy automatyzacji w zespołach pionowych

Ogólnie, biorąc pod uwagę wspomniane wyżej kompromisy, w większości

przypadków prawdopodobnie najbardziej efektywną strukturą

organizacyjną dla automatyzacji testów jest sytuacja, gdy deweloperzy

automatyzacji są członkami pionowych zespołów deweloperów lub gdy to

sami deweloperzy w zespołach pionowych piszą testy. Zwróćmy jednak

uwagę, że nawet jeśli zespoły i architektura podzielone są pionowo, to

nadal pomiędzy pionowymi modułami/zespołami istnieć będą zależności

i interakcje, a wiele scenariuszy powinno dotyczyć więcej niż jednego

modułu.

Kolejnym potencjalnym problemem w tej strukturze jest to, że wiele

fragmentów infrastruktury każdego modułu jest zdublowanych, ponieważ

są one tworzone oddzielnie przez każdy zespół. Dotyczy to zarówno kodu

aplikacji, jak i kodu automatyzacji.

Ale mimo tych problemów, testy produkowane przez takie zespoły

zwykle pokrywają kompletne scenariusze, a ponieważ każdy zespół jest


odpowiedzialny za swoje własne testy, są one dodatkowo bardziej

niezawodne. Ponadto, ponieważ deweloperzy automatyzacji i deweloperzy

aplikacji pracują razem, ich współpraca jest lepsza, a do tego deweloperzy

aplikacji mogą zwykle uzyskać więcej korzyści dzięki automatyzacji.

Elastyczna struktura organizacyjna

Niektóre duże zespoły przyjmują elastyczne podejście, w którym

dynamicznie tworzone są mniejsze zespoły, aby szybko dostosować się do

potrzeb konkretnych historyjek użytkownika. Dan North na kongresie

„Scaling Agile for the Enterprise 2016” w Brukseli wygłosił fascynującą

przemowę28 dotyczącą metody nazywanej przez niego mapowaniem dostaw

(delivery mapping), która pomaga formować te zespoły w wydajny sposób.

Przy użyciu tej lub podobnej techniki, struktura zespołów nieustannie się

zmienia w celu lepszego dostosowania się do historyjki użytkownika oraz

celów, jakie realizowane są przez większe zespoły. Każda funkcja lub

historyjka użytkownika zostaje przypisana do zespołu ad hoc, czasami

nazywanego również zespołem funkcyjnym (feature crew) lub drużyną

(squad).

Podejście to samo w sobie stanowi duże wyzwanie, ale pomaga każdej

takiej drużynie funkcji skupić się na przyznanym zadaniu. Ponadto, jeśli

automatyzacja opracowywana jest przez każdą taką drużynę dla tworzonej

przez nią funkcji, wówczas podejście to zachęca do pisania automatycznych

testów tak, aby obejmowały wszystkie istotne moduły, jakie muszą zostać

zmienione dla tej funkcji. Podejście to jest dosyć trudne, ale jeśli uda się je

zrealizować, to testy automatyczne opracowane dla każdej z funkcji, będą

najprawdopodobniej stosować najodpowiedniejszy zakres testowania dla tej

konkretnej funkcji. Do tego celu bardzo dobrze nadaje się metodyka ATDD

opisywana w rozdziale 16.


Ekspert ds. automatyzacji

Bez względu na faktyczną strukturę zespołów i modułów, decyzje

dotyczące struktury projektowanych i implementowanych przez siebie

testów automatyzacji są często podejmowane pod kątem tego, co jest

łatwiejsze i co wiąże się z mniejszymi problemami w tym procesie. Jeśli

chodzi o zwrot inwestycji z testu (tj. jaką wartość wniesie dany test

w stosunku do jego wiarygodności i łatwości utrzymania), to w wielu

przypadkach wybory te są nieoptymalne. Ale jeśli mamy obok siebie kogoś,

dla kogo testy automatyczne są pasją, i ta osoba ma na ten temat szeroką

wiedzę i doświadczenie, wówczas będzie ona w stanie podejmować lepsze

decyzje w tym zakresie. Jeśli ta osoba będzie podchodzić do tego tematu

entuzjastycznie, to naturalnie ludzie zaczną przychodzić do niej po poradę.

W dużych zespołach warto zadbać, aby taka osoba miała rolę niezależnego

„eksperta ds. automatyzacji testów” i nie była częścią żadnego konkretnego

zespołu. Dzięki temu będzie mogła zachować ogólny pogląd na

automatyzację testów i poprowadzić ją do sukcesu. W ramach swojej

codziennej pracy osoba ta może usprawniać infrastrukturę testów,

przeglądać testy innych osób i przeprowadzać szkolenia, a także doradzać

każdemu zespołowi, jak budować automatyzację, która będzie najlepiej

dopasowana do ich potrzeb.

Podsumowanie

Różne organizacje mają różne struktury, kultury, ograniczenia i mocne

strony. Atrybuty te mają odzwierciedlenie w architekturze systemu, a do

tego są w dużym stopniu skorelowane z procesami biznesowymi.


Automatyzacja testów jest ściśle związana ze wszystkimi tymi

atrybutami i to w obu kierunkach: atrybuty te wywierają na nią wpływ, ale

ona również wpływa na te atrybuty! Jeśli spojrzymy na to z nieco szerszej

perspektywy i przez pryzmat automatyzacji testów, to prawdopodobnie uda

nam się nie tylko stworzyć najlepszą automatyzację testów dla naszej

bieżącej organizacji, ale zdołamy również wykorzystać tę automatyzację do

usprawnienia naszej organizacji!

Tworzenie stabilnej, niezawodnej i cennej automatyzacji testów

wymaga współpracy ludzi z różnych zespołów i dyscyplin, a w zamian

oferuje nam przejrzystość i transparentność w zakresie defektów i zmian

psujących kod. Usprawniona współpraca jest ukrytym „efektem ubocznym”

automatyzacji testów, ale jest również jedną z jej największych zalet!

Osoba, która ułatwia komunikację, jest liderem. Naturalnie

menedżerowie mają do tego lepsze predyspozycje, ale ekspert

automatyzacji lub dowolny inny deweloper automatyzacji, który

przewiduje, w jaki sposób można wykorzystać do tego automatyzację

testów, może odcisnąć swoje piętno na organizacji i stać się liderem. Więcej

informacji na temat tego, w jaki sposób stopniowo zmieniać kulturę swojej

organizacji (nawet bez upoważnienia), oraz tego, jak stać się liderem,

można znaleźć w rozdziale 15.


Część II. „Jak”
W części I omówiliśmy sporą ilość teorii i zaprezentowaliśmy szeroki obraz

świata automatyzacji testów, wyjaśniając przy tym, czym jest, a czym nie

jest automatyzacja. Z dosyć ogólnej perspektywy omówiliśmy jej istotne

aspekty, przedstawiając przy tym narzędzia do podejmowania

strategicznych decyzji dotyczących automatyzacji testów dla naszego

projektu.

W tej części pomówimy bardziej technicznie o tym, jak zaprojektować

i zaimplementować infrastrukturę automatyzacji testów oraz indywidualne

testy, a także jak ją wykorzystać jako część całego cyklu rozwoju

oprogramowania. W kilku kolejnych rozdziałach przebrniemy przez

praktyczny samouczek, w ramach którego zbudujemy projekt automatyzacji

testów dla prawdziwej aplikacji.


Rozdział 9. Przygotowanie do
samouczka

W części I tej książki nieustannie zwracaliśmy uwagę na kwestię łatwości

utrzymania, ale do tej pory nie wyjaśniliśmy, w jaki sposób można to

osiągnąć. Nie powiedzieliśmy również, jak rozpocząć planowanie

i tworzenie od zera nowego rozwiązania automatyzacji testów. Kilka

kolejnych rozdziałów posłuży nam za samouczek, w ramach którego

rozpoczniemy tworzenie rozwiązania automatyzacji testów dla prawdziwej

aplikacji. Celem tego rozdziału jest:

1. Omówienie procesu i podejścia, z jakich będziemy korzystać w kolejnych

rozdziałach. Proces ten nie ogranicza się jedynie do tego samouczka

i można go zastosować w dowolnym projekcie automatyzacji testów.

2. Omówienie aplikacji, dla której zamierzamy napisać testy.

3. Przedstawienie kolejnych kroków dotyczących instalacji wymagań

wstępnych, które są niezbędne do ukończenia pozostałej części tego

samouczka.

Wymagania i założenia wstępne


W rozdziale 3 omówiliśmy związki między umiejętnościami dewelopera

automatyzacji i narzędziami dopasowanymi do tych umiejętności.

Doszliśmy wtedy do wniosku, że pisanie automatyzacji za pomocą

obiektowego języka programowania ogólnego przeznaczenia zapewni nam

największą elastyczność, a jeśli będziemy używać go mądrze, to na dłuższą

metę będziemy mieć większe szanse na uniknięcie problemów związanych

z utrzymaniem.

Z tego powodu w ramach niniejszego samouczka utworzymy

automatyzację w kodzie, a mówiąc dokładniej, posłużymy się językiem

programowania C#. Lepsza znajomość języka Java lub Python, nawet bez

rozumienia każdego niuansu tych języków, umożliwi samodzielne

wykonanie za ich pomocą wszystkich opisywanych tu czynności. Jest tak,

ponieważ większość zasad będzie zawsze taka sama, bez względu na to,

z jakiego obiektowego języka korzystamy. Osoby niemające żadnego

doświadczenia w programowaniu obiektowym, które planują zbudować

automatyzację za pomocą jakiegoś narzędzia niewymagającego żadnego

programowania, i tak powinny przeczytać samouczek i spróbować wynieść

z tego jak najwięcej, ponieważ wiele z opisywanych tu pojęć może mieć

również zastosowanie do tego rodzaju narzędzi. W samouczku tym staramy

się podawać kolejne instrukcje krok po kroku, rozpoczynając od sposobu

konfigurowania wstępnego środowiska programowania, tak aby zawarte

w nim polecenia można było wykonywać nawet bez posiadania

jakiejkolwiek wcześniejszej wiedzy w tym temacie. Jeśli któryś z tych

kroków nie będzie wystarczająco jasny, to brakujące informacje można

wyszukać w sieci. Konkretne pytanie można również zadawać bezpośrednio

na forum tej książki, dostępnym pod adresem

http://www.TheCompleteGuideToTestAutomation.com.
Poza wyborem języka C# jako naszego języka programowania,

skorzystamy również z narzędzia Selenium WebDriver jako technologii do

automatyzacji interfejsu użytkownika, ponieważ testowany przez nas system

jest aplikacją sieci Web, a Selenium jest najbardziej popularnym wyborem,

jeśli chodzi o implementację w kodzie automatyzacji interfejsu

użytkownika, opartą na sieci Web. Ponieważ nasz system nie zawiera

żadnych testów ani też nie był pisany z myślą o jego testowaniu,

rozpoczniemy od kilku testów systemu, które będą obejmować testy

sprawdzające poprawność jego działania (testy poprawności). Mimo że

w tym samouczku nie będziemy wybiegać poza kilka takich testów

poprawności systemu, to podstawowe koncepcje pozostają w większości

takie same również dla innych zakresów testowania. Nie martwmy się

zatem, jeśli nie jesteśmy zaznajomieni z narzędziem Selenium lub jeśli nasz

projekt nie jest aplikacją sieci Web. Jak wspomnieliśmy, większość tych idei

i koncepcji pozostaje taka sama nawet dla testów, które nie mają żadnej

interakcji z interfejsem użytkownika.

Stosowanie procesu do istniejących systemów


automatyzacji testów

Proces, którego używamy w ramach tego samouczka i który zaraz zostanie

omówiony, pokazuje, w jaki sposób pisać testy automatyzacji oraz ich

infrastrukturę, tak aby były one łatwe w utrzymaniu. Mimo że proces ten

działa najlepiej, gdy rozpoczynamy od czystego projektu automatyzacji

testów (i takie jest jego założenie), to większość z jego idei możemy

zastosować również do istniejącego projektu. Zastosowanie tych koncepcji

do istniejącego systemu automatyzacji testów może zmusić nas do pewnych

ustępstw i na początku możemy nie osiągać wszystkich korzyści


oferowanych przez to podejście. Jeśli jednak się na nie zdecydujemy,

będziemy w stanie stopniowo przekształcać naszą istniejącą automatyzację,

tak aby korzystać z pomysłów i technik opisanych w tym samouczku, co

pozwoli nam cieszyć się zaletami tego procesu w postaci łatwiejszego

utrzymania i zwiększonej wiarygodności. Oczywiście, jeśli w pewnym

momencie trzeba będzie napisać nowy system automatyzacji, to będziemy

mogli zastosować wszystkie te koncepcje od samego początku.

Omówienie procesu

Samouczek ten jest oparty na praktycznym procesie, który stosowany był

przez wiele lat i z którego skorzystało już wiele osób. W ramach kilku

kolejnych rozdziałów przejdziemy razem przez ten proces, co pozwali nam

uzyskać pełny obraz tego, jak należy z niego korzystać. Zanim jednak

rozpoczniemy tę podróż, spójrzmy najpierw na „mapę” tej podróży

w postaci krótkiego omówienia tego procesu. Ale jeszcze wcześniej musimy

przedstawić dwie ogólne sposoby tworzenia oprogramowania, które

stanowić będą grunt dla tego omówienia.

„Z dołu do góry” albo „z góry do dołu”

Łatwe w utrzymaniu projekty automatyzacji testów obejmują, poza samymi

metodami testowymi, sporą ilość kodu infrastruktury. Kod infrastruktury

zawiera wspólny kod, którego nie chcemy powtarzać. Niektórzy nazywają

ten kod „pomocniczymi” klasami i metodami lub nawet „zasadami

projektowania przepływu”, ale w rzeczywistości ta część kodu często staje

się dużo większa niż same metody testowe.

Powszechną praktyką w tradycyjnym, bardziej „kaskadowym” podejściu

jest zaprojektowanie i zaimplementowanie całej infrastruktury systemu


oprogramowania, tj. jego niższych warstw, jeszcze przed rozpoczęciem

implementacji wyższych warstw. Podejście to nazywane jest podejściem „z

dołu do góry” (bottom up). Podobnie jak każdy inny projekt

oprogramowania, metodę tę można stosować również do automatyzacji

testów: najpierw projektujemy i implementujemy infrastrukturę, a dopiero

potem rozpoczynamy implementację przypadków testowych. Stosuje się to

zwłaszcza wtedy, gdy infrastrukturę i przypadki testowe implementują inne

osoby. Jednakże, jak wspomnieliśmy w rozdziale 3, podejście to ma pewne

wady, jeśli chodzi o łatwość utrzymania. Z tego powodu zaleca się, aby cały

kod automatyzacji był opracowywany i utrzymywany przez te same osoby.

Gdy testy i kod infrastruktury piszą te same osoby, warto skorzystać

z odmiennego podejścia – podejścia „z góry do dołu” (top-down).

Z początku może się to wydawać mało intuicyjne, ale w ramach tego

podejścia zaleca się zaprojektowanie i zaimplementowanie jednego

przypadku testowego przed implementacją wymaganej przez niego

infrastruktury. W ten sposób nie tylko implementujemy infrastrukturę po

realizacji testu, ale również implementujemy jedynie fragment infrastruktury

potrzebny w tym teście i nic poza tym! Gdy zrobimy tak z pierwszym

testem, kontynuujemy ten proces ze wszystkimi pozostałymi przypadkami

testowymi: najpierw projektujemy i implementujemy kolejny przypadek

testowy, a potem dodajemy brakującą infrastrukturę, której wymaga ten

nowy test. Proces ten gwarantuje nam kilka rzeczy:

Ponieważ najpierw pisany jest kod testu, możemy napisać go w sposób,

który jest najbardziej czytelny i najłatwiejszy do zrozumienia.

Ponieważ infrastruktura tworzona jest według prawdziwych potrzeb,

a nie żadnych spekulacji, jest ona przydatna i łatwa w użyciu.

Za każdym razem, gdy wykonywany jest pełny zestaw testów, testowana

jest również cała infrastruktura. Jeśli w kodzie infrastruktury istnieje


błąd, zostaje on rozpoznany bardzo wcześnie i łatwo jest go naprawić.

Proces

Ponieważ wyjaśniliśmy już, dlaczego preferowane jest stosowanie podejścia

„od góry do dołu”, poniżej przedstawiamy bardziej szczegółowy opis

procesu, który realizujemy w tym samouczku, a który jest zalecany również

poza tym samouczkiem. Każdy z tych kroków omawiamy bardziej

szczegółowo w kilku kolejnych rozdziałach.

1. Zaprojektuj pierwszy przypadek testowy.

2. Napisz szkielet pierwszego przypadku testowego przy użyciu

„pseudokodu”. Innymi słowy, napisz kod w wybranym przez siebie

języku programowania, ale zakładając, że dysponujesz całym

potrzebnym kodem infrastruktury, nawet jeśli tak nie jest. Oczywiście

taki kod nigdy się nie skompiluje…

3. Utwórz minimalny wymagany kod infrastruktury, tak aby pierwszy test

się kompilował, po czym implementuj go, aż będzie działać poprawnie

i test zakończy się sukcesem (zakładając, że powinien to robić).

4. Zaprojektuj kolejny przypadek testowy.

5. Napisz szkielet nowego przypadku testowego w pseudokodzie. Tym

razem, jeśli potrzebujesz kodu infrastruktury, który już istnieje, możesz

po prostu użyć go ponownie. Mogą tutaj wystąpić dwie sytuacje:

a. Jeśli cała infrastruktura potrzebna do obsługi tego testu już istnieje,

to test powinien się poprawnie kompilować i uruchamiać. Jeśli tak

się stanie, to praca z tym przypadkiem testowym jest już zakończona

i możesz przejść do następnego przypadku. To jednak rzadko

powinno mieć miejsce, zwłaszcza przy pierwszych testach.

b. Jeśli potrzebujesz dodatkowego kodu infrastruktury, który jeszcze nie

istnieje (lub nie pasuje on do testu w swojej obecnej postaci), należy


założyć, że kod ten istnieje. Kod nie będzie się kompilował.

6. Dodaj wymaganą infrastrukturę, tak aby nowy test oraz wszystkie

istniejące testy kompilowały i uruchamiały się poprawnie. W trakcie

tego procesu bądź wtedy, gdy wszystkie testy będę się już poprawnie

uruchamiać, zrefaktoryzuj kod, aby usunąć zdublowane fragmenty

i usprawnić strukturę kodu.

Pamiętaj: Twój kod automatyzacji testów ma prawie stuprocentowe

pokrycie, jeśli więc uruchomisz wszystkie testy, to możesz być

pewny, że refaktoryzacja niczego nie zepsuła!

7. Wróć do kroku 4.

Ponadto, gdy napotkamy jakiekolwiek przeszkody techniczne lub

problemy, które zakłócają ciągłość naszej pracy, modyfikujemy kod

infrastruktury, aby usunąć te przeszkody i problemy. Oto kilka przykładów:

Gdy analizujemy testy kończące się niepowodzeniem, upewnijmy się, że

mamy wszystkie niezbędne informacje, które pomogą nam zbadać je

szybciej.

W przypadku napotkania nieoczekiwanych rezultatów z powodu

problemów związanych z izolacją, usprawniamy izolację.

Jeśli chcemy przekazać innym osobom nasze testy do uruchomienia,

upewnijmy się, że są one wystarczająco łatwe w użyciu.

Poznawanie testowanego systemu

W samouczku tym korzystamy z projektu open source o nazwie

MVCForum. MVCForum jest w pełni funkcjonalnym, responsywnym

i obsługującym motywy forum dyskusyjnym z funkcjami podobnymi do

witryny StackOverflow. Projekt ten napisany został w języku C# z użyciem


biblioteki ASP.NET MVC 5. Strona domowa tego projektu znajduje się pod

adresem http://www.mvcforum.com, zaś jego najnowszy kod źródłowy jest

dostępny pod adresem https://github.com/YodasMyDad/mvcforum.

Ponieważ projekt ten mógł ewoluować od czasu napisania tej książki,

sklonowaliśmy jego repozytorium GitHub, tak aby samouczek ten zawsze

nadawał się do użytku.

Omówienie projektu MVCForum

Aplikacja ta jest dosyć bogata w funkcje, a do najważniejszych z nich

należą:

silnik motywów,

system odznak społecznościowych,

obsługa wielu języków,

przesyłanie wiadomości prywatnych,

polubienia, oznaczanie rozwiązań, ulubione.

Pełna lista dostępnych funkcji znajduje się na stronie głównej witryny

GitHub tego projektu29.

Tak czy inaczej, najprostszym sposobem zapoznania się z tą aplikacją

jest przejście na stronę https://www.support.mvcforum.com/, która jest

zarządzana za pomocą samej tej aplikacji (witryna ta nie jest pod naszą

kontrolą, tak więc używana w niej wersja aplikacji może być nowsza, a sama

strona może być nawet niedostępna). Zakładając, że witryna ta nie została

zmieniona w zbyt dużym stopniu, powinna ona wyglądać podobnie do tej

przedstawionej na rysunku 9.1.


Rysunek 9.1. Główna strona witryny wsparcia dla aplikacji MVCForum

Jak widzimy, witryna ta wyświetla listę ostatnio prowadzonych dyskusji.

Wszystkie dyskusje możemy czytać bez konieczności rejestrowania się lub

logowania. Po zarejestrowaniu (które jest darmowe) użytkownicy mogą

tworzyć nowe dyskusje i zamieszczać komentarze w istniejących

dyskusjach. Dyskusje przypisywane są do określonej kategorii i mogą mieć

również zdefiniowane znaczniki, za pomocą których możemy wyszukiwać

interesujące nas dyskusje.

Poza tą podstawową funkcjonalnością, forum to pozwala

zarejestrowanym użytkownikom na wystawienie oceny każdej dyskusji lub

komentarza innej osoby (lubię/nie lubię), a także pozwala inicjatorowi

dyskusji oznaczyć jeden komentarz jako poprawną odpowiedź lub


rozwiązanie. Na podstawie różnych reguł i opcji, które może skonfigurować

administrator witryny, użytkownicy mogą zdobywać punkty i odznaki.

Użytkownicy o największej liczbie punktów zebranych w danym tygodniu

lub roku są wyświetlani na tablicy wyników Leaderboard (patrz rysunek

9.2).

Rysunek 9.2. Strona Leaderboard z punktacją użytkowników

Większość funkcji tej witryny może zostać w dużym stopniu

dostosowana przez administratora, który jest specjalnym użytkownikiem

z dodatkowymi prawami. Gdy administrator zaloguje się w witrynie,

specjalny element menu umożliwia mu przejście na stronę administratora,


gdzie ma on możliwość podejrzenia i zmiany wszystkich dostępnych opcji

tej witryny, jak to pokazano na rysunku 9.3.

Rysunek 9.3. Strona z ustawieniami dla administratora

Przygotowanie środowiska pod samouczek

Zanim będziemy mogli rozpocząć pracę z samouczkiem, musimy najpierw

zająć się pewnymi zadaniami administracyjnymi, a mianowicie instalacją

i przygotowaniem naszego środowiska pracy. Procedura instalacji została

przetestowana w systemie Windows 10 Professional, ale powinna działać

w zasadzie tak samo w każdej innej wersji systemu Windows, zaczynając od


Windows 7. Jeśli korzystamy z czystej instalacji systemu Windows,

będziemy potrzebować co najmniej 25 GB wolnej przestrzeni na dysku, aby

zainstalować wszystkie wymagane komponenty, w tym Visual Studio i bazę

danych SQL Server w edycji Express. Zwróćmy uwagę, że wspomniane

wersje niektórych aplikacji mogą zostać w przyszłości uznane za

przestarzałe lub nie być wcale dostępne. W takim wypadku należy

spróbować skorzystać z ich nowszych wersji. W razie napotkania jakichś

problemów, można spróbować poszukać dla nich rozwiązania w sieci lub na

stronie https://www.TheCompleteGuideToTestAutomation.com, gdzie

odpowiednie rozwiązanie może być już opublikowane. Jeśli takiego

rozwiązania jeszcze tam nie ma, możemy zadać na tej stronie nowe pytanie

i w ten sposób spróbować uzyskać pomoc.

Instalowanie Visual Studio w edycji Community

Jako nasze zintegrowane środowisko programistyczne do pisania testów

w języku C# i ich uruchamiania wykorzystujemy program Visual Studio.

Instalacja Visual Studio jest wymagana, aby samodzielnie wykonywać

poszczególne kroki tego samouczka. W czasie pisania tej książki najnowszą

dostępną wersją programu Visual Studio była wersja 2017. Visual Studio

2017 oferowane jest w pełni funkcjonalnej edycji, z której możemy

korzystać za darmo. Edycję tę można pobrać ze strony

https://www.visualstudio.com/vs/community/. Aby ją zainstalować, należy

postępować zgodnie z instrukcjami instalatora. Gdy zostaniemy poproszeni

o wybór pakietów roboczych (workloads), zaznaczamy następujące

elementy:

.NET Desktop Development (Programowanie aplikacji klasycznych dla

platformy .NET)
ASP.NET and Web Development (Opracowywanie zawartości dla

platformy ASP.NET i sieci Web)

po czym klikamy Install (Zainstaluj). Zwróćmy uwagę, że jeśli

zainstalujemy Visual Studio bez tych pakietów roboczych lub mamy już

zainstalowany program Visual Studio i nie jesteśmy pewni, czy pakiety te

zostały zainstalowane, to po pierwszym otwarciu MVCForum w programie

Visual Studio (o czym powiemy za chwilę) zostaniemy poproszeni

o zainstalowanie brakujących pakietów roboczych.

Pobieranie i instalowanie przeglądarki Chrome

Do komunikacji z testowanym systemem za pośrednictwem narzędzia

Selenium wykorzystujemy przeglądarkę Chrome. Ponadto, za pomocą

narzędzi dla programistów dostępnych w przeglądarce Chrome, będziemy

identyfikować elementy, które będą nam potrzebne podczas

implementowania przypadków testowych. Jeśli przeglądarka Chrome nie

jest jeszcze zainstalowana, można ją pobrać i zainstalować ze strony

https://www.google.com/chrome/. Aby dokonać instalacji, postępujemy

zgodnie z instrukcjami prezentowanymi na ekranie.

Pobieranie i instalowanie bazy danych SQL Server Express

SQL Server jest wymagany przez aplikację MVCForum do przechowywania

jej informacji. Jeśli nie mamy zainstalowanej żadnej edycji bazy danych

SQL Server, pobieramy i instalujemy SQL Server w wersji Express.

W czasie pisania tej książki najnowszą wersją tego systemu był SQL Server

Express 2017, który można pobrać ze strony https://www.microsoft.com/en-

us/sql-server/sql-server-editions-express.
Ważne

Kreator instalacji może zakończyć pracę, jeśli nie zostanie

uruchomiony z uprawnieniami administratora. Dlatego nie należy

uruchamiać go zaraz po pobraniu! Zamiast tego należy otworzyć

folder z lokalizacją pliku instalatora, kliknąć ten plik prawym

przyciskiem myszy i wybrać opcję Run as Administrator

(Uruchom jako administrator).

1. Gdy uruchomi się kreator instalacji, jako typ instalacji wybierz Basic

(Instalacja podstawowa).

2. Wykonaj dalsze instrukcje w kreatorze, aby ukończyć proces instalacji.

3. Kliknij Install SSMS (Zainstaluj SSMS), aby zainstalować komponent

SQL Server Management Studio. Zostaniesz przekierowany na stronę

firmy Microsoft, skąd będziesz mógł pobrać kolejny kreator instalacji.

Pobierz i uruchom ten plik, po czym podążaj za instrukcjami tego

kreatora.

4. Kliknij Close (Zamknij), aby zamknąć okno głównego kreatora instalacji.

Potwierdź zakończenie pracy kreatora.

Pobieranie i budowanie aplikacji

Choć witryna GitHub aplikacji MVCForum wspomina o kilku opcjach

pozwalających na zainstalowanie tej aplikacji, my pobierzemy repozytorium

Git i skompilujemy kod z poziomu programu Visual Studio. Aby to zrobić

należy wykonać następujące kroki:

1. Przejdź na stronę https://github.com/arnonax/mvcforum, kliknij przycisk

„Clone or Download” (Sklonuj lub pobierz) i wybierz „Open in Visual


Studio” (Otwórz w Visual Studio), jak to pokazano na rysunku 9.4.

Rysunek 9.4. Pobieranie repozytorium

2. Powinien uruchomić się program Visual Studio (jeśli nie był uruchomiony

wcześniej) z otwartym panelem Team Explorer, pokazanym na rysunku

9.5 (jeśli z jakiegoś powodu tak się nie stanie, możesz otworzyć ten

panel z poziomu menu View (Widok) Team Explorer). Adres URL

z repozytorium GitHub powinien zostać wypełniony automatycznie.

Możesz również pozostawić domyślną ścieżkę do swojego lokalnego

repozytorium lub wybrać inną lokalizację, np.

C:\TheCompleteGuideToTestAutomation\MVCForum, jak to

przedstawiono na rysunku. Aby ukończyć tę operację, kliknij Clone

(Sklonuj).
Rysunek 9.5. Klonowanie repozytorium GitHub do lokalnego komputera

3. Z panelu Solution Explorer (Eksplorator rozwiązań, opcja View

Solution Explorer, jeśli nie jest widoczny), kliknij dwukrotnie plik

MVCForum.sln, aby otworzyć to rozwiązanie i zawarte w nim projekty.

4. Jeśli jeszcze nie zainstalowałeś wymaganych pakietów roboczych,

zostaniesz o to poproszony w ramach okna dialogowego Install Missing

Features (Zainstaluj brakujące funkcje). Jeśli okno to zostało

wyświetlone, kliknij Install (Zainstaluj).

a. Gdy pojawi się okno z dostępnymi pakietami roboczymi, zaznacz

pakiety .NET Desktop Development oraz ASP.NET and Web


Development, po czym kliknij Modify (Modyfikuj), aby je

zainstalować. Zwróć uwagę, że będziesz musiał zamknąć program

Visual Studio, aby móc kontynuować.

b. Gdy proces aktualizacji zostanie zakończony, ponownie otwórz plik

rozwiązania MVCForum.sln.

5. Jeśli zostanie wyświetlone okno dialogowe zatytułowane Project Target

Framework Not Installed (Platforma docelowa projektu nie jest

zainstalowana), zachowaj zaznaczoną pierwszą opcję, rozpoczynającą

się od słów „Change target to…” (Zmień platformę docelową…) i kliknij

OK.

6. Z głównego menu programu Visual Studio wybierz Build

(Kompilowanie) Build Solution (Kompiluj rozwiązanie). Upewnij

się, że w panelu Output (Dane wyjściowe) jest widoczna następująca

informacja:

===== Build: 19 succeeded, 0 failed, 0 up-to-date, 0


skipped =====

Uwaga

Jeśli panel Output nie jest wyświetlany, to z głównego menu

wybierz View Output. Upewnij się również, że w polu

wielokrotnego wyboru Show output from: (Pokaż dane

wyjściowe z:), widniejącego w górnej części panelu Output,

wybrany jest element Build (Kompilacja).

7. Aby otworzyć aplikację za pomocą przeglądarki Chrome, wybierz Google

Chrome z paska narzędzi, jak to pokazano na rysunku 9.6. Następnie

naciśnij F5, aby uruchomić aplikację.


Rysunek 9.6. Uruchamianie aplikacji za pomocą przeglądarki Google

Chrome

8. Jeśli pojawi się ostrzeżenie zatytułowane Just My Code Warning

(Ostrzeżenie Tylko mój kod), zaznacz ostatnią opcję Continue

Debugging (Don’t Ask Again) (Kontynuuj debugowanie (nie pytaj

ponownie).

9. Pierwsze uruchomienie aplikacji może zająć dłuższą chwilę, po czym

aplikacja otworzy się w przeglądarce Chrome, jak to pokazano na

rysunku 9.7.

Gratulacje! Mamy teraz kompletne środowisko pracy, wymagane do

rozpoczęcia samouczka.

Uwaga

Jeśli miałeś problem z wykonaniem któregoś z powyższych

kroków, sięgnij po dokumentację odpowiedniego produktu oraz


przeszukaj sieć lub witrynę tej książki w celu uzyskania pomocy.

Rysunek 9.7. Aplikacja MVCForum po pierwszym lokalnym uruchomieniu

Instalacja dodatku ReSharper (krok opcjonalny)


ReSharper jest zewnętrznym komercyjnym dodatkiem dostarczanym przez

firmę JetBrains, który zwiększa produktywność pracy w programie Visual

Studio. Osobiście korzystam z tego dodatku każdego dnia i nie wyobrażam

sobie życia bez niego. W ramach tego samouczka pokażemy, w jaki sposób

możemy z niego korzystać, a także jak osiągać te same cele bez użycia tego

dodatku. Zwróćmy uwagę, że produkt ten oferuje bardzo elastyczny program

ewaluacyjny: jest to wersja 30-dniowa, ale możemy w łatwy sposób

zamrozić licznik odliczający pozostałe dni w czasie, gdy nie korzystamy

z tego dodatku!

Dodatek ReSharper dostępny jest do pobrania ze strony

https://www.jetbrains.com/resharper/download/. Po jego pobraniu należy

uruchomić aplikację instalatora i wykonać poniższe czynności:

1. Na pierwszej stronie instalatora można wybrać dodatkowe produkty firmy

JetBrains do zainstalowania. Ponadto, jeśli masz zainstalowaną więcej

niż jedną wersję programu Visual Studio, będziesz mógł wybrać wersje,

dla których chcesz zainstalować dodatek ReSharper.

2. Zaakceptuj postanowienia umowy licencyjnej i kliknij Install. Po

zakończeniu pracy instalatora kliknij Exit, aby zamknąć okno kreatora

instalacji.

3. Przy następnym uruchomieniu programu Visual Studio zostaniesz

poproszony o zaakceptowanie zasad prywatności firmy JetBrain.

Przewiń zawartość okna na sam dół, uaktywnij przycisk I Accept

(Akceptuję) i kliknij go.

4. Na stronie Data Sharing Options (Opcje udostępniania danych) kliknij

OK.

5. Na stronie License Summary (Podsumowanie licencji) kliknij przycisk

Start Evaluation (Rozpocznij ewaluację), jeśli chcesz natychmiast


rozpocząć 30-dniowy okres ewaluacyjny. Następnie kliknij OK, aby

zamknąć okno kreatora.

Uwaga

Jeśli nie rozpoczniesz okresu ewaluacyjnego po zakończeniu

instalacji, możesz rozpocząć go poprzez wybranie z głównego

menu Visual Studio opcji ReSharper Why ReSharper is

Disabled (Dlaczego ReSharper jest wyłączony), kliknięcie

przycisku Start Evaluation i kliknięcie przycisku OK.

Możesz również zatrzymać okres ewaluacji poprzez wybranie

z menu opcji ReSharper Help (Pomoc) Licence

Information (Informacje o licencji), kliknięcie przycisku Pause

Evaluation (Wstrzymaj ewaluację) i kliknięcie OK w celu

zamknięcia okna dialogowego. Zwróć uwagę, że ReSharper liczy

części dni jako pełne dni. Ponadto, jeśli nadal otwarta będzie inna

instancja programu Visual Studio, okres ewaluacyjny będzie

kontynuowany.

6. Po rozpoczęciu ewaluacji zostaniesz poproszony o wybór schematu

skrótów dla programu ReSharper (okno Select ReSharper Ultimate

Shortcuts Scheme). Sugerujemy wybór pierwszej opcji (Visual Studio),

chyba że jesteś bardziej zaznajomiony z programem IntelliJ IDEA.

Wskazówka

Według mnie najbardziej efektywnym sposobem korzystania

z dodatku ReSharper jest obsługiwanie go za pomocą skrótów

klawiszowych zamiast myszy. Z tego powodu warto pobrać


i wydrukować plakat Default Keymap (Domyślna mapa

klawiatury), a następnie umieścić go w pobliżu klawiatury lub

powiesić na ścianie bezpośrednio przed sobą. Gorąco polecamy

również ćwiczenia polegające na korzystaniu z klawiatury zamiast

myszy tak często, jak to tylko możliwe. Więcej wskazówek

w zakresie efektywnej pracy za pomocą klawiatury można znaleźć

w dodatku D.

Korzystanie z narzędzia Git z poziomu Visual


Studio

Choć samouczek ten prowadzi nas krok po kroku i pokazuje większość

kodu, jaki musimy napisać, to w pewnych przypadkach pokazywanie

wszystkiego będzie po prostu niepraktyczne. Ponadto może się zdarzyć, że

gdy coś pójdzie nie tak, będziemy chcieli porównać swój kod z kodem

przedstawionym w tej książce. Z tego powodu staraliśmy się utrzymać

przejrzystą i uporządkowaną historię zmian dla wszystkich kroków tego

samouczka wewnątrz repozytorium Git. Na wszystkich listingach kodu

i istotnych kamieniach milowych umieściliśmy stosowne znaczniki, tak aby

można je było łatwiej znaleźć. Ponieważ Git jest rozproszonym systemem

kontroli wersji, po sklonowaniu repozytorium Git na naszą maszynę lokalną

(jak to przedstawiono wcześniej w tym rozdziale) będziemy dysponować

własną kopią tej historii bezpośrednio na naszej maszynie.

Jeśli jesteśmy zaznajomieni z systemem Git, możemy pracować z nim za

pomocą naszego ulubionego narzędzia (np. Git bash, Get Extensions,

SourceTree itd.). Jeśli nie znamy tego systemu zbyt dobrze, możemy

zapoznać się z poniższym wyjaśnieniem na temat tego sposobu korzystania

z systemu Git bezpośrednio z poziomu Visual Studio.


Większość operacji systemu Git realizowanych w Visual Studio jest

wykonywanych z poziomu panelu Team Explorer, który – jak już

wspomnieliśmy – może zostać otwarty za pomocą opcji Team Explorer

w menu View. Panel Team Explorer obejmuje kilka widoków. Na rysunku

9.8 pokazano widok Home (Strona główna) tego panelu.

Rysunek 9.8. Widok Home w panelu Team Explorer

Z widoku Home możemy w prosty sposób przechodzić do innych

widoków: Changes (Zmiany), Branches (Gałęzie), Sync (Synchronizacja),

Tags (Tagi) i Settings (Ustawienia). Choć przyciski te widoczne są

wyłącznie w widoku Home, to między widokami możemy również

przełączać się z poziomu innych widoków, korzystając z pola wyboru

znajdującego się bezpośrednio pod paskiem narzędziowym tego panelu

(gdzie na rysunku widnieje napis „Home | MVCForum”).


Przełączanie pomiędzy gałęziami

Repozytorium GitHub zawiera kilka gałęzi, z czego większość jest

dziedziczona z oryginalnego repozytorium. Jednak gałąź Tutorial

(Samouczek) została utworzona na potrzeby tej książki i zawiera wszystkie

poprawki pokazane w samouczku. Możemy zaglądać do poszczególnych

poprawek w tej gałęzi, aby podejrzeć więcej szczegółów i lepiej zrozumieć

zmiany opisane w tym samouczku.

Domyślnie repozytorium lokalne zawiera wyłącznie gałąź master, która

odzwierciedla punkt startowy tego samouczka. Aby po raz pierwszy

przełączyć się na gałąź Tutorial, należy wykonać następujące czynności:

1. W panelu Team Explorer przełącz się na widok Branches.

2. Rozwiń folder remotes/origin, aby podejrzeć listę gałęzi w zdalnym

repozytorium. Widok gałęzi powinien być podobny do rysunku 9.9.


Rysunek 9.9. Listowanie repozytoriów zdalnych

3. Kliknij prawym przyciskiem (zdalną) gałąź Tutorial i wybierz opcję

Checkout (Wyewidencjonuj). Gałąź Tutorial powinna zostać dodana do

głównej listy (lokalnych) gałęzi, nieopodal gałęzi master.

Aby przełączać się pomiędzy lokalnymi gałęziami, dwukrotnie klikamy

gałąź, na którą chcemy się przełączyć (np. master lub Tutorial). Zwróćmy

uwagę, że przełączenie się na gałąź Tutorial przeniesie nas do ostatecznego

stanu samouczka. Ponadto, jeśli dokonaliśmy jakichś zmian w jednym

z plików tego rozwiązania, nie będziemy wówczas mogli przełączyć się do


innej gałęzi, jeśli nie cofniemy tych zmian lub ich nie zatwierdzimy.

Oczywiście, jeśli chcemy zachować nasze zmiany, to powinniśmy je

zatwierdzić. Jeśli nie, to należy je cofnąć. Zwróćmy również uwagę, że gdy

przełączamy się do innej gałęzi, zawsze jest brana pod uwagę najnowsza

poprawka z tej gałęzi. Warto utworzyć swoją własną gałąź i zatwierdzić do

niej wprowadzone zmiany (poprzez wybranie opcji New Branch (Nowa

gałąź) w widoku Branches), mimo że możemy bez przeszkód zatwierdzać

nasze zmiany do gałęzi master. Pamiętajmy, że jest to nasza lokalna kopia

repozytorium (i że nie możemy wypychać naszych zmian z powrotem do

witryny GitHub, ponieważ nie mamy odpowiednich uprawnień), tak więc

nie bójmy się zatwierdzać naszych zmian.

Aby podejrzeć zmiany i zatwierdzić je lub cofnąć, należy przełączyć się

na widok Changes (Zmiany). Rysunek 9.10 pokazuje widok Changes.


Rysunek 9.10. Panel Team Explorer, widok Changes

Zanim zdecydujemy, czy chcemy zatwierdzić lub wycofać nasze zmiany,

możemy porównać każdy zmieniony plik z jego stanem początkowym. Aby

to zrobić, klikamy prawym przyciskiem odpowiedni plik i wybieramy

Compare with Unmodified… (Porównaj z niezmodyfikowanym) lub po

prostu klikamy go dwukrotnie. Aby zatwierdzić zmiany, musimy

wprowadzić komunikat zatwierdzenia w żółtym polu tekstowym, a następnie

kliknąć przycisk Commit All (Zatwierdź wszystko). Jeśli zamiast tego

chcemy przywrócić stan jednego lub więcej plików, możemy zaznaczyć te

pliki lub zawierający je folder, a następnie kliknąć prawym przyciskiem

myszy i wybrać opcję Undo Changes… (Cofnij zmiany).

Aby podejrzeć historię poprawek w danej gałęzi, w widoku Branches

klikamy prawym przyciskiem pożądaną gałąź i wybieramy View History….

(Wyświetl historię…). Widok historii otwiera się zwykle w obszarze

dokumentów i wygląda jak na rysunku 9.11.


Rysunek 9.11. Okno historii

Na powyższym rysunku widoczne są znaczniki (po prawej stronie

niektórych poprawek), które mogą ułatwić znalezienie właściwej poprawki.

Najechanie kursorem myszy na taki znacznik wyświetla również powiązany

z nim opis. Podwójne kliknięcie wybranej poprawki spowoduje

wyświetlenie w panelu Team Explorer plików, które zostały zmienione

w ramach tej poprawki. Z tego miejsca możemy porównać każdy plik z jego

poprzednią wersją lub otworzyć go, aby zobaczyć, jak wyglądał on w tej

konkretnej wersji. Niestety, bezpośrednio z poziomu Visual Studio nie

możemy wyewidencjonować całej poprawki, aby zdebugować lub

prześledzić całą strukturę kodu, ale możemy to zrobić z poziomu wiersza

polecenia, wykonując poniższe kroki:

1. Z panelu Team Explorer, w widoku Changes lub Branches, wybierz

Actions (Akcje) Open Command Prompt (Otwórz wiersz

polecenia).

2. W otwartym oknie wiersza polecenia wpisz: git checkout ID, gdzie


ID jest wartością wyświetlaną w kolumnie ID (Identyfikator) okna

historii. Aby na przykład wyewidencjonować poprawkę zatytułowaną

„Added TestEnvironment configuration file”, wpisz: git checkout


ca2a6f05. Alternatywnie, aby wyewidencjonować otagowaną

poprawkę, wpisz git checkout tags/tag-name, gdzie tag-


name jest nazwą tagu, np. git checkout tags/Listing 14-

16 spowoduje wyewidencjonowanie poprawki zatytułowanej „Support

Firefox and Edge”.

3. Wróć do programu Visual Studio. Powinieneś zobaczyć okno dialogowe

zatytułowane „File Modification Detected” (Wykryto zmianę pliku).

Kliknij przycisk „Reload Solution” (Załaduj ponownie). Teraz


powinieneś być w stanie zobaczyć i debugować kod na poprawce, która

została wyewidencjonowana.

Podsumowanie

W rozdziale tym wyjaśniliśmy procedurę, z której będziemy korzystać

w kolejnych rozdziałach, pisząc nasz pierwszy test i budując obsługującą go

infrastrukturę. Dowiedzieliśmy się również, jak możemy przygotować nasze

środowisko, aby móc samodzielnie wykonywać kolejne kroki tego

samouczka. Warto nie tylko przeczytać ten samouczek, ale też robić na

własnym komputerze wszystko, co jest w nim zawarte. Własnoręczne

wykonywanie tych kroków, rozwiązywanie rzeczywistych problemów

i obycie z procesem roboczym jest najlepszym sposobem na zrozumienie

potęgi tego procesu. A więc zaczynajmy!


Rozdział 10. Projektowanie
pierwszego przypadku testowego

W tym rozdziale wyjaśniamy, w jaki sposób możemy przystąpić do

projektowania pierwszego przypadku testowego, który stanowi pierwszy

krok w naszym procesie. Znaczna część przedstawionej tu techniki może

mieć również zastosowanie do kolejnych przypadków testowych. Najpierw

jednak powinniśmy rozważyć to, w jaki sposób możemy dokonać wyboru

pierwszego przypadku testowego.

Wybieranie pierwszego testu do


zautomatyzowania

Pytanie „Od którego testu zacząć?” może wprowadzić nas w zakłopotanie.

Prawdopodobnie mamy dziesiątki, jeśli nie setki czy nawet tysiące

przypadków testowych, które chcielibyśmy zautomatyzować. Są one

zapewne podzielone na funkcje, ekrany, podsystemy, czy też cokolwiek

innego, na podstawie czego kategoryzujemy je w naszej organizacji. Jeśli

system jest złożony i skomponowany z kilku różnych i w dużej mierze

niezależnych podsystemów, wówczas pytanie to będzie stanowić dla nas

jeszcze większe wyzwanie.


Czasem jednak, gdy przychodzę do klientów, który proszą mnie

o pomoc w rozpoczęciu tworzenia automatyzacji testów, to mają już jakieś

pomysły, jak powinien wyglądać taki pierwszy test, lub nawet wybrali już

pierwszy przypadek testowy do zautomatyzowania. Oczywistym wyborem,

który w większości przypadków będzie poprawny, jest rozpoczęcie od

testów sprawdzających poprawność systemu (sanity tests). Większość

klientów dysponuje dobrze znanym i opracowanym przez siebie zbiorem

testów uznawanych za testy „poprawności”, które uruchamiają (ręcznie)

dosyć często. Ale kryteria dotyczące tego, w jaki sposób testy te zostały

wybrane do zestawu testów poprawności, nie zawsze są przejrzyste, zaś

definicja tego, co tak naprawdę powinien zawierać taki zestaw, może się

różnić między poszczególnymi organizacjami lub zespołami. Niestety na

podstawie własnego doświadczenia wiem, że znaczna część tych testów

poprawności w ogóle nie nadaje się do automatyzacji.

Z tego powodu zazwyczaj po tym, jak przejrzymy opisy przypadków

testów manualnych w zestawie testów poprawności klienta, odkładamy je

na bok i zadajemy sobie kilka pytań, które pomogą nam wybrać

najważniejsze testy, od jakich powinniśmy zacząć. Są to mniej więcej takie

pytania:

Kim są główni klienci i użytkownicy systemu?

Co jest podstawową wartością dostarczaną przez system dla tych

klientów/użytkowników?

Które wyniki dostarczają tę wartość?

Jakie dane są niezbędne do wytworzenia tych wyników?

Czasem zachodzi potrzeba uściślenia tych pytań poprzez zadanie pytań

dodatkowych:
Jakie będą szkody, jeśli system przestanie działać?

W jaki sposób system ten pomaga organizacji zarabiać pieniądze lub je

oszczędzać?

Który spośród możliwych wyników działania systemu spowoduje

najwięcej szkód w przypadku, gdy przestanie on funkcjonować na

dłuższą chwilę w środowisku produkcyjnym i nikt z zespołu

deweloperów lub wsparcia tego nie zauważy (hipotetycznie)? Który

użytkownik będzie miał możliwość zauważyć to jako pierwszy, kiedy

i gdzie?

Jaka była pierwsza i najważniejsza funkcja, która stanowiła zachętę do

rozpoczęcia prac nad tym systemem? Co robili użytkownicy, zanim

pojawił się ten system?

Celem wszystkich tych pytań jest próba uzyskania odpowiedzi na jedno

ważne pytanie:

„Co jest najbardziej istotną rzeczą, jaką wykonuje aplikacja?”

Lub też używając słów nieco bardziej odpowiednich w naszej dyskusji:

„Co jest najbardziej istotną rzeczą, jaką powinniśmy

przetestować?”

Zwróćmy uwagę, że odpowiedź na te pytania zawsze obejmuje jakiś

wynik, który jest rezultatem pewnej ważnej i unikalnej funkcjonalności,

a to możemy już zweryfikować.

Niestety wiele testów uznawanych przez ludzi za testy „poprawności”

nie daje odpowiedzi na te pytania. Oto kilka przykładów testów, które nie
są dobrymi kandydatami na pierwsze testy, czy też w ogóle na testy

automatyczne:

Testy, które nie określają konkretnego i szczegółowego spodziewanego

rezultatu. Na przykład: „Kliknięcie przycisku ‘Wyślij zamówienie’

powinno zakończyć się pomyślnie”. Nawet gdy taki przycisk wykonuje

najważniejszą rzecz w całej aplikacji, to jeśli nie potrafimy określić, co

powinno być oczekiwanym rezultatem jego kliknięcia, nie ma nawet

sensu w ogóle go testować.

Opieranie się na komunikacie „sukcesu” jako dowodzie, że jakaś

operacja zakończyła się pomyślnie. Na przykład: „Dokonaj zakupu

i zweryfikuj, czy pojawia się komunikat świadczący o sukcesie”. Mimo

że rezultat końcowy określony jest bardzo przejrzyście, to jednak nie

dostarcza on dowodu na to, że system pomyślnie wykonał akcję, którą

miał wykonać. Innymi słowy, nie stanowi to dowodu, że system

dostarcza wartość, jaką powinien dostarczyć. Wyświetlanie wiadomości

o pomyślnym wykonaniu jest istotne z perspektywy użytkownika, ale

jest mniej istotne od innego wyniku, który jest sednem danej

funkcjonalności. Zadajmy sobie bowiem pytanie: jeśli system wyświetli

komunikat nawet wtedy, gdy nie zrobi on niczego innego, to czy ma to

jakąkolwiek wartość? W powyższym przykładzie weryfikowanie, czy

zakup został wykonany pomyślnie, może wymagać przejścia na stronę

„moje zakupy” i sprawdzenia, czy taki zakup, wraz ze wszystkimi jego

szczegółami, znajduje się na tej stronie, bądź też – co może być jeszcze

lepszym rozwiązaniem – sprawdzenia, czy sprzedawca widzi to

zamówienie!

Testy, które weryfikują, czy użytkownik może się zalogować.

Logowanie nigdy nie jest prawdziwym celem systemu. Jest to po prostu


coś, co użytkownik musi zrobić, aby mógł być jednoznacznie

identyfikowany podczas wykonywania innych istotnych czynności

w czasie korzystania z systemu. Nikt nie budowałby systemu tylko po

to, aby umożliwić jego użytkownikom logowanie się do niego, tak samo

jak żaden użytkownik nigdy nie chciałby korzystać z systemu, którego

najważniejszą funkcją jest to, że pozwala mu się on do niego

zalogować. Fakt, że jest to pierwszy krok w wielu testach, nie sprawia,

że jest to odpowiedni kandydat na pierwszy test! W dalszej części

projektu automatyzacji możemy przetestować pewne funkcje związane

z bezpieczeństwem lub zarządzaniem użytkownikami, i to wyłącznie

w tym kontekście powinniśmy pisać testy obejmujące proces logowania

w postaci samej testowanej funkcji, a nie tylko kroku prowadzącego do

osiągnięcia innego celu.

Testy weryfikujące układ okien lub stron. Problemy związane

z układem stron, takie jak przycięty tekst, niewyrównane kontrolki, złe

kolory itd., są dosyć łatwe do wykrycia przez osobę testującą. Pomijając

już jednak fakt, że rzadko jest to najważniejsza funkcja systemu,

maszyny nie potrafią w prosty sposób wykrywać takich problemów.

Nawet w przypadku użycia narzędzi, które ułatwiają wykrywanie takich

błędów, każda najmniejsza zmiana w układzie tych elementów

spowoduje konieczność podjęcia odpowiednich działań.

Testy, które sprawdzają, czy po pojawieniu się strony pewne przyciski

i pola są wyłączone lub włączone. Jest to nieco lepsze niż testowanie

układu kontrolek, ale nadal nie jest to celem, dla którego budowane są

systemy. Ponadto każdy przycisk lub pole, które może zostać

wyłączone, musi zostać również włączone w jakimś innym przypadku,

co oznacza, że najważniejszą rzeczą do zweryfikowania jest to, czy

dane pole zostaje włączone lub wyłączone w zależności od konkretnych


warunków. Testowanie wszystkich przycisków i pól na stronie pod

kątem tego, czy są włączone lub wyłączone, nie tylko nie mówi nam

zbyt wiele, ale też może zostać w łatwy sposób popsute, jeśli

którekolwiek z tych istotnych warunków zostaną zmienione.

Testy, które weryfikują, czy użytkownik może wypełnić wszystkie pola

w formularzu. Podobnie jak w innych przypadkach, system, który

pozwala użytkownikom na wprowadzanie danych, które nie są później

nigdzie wykorzystywane, nie ma żadnej wartości. Dane powinny mieć

wpływ na inne funkcje, być przesyłane do innego systemu w celu ich

wykorzystania, lub przynajmniej być zapisywane i pobierane ponownie

przez użytkownika w późniejszym czasie. Prawdopodobnie największą

wartość z tych danych uzyskuje się, gdy są one wykorzystywane przez

system do celu wywierania wpływu na inne automatyczne operacje.

Zapisywanie i pobieranie danych nie jest niczym specjalnym dla

typowej aplikacji (chyba że jesteśmy częścią zespołu, który rozwija

silnik bazy danych, co jednak jest mało prawdopodobne). Z tego

powodu warto mieć zdefiniowany osobny test dla każdej wartości

w formularzu, aby tylko zweryfikować, czy wpływa ona na

odpowiednią funkcjonalność w taki sposób, w jaki powinna.

CZY ZAWSZE OPŁACA SIĘ TESTOWAĆ NAJWAŻNIEJSZĄ

FUNKCJĘ?

Może się zdarzyć, że najważniejsza funkcjonalność systemu jest tak

stabilna, że przetestowanie jej nie ma już najwyższego priorytetu. Jeśli

taka sytuacja ma miejsce, to może być to jedna z dwóch opcji:

1. Nie wybraliśmy właściwego systemu do przetestowania. Jeśli

komponenty systemu, które implementują krytyczny scenariusz,


nie będą w najbliższym czasie modyfikowane, to prawdopodobnie

nie testujemy właściwych komponentów i powinniśmy raz jeszcze

przemyśleć, jaki system (lub podsystem) chcemy przetestować.

2. Główny scenariusz jest dosyć stabilny, ale wykorzystywane przez

niego komponenty są modyfikowane w celu dodania lub zmiany

innych funkcji. W takim wypadku, mimo że szanse na powstanie

regresji są dosyć niskie, nadal warto rozpocząć od tego

scenariusza, i to z dwóch powodów:

a. Mimo że szanse na to mogą być małe, to jednak jest możliwe,

że ten bardzo ważny scenariusz przestanie działać. Lepiej jest

wykryć taki przypadek wcześniej (przez automatyzację) niż

później (przez testerów manualnych lub w środowisku

produkcyjnym).

b. Prawdopodobnie większość innych funkcji i scenariuszy to

warianty scenariusza krytycznego lub w jakiś sposób z nim

powiązane. Rozpoczęcie od tego testu pomoże nam zbudować

infrastrukturę, z której będą mogły korzystać inne testy.

Gdy ustalimy już, co jest najważniejszym wynikiem, który chcemy

przetestować, musimy zaplanować sposób, w jaki go przetestujemy. W tym

celu musimy odpowiedzieć na bardzo proste pytanie (mimo że odpowiedź

na to pytanie nie zawsze jest taka prosta…), a mianowicie:

„Jakie są niezbędne dane mające wpływ na wyniki, które chcemy

przetestować?”

Zwróćmy uwagę na słowo „niezbędne”. Wiele danych tak czy inaczej

ma prawdopodobnie jakiś wpływ na wynik, ale my chcemy rozpocząć od

najprostszego scenariusza, który pokazuje wartość danego systemu. Inne


testy, które napiszemy później, mogą weryfikować wpływ, jaki inne dane

mają na ten sam lub nawet inne wyniki. Domyślnym sposobem udzielenia

odpowiedzi na to pytanie powinno być zapytanie o to właściciela produktu.

Ponieważ jednak właściciel produktu zwykle myśli o tym, w jaki sposób

produkt ten będzie wykorzystywany w środowisku produkcyjnym, często

udzielaną przez niego odpowiedzią jest to, że wszystko jest niezbędne i że

niemożliwe jest wyodrębnienie tylko kilku danych w celu uzyskania

potrzebnego wyniku. Z tego powodu często najlepszym sposobem

udzielenia odpowiedzi na to pytanie jest po prostu samodzielne

poeksperymentowanie lub przeglądanie kodu testowanego systemu. Zwykle

deweloperzy mogą nam pokazać, w który miejscu powinniśmy szukać, co

pozwoli znacznie skrócić ten proces.

Od tych dwóch istotnych pytań możemy rozpocząć tworzenie

scenariusza, w którym mogą powstać inne pytania dotyczące tego, jak

wykonać pewne operacje lub jak zaimplementować je w ramach

automatyzacji. W ogólnym przypadku scenariusz ten powinien polegać na

sterowaniu danymi testowanego systemu w sposób, który będzie miał

wpływ na istotne wyniki, i sprawdzeniu, czy wynik, który chcemy

zweryfikować, faktycznie zmienił się w oczekiwany sposób. Na tym etapie

możemy zajrzeć znów do rozdziału 6, aby rozważyć najbardziej

odpowiednią architekturę dla testu oraz sposób, w jaki test powinien

komunikować się z testowanym systemem. Zwróćmy uwagę, że jeśli nie

mamy kontroli nad odpowiednimi danymi (ponieważ są one pozyskiwane

z systemu zewnętrznego) lub jeśli istotne wyniki można zobaczyć jedynie

za pośrednictwem systemu zewnętrznego, to możemy rozważyć utworzenie

symulatora dla tych zewnętrznych systemów.

Wybieranie pierwszego przypadku testowego dla aplikacji


MVCForum
Wracając do naszego praktycznego samouczka – ponieważ testowany przez

nas system jest internetowym forum dyskusyjnym, jego główna wartość

tkwi w możliwości publikowania w nim pytań skierowanych do szerokiego

grona odbiorców i umożliwiania innym osobom udzielania odpowiedzi na

te pytania. Zwróćmy uwagę, że choć istotną funkcją wyróżniającą są tutaj

reguły dotyczące przyznawania punktów i odznak (ponieważ motywują one

użytkowników do korzystania z witryny, co przynosi korzyści nam

wszystkim) – przez co również i one powinny być brane pod uwagę przy

wyborze pierwszego testu – to jednak nadal wspierają one jedynie główny

cel, jakim jest umożliwienie publicznych dyskusji, bez których reguły te

same w sobie nie mają żadnej wartości.

Co zatem jest najważniejszym wynikiem działania systemu? Z punktu

widzenia zapewnienia wartości, którą zidentyfikowaliśmy powyżej,

najważniejszym wynikiem są wiadomości publikowane przez innych

użytkowników. Zwróćmy uwagę, że jeśli każdy użytkownik będzie mógł

odczytywać tylko swoje wiadomości, to system nie będzie nam dostarczał

podstawowej wartości, jakiej od niego oczekujemy. Z tego powodu

powinniśmy zweryfikować, czy dowolny użytkownik (nawet użytkownik

niezarejestrowany, tj. anonimowy) jest w stanie odczytywać wiadomości

publikowane przez innych. Powinniśmy także sprawdzić, czy jeden

użytkownik może udzielać odpowiedzi lub komentować w ramach dyskusji

innego użytkownika, gdyż inaczej nasza aplikacja będzie po prostu tablicą

ogłoszeń, a nie forum dyskusyjnym. Aby jednak nasze testy były

wystarczająco małe i łatwe w utrzymywaniu, dla każdej z tych weryfikacji

powinniśmy utworzyć oddzielny test.

A co z danymi? W naszym przykładzie odpowiedź jest trywialna.

Również będą nimi wiadomości i komentarze pisane przez użytkowników.

Gdy jednak zaprojektujemy przypadek testowy bardziej szczegółowo,


zobaczymy, że trzeba będzie dostarczyć więcej danych, aby zrealizować ten

scenariusz.

Naukowa metoda projektowania przypadku


testowego

Fakty naukowe nie stają się „faktami naukowymi” tylko dlatego, że

naukowcy tak postanowią. W rzeczywistości żadne twierdzenie naukowe

nigdy nie może zostać w pełni dowiedzione. Wręcz przeciwnie: metoda

naukowa motywuje naukowców do odrzucania teorii innych osób. Aby to

zrobić, muszą oni przeprowadzić jakiś test empiryczny pokazujący

przykład, który jest niespójny z twierdzeniami danej teorii. Wystarczy

znaleźć jeden taki kontrprzykład, aby obalić czyjąś teorię! Jednak w miarę

jak coraz więcej naukowców wykonuje dla danej teorii kolejne testy

empiryczne i żaden z nich nie jest w stanie znaleźć żadnego

kontrprzykładu, teoria taka staje się coraz bardziej stabilna i uznawana jest

za „prawdziwą”.

W podobny sposób, gdy rozpoczynamy projektowanie przypadku

testowego, powinniśmy myśleć o nim jak o twierdzeniu naukowym.

Twierdzenie takie jest zdaniem, które jest albo prawdziwe, albo fałszywe.

Twierdzenie to będziemy się starali obalić w naszym teście, tak więc

pierwszą rzeczą, jaką musimy zrobić podczas projektowania przypadku

testowego, jest sformułowanie tego twierdzenia. Na tym etapie otwieramy

nasz ulubiony edytor tekstowy i zapisujemy w nim to sformułowanie.

Zwykle pod definicją taką dodajemy wiersz złożony ze znaków „=”, aby

oznaczyć w ten sposób tytuł tego testu. Zwróćmy uwagę, że metoda ta jest

przydatna dla wszystkich przypadków testowych, nie tylko tych

automatycznych, ale to właśnie dla testów automatycznych jest najbardziej


istotna, ponieważ oczekiwany rezultat nie może podlegać żadnym

interpretacjom.

W przypadku aplikacji MVCForum twierdzenie dla naszego pierwszego

scenariusza możemy zdefiniować jak na listingu 10.1.

When a registered user starts a discussion, other,


anonymous users can see it (Gdy zarejestrowany
użytkownik rozpocznie nową dyskusję, to jest ona
widoczna dla innych, anonimowych użytkowników)30
==================================================
===================

Listing 10.1. Twierdzenie pierwszego testu

Gdy już zdefiniowaliśmy, co chcemy przetestować, musimy

zdefiniować sposób, w jaki zamierzamy to przetestować, tak aby

w przypadku, gdy test zakończy się niepowodzeniem, można było to

twierdzenie obalić.

Projektowanie kroków testu

Kontynuując naszą analogię metody naukowej, projektujemy kroki testu

jako kroki eksperymentu naukowego. W przypadku eksperymentu

naukowego chcemy całkowicie wyizolować wszelkie nieistotne parametry

i „szum” mogący mieć wpływ na nasz wynik, które nie mają żadnego

związku z twierdzeniem, które chcemy wypróbować i obalić.

Chcemy również, aby nasz eksperyment był tak prosty i krótki, jak to

tylko możliwe, a dzięki czemu każdy, kto go przeczyta, będzie potrafił go

odtworzyć.
Istnieje jednak pewna mała różnica pomiędzy eksperymentem

naukowym a testem automatycznym. Eksperyment naukowy nie wymaga

żadnych dodatkowych modyfikacji, ponieważ prawa fizyki raczej się

w najbliższym czasie nie zmienią… Jednak testy automatyczne piszemy dla

systemów, które nieustannie ewoluują, tak więc musimy się upewnić, że

nasze testy będą łatwe w utrzymywaniu. Różnica ta ma swoje

odzwierciedlenie w poziomie szczegółów, na jakim opisujemy scenariusz.

Choć ostatecznie podczas pisania kodu będziemy musieli podać wszystkie

szczegóły, aby ten scenariusz mógł działać, to podczas projektowania testu

w formie eksperymentu pozostawiamy jedynie minimalną liczbę

potrzebnych szczegółów, które będą pomocne w zrozumieniu przepływu

tego testu. Wszystkie pozostałe szczegóły staną się szczegółami

implementacji, które mają dużo mniejsze znaczenie dla naszego

eksperymentu. W naszym kodzie szczegóły te umieścimy w komponentach

wielokrotnego użytku niższego poziomu, które utworzymy, bądź też

użyjemy domyślnych wartości dla rzeczy, które być może będziemy musieli

zmienić w innych testach lub w przyszłości.

Kontynuując nasz przykład z aplikacją MVCForum, kompletny

przypadek testowy można napisać w postaci eksperymentu jak

w przykładzie na listingu 10.2.

When a registered user starts a discussion, other,


anonymous users can see it
==================================================
===================
Login as a registered user (Zaloguj się jako
zarejestrowany użytkownik)
Start a discussion titled "Hi!" and body "dummy
body" (Rozpocznij dyskusję zatytułowaną "Hi!"
o treści "dummy body")
Enter the site as an anonymous user (from another
Browser) (Wejdź na stronę jako użytkownik
anonimowy (za pomocą innej przeglądarki))
Verify that a discussion titled "Hi!" appears
(Upewnij się, że dyskusja zatytułowana "Hi!" jest
widoczna)
Open that discussion (Otwórz tę dyskusję)
Verify that the body of the discussion is "dummy
body" (Upewnij się, że treścią tej dyskusji jest
"dummy body")

Listing 10.2. Dodawanie kroków do pierwszego testu

Uwagi:

Zauważmy, że w pierwszym wierszu zakładamy, iż mamy do czynienia

z dobrze znanym, zarejestrowanym użytkownikiem. Choć w praktyce

lub w standardowym środowisku testowania taka sytuacja prawie

zawsze ma miejsce, to z powodów związanych z izolacją nie możemy

posłużyć się dowolnie wybranym użytkownikiem. Musimy więc teraz

zacząć myśleć o technice izolacji dotyczącej zarejestrowanych

użytkowników (więcej informacji na ten temat można znaleźć

w rozdziale 7). Gdyby w znaczącej większości testów był potrzebny

tylko jeden zarejestrowany użytkownik, moglibyśmy utworzyć takiego

użytkownika jako wymaganie wstępne w środowisku automatyzacji.

Ponieważ jednak nasza aplikacja jest forum dyskusyjnym, zakładamy


tu, że będziemy tworzyć dla niej wiele testów, które będą wymagać

więcej niż jednego zarejestrowanego użytkownika. Może zatem

powinniśmy utworzyć dwóch zarejestrowanych użytkowników? Ale co

w przypadku, gdy będziemy potrzebować ich więcej? Ponadto dowolna

aktywność zarejestrowanego użytkownika może być widoczna dla

innych użytkowników, tak więc nasze środowisko może zostać

zaśmiecone i pewne testy mogą mieć nieoczekiwane rezultaty… Nie ma

tu jednego właściwego rozwiązania, jednak my, przynajmniej na razie,

umieścimy rejestrację użytkownika wewnątrz samej czynności „Zaloguj

się jako zarejestrowany użytkownik”. Oznacza to, że przy każdym

uruchomieniu testu zostanie zarejestrowany nowy użytkownik. Co

więcej, jeśli wykorzystamy ponownie ten krok w innych testach, to za

każdym razem, gdy będzie on wykonywany, będzie rejestrowany nowy

użytkownik. Choć opis tej operacji może trochę wprowadzać w błąd,

ponieważ ukrywa on fakt, że dodatkowo zarejestruje on nowego

użytkownika, to jednak operacja ta jest w pełni niepodzielna

i autonomiczna, co pozwala na jej ponowne wykorzystanie, a do tego

spełnia ona całkowicie swoje zadanie: loguje przy użyciu jakiegoś

zarejestrowanego użytkownika. Liczba użytkowników tworzonych

podczas uruchamiania testów może nieco niepokoić, ale raczej nie

będzie to stanowić większego problemu, ponieważ system powinien być

w stanie to obsłużyć. Prawdopodobnie przy każdym uruchomieniu i tak

będziemy zaczynać testowanie z nową, pustą bazą danych, aby

usprawnić w ten sposób izolację, tak więc nie ma to aż tak wielkiego

znaczenia.

Skorzystaliśmy z trwale zakodowanych ciągów znaków „Hi!” oraz

„dummy body”. Jednak podczas implementacji tego testu

prawdopodobnie zamienimy tekst „Hi!” na jakiś losowy ciąg znaków,


ponieważ będziemy musieli rozpoznawać wiadomości na podstawie

tytułu. Dzięki temu, nawet jeśli uruchomimy nasz test wiele razy (lub

różne testy, z których wszystkie wykorzystują ten sam ciąg znaków), to

zawsze będziemy mogli zidentyfikować wiadomość, którą

utworzyliśmy. Jeśli zaś chodzi o treść wiadomości, to możemy

zachować obecny tekst „dummy body”, ponieważ nie musimy

wyszukiwać na podstawie tej wartości.

Może nam się wydawać, że lepiej używać dłuższych i bardziej

złożonych treści wiadomości (na przykład takich, które zawierają

pogrubiony lub podkreślony tekst) i sprawdzać, czy wyświetlają się one

poprawnie. Nie jest to jednak celem tego testu i powinno być

weryfikowane w osobnych testach, ponieważ nie chcemy, aby ten

prosty test zakończył się niepowodzeniem z tego rodzaju powodu.

Gdybyśmy spróbowali wykonać ten scenariusz ręcznie zaraz po

zainstalowaniu aplikacji, zobaczylibyśmy, że nowo zarejestrowany

użytkownik nie może utworzyć nowej dyskusji. Powodem jest to, że

domyślnie istnieje tylko jedna kategoria, o nazwie „Example Category”

(Kategoria przykładowa), i tylko administrator ma uprawnienia do

tworzenia w niej nowych dyskusji. Ponadto, podczas tworzenia nowej

dyskusji użytkownik musi przypisać ją do wybranej kategorii. Dlatego

możemy albo dodać wymagane uprawnienia do kategorii „Example

Category”, albo też dodać kolejną kategorię, która będzie używana jako

kategoria domyślna dla wszystkich testów niewymagających konkretnej

kategorii i nadać wszystkim zarejestrowanym użytkownikom niezbędne

uprawnienia do tworzenia nowych dyskusji w tej kategorii. Choć ta

druga opcja jest bardziej przejrzysta, to pierwszy wariant jest prostszy,

więc zaczniemy od niego. Napiszemy kod w taki sposób, aby później

można było łatwo zmienić tę decyzję. Tak czy inaczej, powinno to być
wymaganiem wstępnym testu, a nie częścią samego testu. Później

będziemy musieli zdecydować, jak zamierzamy to zaimplementować:

poprzez wykonywanie tych zmian za pośrednictwem interfejsu

użytkownika (z administratorem jako użytkownikiem) przed

uruchomieniem pierwszego testu, poprzez wprowadzanie zmian do

bazy danych, za pomocą pliku kopii zapasowej itd.

Myślenie w kontekście obiektów i jednostek

W zasadzie kolejnym etapem w naszym przepływie pracy powinno być

przełożenie tych kroków na kod. Aby jednak kod ten był łatwy

w utrzymaniu i możliwe było jego ponowne wykorzystywanie, należy

stosować się do odpowiednich zasad projektowania obiektowego. Aby

łatwiej nam było przetłumaczyć te kroki na kod obiektowy, powinniśmy

zrobić jedną rzecz przed rozpoczęciem kodowania: obok każdego wiersza

napisać rzeczownik, który reprezentuje „obiekt” lub „jednostkę” tworzącą

kontekst dla operacji opisywanej przez ten wiersz. Kontekst ten lepiej

opisują terminy biznesowe lub rzeczywiste niż techniczne, ponieważ

terminy te wydają się mieć sens dla większości ludzi, a ponadto mają

szansę pozostać poprawne przez długi czas, a nawet po wprowadzeniu

w systemie poważnych zmian architektonicznych lub technologicznych.

Zwróćmy uwagę, że nie istnieje żaden deterministyczny wzór na

wymyślanie tych kontekstów (lub obiektów). Jeśli dwukrotnie

zademonstrujemy sposób pisania takiego testu różnym osobom, zwykle

zastosujemy w nich dwa różne sposoby opisywania jego kroków

i odpowiadających im obiektów. Umiejętność wyczucia, które abstrakcje

mają większy sens i prowadzą do lepiej zaprojektowanego kodu, wymaga

pewnego doświadczenia.
W wielu przypadkach, wliczając w to również nasz przykład,

kontekstem dla pierwszej operacji będzie po prostu sama aplikacja

(testowany system), zaś kontekstem dla innych operacji są często rezultaty

poprzednich operacji. Gdy będziemy to później tłumaczyć na kod, będzie to

oznaczać, że metoda zwraca jakiś obiekt, a następny wiersz wywołuje na

tym obiekcie kolejną metodę. Czasami najlepszym sposobem na opisanie

kontekstu wiersza jest skorzystanie z kilku rzeczowników (dwóch lub

trzech, ale nie więcej!), z których pierwszy jest szerszym kontekstem,

a kolejne są bardziej szczegółowe. Przekłada się to na jednostki zawierające

inne jednostki lub obiekty odnoszące się do innych obiektów w kodzie.

Możemy na przykład zapisać te konteksty jak na listingu 10.3.

When a registered user starts a discussion, other,


anonymous users can see it
==================================================
===================
Login as a registered user
// MVCForum (aplikacja)
Start a discussion titled "Hi!" and body "dummy
body"
// LoggedInUser
Enter the site as an anonymous user (from another
browser)
// MVCForum (nowa instancja)
Verify that a discussion titled "Hi!" appears
// MVCForum.LatestDiscussions.Top (*)
Open that discussion
// DiscussionHeader
Verify that the body of the discussion is "dummy
body"
// Discussion

Listing 10.3. Dodawanie kontekstu do każdego kroku

UWAGI (*)

Gdy po raz pierwszy zaprojektowaliśmy ten test, nie powiedzieliśmy,

gdzie dokładnie powinna pojawiać się dyskusja. Z technicznego

punktu widzenia możemy zobaczyć ją albo na liście ostatnich dyskusji

(wyświetlanej na stronie głównej), albo przejść do kategorii tej

dyskusji i wylistować w niej wszystkie dyskusje. Teoretycznie są to

dwie niepowtarzalne funkcje, które mogą się zmieniać niezależnie od

głównej funkcjonalności, którą weryfikujemy w teście. Aby to

rozwiązać, możemy więc napisać MVCForum.AllDiscussions


zamiast MVCForum.LatestDiscussions. Tę listę można

zaimplementować za pomocą leniwego wczytywania (tj. pobrać jakiś

element dopiero przy próbie uzyskania do niego dostępu, przechodząc

po wszystkich stronach, jeśli jest więcej niż jedna), aby uniknąć

jednoczesnego wczytywania do pamięci wszystkich danych. Jeśli

jednak mamy być pragmatyczni, to sensowne wydaje się założenie, że

zawsze będzie istniał jakiś sposób pozyskania listy wszystkich

dyskusji, które uporządkowane będą od najnowszej do najstarszej,

więc w przypadku zmiany tej funkcji po prostu podmienimy jej

implementację.

Ponadto możemy być pewni że dana dyskusja będzie najnowsza (na

samej górze), tylko wtedy, gdy nikt poza nami nie używa tego samego
środowiska. Zajmiemy się tym później w ramach naszego rozwiązania

izolacji (informacje na temat technik izolacji znajdują się w rozdziale

7).

Zwróćmy uwagę na to, że kontekst „Zalogowany użytkownik” drugiej

operacji jest rezultatem pierwszej operacji „Zaloguj się jako zarejestrowany

użytkownik”, „Nagłówek dyskusji” należy do dyskusji zidentyfikowanej

w poprzednim kroku, zaś sama „Dyskusja” jest rezultatem otwarcia

dyskusji z „Nagłówka dyskusji”.

Modelowanie aplikacji

Często mówimy, że obiekty te i powiązania między nimi modelują aplikację

oraz rzeczywiste jednostki, które są przez nią reprezentowane. Mówimy

również, że te obiekty i powiązania tworzą model, który reprezentuje

aplikację i rzeczywiste jednostki. Przykładowo zdanie „Klient dodaje

produkt do zamówienia” możemy zamodelować za pomocą klas

Customer (klient), Product (produkt) i Order (zamówienie). Klasy te

będą prawdopodobnie zawierać metody pokazane na listingu 10.4.

class Customer {
public IList<Order> Orders { get {
/*...*/ } }
public Order CreateOrder()
{
//...
}
//...
}
class Order {
public IList<Product> Products { get {
/*...*/ } }
public void AddProduct(Product product)
{
//...
}
public void Submit()
{
//...
}
//...
}
class Product {
public string Description { get {
/*...*/ } }
public decimal Price { get { /*...*/ } }
//...
}

Listing 10.4. Przykład modelowania

Wzorzec obiektu strony

Powszechnie stosowanym i dobrze znanym wzorcem do modelowania

testowanego systemu w sposób obiektowy jest wzorzec obiektu strony

(Page Object Pattern). Wzorzec ten ma zastosowanie wyłącznie do

automatyzacji testów interfejsu użytkownika i opisuje kompletne strony


sieci Web lub okna (lub też ich fragmenty), udostępniając metody, które

odzwierciedlają operacje, jakie użytkownik może wykonywać w tym

obszarze interfejsu.

Dla wielu ludzi wzorzec obiektu strony jest bardzo interesujący,

ponieważ jest on łatwy do zrozumienia i zaimplementowania, a do tego

uwalnia dewelopera automatyzacji od zbyt kreatywnego myślenia

o modelowaniu jego dziedziny biznesowej. Ja również wykorzystuję ten

wzorzec, ale nie trzymam się go zbyt sztywno, ponieważ postrzegam go

wyłącznie jako jedną z wielu opcji do modelowania aplikacji. Poniżej

wymienione są jego wady, których powinniśmy być świadomi, jeśli

podczas modelowania aplikacji korzystamy głównie z obiektów strony:

Ponieważ ma on zastosowanie wyłącznie do interfejsu użytkownika, nie

może modelować funkcji, które nie są związane z interfejsem.

Przy drastycznej zmianie interfejsu użytkownika trzeba ponownie

napisać bardzo duże fragmenty automatyzacji, nawet jeśli logika

biznesowa pozostaje mniej więcej taka sama.

Jeśli będziemy chcieli później zmienić jakieś operacje, tak aby były one

wykonywane za pośrednictwem niższej warstwy (np. poprzez API),

trzeba będzie zmienić to nie tylko naszą implementację, ale również

model. Innymi słowy, jeśli użyjemy abstrakcji reprezentujących

biznesowe operacje i jednostki zamiast stron interfejsu użytkownika, to

jest bardziej prawdopodobne, że takie zmiany ograniczone będą do

wewnętrznych szczegółów konkretnej klasy lub metody, natomiast

w przypadku użycia wyłącznie obiektów strony pewnie trzeba będzie

zamienić sporą ich ilość.

Jeśli jednak decydujemy się na użycie wzorca obiektu strony jako

naszej podstawowej abstrakcji, to możemy skorzystać z kilku poniższych


wskazówek i zalecanych praktyk, dzięki którym nasz kod będzie łatwiejszy

w utrzymaniu:

Ukrywajmy szczegóły Selenium wewnątrz klasy obiektu strony. Jeśli na

przykład mamy na stronie przycisk Submit (Wyślij), to zamiast

publicznej właściwości (lub metody ustawiającej) zwracającej

IWebElement dla tego przycisku, powinniśmy umieścić metodę


void Submit() na obiekcie, który wyszukuje przycisk i klika go.
Ponadto unikajmy udostępniania wszystkich pól w formie publicznych

właściwości (tj. metod pobierających i ustawiających wartość dla pola)

i metody dla każdego przycisku. Zamiast tego możemy rozważyć

udostępnienie metod wykonujących bardziej ogólne i koncepcyjne

operacje biznesowe. Przykładowo, zamiast udostępniać dla klasy

LoginPage metody pobierające i ustawiające wartości dla pól


UserName i Password oraz metodę do klikania przycisku Submit,
warto mieć jedną metodę Login, która przyjmuje nazwę użytkowania
i hasło w postaci argumentów, wypełnia je i klika przycisk Submit.

Korzystanie z bardziej ogólnych metod sprawi, że nasze testy będą

bardziej czytelne i łatwiejsze w utrzymaniu. Nawet jeśli elementy na

stronie będą ulegać zmianie, to będziemy musieli zmienić jedynie kod

wewnątrz tej metody, bez żadnego kodu, który ją wywołuje.

Gdy operacja prowadzi użytkownika na inną stronę lub wyświetla nowy

widok, metoda wykonująca tę operację powinna zwrócić obiekt strony,

który reprezentuje ten nowy widok. Dzięki temu kod będzie dużo

prostszy i łatwiej nam będzie ponownie go wykorzystać. Jest to

szczególnie korzystne z powodu funkcji automatycznego uzupełniania,

jaka dostępna jest w większości środowisk IDE. Jeśli na przykład po

zalogowaniu użytkownik zostanie przekierowany na stronę główną,


metoda Login z poprzedniego przykładu powinna zwrócić obiekt
MainPage. Ostatecznie więc sygnatura metody powinna wyglądać
następująco:

MainPage Login(string username, string


password)
Mimo swojej nazwy, „obiekty strony” nie powinny odpowiadać

wyłącznie całym stronom. Zamiast tego zaleca się rozłożenie strony na

podstrony lub widoki, zgodnie z jej logicznym układem. Na przykład

w typowej aplikacji poczty elektronicznej może znajdować się obiekt

MainPage zawierający właściwości dla paska narzędzi, drzewa


folderów, listy nagłówków i panelu podglądu. Każdy z tych widoków

powinien mieć swój własny obiekt strony. Idea ta może nawet zostać

wykorzystana ponownie w formie zagnieżdżenia, przez co widoki te

przechowywać będą swoje własne widoki wewnętrzne. Przykładowo

panel podglądu może przechowywać nagłówek wiadomości (który jest

stały) oraz treść (którą możemy przewijać). Na rysunku 10.1 i listingu

10.5 pokazano odpowiednio, jak wygląda to w interfejsie użytkownika

oraz w kodzie.
Rysunek 10.1. Zagnieżdżone obiekty strony

class MainPage
{
// ...
public ToolbarView ToolBar { get {
/*...*/ } }
public FoldersTreeView FoldersTree { get
{ /*...*/ } }
public HeadersListView HeadersList { get
{ /*...*/ } }
public PreviewPane PreviewPane { get {
/*...*/ } }
}
class ToolbarView
{
//...
}
class FoldersTreeView
{
//...
}
class HeadersListView
{
//...
}
class PreviewPane
{
public MessageHeaderView MessageHeader {
get { /*...*/ } }
public MessageBodyView MessageBody { get
{ /*...*/ } }
}
class MessageHeaderView
{
//...
}
class MessageBodyView
{
//...
}
Listing 10.5. Obiekty strony dla typowej aplikacji głównej

Dla widoków, które pojawiają się w wielu miejscach w aplikacji,

powinniśmy użyć innych instancji tej samej klasy obiektu strony. Na

przykład aplikacja MVCForum w różnych miejscach oferuje edytor

tekstu sformatowanego: podczas tworzenia nowej dyskusji, podczas

odpowiadania w ramach dyskusji, a także w konsoli administratora do

wprowadzenia opisu dla kategorii. Kolejnym typowym przypadkiem

jest sytuacja, gdy mamy tabele obsługujące sortowanie, grupowanie

i filtrowanie, ale pojawiają się one w różnych miejscach z różnymi

kolumnami i wierszami. W niektórych przypadkach wiersze te mogą

być znacznie bogatsze od zwykłego wiersza wartości i mogą wyglądać

jakby zawierały swój własny widok podrzędny. W takim wypadku

wiersze te mogą mieć swój własny obiekt strony, a tabela powinna

udostępniać właściwość, która zwraca kolekcję obiektów strony

reprezentujących te podrzędne widoki wiersza. Przykładem tego jest

lista dyskusji w aplikacji MVCForum, ponieważ każdy nagłówek

dyskusji jest widokiem zawierającym ikonę, tytuł, kategorię, autora itd.

We wszystkich tych przypadkach powinniśmy używać różnych instancji

tej samej klasy obiektu strony. Oznacza to, że należy unikać używania

statycznych elementów członkowskich w tych klasach. Nie zaleca się

ponadto korzystania z singletonów, ponieważ nawet jeśli uważamy, że

nigdy nie będziemy potrzebować więcej niż jednej instancji, to w wielu

przypadkach założenia te okazują się być błędne. A kiedy już nadejdzie

taka chwila, w której założenie to okaże się błędne, będzie nam

niezwykle trudno je zmienić. Nawet w przypadku strony głównej

możemy utworzyć dwie instancje do symulowania dwóch

jednoczesnych użytkowników!
Istnieją takie widoki, które pojawiają się w różnych miejscach

w aplikacji, ale z pewnymi różnicami. Są też podobne do siebie widoki,

w których pewna część jest stała, a część zmienia się w zależności od

rodzaju widoku. W takich przypadkach rozważmy skorzystanie z klas

abstrakcyjnych zawierających stałe części jako zwykłe właściwości,

a części różne jako właściwości abstrakcyjne. Następnie dla każdego

typu tworzymy klasy pochodne. Na przykład w aplikacji poczty e-mail

zwykłe wiadomości i zaproszenia na spotkanie mają ze sobą wiele

wspólnego, ale w pewnych aspektach różnią się między sobą, więc ich

panel podglądu może wyglądać nieco inaczej. W powyższym

przykładzie klasa MessageBodyView może być abstrakcyjną klasa


bazową, zaś MailMessageBodyView
i MeetingMessageBodyView mogą być klasami pochodnymi.

Co poza wzorcem obiektu strony?

Jak już wspomniałem, nie trzymam się sztywno wzorca obiektu strony.

Zamiast tego używam dowolnej abstrakcji, która moim zdaniem najlepiej

reprezentuje koncepcje biznesowe i operacje, które są mi potrzebne.

Podczas modelowania aplikacji staram się myśleć o powtórnym

wykorzystaniu, przejrzystości i braku duplikacji. Jak pokazano w kolejnych

rozdziałach, proces usuwania duplikacji odgrywa dużą rolę w ciągłym

usprawnianiu modelu wraz z upływem czasu, w miarę jak dodawane są

kolejne testy. Projektowanie i usprawnianie modelu często nasuwa istotne

pytania i ujawnia nieścisłości pomiędzy faktyczną a pożądaną

funkcjonalnością (innymi słowy, błędy) i prowadzi nas w kierunku lepszego

zrozumienia produktu.

Jeśli zamiast stron interfejsu użytkownika będziemy używać bardziej

koncepcyjnych jednostek biznesowych, to gdy pewnego dnia w przyszłości


będziemy chcieli zmienić zakres naszych testów z interfejsu użytkownika

na API lub na jakiś inny zakres, będzie to znacznie prostsze, ponieważ nie

trzeba będzie zmieniać modelu, a jedynie samą implementację. To samo

dotyczy również poszerzania zakresu – jeśli obecnie używamy testowania

API i chcemy umożliwić testowanie interfejsu użytkownika w przyszłości,

to również będzie to wymagać zmiany samej implementacji, bez

konieczności modyfikowania modelu.

Jeśli aplikacja jest bardzo dobrze zaprojektowana, to prawdopodobnie

wszystkie istotne funkcje, które chcemy przetestować w naszym pierwszym

teście (lub testach) są zawarte w warstwie logiki biznesowej, która już

modeluje jednostki biznesowe i powiązane z nimi operacje. W takim

wypadku powinniśmy pisać te testy bezpośrednio w warstwie logiki

biznesowej jako testy jednostkowe lub integracyjne. W rzeczywistości

jednak często separacja nie jest tak duża, jak byśmy chcieli, tak więc

możemy zamiast tego pisać testy jako testy systemu (więcej informacji na

temat wybierania odpowiedniego zakresu testowania można znaleźć

w rozdziale 6).

Gdy modeluję aplikację za pomocą ogólnych jednostek biznesowych, to

nadal często wykorzystuję w tle model obiektu strony. Inaczej mówiąc,

testy wykorzystują obiekty reprezentujące jednostki biznesowe, ale obiekty

te korzystają z obiektów strony wewnętrznie do interakcji z aplikacją za

pośrednictwem interfejsu użytkownika i nie są bezpośrednio używane przez

metody testowe.

Jak widzimy i jak wkrótce zobaczymy to jeszcze wyraźniej, proces

definiowania kontekstu dla każdej operacji prowadzi nas od tekstowego

opisu testu do obiektowego projektu, który możemy zaimplementować

w kodzie. W kolejnym rozdziale kontynuujemy nasz samouczek

i zaczniemy w końcu pisać kod!


Podsumowanie

Rozdział ten rozpoczęliśmy od wyboru pierwszego testu poprawności do

zautomatyzowania, następnie zdefiniowaliśmy przypadek testowy „metodą

naukową”, aż w końcu wymodelowaliśmy testowany system w ramach

podejścia obiektowego, co wkrótce posłuży nam za przewodnik projektowy

dla naszego kodu testu. Choć nie napisaliśmy jeszcze ani jednego wiersza

kodu i zaplanowaliśmy tylko jeden test, to wykonaliśmy pewną bardzo

istotną pracę. Pominięcie tego projektu lub niedbałe jego wykonanie

doprowadzi później do kosztownych problemów związanych

z utrzymaniem systemu. Zwróćmy jednak uwagę, że poprawne

opracowanie tego projektu również wymaga pewnej praktyki

i doświadczenia, tak więc bądźmy cierpliwi i spróbujmy uczyć się na bazie

własnych doświadczeń.
Rozdział 11. Kodowanie pierwszego
testu

Nareszcie! Po 10 rozdziałach możemy już przystąpić do pisania kodu…

W tym rozdziale zbudujemy szkielet dla pierwszego testu, który

zaprojektowaliśmy w poprzednim rozdziale. Poza kodem samego testu

napiszemy również puste klasy i metody, z których powinien on korzystać.

Rozdział ten zakończymy w momencie, w którym kod się kompiluje, ale

niczego jeszcze nie wykonuje. W następnym rozdziale będziemy

kontynuować proces implementowania tych metod, dopóki test nie zakończy

się sukcesem. Wiele fragmentów pisanego przez nas kodu posłuży również

za infrastrukturę, którą będziemy mogli wykorzystać w kolejnych testach.

Jak już wspomnieliśmy w rozdziale 9, będziemy korzystać z programu

Visual Studio, języka C# i oczywiście biblioteki Selenium. Zaczynajmy!

Tworzenie projektu

Aby napisać test, musimy najpierw utworzyć dla niego nowy projekt.

W programie Visual Studio projekt jest zawsze częścią jakiegoś rozwiązania

(solution). Możemy więc albo utworzyć nowe rozwiązanie dla automatyzacji

testów, albo dodać nasz projekt do istniejącego rozwiązania testowanego


systemu. Oba te sposoby są poprawne i stosowane w praktyce. Jeśli to tylko

możliwe, powinniśmy dodawać testy do istniejącego rozwiązania

testowanego systemu, ponieważ dzięki temu deweloperom łatwiej będzie

uruchamiać te testy. Ponadto w ten sposób zapewnimy sobie możliwość

ponownego wykorzystywania w naszych testach istniejących fragmentów

testowanego systemu. Wykorzystywanie fragmentów testowanego systemu

do jego przetestowania może wydawać się dziwnym, a nawet złym

pomysłem, ale ponowne użycie pewnych rzeczy, takich jak stałe czy nawet

interfejsy, może okazać się bardzo pomocne i zagwarantować, że test

korzysta z tych samych wartości co sama aplikacja. Testowanie poprawności

samych wartości nie jest zbyt istotne z punktu widzenia automatyzacji,

ponieważ przy każdej modyfikacji stałej trzeba również zmienić jej wartość

w testach…

Rozpoczynamy od dodania nowego projektu testu do rozwiązania

MVCForum. Aby to zrobić, należy wykonać poniższe kroki:

1. Uruchom Visual Studio, a następnie z lokalizacji repozytorium Git otwórz

rozwiązanie MVCForum (MVCForum.sln) pobrane w rozdziale 9. Jeśli

wykonywałeś polecenia z rozdziału 9 krok po kroku, prawdopodobnie

MVCForum będzie widoczne na liście ostatnio używanych rozwiązań.

2. Otwórz okno Solution Explorer (Eksplorator rozwiązań), dostępne

w menu View Solution Explorer), następnie kliknij prawym

przyciskiem myszy główny element Solution ‘MVCForum’

(Rozwiązanie „MVCForum) i wybierz Add (Dodaj) New Project…

(Nowy Projekt).

3. Wewnątrz okna dialogowego Add New Project (Dodawanie nowego

projektu) widocznego na rysunku 11.1, z panelu po lewej stronie wybierz

Visual C# Test (1), a następnie ze środkowego panelu (2) wybierz

Unit Test Project (.NET Framework) (Projekt testów jednostkowych


(.NET Framework)). W polu tekstowym Name (Nazwa projektu) wpisz

„MVCForumAutomation” (3) i kliknij OK (4).

Rysunek 11.1. Dodawanie nowego projektu testu

Uwaga

Typ projektu „Unit Test Project (.NET Framework)” tworzy

projekt testu, który wykorzystuje bibliotekę testów jednostkowych

MSTest.

Jeśli przewiniesz w dół zawartość panelu Solution Explorer,

powinieneś zobaczyć nowy projekt MVCForumAutomation, który został

dodany do projektu. Projekt ten zawiera plik UnitTest1.cs. Kliknij ten plik
dwukrotnie, aby otworzyć go w edytorze kodu źródłowego. Wygląd tego

pliku w edytorze tekstowym został przedstawiony na rysunku 11.2.

Rysunek 11.2. Plik UnitTest1.cs

Uwaga

Jeśli korzystasz z programu Visual Studio Community Edition,

wówczas drobny, szary tekst wyświetlany nad deklaracjami klas

i metod („0 references… na rysunku powyżej”) może nie być

widoczny, ponieważ jest to funkcja dostępna wyłącznie

w edycjach Professional i wyższych. Ponadto, jeśli nie korzystasz

z dodatku ReSharper, nie będziesz widzieć zielonych kółek, które

na powyższym rysunku wyświetlane są w pobliżu wierszy 7 i 10.

Jest to przykładowy plik, który Visual Studio dodaje do projektu.

Zmodyfikujemy go już za chwilę, ale najpierw przyjrzyjmy się i spróbujmy


zrozumieć zawartość tego pliku.

W projekcie MSTest testy są metodami oznaczonymi atrybutem

[TestMethod]. Aby projekt MSTest mógł szukać tych atrybutów

wewnątrz jakieś klasy, klasa ta musi zostać oznaczona atrybutem

[TestClass]. Projekt MSTest może się składać z wielu klas testowych,

z których każda może zawierać wiele metod testowych. Każda metoda

testowa może być uruchamiana niezależnie od pozostałych, ale możemy

również wykonywać razem wszystkie testy w danej klasie lub wszystkie

testy dostępne w całym projekcie. Istnieją inne możliwości filtrowania

testów, ale wykraczają one poza zakres tej książki. To tylko część informacji

dotyczących projektu MSTest, ale na razie jest to wszystko, co trzeba

wiedzieć.

Aby zobaczyć, w jaki sposób przebiega wykonanie tego testu (mimo że

nadal jest on pusty), musimy najpierw skompilować nasz projekt. W tym

celu klikamy prawym przyciskiem projekt MVCForumAutomation

i wybieramy opcję Build (Kompiluj). Następnie musimy otworzyć panel

Test Explorer (jeśli nie został otwarty wcześniej) za pomocą opcji Test

Windows Test Explorer (Eksplorator testów). Panel ten wyświetla listę

testów, jakie dostępne są w danym rozwiązaniu, pozwalając nam na ich

uruchamianie i podglądanie ich rezultatów. Po skompilowaniu naszego

projektu, w panelu Test Explorer powinien pojawić się element o nazwie

TestMethod1. Jeśli klikniemy ten element prawym przyciskiem myszy

i wybierzemy opcję Run Selected Test (Uruchom wybrane testy), to

widoczna przy jego nazwie ikona powinna po kilku sekundach zmienić się

na zielony znak V, co oznacza, że test zakończył się sukcesem.

Uwaga
Sytuacja, w której test zakończył się sukcesem, mimo że był

pusty, może trochę dziwić. Dlatego pamiętajmy o prostej zasadzie:

test zawsze kończy się sukcesem, chyba że z wnętrza jakiejś

metody tego testu zgłoszony zostanie wyjątek. Biblioteka MSTest

dostarcza klasę o nazwie Assert, która udostępnia kilka metod

do wykonywania weryfikacji. Każda z tych metod wyrzuca

wyjątek w przypadku, gdy odpowiednia weryfikacja kończy się

niepowodzeniem.

Wskazówka

W programie Visual Studio każda akcja, jak na przykład

uruchomienie metody testowej, może zostać wykonana na kilka

różnych sposobów. W powyższym przykładzie został pokazany

tylko jeden z nich, ale nic nie stoi na przeszkodzie, aby zrobić to

w jakiś inny sposób.

Modyfikowanie nazw klas, plików i metod


testowych

Posługiwanie się domyślną nazwą klasy UnitTest1 nie jest dobrym

pomysłem, ponieważ nazwa ta tak naprawdę nic nam nie mówi. Musimy

zmienić zarówno nazwę tej klasy, jak i nazwę samego pliku, w której się ona

znajduje. Ponieważ do naszej klasy zamierzamy wprowadzić również testy

sprawdzające poprawność działania aplikacji, klasę tę nazwiemy po prostu

SanityTests. Gdy zmieniamy nazwę klasy, Visual Studio pozwala nam

od razu zmienić nazwę pliku tak, aby pasowała ona do nazwy zawartej

w nim klasy:
1. W edytorze kodu kliknij dwukrotnie nazwę klasy (UnitTest1), aby ją

zaznaczyć.

2. Wpisz SanityTests, aby zamienić dotychczasową nazwę klasy.


3. Naciśnij Ctrl+. (przytrzymaj Ctrl i naciśnij klawisz ze znakiem kropki),

aby otworzyć menu kontekstowe Visual Studio i wybierz akcję Rename

file to match type name (Zmień nazwę pliku, aby pasowała do nazwy

typu).

Wskazówka

Jest wiele różnych konwencji nazewnictwa dla klas i metod

testowych, ale nie ma żadnego konkretnego standardu. Jeśli chodzi

o nazwę metody testowej, to powinna być ona podobna do

twierdzenia, które zdefiniowaliśmy w poprzednim rozdziale

(nawet jeśli będzie to dosyć długa nazwa). Co do nazw klas, to

warto rozważyć utworzenie jednego pliku dla wszystkich testów

danej funkcji i nazwanie zawartej w nim klasy na podstawie tej

funkcji. Ponieważ jednak pierwsze testy są zwykle bardzo ogólne

i nie odnoszą się do konkretnej funkcji, naszą pierwszą klasę

nazwaliśmy SanityTests. Tak czy inaczej, bez większego

problemu możemy zmienić te nazwy w późniejszym czasie.

Proces zmiany nazwy metody testowej jest bardzo łatwy. Po prostu

edytujemy ją w taki sposób, aby jej nazwa odzwierciedlała test, jaki jest nam

potrzebny. W ramach zalecanej konwencji nazewnictwa należy skorzystać

z twierdzenia zdefiniowanego podczas planowania testu, usunąć z niego

znaki spacji i rozpocząć każde kolejne jego słowo od wielkiej litery (czyli

użyć notacji PascalCase31). W naszym przykładzie nazwa metody testowej

może więc wyglądać następująco:


WhenARegisteredUserStartsADiscussionOtherAnonymousU
sersCanSeeIt

Zgodnie z powyższym, nasza klasa testowa, która zawarta jest w pliku

o nazwie SanityTest.cs, będzie wyglądać jak na listingu 11.1.

using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace MVCForumAutomation
{
[TestClass]
public class SanityTests
{
[TestMethod]
public void
WhenARegisteredUserStartsADiscussionOther
AnonymousUsersCanSeeIt()
{
}
}
}

Listing 11.1. Plik SanityTest.cs

Uwaga

W powyższym kodzie znak podziału wiersza przed nazwą metody

testowej znajduje się tam wyłącznie z powodu ograniczonego


miejsca na stronie tej książki. W edytorze kodu nazwa ta powinna

znajdować się w tym samym wierszu, w którym znajdują się

poprzedzające ją słowa kluczowe public void.

Pisanie pseudokodu

W kolejnym kroku tekst, który napisaliśmy w poprzednim rozdziale,

przetłumaczymy w edytorze kodu na poprawną składnię języka C#. Bez

zagłębiania się zbytnio w teorię kompilatorów, poprawna składnia nie

oznacza, że kod się kompiluje. Krótko mówiąc, kod „wygląda jak”

poprawna składnia języka C#, mimo że niektóre wymagane definicje

i identyfikatory nie są w nim obecne. Z tego powodu na tym etapie kod taki

nazywać będziemy pseudokodem, mimo że w dalszej części będziemy

starali się go już nie zmieniać, a jedynie dodawać do niego brakujące

deklaracje i implementacje. Ostatecznie ten pseudokod stanie się więc

prawdziwym kodem.

Zamiast przenosić ten pseudokod tam i z powrotem między edytorem

tekstowym (gdzie napisaliśmy opis tekstowy testu) i programem Visual

Studio, możemy sobie to ułatwić i po prostu skopiować i wkleić ten tekst do

edytora kodu w postaci komentarza, napisać kod, a następnie usunąć ten

komentarz. Zwróćmy uwagę, że ponieważ kod ten powinien być czytelny

i wyglądać bardzo podobnie do oryginalnego tekstu, nie ma potrzeby

zachowywać tego komentarza. Na rysunku 11.3 pokazano, jak powinien

ostatecznie wyglądać ten kod, ale jeszcze przed usunięciem wspomnianego

komentarza.
Rysunek 11.3. Pisanie pseudokodu

Uwaga

W rzeczywistości kod pokazany na rysunku 11.3 ma pokreśloną

składnię wyróżnioną czerwonym kolorem, który używany jest

przez narzędzie ReSharper do wskazywania brakujących definicji.

Uwagi odnośnie do pseudokodu

Jak widać, utworzony przez nas pseudokod jest poprawną składnią języka

C#, mimo że nadal kod ten nie kompiluje się z powodu wielu brakujących

definicji. Ponadto dosyć dobrze odzwierciedla on tekst zawarty

w komentarzu, wykorzystując kontekst zdefiniowany przez nas w tekście

w postaci obiektów w kodzie.


Podczas pisania tego kodu musieliśmy podjąć pewne dodatkowe decyzje

projektowe. Niektóre z nich wynikają z kodu przedstawionego na rysunku

11.3, ale inne są bardziej decyzjami domniemanymi. Oto kilka z nich:

Choć w krokach zapisanych tekstem napisaliśmy „Login as a registered

user” (Zaloguj się jako zarejestrowany użytkownik), w kodzie użyliśmy

nazwy RegisterNewUserAndLogin, aby lepiej odzwierciedlić to,


co dany test robi. Jeśli w aplikacji spróbujemy zarejestrować nowego

użytkownika, to zauważmy, że gdy rejestracja się powiedzie, wówczas

użytkownik ten zostanie również automatycznie zalogowany. Z tego

powodu metoda RegisterNewUserAndLogin jest tak naprawdę


pojedynczą operacją, mimo że zawiera w swojej nazwie spójnik „and”.

Zwróćmy uwagę, że jeśli to zachowanie zostanie później zmienione, to

wówczas trzeba będzie zmienić jedynie wewnętrzną implementację tej

metody i wykonać ten sam kod w postaci dwóch odrębnych operacji

wewnętrznych (rejestracja i logowanie), bez konieczności

modyfikowania żadnego testu, który wykorzystuje tę metodę.

Podczas tworzenia dyskusji w aplikacji użytkownik może podać dla niej

kilka parametrów. Najprostszym sposobem wymodelowania tej

funkcjonalności jest zakodowanie każdego z tych parametrów w formie

parametru metody CreateDiscussion. Obecnie jednak istnieją już


cztery parametry (z wyłączeniem treści wiadomości), z których

większość jest opcjonalna. Ponadto, w miarę jak systemy ewoluują, do

jednostek, które użytkownik może utworzyć, dodawanych jest coraz

więcej parametrów. Posiadanie przeciążonych metod z tak dużą liczbą

parametrów sprawia, że metoda taka jest problematyczna i trudna

w utrzymaniu. Z tego powodu preferuję korzystanie ze wzorca

budowniczego danych testowych (Test Data Builder)32. Właściwość

statyczna With klasy Discussion utworzy nową instancję i zwróci


obiektDiscussionBuilder, zawierający po jednej metodzie dla
każdego parametru. Metoda CreateDiscussion powinna używać

tego budowniczego do wypełniania wartości w formularzu.

Zdanie „Enter the site as an anonymous user” (Wejdź na stronę jako

użytkownik anonimowy) tak naprawdę nie jest operacją wykonywaną

przez użytkownika po przejściu na stronę, ale jest to stan istniejący zaraz

po przejściu na tę stronę. Z tego powodu musimy jedynie utworzyć nową

instancję przeglądarki i przenieść ją na tę stronę. Zdecydowaliśmy się

nazwać tę klasę MVCForumClient, ponieważ reprezentuje ona


właśnie takiego klienta. Zwróćmy uwagę, że identyfikator MVCForum
nie jest nazwą klasy, lecz nazwą właściwości w klasie testowej.

Właściwość MVCForum jest tak naprawdę typu MVCForumClient,


mimo że nie jest to jeszcze widoczne z poziomu naszego pseudokodu.

Do utworzenia nowej instancji klasy MVCForumClient dla


anonimowego użytkownika rozważałem skorzystanie ze wzorca metody

wytwórczej33 (Factory Method). Być może w przyszłości zmienię swoją

decyzję, ale na razie nie widziałem żadnych korzyści, jakie można by

w ten sposób osiągnąć i nie sprawić przy tym, że kod będzie bardziej

problematyczny.

Discussion:
Zadeklarowałem dwie zmienne typu

createdDiscussion oraz viewedDiscussion. Pierwsza


reprezentuje dyskusję utworzoną przez zarejestrowanego użytkownika,

a druga to, co widzi anonimowy użytkownik. Choć powinny być one

takie same, nie możemy tego zakładać w teście, ponieważ jest to coś, co

chcemy zweryfikować poprzez porównanie ich tytułów i treści.

Metoda Assert.AreEqual jest najbardziej powszechną metodą do


wykonywania weryfikacji w testach. Podobna klasa istnieje w niemal
wszystkich bibliotekach testów jednostkowych, przy czym mogą między

nimi występować pewne drobne różnice. W bibliotece MSTest

pierwszym argumentem tej metody jest oczekiwany wynik, drugim

argumentem jest faktyczny wynik, a trzeci argument jest opcjonalnym

komunikatem do wyświetlenia w przypadku, gdy sprawdzenie zakończy

się niepowodzeniem. Zwróćmy uwagę, że zamiana ze sobą pierwszego

i drugiego argumentu nie zmieni w żaden sposób wyniku tej weryfikacji,

jednak bez względu na trzeci parametr, komunikat błędu zawsze

wymienia kolejno oczekiwany i właściwy rezultat za pomocą tych

argumentów. Jeśli więc zamienimy je ze sobą miejscami, to taki

komunikat może być niejasny.

SŁOWO KLUCZOWE VAR W JĘZYKU C#

W języku C# kompilator może wywnioskować typ lokalnej zmiennej

na podstawie wyrażenia użytego do zainicjalizowania tej zmiennej

(zakładając, że inicjalizujemy ją w tym samym wierszu), co sprawia, że

nie musimy jawnie określać jej typu. Aby skorzystać z tej możliwości,

typ zamieszczony po lewej stronie nazwy zmiennej należy zamienić na

słowo kluczowe var. Na przykład poniższe dwa wiersze kodu są

swoimi odpowiednikami i generują dokładnie taki sam kod pośredni.

int i = 3; // jawna deklaracja typu


var i = 3; // deklaracja typu wywnioskowanego
przez kompilator

Zwróćmy uwagę, że w ten sposób nie czynimy zmiennej dynamiczną,

ponieważ kompilator ustala jej typ już na etapie kompilacji i nie może

się on później zmienić w czasie działania programu. Oznacza to, że

zmienna taka nadal jest silnie typowana (tj. jej typ jest statyczny), tak
więc jeśli spróbujemy wywołać metody lub uzyskać dostęp do

właściwości, które nie istnieją w obrębie tego typu, to otrzymamy błędy

na etapie kompilacji, a nie tylko błędy w czasie wykonywania

programu, jak miałoby to miejsce w przypadku typu dynamicznego.

Użycie tego słowa kluczowego jest kontrowersyjne, ale jest w dużej

mierze rzeczą gustu. Gdy zostało ono po raz pierwszy wprowadzone

w języku C# 3.0, na początku go nie lubiłem, ale z czasem do niego

przywykłem i dzisiaj, gdy patrzę na kod, który z niego nie korzysta,

wydaje mi się on nieco nieporęczny.

Gdy sam piszę pseudokod dla testu, zwykle stosuję w nim słowo

kluczowe var. Jednak w celu lepszego wyjaśnienia moich intencji,

w powyższym przykładzie wszystkie typy podawałem jawnie. Ale bez

obawy, zmienię je z powrotem na var, gdy skończymy pisać ten kod.

Uzupełnianie kodu w celu jego skompilowania

W porządku, napisaliśmy pseudokod, ale co teraz? Przecież on się nawet nie

kompiluje! I gdzie jest Selenium?! Dobrze, ale zanim skorzystamy

z Selenium, musimy najpierw sprawić, że nasz kod będzie się poprawnie

kompilował.

Na tym etapie najważniejsze jest, abyśmy nie próbowali implementować

od razu wszystkiego. Trzeba skupić się wyłącznie na uzupełnieniu naszego

kodu, tak aby można go było skompilować, a także na utworzeniu modelu

będącego strukturą klas i sygnatur metod, ale bez pisania żadnego kodu

wewnątrz tych metod. Zrobienie modelu przed rozpoczęciem

implementowania tych metod zapewni jego kompletność (przynajmniej na

potrzeby naszego pierwszego testu) i to, że nie zapomnieliśmy po drodze


o niczym istotnym. Ponadto, dopóki nasz kod się nie kompiluje, nie

będziemy w stanie uruchomić naszego testu i sprawdzić, czy w ogóle działa

on zgodnie z oczekiwaniem, dlatego prawdopodobnie napiszemy więcej

kodu niż potrzebujemy lub będziemy w stanie szybko przetestować. Chcemy

za pomocą małych kroków upewnić się, że każdy fragment naszego kodu

automatyzacji testu robi dokładnie to, co powinny robić. Pamiętajmy: jeśli

jakość kodu automatyzacji testu będzie niska, jego wiarygodność

również będzie niska! Skupmy się więc na bieżącym kroku: sprawmy, aby

kod po prostu się kompilował.

Wskazówka

Posługiwanie się klawiaturą zamiast myszą wszędzie tam, gdzie to

możliwe, znacząco zwiększa produktywność podczas pisania,

edytowania lub nawet czytania kodu, przy czym przyzwyczajenie

do korzystania z klawiatury może zająć trochę czasu. Dodatek D

zawiera szereg cennych porad dotyczących efektywnej pracy

z klawiaturą. Z tego względu poniższe instrukcje wykorzystują

skróty klawiszowe wszędzie tam, gdzie to możliwe.

Wszystkie metody i właściwości, które utworzymy, aby nasz kod mógł

się skompilować, będą zawierać pojedynczą instrukcję: throw new


NotImplementedException();. Jak zobaczymy nieco później,

wyrzucane przez tę instrukcję wyjątki poprowadzą nas przez ten proces

i zagwarantują, że nie zapomnieliśmy niczego zaimplementować.

Jeśli korzystamy z narzędzia ReSharper, możemy używać kombinacji

klawiszy Alt+PgUp/Alt+PgDn do przełączania się między błędami

i ostrzeżeniami kompilacji. Możemy również skorzystać ze skrótów

Alt+Shift+PgUp/Alt+Shift+PgDn, aby przełączać się między samymi


błędami. Jeśli nie korzystamy z narzędzia ReSharper, możemy po prostu

podążać za kolejnymi czerwonymi falistymi liniami lub otworzyć panel

z błędami (View Error List (Lista błędów)) i za pomocą klawiszy

F8/Shift+F8 przełączać się między kolejnymi błędami.

Gdy kursor znajdzie się na nierozpoznanej nazwie klasy lub metody,

wówczas poprzez wciśnięcie klawiszy Alt+Enter (ReSharper) lub Ctrl+.

(czyste Visual Studio) możemy otworzyć menu kontekstowe i wybrać (za

pomocą strzałek i klawisza Enter) opcję umożliwiającą utworzenie tej klasy

lub metody. Zwróćmy uwagę, że dodatek ReSharper daje nam znacznie

większą kontrolę nad tworzeniem klas i metod. Istotne jest zwłaszcza to, że

w przypadku tworzenia metody za pomocą dodatku ReSharper nasz kursor

jest przenoszony na kolejne typy oraz nazwy parametrów i wartości

zwracanych przez metody, aby może je było edytować, podczas gdy Visual

Studio bez dodatku ReSharper wprowadza w tych miejscach domyślne

nazwy oraz typy, które jest w stanie sam wywnioskować, dlatego nie zawsze

są one takie, jak byśmy tego chcieli. W takim wypadku musimy ręcznie

przejść do deklaracji takiej metody (za pomocą klawisza F12) i edytować te

wartości ręcznie. Zarówno ReSharper, jak i czysty program Visual Studio,

do tworzonych w ten sposób metod dodają automatycznie instrukcję throw


new NotImplementedException();, ale niestety nie robią tego

domyślnie dla właściwości (zwracają one wartość null), dlatego musimy

zmieniać je ręcznie.

Wskazówka

Okno dialogowe Options (Opcje) ReSharpera umożliwia zmianę

jego domyślnego zachowania związanego z tworzeniem treści

instrukcji throw new NotImplementedException();


również dla właściwości. Aby zmienić to zachowanie, wystarczy
wybrać opcję ReSharper Options, z paska nawigacji po lewej

wybrać Code Editing (Edytowanie kodu) Members

Generation (Tworzenie elementów członkowskich), a następnie

w części Generated property body style (Styl treści generowanej

właściwości) zaznaczyć opcję Accessors with default body

(Metody dostępu z domyślną treścią).

Bez względu na to, czy korzystamy z narzędzia ReSharper, czy

z wbudowanego menu kontekstowego programu Visual Studio, podczas

tworzenia nowej klasy mamy opcję utworzenia jej albo w oddzielnym pliku,

albo wewnątrz bieżącego pliku. Mimo że ostatecznie każda klasa powinna

znajdować się w swoim własnym pliku, utworzenie wszystkich klas w tym

samym pliku może być niekiedy wygodne, przynajmniej do momentu,

w którym kod poprawnie się skompiluje lub pierwszy test zakończy się

powodzeniem. Potem możemy przenieść każdą klasę do jej własnego pliku.

ReSharper pozwala nam przenieść w ten sposób wszystkie klasy za pomocą

pojedynczego kliknięcia, podczas gdy w czystym Visual Studio musimy je

przenosić jedna po drugiej.

Deklarowanie klasy LoggedInUser

Pierwszy błąd kompilacji, na jaki się natkniemy, mówi o braku definicji typu

LoggedInUser. Umieszczamy kursor na tym identyfikatorze i wciskamy

Alt+Enter (lub Ctrl+.), po czym wybieramy opcję Create type

‘LoggedInUser’ (Utwórz typ „LoggedInUser”) lub Create class

’LoggedInUser’ (Utwórz klasę „LoggedInUser”) w przypadku braku

narzędzia Resharper – jak na rysunku 11.4.


Rysunek 11.4. Tworzenie typu LoggedInUser za pomocą menu

kontekstowego

Deklarowanie właściwości MVCForum

Kolejnym niezadeklarowanym identyfikatorem jest MVCForum. O ile

identyfikator LoggedInUser mógł odnosić się jedynie do nazwy typu

(klasy) z powodu jego umiejscowienia w składni, to w przypadku

identyfikatora MVCForum kompilator może zadowolić się kilkoma opcjami.


Najbardziej oczywista z nich również jest nazwą klasy, ale wtedy metoda

RegisterNewUserAndLogin musi być statyczna, a to nie było naszym


zamiarem. Chcieliśmy, aby MVCForum było właściwością tylko do odczytu

typu MVCForumClient. Jeśli korzystamy z narzędzia ReSharper, to

z menu kontekstowego wybieramy opcję Create read-only property

‘MVCForum’ (Utwórz właściwość tylko do odczytu „MVCForum”).

W przeciwnym wypadku wybieramy opcję Create property

‘SanityTests.MVCForum’ (Utwórz właściwość

„SanityTests.MVCForum”). W obu przypadkach zostanie użyty typ

object, ponieważ ani Visual Studio, ani ReSharper nie wiedzą, jakiego
typu powinna być ta właściwość. Ale ReSharper dodatkowo umieszcza

kursor na słowie kluczowym object i pozwala nam je od razu zmienić,

bez konieczności jego ręcznego zaznaczania. Zmieniamy więc typ

właściwości z object na MVCForumClient, który oczywiście również

nie jest jeszcze zadeklarowany. Ponadto chcemy zmienić jego metodę

pobierającą w taki sposób, aby wyrzucała wyjątek

NotImplementedException, oraz usunąć zduplikowaną prywatną

metodę ustawiającą (nie trzeba będzie tego robić, jeśli zastosowaliśmy się do

wcześniejszej wskazówki). Teraz możemy utworzyć klasę

MVCForumClient, podobnie jak utworzyliśmy klasę LoggedInUser.


Dotychczasowy kod powinien wyglądać jak na listingu 11.2.
Listing 11.2. Kod po dodaniu właściwości MVCForum i klasy

MVCForumClient

Uwaga

Chociaż nie ma żadnego oczywistego powodu, dla którego

właściwość MVCForum powinna być zadeklarowana jako tylko

do odczytu, to jednak dzięki temu nasz kod będzie bardziej

solidny i mniej podatny na błędy, zwłaszcza na dłuższą metę. Ta

koncepcja dotycząca zapobiegania potencjalnym błędom została

omówiona bardziej szczegółowo w ramach tematu Poka-Yoke

w dodatku D.

Deklarowanie metody RegisterNewUserAndLogin

Gdy karetka (kursor klawiatury) znajduje się w obszarze odwołania do

metody RegisterNewUserAndLogin, z menu kontekstowego

wybieramy opcję Create Method

‘MVCForumClient.RegisterNewUserAndLogin’. Zwróćmy uwagę, że

ponieważ zadeklarowaliśmy już klasę LoggedInUser, Visual Studio wie,

że należy użyć LoggedInUser jako typu zwracanego tej metody. Gdyby

zamiast tego posłużyć się słowem kluczowym var i nie korzystać

z narzędzia ReSharper, to Visual Studio jako typu zwracanego użyłby typu

object. Musielibyśmy wówczas ręcznie przejść do deklaracji tej metody

i sami zmienić jej typ. Gdybyśmy z kolei korzystali z narzędzia ReSharper,

wówczas po utworzeniu nowej metody karetka zostałaby przeniesiona do

nowej deklaracji, umożliwiając nam wprowadzenie oczekiwanego przez nas

typu zwracanego. Gdybyśmy wtedy wprowadzili nazwę nieistniejącej klasy


(np. LoggedInUser), mielibyśmy możliwość skorzystania z opcji Create

type ‘LoggedInUser’ w celu utworzenia tej klasy.

Zwróćmy również uwagę na to, że treść nowej metody zawiera

instrukcję "throw new NotImplementedException();". Na razie


nie będziemy tu niczego zmieniać, gdyż chcemy jedynie skompilować nasz

kod. Później zobaczymy, w jaki sposób możemy zamienić treść tej metody

na właściwą implementację. Zauważmy, że w zintegrowanych środowiskach

programowania dla innych języków treść ta pozostaje czasami pusta (lub jest

tam umieszczana instrukcja return null;), co jest dosyć niepożądane,

ponieważ utrudnia to później znalezienie wszystkich miejsc, które należy

zaimplementować. Jeśli więc korzystamy z jednego z tych środowisk, to

warto wyrobić sobie nawyk dodawania instrukcji podobnej do throw new


NotImplementedException(); podczas tworzenia nowej metody.

Deklarowanie pozostałych klas i metod

Kontynuujemy przejście po kolejnych niezadeklarowanych identyfikatorach

i tworzymy klasy i metody, jak zrobiliśmy to wcześniej. Oto kilka uwag:

Metoda CreateDiscussion zawiera jako swój argument złożone


wyrażenie, które również nie jest jeszcze zdefiniowane. Z tego powodu,

CreateDiscussion, to
jeśli spróbujemy utworzyć najpierw metodę

sugerowanym typem jej argumentu będzie object. Jeśli jednak

najpierw zdefiniujemy identyfikatory w wyrażeniu jej argumentu,

wówczas metoda CreateDiscussion zostanie utworzona


z poprawnym typem argumentu. W zasadzie lepiej definiować najpierw

wyrażenia umieszczone w nawiasach, przed zadeklarowaniem wyrażeń

zewnętrznych. W naszym przypadku powinniśmy to zrobić

w następującej kolejności:

a) Tworzymy klasę Discussion.


b) Tworzymy właściwość tylko do odczytu o nazwie With
i nadajemy jej typ DiscussionBuilder (zgodnie z uwagami

dotyczącymi pseudokodu), który nie jest jeszcze zdefiniowany.

c) Tworzymy klasę DiscussionBuilder jako klasę

zagnieżdżoną wewnątrz klasy Discussion.


d) Tworzymy metodę DiscussionBuilder.Body
(powinniśmy to zrobić z poziomu oryginalnego wiersza).

Zmieniamy jej typ zwracany na DiscussionBuilder,


a nazwę parametru na body. Zwróćmy uwagę, że we wzorcu

budowniczego danych testowych wszystkie metody zwracają tę

samą instancję (this), aby ułatwić łączenie ze sobą kilku

wywołań. To właśnie dlatego zwracamy

DiscussionBuilder z metody zadeklarowanej wewnątrz

klasy DiscussionBuilder.

e) Na koniec tworzymy metodę CreateDiscussion.


Sugerowanym typem jej argumentu powinien być już

DiscussionBuilder, a jej typem zwracanym

Discussion. Powinniśmy tylko zmienić nazwę tego

argumentu z sugerowanej nazwy na builder.

Typ właściwości LatestDiscussions powinien być nową klasą,


której nazwą również będzie LatestDiscussions.

Właściwości Discussion.Title i DiscussionHeader.Title


powinny być typu string.

Gdy nasz kod wygląda jak na listingu 11.3, powinien się on już

pomyślnie kompilować, zaś model aplikacji będzie już kompletny,

przynajmniej w zakresie potrzeb naszego pierwszego testu! Aby

skompilować nasz kod, wybieramy opcję Build (Kompilowanie) Build


Solution (Kompiluj rozwiązanie). Jeśli kod faktycznie się skompiluje,

powinniśmy zobaczyć niewielki komunikat „Build Succeded” (Kompilacja

powiodła się) na pasku stanu w dolnej części ekranu. Jeśli kompilacja nie

powiedzie się, wówczas pojawi się panel Error List (jeśli tak się nie stanie,

należy wybrać opcję View Error List) zawierający wykaz błędów

kompilacji. W takim wypadku naprawiamy błędy i próbujemy ponownie.

Uwaga

Niektóre wiersze na poniższym listingu zostały podzielone

z powodu braku dostępnego miejsca. Jeśli skopiujesz ten kod bez

zmian i umieścisz znak nowego wiersza wewnątrz wartości typu

String, jak ma to miejsce na listingu, otrzymasz błąd kompilacji

„Newline in constant” („W stałej występuje symbol przejścia do

następnego wiersza”) wraz z pewnymi innymi błędami

dotyczącymi składni. Aby to naprawić, złącz ponownie łańcuch

tekstowy do postaci pojedynczego wiersza.


Listing 11.3. Plik SanityTests.cs, gdy kod się kompiluje
To także dobry moment na podzielenie tego pliku na kilka mniejszych –

po jednym pliku dla każdej klasy. Dokonujemy tego za pomocą menu

kontekstowego. Jeśli korzystamy z narzędzia ReSharper, możemy kliknąć

prawym przyciskiem plik SanityTests.cs w panelu Solution Explorer,

wybrać Refactor (Refaktoryzuj) Move Types Into Matching Files…

(Przenieś typy do pasujących plików), a następnie w otwartym oknie

dialogowym kliknąć Next (Dalej). Teraz powinniśmy już mieć po jednej

klasie w każdym pliku. Upewnijmy się, że po tej zmianie nasz kod nadal się

kompiluje.

Na tym etapie warto również zmienić wszystkie jawne nazwy typów na

słowo kluczowe var.

Omówienie kodu modelu

Jeśli nad projektem pracuje więcej niż jeden deweloper automatyzacji, to po

utworzeniu wszystkich klas i metod pozwalających na skompilowanie testu

jest świetny moment na dokonanie przeglądu kodu. Choć tak naprawdę

niczego jeszcze nie zaimplementowaliśmy, osoba przeglądająca kod będzie

mogła sprawdzić:

1. Czy kod testu jest przejrzysty i czytelny.

2. Czy kod testu odzwierciedla kroki, które udowadniają lub obalają

twierdzenie będące nazwą testu.

3. Czy model (klasy i metody) prawidłowo przedstawia rzeczywistość

i tworzoną aplikację.

4. Jeśli nie jest to pierwszy test, to czy nie istnieją już podobne metody lub

klasy, które mogłyby zostać wykorzystane ponownie.

Jeśli recenzent będzie miał jakieś istotne uwagi, to ewentualne problemy

będzie nam łatwiej wyeliminować na obecnym etapie niż po


zaimplementowaniu całego testu!

Podsumowanie

W rozdziale tym napisaliśmy kod metody testowej, zgodnie z opisowym

planem przygotowanym w poprzednim rozdziale. Choć napisaliśmy sporą

ilość kodu, który w zasadzie niczego nie robi, to utworzyliśmy tak naprawdę

szkielet (lub projekt) dla testu i jego kodu infrastruktury, zapewniając jego

modułowość i możliwość ponownego wykorzystania. Ponadto, ponieważ

staraliśmy się trzymać opisu testu, kod metody testowej okazał się bardzo

czytelny. W kolejnym rozdziale zaimplementujemy w końcu nasze metody

i dzięki temu test będzie już robił to, co powinien.


Rozdział 12. Uzupełnianie
pierwszego testu

Dobrze, skoro nasz kod już się kompiluje, to czym powinniśmy się teraz

zająć?

(Rozlega się dźwięk brzęczyka) ZŁA odpowiedź! Na pewno pomyślałeś

sobie: „zacznijmy implementować metody” (a przynajmniej mówi tak 99%

osób, którym zadaję to pytanie). Jednak to, co zamierzamy zrobić, może na

początku wydawać się trochę niemądre – uruchomimy nasz test!

Ale zaraz, nie możemy! To nie może przecież działać! Komputer

wybuchnie! Cóż, racja, nie będzie to działać. Ale również nie wysadzi to

komputera (wiem, że tak naprawdę nie o to chodziło …). Co zatem się

stanie? Test oczywiście zakończy się niepowodzeniem, ponieważ każdy

wyjątek powoduje niepomyślne zakończenie testu, a my mamy w teście

wiele instrukcji throw new NotImplementedException();.


Chodzi o to, że gdy uruchomimy ten test i zakończy się on niepowodzeniem,

będziemy dokładnie wiedzieć, którą metodę usiłował on wykonać jako

pierwszą – będzie to więc pierwsza metoda do zaimplementowania.

Jeśli będziemy trzymać się tej procedury implementowania po jednej

metodzie naraz i uruchamiania testu, aby zobaczyć, w którym miejscu test

zostaje przerwany, będziemy mogli przetestować każdą nowo napisaną przez


nas metodę i sprawdzić, czy działa ona poprawnie i stabilnie. Jeśli nie będzie

ona działać i test zakończy się niepowodzeniem na czymś, co już

zaimplementowaliśmy, będzie można do tego wrócić i to naprawić. W ten

sposób stale poprawiamy i umacniamy niezawodność naszego kodu.

Ponadto jest to również dobry sposób na to, aby upewnić się, że nasze testy

są powtarzalne, bez konieczności resetowania danych testowych, środowisk

itd. Będzie to miało duże znaczenie później, gdy będziemy chcieli uczynić je

częścią procesu CI/CD (patrz rozdział 15).

Uruchamianie testu w celu znalezienia pierwszej


metody do zaimplementowania

Tak więc jak powiedzieliśmy wcześniej, najpierw uruchamiamy test, aby

dowiedzieć się, dlaczego kończy się on niepowodzeniem. Kontynuując to,

na czym skończyliśmy w poprzednim rozdziale, aby uruchomić nasz test,

otwieramy panel Test Explorer (jeśli nie jest jeszcze otwarty) za pomocą

opcji Test Windows Test Explorer, a następnie klikamy prawym

przyciskiem nazwę tego testu i wybieramy Run Selected Tests (Uruchom

wybrane testy). Test powinien zakończyć się niepowodzeniem, co zostanie

wskazane za pomocą czerwonego znaku X w pobliżu nazwy tego testu.

W dolnej części panelu Test Explorer powinniśmy zobaczyć szczegóły

dotyczące tego niepowodzenia, wliczając w to komunikat błędu oraz ślad

stosu (stack trace) pokazujący łańcuch wywołania metod, w których wyjątek

został zgłoszony. Domyślnie ten dolny obszar panelu jest dosyć mały, ale

możemy go powiększyć, przeciągając podziałkę w górę. Po zakończeniu

testu niepowodzeniem panel Test Explorer powinien wyglądać jak na

rysunku 12.1.
Rysunek 12.1. Panel Test Explorer pokazujący pierwsze niepowodzenie

testu

Komunikat błędu jest następujący: „Message: Test method

MVCForumAutomation.

SanityTests.WhenARegisteredUserStartsADiscussionOtherAnonymous

UsersCanSeeIt threw exception: System.NotImplementedException:

The method or operation is not implemented”.


Uwaga

Niektóre fragmenty tego komunikatu mogą być napisane w języku

używanego systemu operacyjnego.

Choć wygląd tego komunikatu może zniechęcać, to tak naprawdę nie jest

on aż tak skomplikowany. Najwięcej miejsca zajmuje w nim nazwa testu,

która nie dość, że sama w sobie jest długa, to prezentowana jest tu w pełnej

formie, uwzględniającej również nazwę klasy oraz przestrzeń nazw. Aby

więc lepiej zrozumieć ten komunikat, możemy go uprościć do następującej

postaci: „Message: Test method <nazwa metody testowej> threw

exception: System.NotImplementedException: The method or operation

is not implemented” (Komunikat: Metoda testowa <nazwa metody

testowej> zgłosiła wyjątek: System.NotImplementedException:


Metoda lub operacja nie jest zaimplementowana). Teraz ma to większy sens!

W wyjątku NotImplementedException dobre jest to, że górna część

śladu stosu mówi nam, gdzie dokładnie ta niezaimplementowana metoda się

znajduje. Jeśli najedziemy wskaźnikiem myszy na odnośnik w śladzie stosu,

powinniśmy zobaczyć etykietkę narzędzia wskazującą dokładną ścieżkę do

pliku wraz z numerem wiersza. Jeśli klikniemy ten odnośnik, zostaniemy

przeniesieni w to konkretne miejsce, które w tym wypadku jest treścią

metody pobierającej dla właściwości MVCForum. Proces implementacji

metody pobierającej tej właściwości jest bardzo prosty. Po prostu

inicjalizujemy właściwość za pomocą nowej instancji MVCForumClient,


jak w poniższym przykładzie:

public MVCForumClient MVCForum { get; } = new


MVCForumClient();
A co robimy potem? Mam nadzieję, że tym razem uda się odgadnąć:

uruchamiamy test ponownie! Zwróćmy uwagę, że przy ponownym

uruchomieniu testu Visual Studio automatycznie zapisze nasze zmiany i raz

jeszcze skompiluje nasz kod, tak aby przy kolejnym uruchomieniu testu

uwzględniał nasze ostatnie modyfikacje. Teraz wyjątek

NotImplementedException jest zgłaszany w metodzie

MVCForumClient.RegisterNewUserAndLogin. Jednak najpierw

musimy się trochę cofnąć, ponieważ w konstruktorze MVCForumClient


powinna zostać otwarta strona aplikacji, a dopiero potem należy

zaimplementować metodę RegisterNewUserAndLogin.

Dodawanie Selenium do projektu

Do zaimplementowania konstruktora MVCForumClient w celu otwarcia

strony aplikacji MVCForum, powinniśmy użyć narzędzia Selenium

WebDriver. Skorzystamy z niego we wszystkich poniższych metodach, które

powinny oddziaływać z tą witryną.

Uwaga

Choć Selenium jest bardzo popularnym narzędziem do

automatyzacji testów, to nauka jego obsługi nie jest celem tej

książki. Wyjaśnimy tu jedynie absolutne minimum, które jest

niezbędne do zrozumienia tego samouczka. W sieci dostępnych

jest wiele różnych zasobów związanych z narzędziem Selenium

WebDriver. Ponadto zachęcam do przeczytania książki Selenium

WebDriver Recipes in C# autorstwa Zhimina Zhana34.


Dodawanie Selenium do projektu w Visual Studio jest bardzo proste:

1. W panelu Solution Explorer kliknij prawym przyciskiem projekt

MVCForumAutomation i wybierz Manage NuGet Packages…

(Zarządzaj pakietami NuGet). Otworzy się okno NuGet Package

Manager (Menedżer pakietów NuGet).

2. Kliknij przycisk Browse (Przeglądaj) widoczny w górnej części tego

okna.

3. W polu Search (Wyszukaj) wpisz „Selenium”, po czym wciśnij Enter.

Zwróć uwagę, że z jakiegoś powodu Visual Studio nie pokazuje żadnych

wyników, dopóki nie wpiszesz całego słowa. Powinny teraz pojawiać się

wyniki zbliżone do tych przedstawionych na rysunku 12.2.

Rysunek 12.2 Okno NuGet Package Manager pokazujące wyniki dla

zapytania „Selenium”
Zaznaczamy kolejno każdy z pierwszych trzech wyników

(Selenium.WebDriver, Selenium.Support oraz

Selenium.Chrome.WebDriver) i klikamy przycisk Install widoczny po

prawej stronie. To wszystko! Dodaliśmy Selenium do naszego projektu

testu.

Uruchamianie IISExpress

Tak naprawdę, zanim będziemy mogli otworzyć witrynę MVCForum za

pośrednictwem przeglądarki (i tym samym za pomocą Selenium), musimy

najpierw się upewnić, że witryna ta działa poprawnie. Choć w prawdziwym

środowisku CI będziemy raczej wdrażać tę aplikację na jakimś serwerze

sieci Web, w kontenerze lub w chmurze, czy jeszcze gdzieś indziej,

i wykonywać nasze testy w oparciu o to wdrożenie, to do lokalnego

uruchamiania tej witryny będziemy używać serwera IISExpress.exe. Na

razie będziemy ją uruchamiać ręcznie, ale później dobrym pomysłem byłoby

zaimplementowanie jakiejś opcji pozwalającej na jej automatyczne

uruchamianie przy każdym rozpoczęciu zestawu testów (przykładowo na

podstawie jakiegoś pliku konfiguracyjnego).

Aby uruchomić tę witrynę z użyciem serwera IISExpress, otwieramy

okno wiersza polecenia i wpisujemy poniższą instrukcję:

"%ProgramFiles(x86)%\IIS Express\iisexpress.exe"
/path:C:\
TheCompleteGuideToTestAutomation\MVCForum\MVCForum.
Website\

Jeśli pobraliśmy repozytorium Git do innej lokalizacji, to zamieniamy

fragment TheCompleteGuideToTestAutomation na ścieżkę do lokalizacji,

z której korzystamy.
Zakładając, że IISExpress uruchomiło się poprawnie, możemy teraz

otworzyć przeglądarkę i przejść pod adres http://localhost:8080,


aby zobaczyć, czy witryna MVCForum jest uruchomiona. Jeśli chcemy

zatrzymać serwer IISExpress, wciskamy klawisz „Q” w oknie polecenia

uruchamiającego serwer IISExpress. Spowoduje to zatrzymanie witryny,

przez co nie będziemy mogli uzyskać do niej dostępu z poziomu

przeglądarki.

Implementowanie konstruktora
MVCForumClient

Wewnątrz klasy MVCForumClient dodajemy konstruktor, jak to

pokazano na listingu 12.1. Wciskamy Ctrl+. i wybieramy Import type

‘OpenQA.Selenium.Chrome.ChromeDriver’ (lub wciskamy Alt+Enter,

jeśli korzystamy z narzędzia ReSharper), aby dodać instrukcję using na

początku tego pliku.

public MVCForumClient()
{
var webDriver = new ChromeDriver();
}

Listing 12.1. Konstruktor klasy MVCForumClient

Wiersz zawarty wewnątrz konstruktora powiadamia Selenium, aby

otworzył przeglądarkę Chrome. Uruchamiamy teraz ponownie test

i sprawdzamy, czy to działa.


Oczywiście test nadal kończy się niepowodzeniem z powodu wyjątku

NotImplementedException w metodzie

MVCForumClient.RegisterNewUserAndLogin, ponieważ nie

została ona jeszcze zaimplementowana. Ale zmieniło się to, że teraz przy

uruchomieniu testu otwierane jest nowe, puste okno przeglądarki Chrome.

Okno to nie jest jednak zamykane po zakończeniu testu, co jest dosyć

denerwujące, zwłaszcza jeśli uruchomiliśmy nasz test kilka razy.

Pozbędziemy się więc tego problemu, zanim przejdziemy dalej.

Aby zamknąć okno przeglądarki po zakończeniu testu, możemy użyć

metody finalizatora obiektu, która wywoływana jest przez mechanizm

odzyskiwania pamięci (Garbage Collector) platformy .NET, gdy dany obiekt

nie jest już potrzebny.

Uwaga

Garbage Collector wywołuje metodę finalizatora asynchronicznie,

co oznacza, że okno przeglądarki może pozostać otwarte jeszcze

przez kilka sekund. Aby się z tym uporać, możemy skorzystać

z metody AddCleanupAction z biblioteki Test Automation

Essentials. Więcej informacji na temat tej metody można znaleźć

w dodatku B. Na razie nie będziemy jednak z niej korzystać, żeby

dodatkowo nie komplikować tego samouczka.

Aby zamknąć okno przeglądarki, na utworzonym przez nas obiekcie

ChromeDriver musimy wywołać metodę Quit. Ponieważ jednak

chcemy wywołać ją z metody finalizatora tego obiektu, musimy najpierw

przekształcić lokalną zmienną webDriver na element członkowski.

Ponieważ chcemy tutaj dostosować się do domyślnej konwencji


nazewnictwa zalecanej przez narzędzie ReSharper, do nazwy tego pola

dodamy znak podkreślenia, tak więc pole to będzie teraz nosić nazwę

_webDriver.
Jeśli używamy narzędzia ReSharper, możemy automatycznie

przekształcić zmienną lokalną na pole klasy. Umieszczamy karetkę na

nazwie tej zmiennej lokalnej, wciskamy Ctrl+Shift+R w celu otwarcia

menu kontekstowego Refactor This (Zrefaktoryzuj to), po czym wybieramy

Introduce Field… (Wstaw pole…). W wyświetlonym polu dialogowym

zmieniamy nazwę na _webDriver, zaznaczamy pole wyboru Make

readonly (Pole tylko do odczytu) i klikamy Next.

Ostatecznie, po dodaniu pola i metody finalizatora, konstruktor

utworzony przez nas w listingu 12.1 powinien zostać zastąpiony kodem

z listingu 12.2.

private readonly ChromeDriver _webDriver;

public MVCForumClient()
{
_webDriver = new ChromeDriver();
}

~MVCForumClient()
{
_webDriver.Quit();
}

Listing 12.2. Konstruktor i finalizator klasy MVCForumClient


Jeśli uruchomimy nasz test ponownie, to zauważymy, że teraz

przeglądarka otwiera się i zamyka automatycznie.

Ale jest jeszcze jedna rzecz, którą musimy zrobić w konstruktorze:

przejść do witryny aplikacji MVCForum. Ponieważ obecnie uruchamiamy ją

lokalnie za pomocą serwera IISExpress, musimy przejść pod adres

http://localhost:8080. Możemy to zrobić poprzez ustawienie

wartości dla właściwości Url, zgodnie z listingiem 12.3.

public MVCForumClient()
{
_webDriver = new ChromeDriver();
_webDriver.Url = "http://localhost:8080";
}

Listing 12.3. Przechodzenie do lokalnej witryny MVCForum

Zwróćmy uwagę, że konstruktor ten zawiera obecnie dwa istotne i trwale

zakodowane szczegóły: rodzaj wykorzystywanej przez nas przeglądarki

(Chrome) oraz adres URL witryny. Będziemy zapewne chcieli umożliwić

uruchamianie testów w różnych przeglądarkach i w różnych środowiskach

(adresy URL), tak więc powinniśmy wyodrębnić te szczegóły do

zewnętrznego pliku konfiguracyjnego. Ponieważ jednak nasz kod pisany jest

w sposób zapobiegający duplikacji takich szczegółów, odłożymy

implementację tego mechanizmu czytającego z pliku konfiguracyjnego na

później, kiedy naprawdę będzie nam potrzebny. Zmodyfikowanie tego nie

powinno nam sprawić problemu (ponieważ znajduje się to wyłącznie

w jednym miejscu), a wówczas będziemy mogli sprawdzić, czy faktycznie

działa to we wszystkich wymaganych środowiskach. Na razie zmienimy typ

pola _webDriver na interfejs IWebDriver, który jest wspólny na


wszystkich przeglądarek, tak aby przypadkiem nie opierać na funkcjach

specyficznych dla przeglądarki Chrome (zobacz temat Poka-Yoke w dodatku

D). Nie zapomnijmy dodać odpowiedniej instrukcji using w celu użycia

przestrzeni nazw OpenQA.Selenium. Dodamy również odpowiedni

komentarz, który przypomni nam, jak również innym osobom, że należy

zmienić to w przyszłości – a tak dokładnie to zmienimy to w rozdziale 14.

Jeśli teraz uruchomimy test, to nadal będzie on kończyć się

niepowodzeniem z tego samego powodu, ale będziemy mogli zobaczyć, że

przeglądarka przed zamknięciem przechodzi do naszej witryny.

Implementowanie metody
RegisterNewUserAndLogin

Teraz możemy już przystąpić do implementacji metody

RegisterNewUserAndLogin. Zwykle rejestrowanie nowego

użytkownika jest dosyć długim procesem. Choć tutaj mamy jedynie

jednostronicowy formularz z bardzo małą liczbą szczegółów, które

użytkownik musi wypełnić, to nadal nie jest to tak do końca niepodzielna

operacja. Z tego powodu nie będziemy implementować całego procesu

rejestracji użytkownika w pojedynczej metodzie. Zamiast tego skorzystamy

z techniki „od góry do dołu”, której użyliśmy do napisania testu, lecz tym

razem wykorzystamy ją do zaimplementowania metody

RegisterNewUserAndLogin. Tak więc najpierw zamienimy instrukcję


throw new NotImplementedException(); na kod z listingu 12.4,
co spowoduje, że nasz kod znów nie będzie się kompilował.

public LoggedInUser RegisterNewUserAndLogin()


{
var username = Guid.NewGuid().ToString();
const string password = "123456";
const string email = "abc@def.com";

var registrationPage = GoToRegistrationPage();


registrationPage.UserName = username;
registrationPage.Password = password;
registrationPage.ConfirmPassword = password;
registrationPage.Email = email;

registrationPage.Register();

return new LoggedInUser();


}

Listing 12.4. Implementowanie metody

MVCForumClient.RegisterNewUserAndLogin

Uwagi:

Ponieważ chcemy, aby nazwa użytkownika była niepowtarzalna,

zmienną username inicjalizujemy za pomocą nowego identyfikatora


GUID. Zauważmy, że czasami warto jest utworzyć specjalną klasę do

generowania losowych ciągów znaków i wartości, zgodnie z różnymi

ograniczeniami, takimi jak długość, dozwolone znaki itd. Ale na razie

możemy to sobie darować.

Hasła i adresy e-mail nie muszą być niepowtarzalne ani też realistyczne.

Wystarczy, że spełniają one wymagania dotyczące rejestracji.

Metoda GoToRegistrationPage, którą musimy jeszcze


zaimplementować, będzie klikać przycisk Register (Zarejestruj)
i zwracać obiekt strony reprezentujący stronę rejestracji. Klasę tego

obiektu strony nazwiemy RegistrationPage (więcej informacji na


temat wzorca obiektu strony można znaleźć w rozdziale 10).

RegistrationPage
Wszystkie metody ustawiające wartości w klasie

będą wprowadzać tekst do odpowiednich pól. Metoda Register

będzie klikać przycisk Register.

Obecnie zwracamy nowy obiekt LoggedInUser, bez dostarczania


czegokolwiek w jego konstruktorze. W dosyć niedalekiej przyszłości

będziemy prawdopodobnie musieli zainicjalizować go za pomocą nazwy

użytkownika, ale zrobimy to dopiero wtedy, gdy zmusi nas do tego

obecnie tworzony przez nas proces.

Jak nietrudno zgadnąć, musimy teraz sprawić, żeby nasz kod ponownie

się kompilował. Osiągniemy to poprzez utworzenie brakujących metod,

właściwości i klas, podobnie jak miało to miejsce podczas tworzenia klas

i metod w poprzednim rozdziale, jednak tym razem wszystkie nowe klasy

utworzymy od razu w osobnych plikach. Mówiąc dokładniej, musimy

utworzyć metodę GoToRegistrationPage, której typem zwracanym

jest RegistrationPage, a także samą klasę RegistrationPage


wraz ze wszystkimi jej elementami, które wywołujemy. Listing 12.5

pokazuje definicję klasy RegistrationPage (dodamy ją w nowym

pliku). Zwróćmy uwagę, że nadal nie implementujemy treści żadnej

z nowych metod, a zamiast tego zachowujemy w nich instrukcje throw


new NotImplementedException();.
Listing 12.5. Definicja klasy RegistrationPage

Ponownie uruchamiamy nasz test. Nie jest zaskoczeniem, że test kończy

się niepowodzeniem z powodu wyjątkuNotImplementedException


wewnątrz metody MVCForumClient.GoToRegistrationPage.
Musimy więc teraz zaimplementować tę metodę.
Aby dostać się do strony rejestracji, musimy kliknąć przycisk Register

znajdujący się w górnej części strony (w pobliżu łącza Log On). Na rysunku

12.3 pokazuje stronę główną zawierającą przycisk Register.

Rysunek 12.3. Strona główna zawierająca przycisk Register

Aby go kliknąć, musimy najpierw poinformować Selenium, w jaki

sposób może on znaleźć ten przycisk. Selenium może identyfikować

elementy za pomocą różnych cech, nazywanych w żargonie Selenium

lokalizatorami (locators). Istnieją różne rodzaje lokalizatorów (zobacz

kolejną uwagę), ale najważniejszą regułą jest to, że lokalizator musi pasować

wyłącznie do elementu (lub elementów), którego szukamy, a nie do żadnego

innego. Wszystkie lokalizatory dopasowywane są do modelu DOM35 strony.


Aby podejrzeć model DOM danej strony i znaleźć najlepszy lokalizator

dla przycisku Register, otwieramy najpierw przeglądarkę Chrome

i przechodzimy do witryny MVCForum (http://localhost:8080),

po czym klikamy prawym przyciskiem przycisk Register i z podręcznego

menu wybieramy Inspect (Zbadaj). Spowoduje to otwarcie panelu

Developer Tools (Narzędzia dla programistów – panel otwierany

i zamykany za pomocą klawisza F12), które wyświetli nam drzewo

elementów modelu DOM i podświetli element odpowiadający przyciskowi

Register. Zostało to pokazane na rysunku 12.4.

Rysunek 12.4. Badanie elementu Register za pomocą narzędzi dla

programistów
Wskazówka

Dodatek D zawiera objaśnienie wiele różnych lokalizatorów

narzędzia Selenium oraz wskazówki dotyczące wyboru najbardziej

odpowiedniego lokalizatora.

Proszenie dewelopera o dodanie unikalnego identyfikatora


automatyzacji

Choć możemy zidentyfikować przycisk Register za pomocą jego tekstu

(„Register”), to jednak nie jest to zalecane, zwłaszcza że witryna ta jest

wielojęzyczna i ten sam element może być wyświetlany z tekstem w innym

języku. Nawet gdyby witryna nie była wielojęzyczna, to nadal nie

powinniśmy opierać się na konkretnym zapisie słów, ponieważ w przypadku

zmiany tego tekstu nasz test będzie się kończył niepowodzeniem, aż nie

zmienimy również naszego kodu. Jednocześnie nie dysponujemy żadnym

innym lokalizatorem, który jednoznacznie identyfikowałby ten element

i byłby dla nas lepszym wyborem.

W takich przypadkach zalecanym podejściem jest dodanie atrybutu id.


Jeśli mamy dostęp do kodu źródłowego testowanego systemu, możemy sami

dokonać tej zmiany. Jeśli nie, to powinniśmy poprosić dewelopera, aby

dodał on ten atrybut za nas. Jest to zaledwie jednominutowe zadanie, które

nie powinno mieć żadnych efektów ubocznych, tak więc nikt nie powinien

w tej sprawie protestować. Jeśli napotkamy jakiś sprzeciw, wyjaśnijmy

potrzebę wprowadzenia tej zmiany dla zapewnienia niezawodności testu lub

– jeśli to konieczne – zaangażujmy w to swojego menedżera. Jeśli z jakiegoś

powodu miałoby to potrwać dłużej, możemy w międzyczasie skorzystać

z lokalizatora LinkText, a później zamienić go na Id.


Inny wariant tego podejścia polega na tym, aby zamiast atrybutu id
dodać unikalną nazwę klasy, wyłącznie na potrzeby automatyzacji testów.

Możemy poprzedzić tę nazwę przykładowo przedrostkiem „auto-”, aby

jasno sprecyzować, że jest ona wykorzystywana przez automatyzację.

Ponieważ elementy mogą mieć po kilka klas, podejście to jest

bezpieczniejsze, ponieważ gwarantuje ono, że ta nazwa klasy będzie

używana tylko do automatyzacji i nie będzie modyfikowana z żadnego

innego powodu.

W naszym samouczku mamy dostęp do pełnego kodu źródłowego

testowanego systemu, tak więc sami możemy dodać klasę „auto-

register”. W tym celu należy wykonać poniższe czynności:


1. W polu wyszukiwania dostępnym w panelu Solution Explorer wpisz

_Layout.cshtml.

2. Dwukrotnie kliknij plik _Layout.cshtml, aby otworzyć go w edytorze.

3. Przejdź do wiersza nr 86 i dodaj deklarację klasy w znaczniku <a>, jak

w poniższym przykładzie:

<li><a class="auto-register"
href="@Url.Action("Register",
"Members")">
@Html.LanguageString("Layout.Nav.Register")</a>
</li>

4. Zapisz plik i skompiluj rozwiązanie.

5. Przejdź do przeglądarki Chrome i wciśnij klawisz F5, aby odświeżyć

stronę. Powinieneś teraz zobaczyć nazwę nowej klasy w narzędziach dla

programistów.

Uwaga
Nie zapomnij wyczyścić pola wyszukiwania w panelu Solution

Explorer, aby ponownie zobaczyć wszystkie elementy, jakie

składają się na to rozwiązanie.

Teraz jesteśmy już gotowi do zaimplementowania metody

MVCForumClient.GoToRegistrationPage, zgodnie z listingiem

12.6.

Listing 12.6. Zaimplementowana metoda GoToRegistrationPage

Tworzymy również konstruktor klasy RegisterPage, który przyjmuje


argument webDriver. Za pomocą narzędzia ReSharper lub menu

kontekstowego Visual Studio możemy utworzyć ten konstruktor

automatycznie, ale upewnijmy się, że pole _webDriver zostało

zadeklarowane jako pole tylko do odczytu (readonly), jak w przykładzie


na listingu 12.7.
Listing 12.7. Konstruktor klasy RegistrationPage i pole _webDriver

Uwaga

Podążając ściśle za naszym procesem, musiałem napisać możliwie

najprostszą rzecz, która omija nasz bieżący wyjątek, tak więc nie

miałem żadnego powodu, aby na tym etapie przekazywać do

konstruktora klasę RegistrationPage obiektu webDriver.


Ponieważ jednak klasa RegistrationPage jest obiektem

strony, który będzie wymagał obiektu webDriver do uzyskania

dostępu do elementów na tej stronie, pozwoliłem sobie pójść nieco

na skróty i utworzyć parametr webDriver już teraz.

Implementowanie metod ustawiających dla właściwości

Po zaimplementowaniu metody GoToRegistrationPage test zakończy

się niepowodzeniem w miejscu metody ustawiającej właściwość

RegistrationPage.Username. Jeśli zidentyfikujemy ten element za


pomocą narzędzi dla programistów, to zobaczymy, że zawiera on atrybut id,
którego wartością jest UserName. Tak więc możemy zaimplementować tę

metodę ustawiającą jak na listingu 12.8.

public string Username {


get { throw new
System.NotImplementedException(); }
set
{
var usernameInput =
_webDriver.FindElement(By.Id("UserName"))
;
usernameInput.SendKeys(value);
}
}

Listing 12.8. Właściwość RegistrationPage.Username

W dalszej części tego procesu test będzie kończył się niepowodzeniem

na metodach ustawiających właściwości Password, ConfirmPassword


oraz Email. W podobny sposób implementujemy więc jedna po drugiej

metody ustawiające dla tych właściwości, jak zrobiliśmy to dla właściwości

UserName. Na tym etapie plik RegistrationPage.cs powinien wyglądać

jak na listingu 12.9.


Listing 12.9. Plik RegistrationPage.cs po zaimplementowaniu wszystkich

właściwości

Uwaga
Jeśli zastanawiasz się, czy umieszczanie literałów znakowych

lokalizatorów bezpośrednio w ciele właściwości jest dobrą

praktyką, czy też nie, zapoznaj się z tematem trwale

zakodowanych ciągów znaków w dodatku D, aby uzyskać

wyczerpujące informacje na ten temat.

Usuwanie duplikacji z metod ustawiających właściwości

Teraz nasz test kończy się niepowodzeniem na niezaimplementowanej

metodzie Register. Ale zanim ją zaimplementujemy, zwróćmy uwagę,

jak podobne do siebie są wszystkie metody ustawiające właściwości, które

przed chwilą zdefiniowaliśmy! Jest to bardzo dobry przykład

zduplikowanego kodu. Co prawda są tylko cztery takie metody, a do tego są

one dosyć małe i wszystkie znajdują się w tej samej klasie, ale wciąż jest to

duplikacja kodu, którą możemy wyeliminować poprzez refaktoryzację.

W miarę jak będziemy usuwać takie duplikaty, nasz kod będzie się stawał

bardziej ogólny, możliwy do rozszerzenia i ostatecznie łatwiejszy

w utrzymaniu. Choć możliwe jest, że duplikacja ta będzie powtarzać się

również w innych obiektach strony, to nie powinniśmy zbyt daleko posuwać

się z tym uogólnianiem, tak więc teraz usuniemy jedynie duplikację w tej

jednej klasie. Jeśli później zobaczymy, że inne klasy również potrzebują tego

działania, wówczas usuniemy tę duplikację i sprawimy, że nasz kod będzie

jeszcze bardziej ogólny. Teraz jednak skupmy się na usuwaniu duplikacji

między metodami ustawiającymi wewnątrz klasy RegistrationPage.


Zrobimy to poprzez wprowadzenie nowej metody prywatnej

FillInputElement, która zawierać będzie ten wspólny kod

i otrzymywać będzie identyfikator elementu oraz wartość do wprowadzenia.

Metodę tę będziemy wykorzystywać we wszystkich metodach ustawiających

właściwości zamiast ich dotychczasowej treści.


Wskazówka

Korzystając z narzędzia ReSharper, do metody ustawiającej

przykładowo właściwość Username możemy zastosować

następujące refaktoryzacje: Extract Method (Wyodrębnij

metodę), Introduce Parameter (Wprowadź parametr) na literale

znakowym „Username”, Change signature (Zmień sygnaturę)

w celu zamiany kolejności parametrów, oraz Rename (Zmień

nazwę) w celu zmiany nazwy zmiennej usernameInput na

input (ponieważ teraz nasza metoda nie dotyczy już tylko

elementu związanego z nazwą użytkownika). Następnie możemy

zamienić implementacje wszystkich pozostałych metod

ustawiających na wywołania do nowo utworzonych metod.

ReSharper może nam również pomoc zmienić kolejność

właściwości i metod wewnątrz klasy w prostszy i bardziej

bezpieczny sposób. Zwróćmy uwagę, że wykonywanie tej

sekwencji niewielkich manipulacji w kodzie znacząco zmniejsza

szansę na to, że coś pójdzie bardzo źle.

Po tej refaktoryzacji plik RegistrationPage.cs powinien wyglądać jak na

listingu 12.10.
Listing 12.10. Plik RegistrationPage.cs po usunięciu duplikacji

Raz jeszcze uruchamiamy nasz test i upewniamy się, że nasza

refaktoryzacja niczego nie popsuła. Test nadal kończy się niepowodzeniem

w miejscu metody Register, tak więc wygląda na to, że wszystko jest

w porządku. Możemy więc teraz zaimplementować metodę Register


zgodnie z listingiem 12.11.

public void Register()


{
var form =
_webDriver.FindElement(By.ClassName("form
-register"));
form.Submit();
}

Listing 12.11. Metoda RegistrationPage.Register

Uwaga

Kliknięcie wewnątrz form elementu button


elementu

zawierającego atrybut style=”submit” ma dokładnie taki sam

efekt jak wywołanie metody Submit na elemencie form. W tym

przypadku zlokalizowanie elementu form było łatwiejsze niż

znalezienie elementu button, tak więc zdecydowałem się to

zrobić w ten sposób.

Po ponownym uruchomieniu testu zakończy się on niepowodzeniem

w wierszu następującym po wywołaniu metody

RegisterNewUserAndLogin, co oznacza, że na razie zakończyliśmy

pracę nad tą metodą. Być może trzeba będzie do niej wrócić, aby przekazać

coś do konstruktora obiektu LoggedInUser, który ta metoda zwraca, ale

na razie będziemy nadal podążać za komunikatami błędów.

Napotykanie błędu izolacji

Kontynuując ten proces, będziemy musieli zaimplementować metody

pobierające dla właściwości Discussion.With oraz


Discussion.DiscussionBuilder.Body. Gdy to zrobimy, test

będzie kończył się niepowodzeniem z powodu wywołania metody

LoggedInUser.CreateDiscussion. Jeśli jednak nie zmieniliśmy

niczego za pośrednictwem strony administratora, to po otwarciu aplikacji

i ręcznym zarejestrowaniu nowego użytkownika zauważmy, że użytkownik

wcale nie widzi przycisku Create Discussion (Utwórz dyskusję).

W rzeczywistości zwróciliśmy już na to uwagę to już w rozdziale 10, gdy

planowaliśmy scenariusz testu i zdecydowaliśmy, że naszą pierwszą próbą

będzie przyznanie każdemu uprawnień do tworzenia nowych dyskusji

(tematów) w kategorii „Example Category”. Na rysunku 12.5 pokazano

stronę administratora do edytowania uprawnień, z zaznaczonym

uprawnieniem Create Topics (Tworzenie tematów) dla kategorii „Example

Category”.
Rysunek 12.5. Strona administratora pozwalająca na zmianę uprawnień

Wykonywanie akcji w celu zmiany tego uprawnienia nie jest częścią

samego testu, tak więc zrobimy to w metodzie inicjalizującej test. Metodę

inicjalizacji testu napiszemy ponownie „z góry do dołu”: rozpoczynając od

pseudokodu, poprzez tworzenie metod i klas, a kończąc na implementacji

metod jedna po drugiej, kierując się przy tym kolejnymi niepowodzeniami

testu. Listing 12.12 przedstawia metodę TestInitialize w klasie

testowej SanityTests.

[TestInitialize]
public void TestInitialize()
{
var adminUser = MVCForum.LoginAsAdmin();
var adminPage =
adminUser.GoToAdminPage();
var permissions =
adminPage.GetPermissionsFor(TestDefaults.
StandardMembersRole);
permissions.AddToCategory(TestDefaults.Ex
ampleCategory,
PermissionTypes.CreateTopics);
adminUser.Logout();
}

Listing 12.12. Metoda SanityTests.TestInitialize

Uwaga
W bibliotece MSTest metoda oznaczona atrybutem

[TestInitialize] jest uruchamiana przed każdym testem

w tej klasie. Istnieją również atrybuty [ClassInitialize]


i [AssemblyInitialize] służące do oznaczania metod,

które mają być odpowiednio wykonywane przed wszystkimi

testami w klasie lub w całym zestawie. W podobny sposób metody

oznaczane za pomocą atrybutów[TestCleanup],


[ClassCleanup] oraz [AssemblyCleanup] są

wykonywane odpowiednio po każdym teście, klasie i zestawie.

Nazwy samych metod nie muszą pasować do nazw atrybutów, ale

zgodność taka wprowadza pewną wygodę. Większość innych

bibliotek testowania jednostkowego również dostarcza podobne

mechanizmy do uruchamiania metod w sytuacjach „przed” i „po”.

Uwagi:

Metoda MVCForumClient.LoginAsAdmin powinna zwrócić obiekt


typu LoggedInAdmin. Ponieważ użytkownik administratora może

wykonywać takie same czynności jak zalogowany użytkownik,

LoggedInAdmin będzie dziedziczyć po klasie


sprawimy, że klasa

LoggedInUser. Zauważmy, że metoda Logout powinna znajdować


się w klasie bazowej, ponieważ każdy użytkownik może wylogować się

z systemu.

Choć rola „Standard Members” (Zwykli członkowie) istnieje w systemie

domyślnie, użytkownik administratora może dodawać lub usuwać takie

role. Z tego powodu pozostawiamy możliwość podawania różnych ról

w metodzie GetPermissionsFor.
Moglibyśmy po prostu przekazać ciąg znaków „Standard Members” jako

parametr do metody GetPermissionsFor w celu zidentyfikowania


tej roli, ale byłby to bardzo zły model. „Rola” w aplikacji MVCForum

nie jest tylko ciągiem znaków – jest to kompletna jednostka. Stosujemy

się do podstawowej reguły: Jeśli użytkownik może wpisać, co tylko

zechce, to używamy ciągu znaków. Jeśli może on tylko wybierać

spośród ciągów znaków, to powinniśmy używać obiektu silnie

typowanego. Jeśli w miejscu, w którym użytkownik może wyłącznie

wybierać istniejące elementy, skorzystamy z ciągu znaków, nasz kod

stanie się bardziej podatny na błędy (patrz temat Poka-Yoke w dodatku

D). Jeśli ktoś będzie musiał użyć tej metody później i będzie mógł

przekazać dowolny ciąg znaków, to istnieje większa szansa, że metoda

zakończy się błędem w czasie wykonywania, niż w przypadku, gdyby

musiał on przekazać silnie typowany obiekt.

Ponieważ nadal chcemy opierać się na pewnych domyślnych

jednostkach, które istnieją w systemie, ale nie chcemy tworzyć dla nich

ścisłej zależności, zdefiniowaliśmy właściwość TestDefaults


w klasie o tej samej nazwie do przechowywania tych domyślnych

jednostek. Jeśli w jakimś momencie wartości domyślne w aplikacji

zostaną zmienione lub będziemy chcieli użyć innych wartości

domyślnych na potrzeby testu, to klasa ta powinna być jedynym

miejscem, które trzeba będzie zmienić. Jeśli nie użyjemy takiej klasy, to

gdy taka zmiana nastąpi, będziemy musieli zmodyfikować wiele miejsc

w kodzie naszych testów.

Wszystko co powiedzieliśmy na temat roli „Standard Members” ma

również zastosowanie do kategorii „Example Category”

Lista rodzajów uprawnień jest stała (to znaczy jest predefiniowana

w systemie i użytkownik administratora nie może jej zmienić). Z tego


powodu decydujemy się wymodelować typ PermissionTypes jako
typ wyliczeniowy (enum), aby ograniczyć się wyłącznie do elementów

tej listy i uniknąć potencjalnych pomyłek.

Kontynuując nasz proces, tworzymy najpierw klasy i metody, dzięki

którym nasz kod się kompiluje, a następnie uruchamiamy test, aby

sprawdzić, w którym miejscu zakończy się on niepowodzeniem. Na koniec

implementujemy brakującą metodę.

Pomijając już wszelkie szczegóły (mając nadzieję, że wiadomo już o co

chodzi), podczas implementowania metody

MVCForumClient.LoginAsAdmin zauważyłem duplikację między


klasami RegistrationPage i LoginPage w miejscu, gdzie obie te

klasy wymagają metody FillInputElement, którą

zaimplementowaliśmy w klasie RegistrationPage. Aby usunąć tę

duplikację, wyodrębniłem klasę bazową o nazwie FormPage, która zawiera

tę metodę i zmieniłem klasę LoginPage w taki sposób, aby była ona klasą

pochodzącą FormPage.
z Zauważmy, że zarówno klasa

RegistrationPage, jak i klasa LoginPage zawierają właściwości


Username i Password, które odpowiadają polom wprowadzania danych
Username i Password. Choć właściwości te i ich implementacje są

identyczne, to tak naprawdę nie jest to prawdziwa duplikacja! Jest tak,

ponieważ nie ma żadnego bezpośredniego związku pomiędzy tymi polami.

Na każdym z tych formularzy realizują one inny cel: na formularzu

rejestracji ich celem jest umożliwienie użytkownikowi wyboru jego nazwy

użytkownika i hasła, podczas gdy na formularzu logowania – umożliwienie

wprowadzenia tego, co zostało przez niego wybrane wcześniej.

Kolejną istotną zmianą, o której warto wspomnieć, jest to, że do

właściwości TestDefaults dodałem domyślną nazwę użytkownika

i hasło administratora i musiałem przekazać ją do konstruktora obiektu


MVCForumClient, aby mógł on użyć ich w metodzie LoginAsAdmin.
W konsekwencji konieczne było również przekazanie jej do instancji, którą

tworzymy wewnątrz testu (zmienić wiersz „var


anonymousUser =
new MVCForumClient();” na „var anonymousUser = new
MVCForumClient(TestDefaults);”). Ale ponieważ zaśmieca to
kod testu szczegółami implementacji, wyodrębniłem tworzenie instancji do

oddzielnej metody o nazwie OpenNewMVCForumClient, tak więc wiersz


ten zostaje ostatecznie przekształcony na „var anonymousUser =
OpenNewMVCForumClient();”, co lepiej oddaje jego naturę.

Uwaga

Przy pierwszym uruchomieniu aplikacji zobaczymy pojedynczą

dyskusję zatytułowaną „Read me”. Gdy ją otworzymy,

zobaczymy, że pojawiają się tam domyślna nazwa i hasło

użytkownika – są to odpowiednio „admin” oraz „password”.

Administrator powinien zmienić nazwę użytkownika i hasło,

zanim rozpocznie korzystanie z aplikacji. Ponieważ używamy tej

witryny tylko lokalnie i nie mamy żadnych rzeczywistych

i istotnych informacji w bazie danych, możemy nadal korzystać

z tych poświadczeń.

Kompletny kod źródłowy, w którym metoda TestInitialize jest

całkowicie ukończona, możemy podejrzeć poprzez wyewidencjonowanie

poprawki Git oznaczonej jako TestInitializeCompleted. Kod ten dostępny

jest również online adresem

https://github.com/arnonax/mvcforum/tree/TestInitializeCompleted/MVCFor

umAutomation.
Implementowanie metody CreateDiscussion
i analizowanie niepowodzenia

Gdy metoda TestInitialize działa już zgodnie z oczekiwaniem,


zaimplementujemy metodę LoggedInUser.CreateDiscussion.
Implementacja tej metody została pokazana na listingu 12.13.

public Discussion
CreateDiscussion(Discussion.DiscussionBuilder
builder)
{
var newDiscussionButton =
WebDriver.FindElement(By.ClassName
("createtopicbutton"));
newDiscussionButton.Click();

var createDiscussionPage = new


CreateDiscussionPage(WebDriver);
builder.Fill(createDiscussionPage);
createDiscussionPage.CreateDiscussion();

return new Discussion(WebDriver);


}

Listing 12.13. Metoda LoggedInUser.CreateDiscussion

Zwróćmy uwagę na sposób, w jaki użyliśmy wzorca konstruktora

danych testowych: po prostu wywołujemy metodę Fill budowniczego,

która powinna wykonać całą pracę związaną z wypełnianiem wszystkich


istotnych wartości. Sama metoda CreateDiscussion nie powinna się

zmieniać w celu obsługi dodatkowych parametrów.

Gdy jednak uruchomimy test, otrzymamy następujący komunikat błędu:

Message: Test method MVCForumAutomation.SanityTests.

WhenARegisteredUserStartsADiscussionOtherAnonymousUsersCanSee

It threw exception: OpenQA.Selenium.NoSuchElementException: no

such element: Unable to locate element: {“method”:“class

name”,“selector”:“createtopicbutton”}, zaś ślad stosu będzie wskazywał

na wywołanie metody FindElement w metodzie, którą właśnie

zaimplementowaliśmy. Ponowne sprawdzenie nazwy klasy przycisku daje

nam pewność, że jest ona poprawna.

Aby przeanalizować to, co się dzieje, mamy dwie opcje:

1. Wstawić punkt przerwania w odpowiednim wierszu i zdebugować test.

2. Dodać odpowiednie informacje diagnostyczne do wyniku testu.

Choć większość osób wybierze tę pierwszą opcję, w ogólnym przypadku

należy spróbować najpierw tej drugiej opcji, a dopiero w przypadku jej

niepowodzenia powrócić do debugowania. Powodem, dla którego

powinniśmy skorzystać z drugiej opcji jest to, że informacje diagnostyczne

dodawane do testu mogą nam się przydać również podczas badania

przyszłych niepowodzeń. W szczególności, jeśli test kończy się

niepowodzeniem podczas kompilacji nocnej lub w ramach kompilacji ciągłej

integracji i nie może zostać odtworzony w środowisku dewelopera

automatyzacji, wówczas debugowanie nam nie pomoże, w przeciwieństwie

do informacji diagnostycznych. Więcej informacji na temat badania

przyczyn niepowodzeń można znaleźć w kolejnym rozdziale.

Tak więc najbardziej podstawową informacją diagnostyczną, która może

pomóc nam zidentyfikować ten problem, jest zrzut ekranu strony w chwili

wystąpienia błędu. Z jednej strony, jeśli zobaczymy przycisk na zrzucie


ekranu, oznacza to, że jest jakiś problem w sposobie, w jaki próbujemy go

znaleźć. Z drugiej strony, jeśli nie widzimy tego przycisku na zrzucie, to

będziemy musieli przeprowadzić dalszą analizę, ale zrzut ekranu dostarczy

nam prawdopodobnie więcej wskazówek dotyczących tego problemu.

Listingi 12.14 i 12.15 zawierają kod, który dodajemy do plików

SanityTests.cs i MVCForumClient.cs w celu wykonania zrzutu ekranu.

Zwróćmy uwagę, że większość kodu dodanego do pliku SanityTests.cs

dotyczy biblioteki MSTest, ale możemy osiągnąć podobny rezultat również

w innych bibliotekach testów jednostkowych.

public TestContext TestContext { get; set; }


[TestCleanup] public void TestCleanup()
{
if (TestContext.CurrentTestOutcome !=
UnitTestOutcome.Passed)
{

var screenshotFilename = $"Screenshot.


{TestContext.TestName}.jpg";
MVCForum.TakeScreenshot(screenshotFilenam
e);
TestContext.AddResultFile(screenshotFilen
ame);
}
}

Listing 12.14. Plik SanityTests.cs – dodawanie zrzutu ekranu

wykonywanego w chwili wystąpienia niepowodzenia


public void TakeScreenshot(string
screenshotFilename)
{
_webDriver.TakeScreenshot().SaveAsFile(screens
hotFilename);
}

Listing 12.15. Plik MVCForumClient.cs – dodawanie zrzutu ekranu

wykonywanego w chwili wystąpienia niepowodzenia

Uruchamiamy test ponownie i patrzymy, co się stanie. Oczywiście test

nadal kończy się niepowodzeniem z tym samym komunikatem błędu, ale

teraz panel Test Explorer pokazuje nam dodatkowy odnośnik o nazwie

Output (Wynik) widoczny na rysunku 12.6.


Rysunek 12.6. Test Explorer pokazuje odnośnik Output

Kliknięcie odnośnika Output spowoduje otwarcie specjalnego okna

dokumentu w Visual Studio, zawierającego praktycznie takie same

informacje, jak te widoczne w dolnej części panelu Test Explorer, ale

z dodatkową sekcją Attachments (Załączniki), zawierającą odnośnik do

naszego zrzutu ekranu. Kliknięcie tego odnośnika otworzy nam zrzut ekranu

przedstawiony na rysunku 12.7, który wszystko wyjaśnia…


Najwyraźniej problemem było to, że utknęliśmy na stronie rejestracji po

kliknięciu przycisku Register i nie dotarliśmy nawet na stronę główną, na

której pojawia się przycisk New Discussion (Nowa dyskusja). Jak pokazuje

nam zrzut ekranu, powodem tego niepowodzenia w procesie rejestracji jest

powtórzony adres e-mail, który powinien być unikalny, tak jak nazwa

użytkownika.

Aby to naprawić, przy każdym uruchomieniu będziemy również losowo

tworzyć adres e-mail. W tym celu w metodzie

MVCForumClient.RegisterNewUserAndLogin wiersz:

const string email = "abc@ def.com";

zamieniamy na:

var email = $"abc@{Guid.NewGuid()}.com";


Rysunek 12.7. Zrzut ekranu z błędem

Kończenie testu

Ten problem mamy już z głowy, a dodatkowo mamy teraz także prosty

mechanizm, który wykonuje zrzut ekranu przy każdym wystąpieniu błędu.

Możemy więc kontynuować nasz cykl uruchamiania, naprawiania

i refaktoryzacji testu do momentu, aż test przestanie kończyć się

niepowodzeniem. Po około dziewięciu takich dodatkowych cyklach test

zakończy się sukcesem. Rysunek 12.8 pokazuje, jak takie pomyślne

zakończenie testu będzie wyglądać.


Aby zobaczyć końcowy kod testu, możemy wyewidencjonować

poprawkę Git oznaczoną jako FirstTestCompleted lub podejrzeć ten kod

online pod adresem

https://github.com/arnonax/mvcforum/tree/FirstTestCompleted. Kolejne

kroki, które nas tutaj doprowadziły, możemy również zobaczyć w historii

poprawek dostępnej pod adresem

https://github.com/arnonax/mvcforum/commits/FirstTestCompleted lub

w programie Visual Studio (szczegóły na ten temat można znaleźć

w rozdziale 9).

Rysunek 12.8. Pierwszy test zakończony powodzeniem


Podsumowanie

Podczas tego procesu utworzyliśmy wiele klas i jeszcze więcej metod. Może

się to wydawać sporo jak na jeden test, ale wszystkie te klasy i metody

zostały napisane w sposób, który pozwala na ich ponowne użycie, więc

będziemy je mogli wykorzystać również w innych testach. Cały napisany

przez nas kod był wielokrotnie wykonywany i testowany, i uznany został za

stabilny. Jest jeszcze kilka rzeczy do zrobienia, aby testy te mogły być

uruchamianie na różnych maszynach lub w różnych środowiskach,

obsługiwały wiele przeglądarek i rejestrowały więcej danych, aby ułatwić

badanie przyczyn niepowodzeń. Jednak to, co zrobiliśmy do tej pory, może

służyć nam przez długi czas, nie sprawiając przy tym problemów

z utrzymaniem. W kolejnym rozdziale ulepszymy ten kod, aby ułatwić sobie

badanie przyczyn niepowodzeń, zaś w rozdziale 14 dodajemy więcej testów

i poprawimy część rzeczy, które odłożyliśmy na później.


Rozdział 13. Badanie niepowodzeń

Oto prawdziwa historia: W poprzednim rozdziale zakończyliśmy nasz

pierwszy test, który konsekwentnie kończy się sukcesem. Pierwotnie

zamierzałem na tym etapie kontynuować pokazywanie kolejnych testów,

a dopiero później przedstawić rozdział poświęcony badaniu niepowodzeń.

Zderzyłem się jednak z rzeczywistością i zmieniłem swoje plany.

Po ukończeniu pisania poprzedniego rozdziału chciałem wysłać autorowi

aplikacji MVCForum, Lee Messengerowi, żądanie ściągnięcia36

z utworzonymi przeze mnie testami, ponieważ projekt ten nie miał jeszcze

żadnego testu. Ale zanim mogłem to zrobić, musiałem najpierw pobrać jego

najnowsze zmiany i scalić je z moim repozytorium. Gdy to zrobiłem, test

przestał działać.

Warto zauważyć, że w rzeczywistości do takich sytuacji dochodzi cały

czas. Za każdym razem, gdy jakiś deweloper dokona zmian w aplikacji,

istnieje szansa, że niektóre z testów przestaną działać. Z tego powodu częste

integrowanie i uruchamianie testów gwarantuje, że zmiany pomiędzy

kolejnymi uruchomieniami są małe i łatwo się zorientować, co zostało

zmienione. Naturalnie odpowiednie utrzymywanie aplikacji, ale przede

wszystkim testów, gwarantuje, że naprawa tych problemów będzie bardzo

prosta.
Doświadczenie to zainspirowało mnie, aby skupić się w tym rozdziale na

tym konkretnym przykładzie, zamiast wyjaśniać temat badania niepowodzeń

wyłącznie od strony teoretycznej.

Integrowanie z najnowszą wersją aplikacji


MVCForum

Gdy uruchomiłem nasz pierwszy test po zintegrowaniu go z najnowszą

wersją aplikacji MVCForum, zobaczyłem błąd widoczny na rysunku 13.1.


Rysunek 13.1. Pierwszy błąd po zintegrowaniu z najnowszą wersją aplikacji

MVCForum

Jak wspomnieliśmy krótko w rozdziale 10, w celu zapewnienia izolacji

i spójności będziemy czyścić bazę danych przed każdym uruchomieniem


testu. Ponieważ operacja ta powinna być wykonywana tylko przy każdym

uruchomieniu, a nie dla każdego testu, dobrym pomysłem jest skorzystanie

z pliku wsadowego lub jakiegoś innego skryptu, który nie będzie częścią

kodu testu. W międzyczasie, aby upewnić się, że faktycznie wszystko działa

jak należy, sporadycznie odtwarzałem ręcznie bazę danych przez

uruchomieniem testu. Wspomniany błąd, który pojawił się po zintegrowaniu

z najnowszymi zmianami, występował jedynie po odtworzeniu bazy danych.

Aby odtworzyć bazę danych, zatrzymujemy serwer IISExpress, usuwamy

bazę danych MVCForum z Microsoft SQL Server Management Studio,

tworzymy nową bazę danych o nazwie MVCForum i uruchamiamy

ponownie serwer IISExpress. Przy następnym otwarciu witryny

w przeglądarce, wszystkie tabele i domyślne dane zostaną utworzone

automatycznie.

Jak możemy się dowiedzieć z tego komunikatu błędu i śladu stosu, nie

udało nam się znaleźć menu „My Tools” (Moje narzędzia), za pomocą

którego można przejść do strony administratora. Klikamy odnośnik Output

i patrzymy na zrzut ekranu, aby zobaczyć, czy menu to pojawia się, czy nie.

Zrzut ten przedstawiony jest na rysunku 13.2.


Rysunek 13.2. Zrzut ekranu błędu

Na podstawie zrzutu ekranu dowiadujemy się, że nie udało nam się

zalogować.

Usprawnianie raportowania błędów

Możliwość odczytania problemu ze zrzutu ekranu jest czymś bardzo fajnym,

ale pierwsza otrzymana przez nas wskazówka informowała, że menu My

Tools nie zostało znalezione, co nie do końca wskazywało prawdziwą

przyczynę problemu. Tak więc zanim naprawimy, a nawet zaczniemy dalej

badać główną przyczynę problemu, poprawmy najpierw ten komunikat


błędu, aby był on bardziej miarodajny na wypadek, gdyby podobny problem

miał się pojawić w przyszłości.

Aby to zrobić, po kliknięciu przycisku LogOn sprawdzimy, czy nie ma

czerwonego komunikatu o błędzie, a jeśli jest, to natychmiast zakończymy

testy niepowodzeniem i dostarczymy wszystkie istotne informacje w ramach

komunikatu o błędzie.

Aby zidentyfikować ten pasek komunikatu błędu, możemy spróbować

odtworzyć ten problem ręcznie lub też uruchomić test za pomocą debuggera

i przerwać jego wykonywanie po kliknięciu przycisku LogOn. Istnieje

również inna opcja, z której skorzystamy w dalszej części tego rozdziału,

a jest nią zapisanie tej strony do pliku i otwarcie jej w trybie offline. Aby

jednak nie komplikować tego przykładu, odłożymy tę metodę na później.

Metodą, która naprawdę dokonuje logowania i jest najbardziej

odpowiednia dla tego sprawdzenia, jest metoda prywatna

MVCForumClient.LoginAs<TLoggedInUser>. Listingi 13.1 i 13.2

pokazują zmiany, których dokonaliśmy w tej metodzie oraz klasie

LoginPage w celu usprawnienia tego komunikatu błędu. Oczywiście

metody te implementujemy po kolei, jak robiliśmy to wcześniej.

private TLoggedInUser LoginAs<TLoggedInUser>(string


username, string
password, Func<TLoggedInUser> createLoggedInUser)
where TLoggedInUser : LoggedInUser
{
var loginPage = GoToLoginPage();
loginPage.Username = username;
loginPage.Password = password;
loginPage.LogOn();
var loginErrorMessage =
loginPage.GetErrorMessageIfExists();
Assert.IsNull(loginErrorMessage, $"Login
failed for user:{username} and password:
{password}. Error message:
{loginErrorMessage}");

return createLoggedInUser();
}

Listing 13.1. Usprawniona walidacja w metodzie

MVCForumClient.LoginAs

/// <returns>
/// Komunikat błędu wyświetlany na stronie
logowania lub null, jeśli nie
jest wyświetlany żaden błąd
/// </returns>
public string GetErrorMessageIfExists()
{

var errorMessageElement =
WebDriver.TryFindElement(
By.ClassName("validation-summary-errors"));
return errorMessageElement?.Text;
}

Listing 13.2. Metoda LoginPage.GetErrorMessageIfExists


Uwagi:

1. Ponieważ zwykle staramy się unikać null jako poprawnych wartości

(więcej szczegółów na ten temat można znaleźć w dodatku D),

dodaliśmy komentarz XML, który jasno wskazuje, że metoda ta może

zwrócić null, jeśli nie pojawi się żaden błąd. Visual Studio wyświetla

te komentarze XML w postaci etykietek narzędzia po najechaniu

kursorem myszy na nazwę metody.

2. Metoda TryFindElement nie jest jeszcze zadeklarowana.

Zadeklarujemy ją jako metodę rozszerzającą37, aby podnieść czytelność

kodu. Metoda ta będzie zwracać znaleziony element lub null, jeśli

elementu nie udało się znaleźć.

Listing 13.3 pokazuje implementację metody TryFindElement.


Listing 13.3. Plik SeleniumExtensions.cs zawierający metodę rozszerzeń

TryFindElement.

Uwaga

Pierwszy parametr metody, context, który oznaczony jest

słowem kluczowym this, jest obiektem, do którego jest

stosowana ta metoda rozszerzająca. Zadeklarowaliśmy go jako

parametr typu ISearchContext. Po interfejsie tym dziedziczą


interfejsy IWebDriver i IWebElement. Dzięki temu będzie
on miał zastosowanie do wszystkich obiektów, które implementują

którykolwiek z tych interfejsów.

Gdy uruchomimy test, zobaczymy następujący komunikat błędu:

Assert.IsNull failed. LogOn fail for user:admin and password:password.

Error message: The user or password provided is incorrect. ([…]

Komunikat błędu: Podana nazwa użytkownika lub hasło są niepoprawne).

Jak widzimy, ten komunikat błędu jest znacznie bardziej dokładny od

komunikatu, który otrzymaliśmy wcześniej. Widzimy w nim nawet nazwę

użytkownika i hasło, które chcieliśmy wykorzystać. Wiemy zatem, że nie

udało nam się zalogować za pomocą domyślnego konta („admin”

i „password”) administratora… ale dlaczego?

Unikanie debugowania

W przypadku napotkania jakiegoś błędu, instynkt wielu deweloperów

(produktu bądź też automatyzacji) podpowiada im, by rozpocząć

debugowanie. Faktycznie, dzisiejsze środowiska IDE oferują bogate

możliwości debugowania i mogą nam one dać głęboki wgląd w to, co się

dzieje. Debugowanie ma jednak kilka wad:

1. Możemy jedynie debugować błąd, który wciąż powtarza się w naszym

środowisku. Debugowanie innych środowisk jest możliwe, ale zwykle

jest dużo bardziej problematyczne, zaś debugowanie scenariusza, który

nie zawsze kończy się niepowodzeniem, jest całkowitą stratą czasu.

W kontekście automatyzacji testów oznacza to, że niepowodzenia, do

których dochodzi jedynie w środowisku kompilacji, będą trudne

w debugowaniu, zaś testy migoczące (testy, które nie zawsze kończą się

niepowodzeniem) będą znacznie trudniejsze do zdiagnozowania.


2. Większość informacji i wiedzy, którą uzyskaliśmy podczas trwania sesji

debugowania, zostanie utracona po jej zakończeniu. Jeśli ktoś inny (lub

my sami) będzie zmuszony debugować podobny problem w przyszłości,

będzie on musiał przejść przez całą tę sesję debugowania całkowicie od

nowa, starając się zrozumieć to, czego my się już nauczyliśmy.

Z tego powodu w większości przypadków należy unikać debugowania,

a zamiast tego stale usprawniać nasze raportowanie błędów, rejestrowanie

i zbieranie innych informacji diagnostycznych, tak aby coraz łatwiej nam

było badać niepowodzenia. Na początku taka inwestycja może być bardziej

kosztowna od debugowania, ale na dłuższą metę zdecydowanie nam się to

opłaci.

Badanie głównej przyczyny

Jak pamiętamy z poprzedniego rozdziału, z tematu Read Me (tworzonego

automatycznie przy pierwszym uruchomieniu) dowiedzieliśmy się, że

domyślnymi wartościami dla nazwy użytkownika i hasła administratora są

odpowiednio „admin” i „password”. Spójrzmy teraz raz jeszcze na temat

Read Me i zobaczmy, czy coś się zmieniło. Rysunek 13.3 pokazuje nowy

temat Read Me.


Rysunek 13.3. Zaktualizowany temat Read Me

Jak widzimy, dotychczasowe hasło password zostało teraz zmienione na

(r=)76tn. Wygląda na to, że nie jest to po prostu nowe hasło domyślne, jak

wcześniej, ale raczej hasło wygenerowane losowo (jeśli uruchomisz ten kod

u siebie, zapewne zobaczysz inne hasło). Po prześledzeniu kodu aplikacji

MVCForum i odbyciu rozmowy z jej deweloperem okazało się, że domyślne

hasło zostało zmienione z prostego słowa „password” na pewien losowo

generowany ciąg znaków, aby poprawić bezpieczeństwo wdrażania tej

aplikacji w środowisku produkcyjnym. Nowe hasło generowane jest

w momencie tworzenia bazy danych.

Rozwiązywanie problemu
Oczywiście nie chcemy używać hasła (r=)76tn, ponieważ zmieni się ono

przy następnym tworzeniu bazy danych. Zatem aby nasz test był poprawny

również w przyszłości, musimy w jakiś sposób poznać to losowo

generowane domyślne hasło administratora. Oczywiście hasło nie jest

przechowywane w bazie danych w postaci czystego tekstu, więc jedynym

sposobem jego pozyskania jest wykonanie takiej samej czynności, jaką

wykonałby prawdziwy użytkownik: musimy odczytać go z tematu Read

Me!

W jaki sposób możemy znaleźć i otworzyć temat Read Me w naszym

teście? Istnieje kilka opcji, z których każda ma pewne wady, jednak my

skorzystamy teraz z najprostszego rozwiązania. Jeśli będziemy ku temu

ważny powód, to będzie można później zmienić to rozwiązanie na jakieś

inne. Wiemy już, w jaki sposób pozyskać najnowszy temat z listy ostatnich

dyskusji. Ponieważ jednak testy dodadzą nad nim kilka dodatkowych

tematów, będziemy musieli uzyskać temat, który znajduje się na samym

dole. Pobranie tematu z samego dołu może być kłopotliwe, jeśli tematy te

obejmują więcej niż jedną stronę, ale na razie nie będziemy rozważać takiej

sytuacji. Później zdecydujemy, czy rozszerzyć tę implementację, aby

przechodziła na ostatnią stronę, czy też przesunąć tę inicjalizację do kodu

[AssemblyInitialize], aby zawsze była wykonywana w czystym

środowisku i w ten sposób całkowicie rozwiązywała ten problem. Możemy

nawet zdecydować się na rozpoczynanie testów od przywracania bazy

danych z kopii zapasowej, w której te uprawnienia są już podane.

Trzeba będzie również znaleźć samo hasło wewnątrz treści całej

wiadomości, ale to powinno być już stosunkowo proste, ponieważ możemy

użyć narzędzia Selenium do wyszukania drugiego pogrubionego elementu

w treści wiadomości. Listing 13.4 pokazuje zaktualizowaną metodę


TestInitialize wraz z wywoływaną przez niego metodą

GetAdminPassword.

[TestInitialize]
public void TestInitialize()
{

var adminPassword = GetAdminPassword();


var adminUser =
MVCForum.LoginAsAdmin(adminPassword);
var adminPage = adminUser.GoToAdminPage();
var permissions =
adminPage.GetPermissionsFor(TestDefaults.
StandardMembers);
permissions.AddToCategory(TestDefaults.Example
Category,
PermissionTypes.CreateTopics);
adminUser.Logout();
}

private string GetAdminPassword()


{
var readMeHeader =
MVCForum.LatestDiscussions.Bottom;
var readmeTopic =
readMeHeader.OpenDiscussion();
var body = readmeTopic.BodyElement;
var password =
body.FindElement(By.XPath(".//strong[2]"));
return password.Text;
}

Listing 13.4. Uzyskiwanie hasła administratora z dyskusji Read Me

Uwaga

W rozdziale 10 jako pierwszą wskazówkę w zakresie poprawnego

stosowania wzorca obiektu strony podaliśmy nieeksponowanie

elementów strony w postaci publicznych właściwości.

Właściwość Discussion.BodyElement zdaje się naruszać tę


regułę. Jest to jednak specjalny przypadek, ponieważ ciało

elementu może zawierać prawie dowolny kod HTML, który

najbardziej naturalnie reprezentowany jest przez obiekt

IWebElement. Z tego powodu możemy uznać to za wyjątek od

tej reguły.

Więcej problemów…

Po zaimplementowaniu brakujących metod i właściwości oraz usunięciu

zduplikowanego kodu, test nadal kończy się niepowodzeniem, lecz tym

razem komunikat błędu jest inny:

Message: Initialization method

MVCForumAutomation.SanityTests.TestInitialize threw exception.

OpenQA.Selenium.NoSuchElementException: no such element: Unable

to locate element: {“method”:“class name”, “selector”:“postcontent”}

Ze zrzutu ekranu, śladu stosu i samego kodu możemy wywnioskować, że

dyskusja Read Me nie otworzyła się. Powinno się to stać w metodzie


DiscussionHeader.OpenDiscussion, ale niestety główna

przyczyna tego błędu nie jest wystarczająco oczywista. Naprawmy więc

również i to.

Trzeba zmienić metodę DiscussionHeader.OpenDiscussion


w taki sposób, aby kończyła się ona niepowodzeniem, gdy nie będzie

w stanie pomyślnie ukończyć swojego zadania. W rzeczywistości, jeśli

dyskusja nie jest wyświetlana, to powinniśmy nawet zakończyć

Discussion (który wywoływany jest


niepowodzeniem konstruktor klasy

przez metodę DiscussionHeader.OpenDiscussion). Co więcej,

ponieważ klasa Discussion jest praktycznie obiektem strony, to jeśli

możemy zidentyfikować jakiś element będący kontenerem widoku dyskusji

(tj. zawiera on wszystko to, co powiązane jest z dyskusją, ale bez menu

i wszystkich elementów ją otaczających), trzeba użyć go jako elementu

głównego dla obiektu strony. Ten element główny powinien być użyty jako

kontekst dla wszystkich wywołań metody FindElement zamiast obiektu

IWebDriver. Wtedy będziemy mogli zakończyć niepowodzeniem ten

konstruktor, jeśli nie uda nam się go znaleźć. Na listingu 13.5 pokazano

fragment klasy Discussion po wprowadzeniu tych zmian.


Listing 13.5. Kończenie niepowodzeniem konstruktora klasy Discussion,

jeśli dyskusja nie jest otwarta

Zwróćmy uwagę, że przed zmianą mieliśmy element członkowski

IWebDriver _webDriver, a teraz został on zamieniony na

IWebElement _container.

Uwaga

W klasie tej nie uznajemy użycia metody Assert za asercję

testu, gdyż jest ona tylko wygodnym sposobem zgłoszenia

wyjątku. Jednak niektóre biblioteki, jak choćby JUnit, raportują

niepowodzenia powstałe w wyniku asercji w inny sposób niż

niepowodzenia powstałe w wyniku zgłoszenia wyjątku, dlatego


w takim wypadku nie zaleca się łączenia ze sobą tych dwóch

konstrukcji.

Zabawa w detektywów

Teraz komunikat dotyczący niepowodzenia jest zdecydowanie lepszy:

Assert.IsNotNull failed. Failed to open Discussion (Nie udało się

utworzyć dyskusji), ale dlaczego?

Ze zrzutu ekranu dowiadujemy się, że faktycznie dyskusja nie została

otwarta, tak więc komunikat nie kłamie. Musimy zatem kontynuować nasze

badanie. Jeśli prześledzimy ślad stosu, to powinniśmy zobaczyć, że to

metoda DiscussionHeader.OpenDiscussion wywołała konstruktor


klasy Discussion, który zgłosił wyjątek. Metoda ta jest widoczna na
listingu 13.6.

public Discussion OpenDiscussion()


{
var link =
_topicRow.FindElement(By.TagName("h3"));
link.Click();

var driver = ((IWrapsDriver)


_topicRow).WrappedDriver;
return new Discussion(driver);
}

Listing 13.6. Metoda DiscussionHeader.OpenDiscussion


Ponieważ wywołanie konstruktora Discussion ma miejsce po

wywołaniu metody link.Click();, możemy być pewni, że kliknięcie

zostało wykonane pomyślnie i nastąpił powrót z tej metody (w przeciwnym

wypadku nie dotarlibyśmy do ostatniego wiersza metody, w której wystąpił

błąd). Mimo że przeglądanie śladu stosu lub kodu nie jest najprostszym

sposobem diagnozowania problemów, to jednak jest to najbardziej dokładny

i niezawodny sposób. Aby naprawdę wiarygodnie badać niepowodzenia,

powinniśmy zachowywać się jak detektywi: zbierać dowody, tworzyć

spekulacje na temat podejrzanych i starać się obalić lub udowodnić wersję

każdego z nich, zawężając w ten sposób potencjalne możliwości, dopóki nie

wyjaśnimy głównej przyczyny. To elementarne, drogi Watsonie!

Podsumujmy teraz to, czego dowiedzieliśmy się do tej pory:

1. Instrukcja link.Click(); została pomyślnie wywołana i powróciła

bez zgłaszania wyjątku.

2. Dyskusja nie została otwarta przy wywołaniu konstruktora klasy

Discussion, które następuje zaraz po kliknięciu.


Ponieważ Selenium jest dosyć wiarygodnym narzędziem, możemy

bezpiecznie założyć, że wykonało akcję kliknięcia na elemencie, do którego

odwołuje się zmienna link. Kim są zatem główni podejrzani?


1. W testowanym systemie jest błąd, dlatego po kliknięciu łącza dyskusja nie

zostaje otwarta.

2. Mamy problem z czasem, tj. strona dyskusji nie otworzyła się

wystarczająco szybko. Gdybyśmy poczekali nieco dłużej, operacja ta

zakończyłaby się sukcesem.

3. Kliknęliśmy niewłaściwy element.

Ręczne wykonywanie operacji w zasadzie eliminuje pierwszą opcję.

Druga opcja jest typowym podejrzanym dla wielu deweloperów

automatyzacji testów. Choć w wielu przypadkach jest ona faktycznie


powodem niepowodzenia, to jest ona równie często niewinnym podejrzanym

(o czym powiemy sobie w dalszej części tego rozdziału), ale możemy ją

łatwo obalić poprzez wprowadzenie opóźnienia na poziomie powiedzmy 10

Thread.Sleep(10000);), bezpośrednio
sekund (za pomocą instrukcji

po wierszu link.Click();. Po odrzuceniu tego podejrzenia musimy


pamiętać o usunięciu tego opóźnienia.

Zatem pozostał nam już tylko jeden podejrzany – kliknęliśmy zły

element. Na podstawie kodu widzimy, że identyfikujemy to łącze za pomocą

nazwy znacznika h3 wewnątrz elementu, do którego odnosi się pole

_topicRow. Jeśli zbadamy kod za pomocą opcji Find All References

(Znajdź wszystkie odwołania) dostępnej w menu kontekstowym (lub opcji

Inspect Value Origin, jeśli używamy narzędzia ReSharper), możemy

również zobaczyć, że pole _topicRow jest identyfikowane za pomocą

nazwy klasy topicrow. Korzystając z narzędzi dla programistów

w przeglądarce możemy wyszukać ten element za pomocą selektora

.topicrow h3 (który jest wyrażeniem selektora CSS oznaczającym

znacznik h3 wewnątrz elementu klasy topicrow), jak pokazano na

rysunku 13.4. Więcej informacji na temat różnych lokalizatorów, w tym

selektorów CSS, można znaleźć w dodatku D.


Rysunek 13.4. Wyszukiwanie łącza prowadzącego do dyskusji

Uwaga

Aby w przeglądarce Chrome otworzyć okno wyszukiwania

w narzędziach dla programistów, należy wcisnąć kombinację

klawiszy Ctrl+F.

Jak widać, element ten został znaleziony i jest tylko jeden (o czym

świadczy informacja „1 z 1” po prawej stronie pola wyszukiwania), ale

widzimy również, że zawiera on element „a”, który jest samym łączem

i zajmuje jedynie niewielką część elementu nadrzędnego. Zamknięcie

narzędzi dla programistów i próba kliknięcia obszaru zaznaczonego na


rysunku 13.4 (który po zamknięciu narzędzi dla programistów nie będzie już

podświetlony), ale nie samego tytułu Read Me, nie powoduje otwarcia

dyskusji.

Jedna rzecz nadal wygląda dziwnie. Metoda ta nie zmieniła się od czasu,

gdy użyliśmy jej w pierwszym teście, zanim jeszcze zintegrowaliśmy

najnowsze zmiany z aplikacją MVCForum. Dlaczego więc błąd ten nie

pojawił się wcześniej? Gdybyśmy powrócili teraz do poprzedniej wersji

i zbadali to szczegółowo, okazałoby się, że tytuły tematów klikane w testach

były identyfikatorami GUID, które są dłuższe od tytułu Read Me. Te długie

łącza przekraczały środek obszaru elementu h3, dlatego kliknięcie h3


powodowało kliknięcie łącza. Ponieważ tytuł Read Me jest krótki,

kliknięcie w środku elementu h3 nie powoduje kliknięcia łącza.


Po skazaniu sprawcy (którym jestem ja, jako autor tego kodu…)

możemy to bardzo łatwo naprawić. Wystarczy zamienić lokalizator zmiennej

link z By.TagName("h3") na By.CssSelector("h3 a").

Wykonywanie dodatkowych zrzutów ekranu

Po naprawieniu tego lokalizatora nasza historia jeszcze się nie kończy…

Teraz test pokazuje komunikat błędu: OpenQA.

Selenium.NoSuchElementException: no such element: Unable to locate

element: {“method”:“class name”, “selector”:“createtopicbutton”}.

Patrząc na nowy zrzut ekranu (rysunek 13.5) oraz ślad stosu, możemy

wywnioskować, że procesy rejestracji zostały tym razem wykonane

pomyślnie. Zatem dokonaliśmy jakiegoś postępu.


Rysunek 13.5. Rejestracja zakończyła się pomyślnie

Z powyższego komunikatu błędu i zrzutu ekranu wynika również, że

przycisk Create Discussion jest dostępny. Jak pamiętamy, mieliśmy

podobny problem w poprzednim rozdziale i rozwiązaliśmy go poprzez

dodanie uprawnienia Create Topics do grupy Standard Members.

Możliwe więc, że ponownie mamy do czynienia z problemem

z uprawnieniami.

Aby zbadać, czy pomyślnie dodaliśmy uprawnienia, dobrze byłoby

wykonać po jednym zrzucie ekranu dla każdego kliknięcia, a następnie

zaznaczyć na nich klikany element. Opracowanie takiego mechanizmu może

zająć trochę czasu, tak więc zapiszemy sobie ten pomysł na karteczce

samoprzylepnej i powrócimy do niego później, gdy już naprawimy test. Na

razie możemy zrobić dwa dodatkowe zrzuty ekranu: jeden przed dodaniem

uprawnień i jeden po. W tym celu możemy użyć metody

TakeScreenshot, którą dodaliśmy już do klasy MVCForumClient,


i wywołać ją przed i po wywołaniu
RolePermissionsPage.AddToCategory z metody

TestInitialize. Jak zawsze refaktoryzujemy kod, aby usunąć

duplikację związaną z dodawaniem zrzutu ekranu do wyników testu.

Dokonujemy tego poprzez dodanie zdarzenia

MVCForumClient.OnScreenshotTaken i obsłużenie go w klasie


SanityTests, aby dołączyć plik do wyników testu. Gotowy kod jest
dostępny w historii repozytorium Git pod tag AddingScreenshot.

Gdy uruchomimy test, do jego wyniku zostaną dodane dwa zrzuty

ekranu. Oba te zrzuty są identyczne i wyglądają jak te przedstawione na

rysunku 13.6.

Rysunek 13.6. Zrzut ekranu przed i po dodaniu uprawnienia Create Topics

Widzimy, że trzecie uprawnienie jest zaznaczone, ale co to? Nie jest już

ono uprawnieniem Create Topics, ale nowym uprawnieniem Create Tags,

którego wcześniej nie było… Wygląda na to, że jest to nowe uprawnienie,

które deweloper dodał go, zanim pobraliśmy najnowsze zmiany, a kod

w metodzie AddToCategory opierał się na indeksach uprawnień


(rzutowanych na int z typu wyliczeniowego PermissionTypes),
ponieważ nie było żadnego innego sposobu na zidentyfikowanie właściwego

pola wyboru. Dodanie atrybutu id lub klasy auto-*, jak to zrobiliśmy

w poprzednim rozdziale, również nie było zbyt praktyczne, ponieważ lista

uprawnień jest tworzona dynamicznie w czasie wykonywania.

Możemy to naprawić, dodając nowe uprawnienia CreateTags do typu


wyliczeniowego PermissionTypes w odpowiedniej pozycji. Nie jest to

idealne rozwiązanie, ale po konsultacji z deweloperem doszliśmy do

wniosku, że jest to najlepsze, co możemy na tę chwilę zrobić. Aby uzyskać

lepsze rozwiązanie, deweloper będzie musiał dokonać poważnych zmian,

które raczej nie zostaną zrobione w najbliższej przyszłości.

W końcu nasz test ponownie kończy się sukcesem!

Rejestrowanie oraz inne formy zbierania


dowodów

Choć większość rzeczy możemy badać przy użyciu zrzutów ekran,

komunikatów błędu oraz kodu, to jednak do szybkiego identyfikowania

niektórych problemów potrzebne nam będą dodatkowe informacje.

Przechwytywanie ekranu

Czasem bardzo pomocne może być przechwytywanie i nagrywanie wideo

z ekranu – pozwala nam to dokładnie zobaczyć, co tak naprawdę stało się

podczas wykonywania testu. Możemy zobaczyć cały przepływ testu,

przewinąć do przodu lub do tyłu i, co najważniejsze, możemy zauważyć

wtedy takie rzeczy jak komunikaty błędów – nawet z innych aplikacji lub

z systemu operacyjnego – których nie oczekiwaliśmy.


Jak wszystko w automatyzacji testów i w prawdziwym życiu,

przechwytywanie ekranu ma również swoje wady. Po pierwsze, wymaga

ono sporej ilości wolnego miejsca na dysku, co zależy również od jakości

wideo. Zazwyczaj nie będziemy potrzebować bardzo wysokiej jakości

wideo, chyba że nasza aplikacja wykorzystuje sporą ilość animacji i grafiki.

Aby zaoszczędzić miejsce na dysku, możemy też usunąć takie wideo, gdy

test zakończy się pomyślnie. Nadal jednak będziemy potrzebować znacznie

więcej miejsca niż w przypadku przechwytywania samego tekstu

i wykonywania zrzutów ekranu.

Ponadto, ponieważ test jest wykonywany zwykle bardzo szybko, może

być czasem trudno prześledzić na filmie, co dokładnie automatyzacja stara

się zrobić w określonym momencie. Dotyczy to zwłaszcza narzędzia

Selenium i technologii automatyzacji interfejsu użytkownika, które nie

poruszają wskaźnikiem myszy. Jeśli nie widzimy ruchu wskaźnika, to trudno

nam będzie ustalić, który przycisk został w danej chwili kliknięty. Zatem na

podstawie samego takiego filmu może nam być trudno zrozumieć, co tak

naprawdę się dzieje.

Aby przechwycić wideo, zwykle musimy skorzystać z jakieś

zewnętrznej aplikacji, która nam to umożliwi. Infrastruktura testu powinna

rozpocząć przechwytywanie zaraz po rozpoczęciu testu i zatrzymać je po

zakończeniu testu, usuwając ewentualnie plik wideo, jeśli test zakończył się

sukcesem. Oczywiście, nie licząc powyższego, same testy, jak i dowolny

inny kod w projekcie testu nie powinien być zmieniany, aby można było

skorzystać z przechwytywania wideo.

W programie Visual Studio Enterprise możemy w łatwy sposób

włączyć nagrywanie wideo dla uruchamianych testów za pomocą pliku

*.runsettings lub *.testsettings. Więcej informacji na ten temat można


znaleźć pod adresem https://docs.microsoft.com/en-

us/visualstudio/test/configureunit-tests-by-using-a-dot-runsettings-file.

Rejestrowanie

Kolejną techniką pozwalającą na śledzenie tego, co dzieje się podczas

wykonywania testu, jest rejestrowanie. Rejestrowanie może być tak proste,

jak wypisywanie tekstu na standardowe wyjście konsoli, ale może być ono

również znacznie bardziej skomplikowane, o czym zaraz sobie powiemy.

W przeciwieństwie do przechwytywania ekranu, rejestrowanie wymaga

wstawiania dedykowanych wierszy kodu za każdym razem, gdy chcemy coś

zapisać w dzienniku.

Istnieje wiele różnych bibliotek rejestrowania dedykowanych

praktycznie wszystkim ważnym językom programowania, z czego

większość jest oprogramowaniem open source. Biblioteki te zwykle

pozwalają nam na skonfigurowanie jednego lub więcej plików docelowych,

do których zapisywane będą rejestrowane informacje. Taką lokalizacją

docelową może być wyjście konsoli, plik, baza danych itd. Możemy również

określić pożądany format każdego wpisu, tak aby automatycznie

uwzględniał on datę i godzinę, identyfikator wątku czy nazwę metody.

Ponadto zwykle biblioteki te obsługują kilka poziomów ważności (takich jak

informacje debugowania, informacja, ostrzeżenie, błąd itd.), które musimy

określić przy każdym wpisie do dziennika. Poprzez zmianę konfiguracji,

poziomów tych możemy używać do filtrowania rodzaju wpisów, które nas

interesują.

Rejestrowanie zagnieżdżone

Jednym z wyzwań związanych z dziennikami jest podejmowanie decyzji

o tym, co i z jakim poziomem ważności zapisać do dziennika. Z jednej


strony, jeśli będziemy zapisywać zbyt mało informacji, możemy pominąć

pewne istotne rzeczy, które będą nam potrzebne przy diagnozowaniu

problemu. Z drugiej strony, jeśli będziemy zapisywać ich zbyt wiele, to

znacznie trudniej będzie nam zobaczyć całość i znaleźć to, czego szukamy.

Do dyspozycji mamy kilka rozwiązań, przy czym jedno z nich, tworzenie

dziennika w zagnieżdżony sposób, wydaje się działać najlepiej. Polega to na

tym, że rejestrator wykorzystuje wcięcia do zapisywania szczegółów

niższego poziomu wewnątrz szczegółów wyższego poziomu. Listing 13.7

pokazuje przykład takiego zagnieżdżonego dziennika.

Adding a Category
Opening Admin page
Clicking on 'My Tools'
Clicking on 'Admin'
[Done: Opening Admin Page]
Opening Categories page
Clicking on 'Categories'
[Done: Opening Categories page]
Creating new category
Clicking 'Create New'
Typing 'Test category' in 'Category Name'
Clicking 'Create'
[Done: Creating new category]
Click 'Main Site'
[Done: Adding a Category]

Listing 13.7. Przykład zagnieżdżonego dziennika


Rejestrator śledzi poziom wcięć na podstawie specjalnych metod

StartSection i EndSection, które możemy wywołać. W języku C#


instrukcja using i wzorzec IDisposable mogą być wykorzystywane do

automatycznego kończenia takiej sekcji podczas opuszczania bloku kodu

i dzięki temu nie musimy jawnie wywoływać metody EndSection.


Dowolny wpis dziennika, który zapisywany jest między wywołaniami metod

StartSection i EndSection, ma dodatkowe wcięcie, którego nie mają


pozostałe wpisy. W ten sposób, gdy czytamy dziennik, zawsze mamy przed

sobą wszystkie szczegóły, ale możemy łatwo rozróżnić wpisy wysokiego

poziomu od wpisów niższego poziomu, a także łatwo powiązać te wpisy

niskiego poziomu z wpisem wyższego poziomu, który je rozpoczął.

Uwaga

Instrukcja using w języku C# jest funkcją kompilatora (tzw.

„lukrem składniowym”), będącą odpowiednikiem konstrukcji

try/finally, przy czym zamiast bezpośrednio definiować

zawartość klauzuli finally, przyjmuje ona na początku

wyrażenie typu IDisposable i wywołuje jego metodę

IDisposable.Dispose w niejawnej klauzuli finally. Java


8 ma podobną funkcję o nazwie try-with-resources, która

wykorzystuje interfejs AutoClosable. Więcej informacji na

temat instrukcji using można znaleźć pod adresem


https://docs.microsoft.com/en-us/dotnet/csharp/language-

reference/keywords/using-statement.

Taki mechanizm oferuje projekt TestAutomationEssentials.Common

(patrz dodatek B). Domyślnie biblioteka Test Automation Essentials


wypisuje dzienniki na wyjściu konsoli, ale możemy je łatwo przekierować

do dowolnego innego miejsca docelowego.

Rejestrowanie wizualne

Choć standardowe rejestratory obsługują wyłącznie tekst, niektóre

rejestratory obsługują również obrazy, zwykle poprzez zapis do pliku

HTML. W przypadku automatyzowania interfejsu użytkownika, dziennik,

który zawiera dodatkowo zrzuty ekranu jest znacznie prostszy do analizy od

dziennika, który zawiera wyłącznie tekst. Popularnym narzędziem, które

pozwala nam zapisywać dzienniki testów zawierające obrazy, jest

ExtentReports. Jest on dostępny w darmowej edycji „Community”, a także

bardziej rozbudowanej i płatnej wersji „Professional”.

Niektóre narzędzia do automatyzacji interfejsu użytkownika, jak na

przykład Coded UI, automatycznie wykonują zrzuty ekranu przy każdym

wciśnięciu przycisku myszy lub klawisza na klawiaturze, a także

podświetlają odpowiedni element. Tego rodzaju dziennik analizuje się

jeszcze lepiej i jest on łatwiejszy w użyciu od strony kodowania. Ponieważ

ExtentReports nie jest częścią Selenium, narzędzie to nie oferuje takiej

funkcjonalności. Możliwe jest jednak połączenie obu tych narzędzi za

pomocą klasy EventFiringWebDriver (w bibliotece

Selenium.Support), a także przy użyciu innych sztuczek, dzięki czemu

Selenium również będzie w stanie korzystać z tej funkcji w swoich testach!

Oczywiście najlepszym możliwym rozwiązaniem będzie tu połączenie

rejestrowania zagnieżdżonego z rejestrowaniem wizualnym. W dalszej

części tej książki budujemy taki rejestrator i dodajemy go do projektu.

Dodatkowe opcje rejestrowania i diagnozowania


Poza wspominanymi powyżej opcjami rejestrowania, istnieją również inne

możliwości ułatwienia sobie analizy niepowodzeń. Niektóre z nich dotyczą

narzędzia Selenium, niektóre są bardziej odpowiednie do testowania API,

a niektóre są bardzo ogólne. Możemy wykorzystać więc te opcje, które

najlepiej pasują do naszej aplikacji i pomagają nam badać przyczyny

niepowodzeń. Wiele z tych rzeczy możemy zastosować również do bardziej

ogólnych opcji rejestrowania.

Oto kilka elementów związanych z narzędziem Selenium, z których

możemy skorzystać:

IWebDriver.PageSource – ta właściwość zwraca kod HTML


bieżącej strony w formie ciągu znaków. Dla sterowników niektórych

przeglądarek właściwość ta zwraca oryginalny kod HTML, jaki istniał

przy pierwszym załadowaniu strony, zaś dla innych, wliczając w to

Chrome, zwraca kod HTML reprezentujący bieżący stan modelu DOM

(który może się różnić od oryginalnego kodu HTML z powodu

manipulacji, jakie poczyniliśmy na stronie za pomocą języka JavaScript).

Jeśli zapiszemy ten ciąg znaków do pliku z rozszerzeniem .html,

będziemy mogli otworzyć ten plik w przeglądarce po ukończeniu testu

i zobaczyć mniej więcej, jak strona wyglądała w chwili zapisywania tego

pliku. Niestety, ponieważ zapisuje ona jedynie kod HTML bez

zewnętrznych plików CSS i JavaScript, zwykle strona taka nie będzie

wyglądać poprawnie. Nadal możemy jednak uzyskać z niej istotne

informacje, które mogą być dla nas bardzo pomocne w przypadku, gdy

dany element nie został znaleziony na stronie (tj. gdy zgłaszany jest

wyjątek NoSuchElementException). Nawet jeśli otwieranie takiej


strony w przeglądarce nie działa zbyt dobrze, to zawsze możemy

otworzyć ją w edytorze tekstowym (najlepiej w takim, który koloruje


składnię HTML, przeskakuje do zamykających znaczników itd., jak np.

Notepad++).

IWebDriver.Manage().Logs – ta właściwość może zostać użyta


do uzyskania dzienników z różnych źródeł powiązanych z narzędziem

Selenium. Najbardziej istotny wydaje się być typ dziennika Browser,


w którym możemy zobaczyć komunikat, jaki kod JavaScript aplikacji

wypisuje na konsolę przeglądarki (poprzez wywołanie metody

console.log(…)), oraz inne błędy, która przeglądarka sama


wypisuje w przypadku zgłoszenia wyjątku lub problemów

z komunikacją.

Za pomocą klasy EventFiringWebDriver możemy przechwycić


wszystkie istotne wywołania metod dla obiektów typu IWebDriver

i iWebElement, w tym metody Click, SendKeys, GoToUrl itd.


Za każdym razem, gdy wywoływana jest któraś z tych metod, klasa ta

wyzwala odpowiednie zdarzenia. Możemy zasubskrybować te zdarzenia,

aby automatycznie rejestrować wszystkie te operacje. Dzięki temu nie

musimy pamiętać o dodawaniu wpisu do dziennika przy każdym

kliknięciu, a ponadto nasz kod jest dużo czystszy, a sam dziennik

bardziej spójny.

Oto kilka innych opcji, które nie są związane z narzędziem Selenium:

Rejestrowanie ruchu HTTP – choć można go również wykorzystywać do

testów Selenium, jego głównym zastosowaniem jest testowanie API.

Możemy albo skorzystać z jakiegoś zewnętrznego narzędzia, takiego jak

Fiddler, albo nawet rejestrować wszystkie żądania tuż przed ich

wysłaniem do serwera oraz odpowiedzi zaraz po ich odebraniu. Podobne

dzienniki możemy często uzyskać z tej części serwera sieci Web, która
udostępnia aplikację taką jak Microsoft Internet Information Services

(IIS).

Dziennik aplikacji – dzienniki samej aplikacji również mogą być bardzo

cenne przy badaniu niepowodzeń testów. Możemy albo przechwycić te

dzienniki poprzez przekierowanie ich do testu, albo po prostu skopiować

pliki dziennika po zakończeniu wykonywania testu do katalogu jego

wyników. Jeśli będziemy używać tych dzienników do badania przyczyn

niepowodzeń, to pamiętajmy o skorelowaniu dokładnego czasu każdego

wpisu w dzienniku testu z czasem odpowiednich wpisów w dzienniku

aplikacji. W ten sposób będziemy mogli lepiej zrozumieć główną

przyczynę niepowodzenia, niż przeglądając dziennik w poszukiwaniu

błędów i anomalii. Zauważmy, że może pojawić się pewna drobna

różnica między zegarami różnych komputerów, ale w czasie

wykonywania testu różnica ta powinna być dosyć stała, tak więc jeśli

w obu dziennikach znajdziemy powiązane ze sobą wpisy, to będziemy

mogli ustalić, jak duża jest ta różnica, a następnie bardzo dokładnie

skorelować wszystkie pozostałe wpisy.

Dziennik systemu i oprogramowania pośredniczącego – przegląd

zdarzeń w systemie Windows oraz jego odpowiedniki w innych

systemach operacyjnych, jak również infrastruktura oprogramowania

pośredniczącego, również mogą zostać wykorzystane do wyjaśnienia

pewnych globalnych niepowodzeń. Niepowodzenia te zwykle nie

wskazują na jakiś błąd w testowanym systemie, ale raczej na pewnego

rodzaju problem „środowiskowy”. Choć w większości przypadków

dzienniki te nie dostarczają zbyt wielu informacji związanych

z niepowodzeniami w naszych testach, to jednak czasem naprawdę mogą

nam zaoszczędzić sporo czasu poszukiwania problemu w złym miejscu.

Dzienniki te należy dołączyć tylko wtedy, gdy podejrzewamy jakiś


problem środowiskowy, którego nie możemy zidentyfikować w żaden

inny sposób. Przykładem takich problemów może być mała ilość

wolnego miejsca na dysku, rozłączanie z siecią, problemy związane

z bezpieczeństwem itd. Gdy znajdziemy i zidentyfikujemy taki problem,

powinniśmy albo zaimplementować dla niego konkretne obejście

w kodzie infrastruktury testów, albo dodać jakiś kod do automatycznego

identyfikowania takiej sytuacji i raportowania jej w przejrzysty sposób

przy niepowodzeniu testu. Najlepiej jak napiszemy taki kod przed

rozwiązaniem problemu (np. zwolnimy miejsce na dysku), tak aby

można było go przetestować. Dopiero gdy zobaczymy, że komunikat

błędu jest czytelny lub że nasze obejście działa, możemy rozwiązać

prawdziwy problem i sprawdzić, czy komunikat ten już się nie pojawia.

Dodawanie zagnieżdżonego rejestratora


wizualnego do testów aplikacji MVCForum

Ponieważ nasz test kończy się sukcesem, możemy powrócić do

implementacji wizualnego rejestratora, który zapisaliśmy sobie na boku,

czyniąc go dodatkowo rejestratorem zagnieżdżonym. Kompletne

rozwiązanie dostępne jest pod tagiem VisualLogger w repozytorium Git,

zaś poniżej znajdują się pewne najważniejsze uwagi i przemyślenia na jego

temat:

Jako podstawę dla naszego rejestratora wizualnego wykorzystujemy

bibliotekę NuGet ExtentReports.

Dla funkcji zagnieżdżania wykorzystujemy bibliotekę NuGet

TestAutomationEssentials.Common, przy czym jej domyślne

działanie, które polega na wypisywaniu na standardowe wyjście,


zastępujemy zapisywaniem do dziennika ExtentReports. To działanie

umieszczamy w nowej klasie VisualLogger. Następnie klasy


Logger z biblioteki Test Automation Essentials używamy wszędzie
tam, gdzie to potrzebne do zapisywania wpisów dziennika, zaś jej

metody StartSection – do rozpoczynania nowego poziomu


zagnieżdżenia. Jak wspomnieliśmy wcześniej, metoda StartSection

implementuje interfejs IDisposable, tak więc możemy ją

using, która na zakończenie


wykorzystywać w połączeniu z instrukcją

automatycznie wywołuje metodę Dispose, kończąc w rezultacie

ostatni poziom zagnieżdżenia. Listing 13.8 pokazuje użycie metod

Logger.StartSection i Logger.WriteLine w metodzie


SanityTests.GetAdminPassword.

Listing 13.8. Użycie klasy Logger w metodzie

SanityTests.GetAdminPassword
Zastąpiliśmy domyślne renderowanie komunikatów ExtentReports, aby

zagnieżdżone wpisy dziennika wyświetlane były poprawnie.

Korzystamy z klasy EventFiringWebDriver (z biblioteki


Selenium.Support) do przechwytywania wszystkich wywołań dla

metod IWebDriver.Click i IWebDriver.SendKeys oraz


ustawiania właściwości IWebDriver.URL, aby automatycznie

zapisywać te zdarzenia do dziennika, a przy każdym wystąpieniu takiego

zdarzenia robić zrzut ekranu.

Na zrzutach ekranu zrobionych dla zdarzeń kliknięcia rysujemy

czerwony prostokąt, aby zaznaczyć element, który zamierzamy kliknąć

IWebElement.Location
(za pomocą właściwości

i IWebElement.Size). Gdy spojrzymy na te zrzuty, od razu

będziemy wiedzieli, który element został kliknięty. Całkiem nieźle,

prawda?

Rysunek 13.7 pokazuje część wyniku zagnieżdżonego rejestratora

wizualnego. Widzimy na nim, że wpis Opening Admin Page (Otwieranie

strony administratora) i odpowiadający mu fragment [Done: …]

(Ukończono) mają mniejsze wcięcie niż wpisy między nimi. Możemy

również zobaczyć automatyczne rejestrowanie każdego zdarzenia kliknięcia.

Na pierwszym zrzucie ekranu widać czerwony prostokąt wokół menu My

Tools, a na drugim – kolejny czerwony prostokąt wokół elementu tego menu

o nazwie Admin. Zwróćmy uwagę, że w prawdziwym raporcie możemy

kliknąć taki pomniejszony zrzut ekranu, aby otworzyć go w pełnym

rozmiarze.
Rysunek 13.7. Wynik zagnieżdżonego rejestratora wizualnego

Warto przejrzeć klasę VisualLogger, aby zobaczyć, w jaki sposób

została zaimplementowana. Możemy również przejrzeć poszczególne


operacje zatwierdzania w historii poprawek (poprzedzające tag

VisualLogger), aby zrozumieć każdą z tych funkcji oddzielnie.

Badanie trudniejszych niepowodzeń

Wszystkie napotkane przez nas do tej pory niepowodzenia występowały na

naszej maszynie w sposób konsekwentny. Takie niepowodzenia zwykle bada

się dosyć łatwo. Istnieją jednak przypadki, w których niepowodzenia są

niespójne, a tym samym trudniejsze w badaniu. We wszystkich tych

przypadkach trzeba przede wszystkim sprawdzić komunikat błędu, dziennik,

zrzuty ekranu i wszystkie inne dowody, jakie są nam dostępne. Często

dowody te są wystarczające do wskazania głównej przyczyny danego

niepowodzenia. Jeśli są one niewystarczające, to możemy – a nawet

powinniśmy – dodać więcej takich dowodów (np. wpisy dziennika), które

pomogą nam znaleźć główny powód przy następnym uruchomieniu testu.

Nie zawsze jednak jesteśmy w stanie przewidzieć, który z dowodów pozwoli

nam znaleźć główną przyczynę, a wykonywanie tego metodą prób i błędów

może zająć sporo czasu i być dosyć frustrujące. Poniżej znajduje się kilka

typowych przykładów takich sytuacji, wraz ze wskazówkami do ich

efektywnej obsługi.

Niepowodzenia, które zdarzają się tylko na jednej maszynie

Czasami test kończy się sukcesem na jednej maszynie, ale konsekwentnie

kończy się niepowodzeniem na jednej lub kilku innych maszynach lub

środowiskach. Zwykle deweloper automatyzacji, który utworzył ten test,

upewnił się przed zaewidencjonowaniem swojego kodu, że test kończy się

pomyślnie (lub działa poprawnie), tak więc test ten kończy się sukcesem na

jego maszynie. Problemy mogą się pojawić, gdy kolejny deweloper próbuje
uruchomić ten test lub gdy test wykonywany jest na maszynie kompilacji.

Istnieją pewne różnice między tymi dwoma scenariuszami, ale w dużej

mierze są one takie same. Przynajmniej na początku procesu badania

musimy mieć dostęp do maszyny, na której test kończy się niepowodzeniem.

Zwykle po dojściu do jakichś wstępnych wniosków, możemy odtworzyć

dany problem na innej lub nawet naszej maszynie, aby kontynuować analizę.

Pierwszą rzeczą, którą należy zweryfikować, jest to, czy wersje testu

i testowanego systemu są takie same na obu maszynach. Jeśli nie są, to nie

ma sensu ich porównywać. Następnie próbujemy skopiować pliki

wykonywalne testu z maszyny, na której test ten kończy się sukcesem, do

komputera, na którym kończy się on niepowodzeniem i sprawdzamy, czy

nadal tak jest. Jeśli test zakończy się sukcesem, to jeszcze możemy zbadać

różnice między wersjami, ale to już inna historia. Natomiast w przypadku,

gdy test nadal kończy się niepowodzeniem, różnica może tkwić w wersjach

testowanego systemu.

Jeśli testowany system jest lokalnie zainstalowaną aplikacją, to

kopiujemy go z komputera, na którym test kończy się sukcesem, na

maszynę, na której test kończy się niepowodzeniem, lub też instalujemy

ponownie tę samą wersję, jaka jest zainstalowana na maszynie, na której test

kończy się sukcesem. W przypadku, gdy test uzyskuje zdalny dostęp do

testowanego systemu (np. przez przeglądarkę lub za pomocą protokołu

HTTP), wystarczy przekierować problematyczną maszynę na testowany

system, którego używa maszyna niepowodująca problemów.

Jeśli obie wersje są zgodne, to nasza strategia pozwalająca na

kontynuowanie badania przyczyny niepowodzenia powinna być oparta na

algorytmie poszukiwania „lwa na pustyni” (uogólnienie algorytmu

wyszukiwania binarnego). Bez obawy, nie trzeba wiedzieć, w jaki sposób

zaimplementować taki algorytm. Jego idea polega na dzieleniu każdego


problemu na dwa mniejsze i znalezieniu odpowiedzi na pytanie, który z nich

jest istotny. Proces ten, w ramach którego dzielimy problem na coraz to

mniejsze problemy, kontynuujemy do momentu znalezienia jego głównej

przyczyny. Mówiąc bardziej szczegółowo: identyfikujemy jedną różnicę

między środowiskami, która naszym zdaniem może być odpowiedzialna za

różnicę w wynikach, po czym próbujemy wyeliminować tę różnicę poprzez

zmianę jednej strony tak, aby pasowała do drugiej. Jeśli wyniki się od siebie

różnią (i komunikat błędu nadal pozostaje taki sam), to szukamy kolejnej

kluczowej różnicy i powtarzamy ten proces. Gdy zmieniona przez nas strona

wygeneruje taki sam rezultat jak druga strona, będzie to oznaczać, że

różnica, którą właśnie wyeliminowaliśmy, jest istotna dla tego problemu.

Nawet jeśli w ten sposób moglibyśmy od razu rozwiązać nasz problem, to

nie powinniśmy się zatrzymywać! Musimy zrozumieć główną przyczynę

problemu i podjąć odpowiednie działania, aby uchronić się przed jego

powtórnym wystąpieniem, lub przynajmniej dostarczyć odpowiednie

ostrzeżenia i wskazówki umożliwiające jego rozwiązanie.

Na przykład możemy podejrzewać, że powodem jest różnica w bazach

danych dwóch środowisk. Wówczas można najpierw spróbować zmienić

parametry połączenia naszego środowiska (gdzie test kończy się sukcesem)

na parametry bazy danych środowiska, w którym test kończy się

niepowodzeniem. Jeśli przy użyciu tej drugiej bazy danych test zakończy się

sukcesem na naszej maszynie, będzie to oznaczać, że problem nie jest

związany z bazą danych, więc musimy poszukać innej różnicy. Jeśli test

zakończy się niepowodzeniem, będzie to oznaczać, że problem faktycznie

tkwi w bazie danych, ale nadal nie będziemy wiedzieć, co dokładnie go

powoduje. Może to być różnica w danych, w schemacie lub nawet w wersji

silnika bazy danych. Załóżmy na przykład, że zauważyliśmy, iż wersja

schematu problematycznego środowiska jest nowsza od wersji w naszym

środowisku. Możemy więc zaktualizować schemat naszego środowiska (po


utworzeniu kopii zapasowej bazy danych i po przywróceniu parametrów

połączenia do ich oryginalnych wartości) i zbadać wyniki. Jeśli test

zakończy się niepowodzeniem, to będziemy mogli wyciągnąć wniosek, że

przyczyną niepowodzenia testu jest nowa wersja schematu, przy czym nadal

nie będziemy dokładnie wiedzieli dlaczego. Z kolei zakończenie testu

sukcesem będzie wskazywać, że problem nie dotyczy schematu bazy

danych, tak więc możemy kontynuować porównywanie i dopasowywanie

innych różnic. Kontynuujemy ten proces do momentu znalezienia dokładnej

przyczyny. Rysunek 13.8 przedstawia drzewo decyzyjne dla tego przykładu.

Podczas przechodzenia przez ten proces ważne jest to, aby komunikat

błędu i związane z nim symptomy były takie same. Jeśli w pewnym

momencie otrzymamy inny błąd, powinniśmy najpierw spróbować

dowiedzieć się, czy ten nowy błąd miał miejsce przed czy po wystąpieniu

początkowego błędu. Jeśli nowy błąd powstaje po pierwotnym

niepowodzeniu, oznacza to, że początkowy błąd tym razem nie wystąpił!

Możemy zostać zmuszeni do dalszego zawężenia tego problemu, jak to

opisano wcześniej, ale w międzyczasie powinniśmy traktować nowy błąd

tak, jak gdyby test zakończył się sukcesem. Po zakończeniu badania

oryginalnego problemu i jego rozwiązaniu, rozpoczynamy na nowo całe

badanie, lecz tym razem w kontekście tego nowego niepowodzenia.


Rysunek 13.8. Drzewo decyzyjne do diagnozowania niepowodzenia, które

występuje tylko na jednej maszynie

Jeśli jednak ten nowy błąd powstaje przed wystąpieniem pierwotnego

niepowodzenia – lub w tym samym momencie, ale z innym komunikatem

błędu – to prawdopodobnie oznacza to, że środowiska różnią się czymś

jeszcze, co ma wpływ na uzyskiwane wyniki. W takim wypadku spróbujmy

skupić się najpierw na nowym błędzie, a dopiero potem powrócić do tego

pierwszego. Gdy rzeczy zaczynają się za bardzo komplikować, pomocne

może być ręczne napisanie pliku dziennika (za pomocą edytora tekstowego)

obejmującego rzeczy, których próbowaliśmy, wraz z ich wynikami. Pomoże

nam to lepiej odnaleźć się wokół problemu, niż gdybyśmy stosowali

wyłącznie metodę prób i błędów. Dziennik ten może być dla nas pomocny

zwłaszcza wtedy, gdy będziemy kontynuować naszą pracę na drugi dzień.


Pomoże nam on również, gdy będziemy musieli wyjaśnić innym osobom

istotę rozwiązywanego przez nas problemu i zaprezentować im nasze

dotychczasowe wnioski. Jeśli chodzi o zagnieżdżony rejestrator, to taki

dziennik ręczny zazwyczaj też piszę w sposób zagnieżdżony. Przykładowo,

jeśli chcę zrobić jakiś eksperyment w celu zdiagnozowania pierwotnego

problemu, lecz podczas próby jego wykonania napotykam inny problem,

związany wyłącznie z tym eksperymentem, to staram się najpierw rozwiązać

ten wewnętrzny problem, a dopiero potem kontynuuję zaplanowany

eksperyment. W takim wypadku kwestie związane z problemami samego

eksperymentu rejestruję w dzienniku jako wpisy zagnieżdżone w innych

rzeczach, które powiązane są z oryginalnym początkowym problemem

i innymi próbami.

Uruchamianie testów z poziomu IDE kontra uruchamianie


z poziomu wiersza polecenia

Zasadniczą różnicą pomiędzy uruchamianiem testów na naszej maszynie

lokalnej a uruchamianiem ich na serwerze kompilacji jest to, że na naszej

własnej maszynie możemy uruchamiać testy z poziomu środowiska IDE, zaś

na serwerze kompilacji są one uruchamiane z poziomu wiersza polecenia.

Czasami między modułami uruchamiającymi, które działają

w środowisku IDE i w wierszu polecenia, występują drobne różnice. Aby je

lepiej zbadać, możemy przejrzeć dzienniki kompilacji i spróbować znaleźć

dokładne polecenie, które zostało wywołane podczas kompilacji. Jeśli

spróbujemy wykonać to samo polecenie na naszej maszynie lokalnej, to

możemy natknąć się na różnice w ścieżkach lub na inne różnice

środowiskowe, które nie pozwolą nam uruchomić testów. Z tego powodu

musimy najpierw znaleźć i wyeliminować te różnice. Jeśli uda nam się

uruchomić test na naszej maszynie lokalnej i zakończy się on z takim samym


błędem jak na serwerze kompilacji, będzie to oznaczać, że problem jest

prawdopodobnie związany z różnicą w modułach uruchamiających

używanych w środowisku IDE i w wiersza polecenia. Trzeba kontynuować

badanie i zawężanie możliwej przyczyny różnych zachowań między tymi

modułami uruchamiającymi, ale przynajmniej będzie można przeprowadzić

to badanie w całości na lokalnej maszynie.

Jeśli nie możemy odtworzyć testu za pośrednictwem wiersza polecenia

na naszej maszynie, to prawdopodobnie problem nie jest powiązany z tą

różnicą. Aby upewnić się, że faktycznie tak jest, możemy – jeśli jest to

wykonalne – spróbować uruchomić środowisko IDE na maszynie

kompilacji. Potem można podjąć próbę znalezienia innej podejrzanej

różnicy.

Zawężanie kodu

Podczas próby zawężenia problemu często przydatne może okazać się

zawężenie kodu, który jest powiązany z tym problemem. Możemy

zakomentować wiersze kodu, które wydają się nie być związane z danym

problemem, lub zastosować tymczasowe skróty w celu ominięcia długich

i złożonych operacji. W ten sposób każdy eksperyment będzie wykonywany

szybciej, a do tego zawęzi to zakres problemu. Oczywiście nie należy

ewidencjonować tych zmian, a zamiast tego po prostu skopiować pliki

wykonywalne testu (pliki DLL, JAR itd.) na inną maszynę. Czasem warto

nawet utworzyć całkowicie nowy projekt testowy i skopiować do niego

tylko minimalną ilość kodu, który odtwarza dany problem. Pozwala to

wyeliminować sporą część „szumu” i skupić się na badaniu głównej

przyczyny. Na końcu tego procesu prawdopodobnie znajdziemy konkretny

wiersz kodu, który zachowuje się inaczej w tych dwóch środowiskach,

a wtedy już łatwo nam będzie zrozumieć, co tak naprawdę się dzieje.
Badanie testów wpływających na inne testy

Czasem jest tak, że gdy uruchamiamy testy osobno lub jako część

niewielkiej grupy testów, to zawsze kończą się one sukcesem, ale gdy

uruchamiamy je jako część kompletnego zestawu testów, to zawsze kończą

się one niepowodzeniem. W większości przypadków oznacza to, że mamy

do czynienia z nieumyślnym wpływem jednego testu na drugi. Wskazuje to

również na problem w strategii izolacji. Konkretne techniki izolacji, które

mogą nam pomóc rozwiązać ten problem, omawiane są w rozdziale 7.

Zanim jednak zastosujemy dowolną z tych technik, powinniśmy

najpierw dobrze zrozumieć główną przyczynę tych niepowodzeń. Jak

zawsze, najpierw trzeba spojrzeć do dziennika i na dowody, a następnie na

ich podstawie spróbować zrozumieć przyczynę tego problemu. Jeśli coś

podejrzewamy, spróbujmy w prostszy sposób odtworzyć ten problem

i sprawdzić, czy dostaniemy taki sam wynik. Jeśli tak się stanie, to łatwo

nam będzie opracować jakieś rozwiązanie.

Jeśli jednak nie mamy pojęcia, co może być przyczyną tego problemu,

możemy ponownie zagrać w grę „lew na pustyni”, ale tym razem w nieco

inny sposób: załóżmy, że gdy uruchamiamy cały zestaw testów, test nr 28

zawsze kończy się niepowodzeniem, mimo że kończy się on sukcesem, gdy

uruchomimy go samodzielnie. W takim wypadku powinniśmy utworzyć

nowy zestaw testów, który zawiera wyłącznie testy od 1 do 14, a także test

28. Jeśli test 28 zakończy się teraz sukcesem, będzie to oznaczać, że testem

wywierającym na niego wpływ jest któryś z testów o numerze od 15 do 27.

Gdy upewnimy się, że faktycznie tak jest, ponownie dokonujemy podziału

pozostałych przypadków testowych na dwie części i teraz wykorzystujemy

tylko testy o numerach od 15 do 21 (liczba uzyskana po podziale testów od

15 do 27) oraz test 28. Kontynuujemy ten proces, aż pozostanie nam

dokładnie jeden test, który po wspólnym uruchomieniu z testem 28 odtwarza


wcześniejszy błąd. Załóżmy więc, że pozostały nam testy 16 i 28. Teraz

możemy kontynuować zawężanie przyczyny problemu poprzez usuwanie

niepotrzebnych rzeczy z testu 16, aż w ten sposób dojdziemy w nim do tej

jednej właściwej operacji, która wywiera wpływ na test 28.

Choć może się wydawać, że ten proces trwa niezwykle długo, to

w rzeczywistości nie jest on aż tak rozwlekły. Ponieważ w każdym cyklu

obcinamy liczbę testów o połowę, to jeśli zsumujemy liczbę testów, które

uruchamiamy podczas tych poszukiwań, otrzymamy dokładnie liczbę testów

poprzedzających test kończący się niepowodzeniem (w naszym przykładzie

było to 28 testów). W rzeczywistości będziemy także mieć pewną wiedzę na

temat tego, które z testów mogą przyczyniać się do powstania tego

problemu, a które nie, więc będziemy można jeszcze bardziej zmniejszyć ten

zakres poszukiwań.

Badanie testów migoczących

Testy migoczące są testami, które bez żadnego konkretnego powodu czasem

kończą się niepowodzeniem, a czasem sukcesem, nawet jeśli uruchomimy je

na tym samym serwerze kompilacji i w tym samym środowisku. Bez

względu na to, czy niepowodzenie ma miejsce dosyć często lub rzadko,

zjawisko to jest jedną z najbardziej niepokojących sytuacji dla każdego,

komu zależy na wynikach automatyzacji testów, ponieważ negatywnie

wpływa to na ich wiarogodność. Czasem takie migotanie nie jest

ograniczone wyłącznie do jednego lub kilku konkretnych testów, a wtedy

przy każdym uruchomieniu niepowodzeniem może się zakończyć inny test.

Badanie tych przypadków jest zwykle bardzo frustrujące, ponieważ

w zasadzie nigdy nie jesteśmy w stanie stwierdzić, czy naprawiliśmy już

dany problem, czy po prostu mieliśmy tym razem szczęście. Istnieje jednak

kilka przydatnych technik do obsługi tych przypadków. Zanim jednak


przejdziemy do tych technik, przedstawmy najpierw kilka typowych

antywzorców38 i zapoznajmy się z wadami ich stosowania.

Antywzorce do rozwiązywania problemu testów migoczących

Do rozwiązania problemu z testami migoczącymi często stosuje się opisane

poniższej techniki. Jednak mimo że są one dosyć powszechne, to uważam je

za antywzorce, które mają pewne istotne wady. Techniki te podane są tutaj

jedynie po to, aby uświadomić ich istnienie oraz konsekwencje ich

stosowania.

Ponowne uruchamianie testów kończących się


niepowodzeniem

Jednym z takich antywzorców, który jest stosowany do rozwiązania

problemu z testami migoczącymi, jest ponowne – zwykle dwu- lub

trzykrotne – uruchamianie wszystkich testów, które zakończyły się

niepowodzeniem, a dopiero gdy wszystkie te próby zakończą się

niepowodzeniem, zgłoszenie takiego testu jako zakończonego

niepowodzeniem. Jeśli test taki zakończy się sukcesem chociaż w jednej

próbie, jest on uznawany za zakończony sukcesem.

Podejście to ma jednak kilka wad:

Zakłada ono, że niepowodzenia są powodowane głównie automatyzacją,

a nie prawdziwymi błędami. Choć czasem może to być prawdą, to

w większości przypadków tak nie jest. Jeśli niepowodzenie powstaje

w wyniki automatyzacji, to po prostu ukrywamy i odkładamy w czasie

ten problem, zamiast próbować go zbadać i rozwiązać. Jeśli błąd tkwi

w testowanym systemie, oznacza to, że istnieje błąd, którego nie jest

zgłaszany. Zwróćmy uwagę, że problemy „środowiskowe” są zwykle


problemami izolacji, które są powodowane przez automatyzację, Jak

każde inne błędy, powinny być one identyfikowane i naprawiane, a nie

zamiatane pod dywan.

Gdy deweloper automatyzacji tworzy nowy test lub utrzymuje jakiś

istniejący test, powinien on zwykle uruchomić go kilka razy, aby go

zdebugować, zdiagnozować i ostatecznie upewnić się, że działa

poprawnie. Jeśli test kończący się sukcesem raz na trzy próby uznawany

jest za poprawny, to gdy test ten zakończy się niepowodzeniem podczas

dowolnej z tych czynności (debugowanie, diagnozowanie itd.),

wprowadzi on w zakłopotanie dewelopera automatyzacji, ponieważ nie

będzie on w stanie odróżnić niepowodzeń powodowanych przez jego

ostatnie zmiany od niepowodzeń, które wcześniej uznawane były za

akceptowalne. Jeśli musi on uruchamiać i debugować wszystko po trzy

razy, to znacząco wydłuży to czas jego pracy.

Ponieważ problemy są odkładane w czasie i zamiatane pod dywan, to na

dłuższą metę coraz więcej problemów powoduje, że większa liczba

testów zaczyna migotać z wyższą częstotliwością. Sprawia to, że te trzy

próby stają się niewystarczające, tak więc naturalną reakcją jest

zwiększenie ich liczby, co tylko pogarsza sytuację…

Korzystanie ze stałych opóźnień (Thread.Sleep)

Typową przyczyną migotania testów jest problem z synchronizacją w czasie.

Ponieważ w większości przypadków testowany system działa na innym

procesie niż test, oznacza to również, że działa on na innym wątku.

Wskazuje to, że po każdej operacji wykonanej na testowanym systemie (np.

kliknięcie przycisku, wysłanie żądania HTTP itd.), operacja ta kończy się


w wątku w testowanym systemie, podczas gdy wątek testu działa

równolegle.

Nie oznacza to jednak, że operacje nie mogą być synchronizowane

poprzez oczekiwanie na jakiegoś rodzaju potwierdzenie z testowanego

systemu, że operacja została zakończona. Co więcej, zanim nastąpi powrót

z metody, Selenium zawsze czeka na ukończenie przez przeglądarkę

wykonywanej przez nią operacji. Ponadto istnieje również wiele bibliotek

HTTP/REST, które czekają odpowiedź HTTP, zanim będą kontynuować

pracę. Wciąż jednak pogląd na to, co jest uznawane za „kompletną operację”

podlega dyskusji. Jeśli na przykład przeglądarka wysyła do serwera

asynchroniczne wywołanie HTTP (znane jako AJAX lub XHR) i wyświetla

w tym czasie animację oczekiwania, wówczas Selenium powróci, jak gdyby

operacja ta zakończyła się, mimo że w większości przypadków użytkownik

uznaje operację za ukończoną tylko wtedy, gdy nastąpi powrót z tego

asynchronicznego wywołania, a na ekranie zostanie wyświetlony jego

rezultat. Podobna sytuacja ma miejsce, gdy API REST zwraca odpowiedź

„200 OK” po wypchnięciu komunikatu do kolejki, którą inna usługa

powinna przetworzyć asynchronicznie. W takich sytuacjach powinniśmy

jawnie poinformować test, aby czekał do momentu ukończenia operacji, co

zwykle jest uzależnione od konkretnego scenariusza.

Wskazanie tego, na co czekać i w jaki sposób to robić, może często

stanowić wyzwanie, tak więc niektórzy deweloperzy automatyzacji

wybierają prostszą drogę, która polega na czekaniu przez z góry określony

przedział czasu, przy użyciu metody Thread.Sleep. Choć rozwiązanie to


często eliminuje problem na krótką metę, ma ono jednak pewne istotne

wady:

Załóżmy, że w 50% przypadków operacja zostaje ukończona w ciągu 3

sekund lub szybciej. Nie wystarczy więc czekać przez 3 sekundy,


ponieważ w pozostałych 50% przypadków operacja nie zostanie jeszcze

ukończona. Teoretycznie możemy nigdy nie osiągnąć 100%, ale nie ma

żadnego problemu w tym, aby ustalić jakiś próg, powyżej którego czas

uznawany jest za nieakceptowalny i test powinien zakończyć się

niepowodzeniem, zgłaszając przy tym, że operacja nie została ukończona

w akceptowalnym czasie. Im bardziej jednak będziemy zbliżać się do

100%, tym szybciej będzie rosnąć czas oczekiwania. Oznacza to, że jeśli

chcemy, aby operacja kończyła się sukcesem w 99% przypadków,

musimy czekać około 10 sekund. Rysunek 13.9 pokazuje rozkład gamma

i dystrybuantę czasu, jaki przeważnie jest potrzebny na wykonanie

operacji. Wynika z niego, że w 50% przypadków oczekujemy 7 sekund

dłużej niż to konieczne!


Rysunek 13.9. Rozkład gamma i dystrybuanta czasu wykonywania

przykładowej operacji

Gdy czekamy przez z góry określony czas, to gdy on już upłynie, zwykle

nie wiemy, czy operacja ta naprawdę się zakończyła, czy nie. Jeśli tego

nie sprawdzimy, nasz test może później zakończyć się niepowodzeniem.

Badanie tego niepowodzenia odwraca naszą uwagę od głównej

przyczyny. Na tym etapie powinniśmy już rozumieć, że czas i łatwość

badania niepowodzeń są kluczowe dla wiarygodności oraz ogólnej

wartości automatyzacji testów. Jeśli sprawdzimy, czy operacja

zakończyła się pomyślnie po upływie tego opóźnienia, to przekształcenie

ustalonego oczekiwania na oczekiwanie zakończenia operacji powinno

być łatwe.

Problemy z synchronizacją w czasie a Selenium

W przeciwieństwie do tego, w co wierzy wielu deweloperów

automatyzacji, powrót z każdego wywołania metody w Selenium

następuje tylko wtedy, gdy operacja zakończyła się w przeglądarce

w sposób całkowicie deterministyczny. W wielu przypadkach oznacza

to, że nie ma potrzeby stosowania żadnych opóźnień lub operacji

oczekiwania. Nasuwa się jednak pytanie o to, w jaki sposób

definiujemy „zakończenie wykonywania operacji”. Aby odpowiedzieć

na to pytanie, musimy zrozumieć sposób działania przeglądarki.

Każda strona w przeglądarce (karta lub okno przeglądarki) jest w stanie

uruchamiać kod JavaScript, a także wykonywać pewne inne operacje

wewnętrzne wyłącznie na pojedynczym wątku. Jednak operacje

asynchroniczne wykonywane są również wtedy, gdy strona znajduje się


w stanie bezczynności, czeka na jakieś dane od użytkownika lub dane

z serwera. Przykładowo

wywołania AJAX (znane również jako wywołania XHR lub

XmlHttpRequest) wykonywane są asynchronicznie, ale gdy powrócą,

funkcja wywołania zwrotnego, która obsługuje ich rezultat, jest

wykonywana ponownie w tym samym wątku, jeśli tylko znajduje się

on w stanie bezczynności. Jeśli tak nie jest, wówczas wywołanie

zwrotne będzie oczekiwać w kolejce, dopóki wątek nie przejdzie w stan

bezczynności, po czym zostanie wykonane wywołanie zwrotne.

Operacje asynchroniczne mogą być również wykonywane w JavaScript

za pomocą funkcji setTimeout.

Ponieważ Selenium komunikuje się ze stroną sieci Web, jego

wywołania również są synchronizowane z tym wątkiem. Oznacza to, że

jeśli kod JavaScript wykonywany dla zdarzenia kliknięcia jest

synchroniczny (nie inicjalizuje wywołań AJAX i nie używa funkcji

setTimeout), to mamy gwarancję, że metoda Click, którą

wywołujemy za pośrednictwem Selenium, nie powróci, dopóki

mechanizm obsługi zdarzeń JavaScript nie zakończy działania. Metoda

może powrócić przed ukończeniem operacji tylko wtedy, gdy

procedura obsługi w jawny sposób rozpocznie operację asynchroniczną

(za pomocą wywołania AJAX lub funkcji setTimeout).

Niektóre biblioteki JavaScript, takie jak AngularJS i KnockoutJS, jak

również pewne architektury tworzone wewnątrz firm, wykorzystują

duże ilości operacji asynchronicznych, dlatego są one bardziej podatne

na problemy z synchronizacją w czasie niż witryny, które z takich

operacji nie korzystają.


Selenium dostarcza dwa główne mechanizmy oczekiwania na

ukończenie operacji asynchronicznych, które określane są często jako

oczekiwanie niejawne i oczekiwanie jawne. Oczekiwanie niejawne jest

wartością limitu czasu, którą możemy ustawić za pomocą właściwości

IWebDriver.Manager().Timeouts().ImplicitWait.
Wartość ta ma wpływ na to, jak długo Selenium będzie czekać na

znalezienie elementu, jeśli nie istniał on jeszcze przed zgłoszeniem

wyjątku NoSuchElementException. Należy tu podkreślić, że

oczekiwanie niejawne ma zastosowanie tylko przy wywoływaniu

metody FindElement i nie wpływa ono na żadne inne operacje,

Click. Wartość ta ma również wpływ na


takie jak przykładowo

metodę FindElements (liczba mnoga), ale metoda ta czeka tylko do

chwili, aż zostanie znaleziony przynajmniej jeden element. Być może

jest to coś, czego oczekujemy, ale istnieją przypadki, w których lista

wypełniana jest asynchronicznie, a wtedy metoda ta może powrócić,

zanim zostaną na niej umieszczone wszystkie elementy. Oczekiwanie

jawne jest bardziej ogólnym mechanizmem, który pozwala nam czekać

na różne predefiniowane warunki lub warunki przez nas zdefiniowane.

Oczekiwanie jawne stosujemy poprzez utworzenie instancji klasy

WebDriverWait (z biblioteki Selenium.Support), określenie

wartości limitu czasu i wywołanie metody Until z podaniem

oczekiwanego warunku. Predefiniowane warunki zawarte są w klasie

ExpectedConditions i obejmują takie warunki, jak


ElementIsVisible, ElementToBeClickable,
TextToBePresentInElement i wiele więcej, z możliwością
tworzenia naszych własnych. Oto dwa przykłady:

var wait = new WebDriverWait(webDriver,


TimeSpan.FromSeconds(30));
// Z użyciem predefiniowanego warunku:

var button =
webDriver.FindElement(By.Id("approveButton"));

wait.Until(ExpectedConditions.ElementToBeClickab
le(button));

button.Click();

// Użyj niestandardowego warunku w celu


oczekiwania na utworzenie pliku:

wait.Until(drv => File.Exists(path));

var text = File.ReadAllText(path);

Uwaga

WebDriverJS, wersja JavaScript narzędzia Selenium WebDriver,

powraca przed ukończeniem operacji i pozwala na użycie

wywołań zwrotnych, znanych jako obietnice, do powiadamiania

o zakończeniu wykonywania operacji. Wynika to z faktu, że

JavaScript wykonuje się w pojedynczym wątku i musi zwolnić

główny wątek, aby umożliwić mu przetworzenie odpowiedzi

w przypadku ukończenia operacji.

Właściwe oczekiwanie na ukończenie operacji

Alternatywą dla stałych opóźnień jest oczekiwanie na wystąpienie jakiegoś

zdarzenia lub spełnienie jakiegoś warunku, który wskazuje, że operacja

została ukończona pomyślnie lub nie. Spróbujmy zastanowić się, skąd


użytkownik wie, że jakaś operacja została zakończona. Przykładowo, gdy

wyświetlana jest animacja wczytywania, wskazuje ona użytkownikowi, że

dane nie są jeszcze gotowe, ale gdy animacja ta zniknie, powinno to

oznaczać, że dane są gotowe. Jeśli nie korzystamy z automatyzacji interfejsu

użytkownika, lecz z dowolnego rodzaju API, trzeba pomyśleć nad tym,

w jaki sposób systemy klienckie powinny być informowane o tym, że

operacja została ukończona.

Gdy oczekujemy na zdarzenie lub spełnienie jakiegoś warunku, zawsze

powinniśmy ustawić wartość limitu czasu, tak aby w razie niespełnienia

warunku w tym czasie, operacja ta została przerwana. W ten sposób

możemy uchronić się przed sytuacją, w której test czeka w nieskończoność.

Możemy tę wartość ustawić na dość wysoką, ponieważ w większości

przypadków test nie będzie czekał aż tak długo, a jedynie wtedy, gdy

warunek nie będzie spełniony (co może oznaczać błąd w testowanym

systemie albo błąd w teście). Wartość limitu czasu powinna być na tyle duża,

aby można było uniknąć niepotrzebnych niepowodzeń testów, ale też nie na

tyle wysoka, aby nawet najbardziej cierpliwy użytkownik nie zrezygnował

z oczekiwania na wynik.

Mimo że implementowanie mechanizmu, który sonduje bieżący stan

warunku, aż stanie się on prawdziwy (lub nastąpi przekroczenie limitu

czasu), nie jest zbyt trudne, to jednak rzadko powinniśmy sami

opracowywać taki mechanizm, ponieważ istnieje wiele gotowych bibliotek,

które nam to umożliwiają. Jeśli korzystamy z narzędzia Selenium, to do

dyspozycji mamy WebDriverWait.


klasę Projekt

TestAutomationEssentials.Common również zawiera klasę Wait, a ponadto

dla dowolnego języka, z którego korzystamy, z pewnością dostępnych jest

wiele podobnych implementacji.


Uwaga

Niektóre biblioteki testowania, łącznie z MSTest, mają

zdefiniowaną domyślną wartość limitu czasu wykonywania testu,

która zwykle ustawiona jest bardzo wysoko, bo mniej więcej na

30 minut. Inne biblioteki nie mają wbudowanej takiej domyślnej

wartości, ale pozwalają nam na jej zdefiniowanie i ma ona

wówczas zastosowanie do wszystkich testów. Dodatkowo

większość bibliotek pozwala nam na określenie wartości limitu

czasu dla poszczególnych testów, która zastępuje tę wartość

globalną, jednak powinniśmy z niej korzystać niezwykle rzadko.

Oznacza to, że implementacja naszej własnej pętli, która oczekuje

na spełnienie jakiegoś warunku – i nie dba o zakończenie testu

niepowodzeniem po przekroczeniu pewnej ilości czasu, nawet

jeśli nie spowoduje to trwałego zawieszenia testu z powodu

domyślnej wartości limitu – nadal oczekiwać będzie długi czas

(np. 30 minut), zanim test zakończy się niepowodzeniem. Jeśli

z tego samego powodu niepowodzeniem kończyć się będzie

więcej niż jeden test, to każdy z nich będzie trwał wtedy 30

minut…

Poprawne obsługiwanie i badanie testów migoczących

Czasami istnieje tendencja do obwiniania automatyzacji testów za testy

migoczące. Należy jednak zrozumieć, że tego rodzaju niepowodzenia mogą

być spowodowane również prawdziwymi błędami. W rzeczywistości jest to

nawet bardziej prawdopodobne, ponieważ większość systemów jest z natury

systemami asynchronicznymi, przez co są one bardziej podatne na losowe


problemy z synchronizacją w czasie i wyścigami, podczas gdy

automatyzacja testów jest zazwyczaj jednowątkowa, a tym samym bardziej

odporna na te problemy. Jednakże testy te mogą być również skutkiem

niewłaściwej izolacji lub jakiegoś innego powodu. Tym samym nie możemy

jednoznacznie wskazać, gdzie dokładnie tkwi problem, dopóki nie zbadamy

jego głównej przyczyny.

Za moment opiszemy metodę badania testów migoczących. Jeśli jednak

badanie to nadal trwa zbyt długo, to trzeba jakoś oznaczyć te testy lub je

oddzielić od innych testów, aby inne osoby wiedziały, że są one badane i że

niepowodzenie tych testów prawdopodobnie nie jest regresją. Jest to ważne,

aby móc zachować postrzeganą wiarygodność automatyzacji testów. Jeśli

nie powiadomimy innych o tych przypadkach, to odniosą oni wrażenie, że

cały zestaw testów jest mało wiarygodny.

Nie chcemy jednak kończyć z regularnym uruchamianiem tych testów,

ponieważ chcemy zebrać jak najwięcej danych na temat ich niepowodzeń,

aby być w stanie je poprawnie zbadać. Zatem wręcz przeciwnie, testy te

będziemy chcieli uruchamiać nawet częściej!

Jeśli test kończy się niepowodzeniem z powodu problemu migotania

w 10% przypadków, to bardzo trudno będzie nam go zbadać. Trochę wbrew

naszej intuicji powinniśmy preferować, aby testy częściej kończyły się

niepowodzeniem, ponieważ w ten sposób łatwiej będzie nam odtwarzać

i badać te błędy. Aby zwiększyć prawdopodobieństwo napotkania takiego

błędu, możemy uruchamiać dany test w pętli, powtarzając go, powiedzmy,

50 razy. Spowoduje to zwiększenie szansy na zakończenie testu

niepowodzeniem do ponad 99% (=100%–90%), co w praktyce oznacza, że

możemy odtworzyć dany błąd w dowolnym momencie. Wtedy można

rozpocząć dodawanie informacji diagnostycznych i grać w „lwa na pustyni”,

dopóki nie zidentyfikujemy przyczyny problemu.


Jedną szczególnie przydatną techniką, która pozwala nam zawęzić

zakres problemu, a także zwiększyć efektywność procesu badania, jest

utworzenie oddzielnego testu, który wykonuje podejrzaną operację w pętli

i stopniowo starać się usunąć kod lub operacje, które wydają się nie mieć

związku z tym problemem, bądź też zawęzić pętlę tylko do podejrzanej

części, wykonując wszystkie warunki wstępne tylko raz. W ten sposób

możemy skrócić czas potrzebny na uruchomienie zapętlonego testu. Jeśli

w pewnym momencie problem przestanie się pojawiać, to będzie wiadomo,

że operacja usunięta przez nas z pętli jako ostatnia jest związana z tym

problemem. Jeśli operacja ta nie jest niepodzielna (tj. można ją podzielić na

mniejsze operacje), to powinniśmy dodać ją z powrotem i zawęzić pętlę

w taki sposób, aby wykonywała jedynie część z jej podejrzanych operacji

składowych. Jeśli operacji nie da się już dalej podzielić, wówczas z tak

zawężoną pętlą, która odtwarza dany problem z wysokim

prawdopodobieństwem, możemy udać się do odpowiedniego dewelopera

i umożliwić mu kontynuowanie badania od tego miejsca.

Podsumowanie

Aby automatyzacja testów była stale niezawodna i godna zaufania, bardzo

ważne jest, aby skrzętnie zbadać każdy problem i znaleźć jego główną

przyczynę. Chcemy również zagwarantować, że badanie jest szybkie

i precyzyjne, tak aby można było szybko reagować. W tym celu wszelkie

informacje, które mogą pomóc nam zbadać i zrozumieć, co tak naprawdę się

stało, powinniśmy dodawać do wyników testów. Mogą to być informacje

w postaci czytelnych komunikatów błędu, dzienników, zrzutów ekranu itd.

Badanie niektórych przypadków może stanowić większe wyzwanie, ale

systematyczne podejście polegające na eliminowaniu możliwych czynników,


w przeciwieństwie do losowego zgadywania i próbowania różnych rzeczy

bez celowania w konkretne wnioski, może pomóc nam zbadać główną

przyczynę również i tych przypadków. Nawet testy migoczące, które

wprawiają w zakłopotanie większość osób, mogą być systematycznie badane

poprzez zawężanie dziedziny problemu i przechodzenie po niej w pętli, co

pozwala znacznie zwiększyć prawdopodobieństwa jego odtworzenia.


Rozdział 14. Dodawanie kolejnych
testów

W rozdziałach od 10 do 12 utworzyliśmy nasz pierwszy test i wspierającą

go infrastrukturę. Czas więc na dodanie kolejnych testów, przy

jednoczesnym ulepszeniu obsługującej ich infrastruktury pod kątem

łatwiejszego utrzymania. W rozdziale tym zapewniamy również możliwość

uruchamiania tych testów przez wielu programistów jednocześnie, a także

testowania w wielu przeglądarkach.

Pisanie kolejnych testów

Kolejne testy dodamy w sposób podobny do tego, w jaki napisaliśmy nasz

pierwszy test:

Planujemy test w formie twierdzenia, wraz z jakimś doświadczeniem,

które to twierdzenie udowadnia lub obala.

Tłumaczymy kroki tego eksperymentu na klasy i metody, a następnie

doprowadzamy kod do stanu, w którym się kompiluje, nawet jeśli

metody nie są tak naprawdę jeszcze zaimplementowane (zgłaszają one

wyjątek NotImplementedException).
Uruchamiamy test, a następnie implementujemy pierwszą metodę, która

zgłasza wyjątek NotImplementedException.

Powtarzamy ostatni krok do momentu, aż wszystkie testy będą kończyć

się sukcesem.

Pomiędzy pierwszym a kolejnymi testami istnieje jednak pewna

różnica. O ile w przypadku pierwszego testu na początku nie istniała żadna

klasa czy metoda, to kolejne testy mogą już zawierać niektóre z nich.

W rzeczywistości dla każdego kroku testu mogą wystąpić trzy przypadki:

1. Klasa i metoda już istnieją i mogą zostać wykorzystane bez żadnych

zmian.

2. Klasa lub metoda nie istnieje. Musimy utworzyć ją dokładnie tak, jak

zrobiliśmy to w pierwszym teście.

3. Istnieje podobna klasa lub metoda, ale nie jest ona dokładnie taka, jakiej

potrzebujemy w nowym teście. Zwykle do metody musimy dostarczyć

jakieś dodatkowe lub inne argumenty.

Ostatni przypadek jest najbardziej interesujący, gdyż możemy go

obsłużyć na kilka sposobów:

1. Tworzymy nową metodę (zwykle przeciążenie, czyli metodę o tej samej

nazwie, ale z innymi parametrami) i duplikujemy dla niej kod.

2. Modyfikujemy oryginalną metodę w taki sposób, aby przyjmowała

więcej parametrów. W językach, które nie obsługują opcjonalnych

argumentów, oznacza to konieczność dodania kolejnych argumentów do

wywołań w istniejących testach.

3. Tworzymy przeciążenie z nowymi parametrami, ale usuwamy duplikat –

albo poprzez wywołanie tego przeciążenia z dodatkowymi parametrami,

z poziomu przeciążenia z mniejszą liczbą parametrów, podając wartości

domyślne dla dodatkowych parametrów, albo też poprzez


wyodrębnienie wspólnego kodu do metody prywatnej lub klasy

bazowej, która jest wywoływana przez oba przeciążenia publiczne.

4. Refaktoryzujemy metodę w taki sposób, aby przyjmowała obiekt

budowniczego, w którym możemy określić wartości dla dowolnego

parametru.

Oczywiście pierwsza z tych opcji nie jest zbyt przydatna, jeśli zależy

nam na łatwym utrzymaniu. Możemy to zrobić tylko wtedy, jeśli

podejmiemy się usunięcia duplikacji po ukończeniu testu.

Druga opcja również nie jest zalecana, i to z dwóch powodów:

1. Jeśli w pierwszym teście uznaliśmy, że nie musimy podawać jakiejś

wartości, to prawdopodobnie nie jest ona wymagana do udowodnienia

twierdzenia testu. Dodany w ten sposób argument sprawia, że test jest

bardziej zanieczyszczony „szumem”, mniej czytelny i odwraca uwagę

czytelnika kodu od istoty tego testu. W swojej książce Czysty kod.

Podręcznik dobrego programisty39, Robert C. Martin (znany również

jako Wujek Bob) twierdzi, że prawie żadna metoda nie powinna mieć

więcej niż jeden lub dwa parametry. Choć nie mówi on tego

w kontekście testów, lecz wypowiada się ogólnie o wywoływaniu

metod, to jego rozumowanie jest w zasadzie takie samo.

2. Jeśli korzystamy z argumentów opcjonalnych, to możemy uznać

pierwszy powód za bezwartościowy, ponieważ możemy przekazywać

tylko te wartości, które nas interesują. Jednak z perspektywy samej

metody posiadanie wielu parametrów wskazuje, że metoda jest

prawdopodobnie długa i skomplikowana, zawiera wiele instrukcji if


i ogólnie jest trudna w utrzymaniu. W zależności od używanego języka,

argumenty opcjonalne zwykle mają również inne ograniczenia. Ponadto

niektóre parametry często nie mają żadnego sensu w połączeniu

z innymi, lub przeciwnie – muszą zostać podane razem z jakimś innym


parametrem. Posiadanie ich w formie płaskiej listy parametrów

opcjonalnych stoi w sprzeczności z zasadą (zapobiegania błędom)

Poka-Yoke, opisywaną w dodatku D.

Z tego powodu tej drugiej opcji z jakimś parametrem opcjonalnym

powinniśmy używać tylko wtedy, gdy nigdy nie będziemy potrzebować

więcej niż dwóch argumentów. Aby uniknąć ograniczeń argumentów

opcjonalnych lub obsłużyć inne typy danych dla argumentów, najlepiej

skorzystać z trzeciej opcji (przeciążenia), jednak tylko wtedy, gdy nie

potrzebujemy więcej niż dwóch (lub rzadziej trzech) argumentów. W innym

wypadku należy stosować czwartą opcję, polegającą na wykorzystaniu

wzorca budowniczego. Jest to szczególnie istotne dla metod, które tworzą

jednostki biznesowe. Większość jednostek tworzonych w aplikacjach

biznesowych ma wiele parametrów, ale tylko jeden lub dwa z nich są

istotne dla każdego z testów. Wszystkie pozostałe parametry powinniśmy

pozostawić puste, jeśli nie są wymagane, a jeśli są – używać dla nich

pewnych domyślnych lub losowych wartości. W zasadzie wartości losowe

powinny być używane tylko wtedy, gdy dana wartość musi być

niepowtarzalna. Wzorzec budowniczego danych, opisany w rozdziale 12,

pozwala na tworzenie obiektu, do którego możemy przypisać wszystkie

argumenty. Buduje on jednostkę poprzez złączenie ze sobą kilku metod

zamiast tworzenia jednej dużej metody, która musi brać pod uwagę

wszystkie parametry. W ten sposób ułatwia on utrzymanie kodu.

Planowanie kolejnych testów

W pierwszym teście poszukiwaliśmy scenariusza, który pokazywałby

wartość systemu. Pomogło nam to również wymodelować i zbudować

główne elementy infrastruktury. W ogólnym przypadku, jak to opisano

w rozdziale 4, wybór kolejnego zestawu testów do zautomatyzowania


zależy od ich wartości oraz analizy ryzyka w danym punkcie czasu. Ale

zaraz po zaimplementowaniu pierwszego testu, gdy trzeba wybrać kilka

kolejnych testów spośród testów o podobnej wartości biznesowej,

powinniśmy wybierać te, które pozwolą nam wzbogacić infrastrukturę

i sprawią, że tworzenie dodatkowych testów będzie prostsze. Mamy tu

jednak pewien dylemat: z jednej strony, jeśli dodamy testy dla jakiejś

funkcji, która w bardzo małym stopniu pokrywa się z poprzednią, to

będziemy musieli rozszerzyć nasz model i zbudować dodatkową

infrastrukturę do jego obsługi. Z drugiej strony, jeśli dodamy testy, które

obierają mniej więcej taki sam kierunek co nasz pierwszy test, ale

z pewnymi drobnymi zmianami, to prawdopodobnie będzie konieczne

będzie zrobienie kilku modyfikacji w utworzonym przez nas modelu.

Rozszerzenie modelu w celu obsługi nowych testów i jego refaktoryzacja

w celu usunięcia duplikacji powinno przynieść nam lepsze abstrakcje oraz

bardziej solidny model i infrastrukturę.

Nie ma jednego właściwego rozwiązania powyższego dylematu

i zwykle lepiej zaimplementować po kilka testów z każdego rodzaju.

Zwróćmy uwagę, że jeśli pójdziemy na całość za pierwszym podejściem

i dodamy testy dla całkiem innej funkcji (lub nawet całkowicie innego

systemu), to być może będziemy musieli zbudować zupełnie inną

infrastrukturę. Nie ma w tym nic złego, ale zwykle lepiej bardziej się skupić

na budowie infrastruktury dla jednego projektu, zanim przejdziemy do

kolejnego.

Kolejnym testem, który postanowiłem dodać do tego samouczka, jest

test weryfikujący zdolność do filtrowania dyskusji według kategorii. Wśród

rozważanych przeze mnie kandydatów na kilka kolejnych testów znalazło

się chociażby testowanie odznak, które są funkcją charakterystyczną dla tej

aplikacji, oraz testowanie funkcji głosowania. Jednak implementowanie


tych testów wykracza poza zakres tego samouczka. Zwróćmy uwagę, że we

wszystkich tych testach wprowadzone są nowe koncepcje, ale również

wykorzystywane są istniejące funkcjonalności, które utworzyliśmy już

w pierwszym teście.

Po pokryciu głównych przepływów każdej z funkcji będziemy mogli

zacząć pokrywać bardziej szczegółowo konkretne funkcje. W tym

przypadku większość testów będzie do siebie dosyć podobna i będą one się

różnić jedynie niewielkimi zmianami.

Dodawanie testu: dyskusje mogą być filtrowane według


kategorii

Aby przetestować tę funkcjonalność, musimy mieć więcej niż jedną

kategorię. Nie chcemy także używać domyślnej kategorii „Example

Category”, ponieważ trudno nam będzie zdefiniować, co dokładnie chcemy

w niej zobaczyć, gdyż będzie ona wykorzystywana we wszystkich testach

niemających konkretnej kategorii. Z tego powodu, specjalnie na potrzeby

tego testu, utworzymy dwie nowe unikalne kategorie, A i B. Następnie

utworzymy nową dyskusję (discusson1), przypiszemy ją kategorii A


i upewnimy się, że dyskusja ta widoczna jest wyłącznie w tej jednej

kategorii. Ta druga kategoria powinna być pusta. Listing 14.1 zawiera plan

tekstowy dla tego testu.

Discussions can be filtered by category


=======================================
Create 2 new categories: A and B
Create a new discussion "discussion1" and assign
it to category A
List discussions in category A
Verify that "discussion1" appears in the list
List discussions in category B
Verify that the list is empty

Listing 14.1. Dyskusje mogą być filtrowane według kategorii

Podobnie jak w przypadku pierwszego testu, musimy teraz zdefiniować

kontekst dla każdego kroku, aby ułatwić sobie tworzenie w kodzie

obiektowego modelu aplikacji. Na listingu 14.2 pokazano konteksty, które

wykorzystamy do wykonania każdego kroku.

Discussions can be filtered by category


=======================================
Create 2 new categories: A and B //
MVCForum.AdminConsole
Create a new discussion "discussion1" and assign
it to category A
// jakiś zalogowany użytkownik
List discussions in category A //
MVCForum.Categories
Verify that "discussion1" appears in the list //
CategoryView.Discussions
List discussions in category B //
MVCForum.Categories
Verify that the list is empty //
CategoryView.Discussions

Listing 14.2. Test „Discussions can be filtered by category” z kontekstem


Uwagi:

W pierwszym wierszu tworzymy dwie kategorie, A i B. Bardziej


sensowne wydaje się zaimplementowanie tego kroku w dwóch

wierszach kodu.

Aby można było korzystać z tych kategorii, podczas ich tworzenia

dodamy do nich automatycznie uprawnienia Create Topic. Jest to

szczegół implementacyjny, który jest wymagany, aby test zakończył się

pomyślnie, jednak nadal nie jest on istotną częścią tego testu.

Właściwość AdminConsole będzie automatycznie logować nas


z użyciem nazwy użytkownika „admin” i kierować do konsoli

administratora. Zwróćmy uwagę, że mamy już tę funkcjonalność

w metodzie TestInitialize, podzieloną na mniejsze kroki, tak


więc trzeba zrefaktoryzować istniejący kod, aby usunąć duplikację

i ponownie użyć tej funkcjonalności. Ponadto zmienimy nazwę

utworzonej wcześniej klasy AdminPage na AdminConsole, aby


lepiej opisać jej istotę, ponieważ nie jest to jedynie pojedyncza strona.

Podobnie jak w pierwszym teście, nie będziemy używać trwale

zakodowanych ciągów znaków, takich jak discussion1, a zamiast


tego utworzymy niepowtarzalne ciągi znaków. Ciągu znaków

discussion1 używamy w tekście tylko po to, aby jasno pokazać, że


we wszystkich stosownych miejscach odnosimy się do tej samej

dyskusji. W kodzie będziemy wykorzystywać w tym celu nazwy

zmiennych.

W drugim wierszu napisaliśmy jakiś zalogowany użytkownik,


nie poprzedzając tego komentarza krokiem logowania. Jest tak,

ponieważ w tym teście nie interesuje nas tak bardzo, za pomocą którego

użytkownika się zalogowaliśmy – ważne jest jedynie, że jest to


zarejestrowany użytkownik. W kodzie prawdopodobnie wywołamy

metodę RegisterNewUserAndLogin w celu utworzenia tego


użytkownika. Ponieważ aplikacja ta oparta jest na współpracy pomiędzy

użytkownikami, nie ma tutaj potrzeby definiować „domyślnego

użytkownika” dla tych testów, czego dokonujemy zazwyczaj w wielu

innych aplikacjach, które nie skupiają się tak bardzo na współpracy.

Możemy jednak nadal zdefiniować takiego domyślnego użytkownika

w przyszłości, gdy będziemy mieć wiele testów niezależnych od

użytkowników, ale najpierw musimy mieć ku temu wyraźny powód.

Istnieje jedna ważna jednostka, która ukrywa się „między wierszami”,

a która zdecydowanie będzie nam potrzebna w kodzie. Chodzi tu

oczywiście o jednostkę Category (Kategoria). Instancję tego obiektu

utworzymy w pierwszym kroku, podczas tworzenia kategorii

i przekażemy go jako argument zarówno w czasie tworzenia dyskusji,

jak i do metody Select właściwości MVCForum.Categories.


Obiekt ten będzie przechowywał jedynie nazwę kategorii, ale nadal

warto utworzyć dla niego klasę, aby skorzystać z zalet silnego

typowania i zasady Poka-Yoke.

Listing 14.3 pokazuje szkielet nowej metody testowej, którą dodaliśmy

do pliku SanityTests.cs. Oczywiście plik ten jeszcze się nie kompiluje.

[TestMethod]
public void DiscussionsCanBeFilteredByCategory()
{
var adminConsole = MVCForum.AdminConsole;
var categoryA =
adminConsole.CreateCategory();
var categoryB =
adminConsole.CreateCategory();
var user =
MVCForum.RegisterNewUserAndLogin();
var discussion =
user.CreateDiscussion(DiscussionWith.
Category(categoryA));
var categoryView =
MVCForum.Categories.Select(categoryA);
Assert.AreEqual(1,
categoryView.Discussions.Count,
$"1 discussion is expected in categoryA
({categoryA.Name})");
Assert.AreEqual(discussion,
categoryView.Discussions.
Single(), $"The single discussion in
categoryA ({categoryA.
Name}) is expected to be the same category
that we’ve created
('{discussion.Title}");
categoryView =
MVCForum.Categories.Select(categoryB);
Assert.AreEqual(0,
categoryView.Discussions.Count,
$"No discussions expected in categoryB
({categoryB.Name}");
}
Listing 14.3. Szkielet metody DiscussionsCanBeFilteredByCategory

Jak widzimy, niektóre klasy i metody już mieliśmy, niektóre są

całkowicie nowe, a niektóre co prawda istnieją, ale będą wymagać pewnych

modyfikacji. Przykładowo metoda CreateDiscussion powinna zostać

zmieniona, aby obsługiwała parametr Category, dodawany przez

budowniczego.

Uwaga

CategoryView.Discussions powinno być kolekcją

obiektów DiscussionHeader, podobnie jak w przypadku

listy LatestDiscussions. Oznacza to jednak, że w drugiej

instrukcji Assert porównujemy obiekt Discussion


z obiektem DiscussionHeader. Aby to działało, zamierzam

wyodrębnić z obu tych klas wspólną klasę bazową, która będzie

przechowywać wyłącznie tytuł dyskusji (będący jej unikalnym

identyfikatorem) i zaimplementować metodę Equals w taki

sposób, że będzie ona porównywać nazwę własną tytułu

z tytułem innego obiektu, bez względu na konkretny typ.

Porównywanie unikalnego identyfikatora jednostki jest

wystarczające w przypadku ogólnego zastosowania i sprawia, że

kod jest bardziej przejrzysty.

Dalsze zasady już znamy: tworzymy nowe klasy i metody, aż kod się

w końcu skompiluje. Następnie uruchamiamy test i naprawiamy wszystkie

wyjątki NotImplementedException, aż test zakończy się sukcesem.


Oto lista klas, metod i właściwości, które trzeba dodać, aby można było

skompilować kod:

public AdminConsole MVCForum.AdminConsole { get;


}

Uwaga

Zmieniliśmy nazwę z AdminPage na AdminConsole, zatem

w rzeczywistości jest to istniejąca klasa.

public Category AdminConsole.CreateCategory()

public DiscussionBuilder
Discussion.Category(Category category)

public CategoriesList MVCForum.Categories { get;


}
public class CategoriesList

public CategoryView Select(Category category)

public class CategoryView


public IReadOnlyCollection<DiscussionHeader>
Discussions

Uwaga

Nadal nie wyodrębniliśmy wspólnej klasy bazowej z klas

Discussion i DiscussionHeader. Zrobimy to dopiero


wtedy, gdy konieczne będzie pomyślne wykonanie instrukcji

Assert.

Usuwanie duplikacji między MVCForum.AdminConsole


i AddCreateTopicPermissionToStandardMembers

Gdy nasz test już się skompiluje i zostanie uruchomiony, wówczas pierwszy

napotkany przez nas wyjątek NotImplementedException dotyczyć

będzie właściwości MVCForum.AdminConsole. Jak wspomnieliśmy

wcześniej, kod logowania się jako administrator i nawigowania do konsoli

administratora jest już zawarty w metodzie

AddCreateTopicPermissionToStandardMembers, która jest

wywoływana z metody TestInitialize. Listing 14.4 pokazuje bieżącą


implementację metody

AddCreateTopicPermissionToStandardMembers. Pogrubione

wiersze są tymi, które chcemy wyodrębnić do właściwości

MVCForum.AdminConsole.

Uwaga

W poprzednim rozdziale do projektu dodaliśmy wizualny

rejestrator, ale oprócz tego kod, który istniał w metodzie

TestInitialize, wyodrębniliśmy do metody

AddCreateTopicPermissionToStandardMembers,
aby oddzielić go od inicjalizacji samego rejestratora.
private void
AddCreateTopicPermissionToStandardMembers()
{
using (Logger.StartSection(
"Adding 'Create Topic' permission to Standard
members"))
{
var adminPassword = GetAdminPassword();
var adminUser =
MVCForum.LoginAsAdmin(adminPassword);
var adminConsole =
adminUser.GoToAdminConsole();
var permissions =
adminConsole.GetPermissionsFor(
TestDefaults.StandardMembers);
permissions.AddToCategory(TestDefaults.E
xampleCategory,
PermissionTypes.CreateTopics);
adminUser.Logout();
}
}

Listing 14.4. Metoda AddCreateTopicPermissionToStandardMembers

Próba wyodrębnienia zaznaczonych wierszy do ich własnej metody

ujawnia, że metoda ta musi zwracać dwie wartości: adminConsole, co

jest oczywiste, ale również adminUser, której używany na końcu metody


do wywołania metody Logout. Zwracanie dwóch wartości z metody nie
jest dobrą praktyką. Jednak dużo trudniej jest odpowiedzieć teraz na

pytanie, kiedy należy wylogować administratora w nowym teście.

Spoglądając ponownie na kod testu, widzimy, że musimy wylogować

administratora przed wywołaniem metody

RegisterNewUserAndLogin! Aby nie wprowadzać do naszego kodu

zbyt wiele szumu w postaci wszystkich tych operacji logowania

i wylogowywania, możemy zmienić właściwość AdminConsole na

metodę OpenAdminConsole, sprawić, że klasa AdminConsole będzie


implementować interfejs IDisposable i użyć klauzuli using wokół

instrukcji tworzenia kategorii, aby administrator został automatycznie

wylogowany po zakończeniu zakresu klauzuli using. To jeszcze brzydsze

niż przedtem, ale za to mniejsze zło! Listing 14.5 pokazuje te zmiany po ich

wprowadzeniu do metody testowej.

[TestMethod]
public void DiscussionsCanBeFilteredByCategory()
{
Category categoryA, categoryB;
using (var adminConsole =
MVCForum.OpenAdminConsole())
{
categoryA =
adminConsole.CreateCategory();
categoryB =
adminConsole.CreateCategory();
}
var user =
MVCForum.RegisterNewUserAndLogin();
...
}

Listing 14.5. Metoda DiscussionsCanBeFilteredByCategory

z automatycznym wylogowaniem administratora

Po dokonaniu tych zmian uruchamiamy test ponownie. Zobaczymy

wówczas, że zgodnie z oczekiwaniem kończy się on niepowodzeniem

z powodu metody OpenAdminConsole. Musimy ją więc teraz

zaimplementować, ale jak już wiemy, nie możemy po prostu wykonać na

tych wierszach refaktoryzacji wyodrębnienia metody. Zacznijmy więc od

skopiowania tych wierszy z metody


SanityTests.AddCreateTopicPermissionToStandardMemb
ers do metody MVCForumClient.OpenAdminConsole, a później
zrefaktoryzujemy ten kod w celu usunięcia tej duplikacji.

Jeśli jednak skopiujemy te wiersze w ich dotychczasowej postaci, to

również nie będzie to działać. Metoda GetAdminPassword i właściwość


MVCForum są elementami członkowskimi klasy SanityTests, a nie

klasy MVCForumClient. Jeśli chodzi o właściwość MVCForum, to

rozwiązanie jest niezwykle proste, ponieważ wystarczy zamiast tego

posłużyć się bieżącą instancją (this). Jeśli zaś chodzi o metodę

GetAdminPassword, to musimy przenieść ją samodzielnie. Jeśli

korzystamy z dodatku ReSharper, to gdy nasz kursor znajduje się na nazwie

metody GetAdminPassword, wciskamy Ctrl+Shift+R w celu otwarcia

menu kontekstowego Refactor this i wybieramy Move Instance Method.

W oknie dialogowym wybieramy właściwość MVCForum, klikamy

dwukrotnie Next (za drugim razem akceptujemy zmianę modyfikatora

dostępu tej metody z private na public, aby pozostała dostępna dla

metody AddCreateTopicPermissionToStandardMembers. Po
usunięciu duplikacji ustawimy ją z powrotem na private wewnątrz klasy
MVCForumClient), i gotowe! Metoda została przeniesiona do klasy

MVCForumClient. Jeśli nie korzystamy z narzędzia ReSharper, możemy


ręcznie wyciąć i wkleić ten kod, a następnie wprowadzić odpowiednie

zmiany, których wcale nie ma aż tak wiele. Na listingu 14.6 pokazano

metodę OpenAdminConsole po zmianach.

public AdminConsole OpenAdminConsole()


{
var adminPassword = GetAdminPassword();
var adminUser = LoginAsAdmin(adminPassword);
var adminConsole =
adminUser.GoToAdminConsole();
return adminConsole;
}

Listing 14.6. Pierwsza implementacja metody

MVCForumClient.OpenAdminConsole

Przy następnym uruchomieniu test nie powiedzie się z powodu

właściwości AdminConsole.Dispose. W tym miejscu musimy

wylogować administratora. Jednak klasa AdminConsole nie dysponuje

referencją do zalogowanego administratora, tak więc musimy dodać ją

w konstruktorze. Na szczęście konstruktor AdminConsole wywoływany

jest wyłącznie z klasy LoggedInAdmin, więc możemy po prostu

przekazać this jako argument. Na listingu 14.7 pokazano konstruktor

klasy AdminConsole i metodę Dispose.

public AdminConsole(
IWebDriver webDriver, LoggedInAdmin loggedInAdmin)
{
_webDriver = webDriver;
_loggedInAdmin = loggedInAdmin;
}

public void Dispose()


{
_loggedInAdmin.Logout();
}

Listing 14.7. Konstruktor klasy AdminConsole i jej metoda Dispose

Jeśli teraz uruchomimy test ponownie, to nie powiedzie się on

z powodu metody AdminConsole.CreateCategory, co oznacza, że

metoda OpenAdminConsole i odpowiadająca jej metoda Dispose


działają. Teraz jednak mamy zduplikowany kod, który musimy

wyeliminować! To na szczęście będzie bardzo proste, a przy tym uprościmy

w ten sposób metodę

AddCreateTopicPermissionToStandardMembers. Listing 14.8

zawiera metodę

AddCreateTopicPermissionToStandardMembers po usunięciu

duplikacji.
Listing 14.8. Metoda AddCreateTopicPermissionToStandardMembers po

usunięciu duplikacji

Teraz możemy również zmienić z powrotem modyfikator metody

GetAdminPassword na private, ponieważ używamy jej tylko

w metodzie OpenAdminConsole, która znajduje się w tej samej klasie

(MVCForumClient).

Skoro już mowa o metodzie OpenAdminConsole, to musimy

zmienić jeszcze jedną rzecz: wygląda na to, że za każdym razem, gdy

chcemy otworzyć konsolę administratora, niepotrzebnie szukamy tematu

Read Me w celu uzyskania hasła administratora. Jeśli zrobiliśmy to już raz,

to możemy zachować to hasło do celu późniejszego wykorzystania.

Możemy to osiągnąć poprzez podniesienie poziomu lokalnej zmiennej

adminPassword do postaci pola i zmodyfikowanie jego typu string


na Lazy<String>40, określając metodę GetAdminPassword jako

fabrykę wartości. Listing 14.9 pokazuje odpowiednie zmiany w klasie

MVCForumClient.
private readonly Lazy<string> _adminPassword;
public MVCForumClient(TestDefaults testDefaults)
{
_adminPassword = new Lazy<string>
(GetAdminPassword);
...
}

public AdminConsole OpenAdminConsole()


{
var adminUser =
LoginAsAdmin(_adminPassword.Value);
var adminConsole =
adminUser.GoToAdminConsole();
return adminConsole;
}

Listing 14.9. Jednokrotne wykonywanie metody GetAdminPassword

Ponowne wykorzystywanie istniejącego kodu

Możemy teraz przystąpić do implementacji metody

AdminConsole.CreateCategory za pomocą naszego standardowego


procesu „z góry do dołu”. Podobnie jak podczas implementowania drugiego

testu, w przypadku nowej metody mamy trzy możliwe scenariusze dla

metod, które są przez nią wywoływane: może być potrzebne wywołania

metody, która już istnieje, lub metody, która w ogóle jeszcze nie istnieje,

bądź mamy już podobną metodę, lecz nie taką, jakiej potrzebujemy, więc

musimy zmodyfikować istniejący kod, aby osiągnąć nasz nowy cel i usunąć
duplikację. Listing 14.10 pokazuje, w jaki sposób metoda

AdminConsole.CreateCategory wykorzystuje metody

GetPermissionsFor i AddToCategory, które

zaimplementowaliśmy dla poprzedniego testu.

public Category CreateCategory()


{

var categoryName = Guid.NewGuid().ToString();

var categoriesPage = OpenCategoriesPage();


var category =
categoriesPage.Create(categoryName);
GetPermissionsFor(_testDefaults.StandardMembe
rs)
.AddToCategory(category,
PermissionTypes.CreateTopics);
return category;
}

Listing 14.10. Metoda AdminConsole.CreateCategory wykorzystuje

istniejące metody

Kompromisy w zakresie duplikacji

Gdy dojdziemy do niepowodzenia testu związanego z metodą

CategoryView.Discussions i spróbujemy ją zaimplementować,

uświadomimy sobie, że jest ona bardzo podobna do już istniejącej klasy

LatestDiscussions. Co prawda w klasie LatestDiscussions


potrzebujemy elementów górnego i dolnego, podczas gdy w klasie
CategoryView musimy zwrócić listę elementów, jednak w dużej mierze

są one do siebie bardzo podobne. Obie te klasy reprezentują listę

nagłówków dyskusji, a struktura zawartych w nich elementów DOM jest

niemal identyczna. Spróbujmy więc usunąć tę duplikację. Listing 14.11

pokazuje klasę LatestDiscussions po wprowadzeniu tych zmian.


Listing 14.11. Oryginalna klasa LatestDiscussions

Wygląda na to, że metoda GetAllTopicRows jest czymś, co

możemy wykorzystać ponownie dla klasy CategoryView. Krótko

mówiąc, moglibyśmy przenieść ją do wspólnej klasy bazowej i zdefiniować

z modyfikatorem protected, aby umożliwić jej ponowne wykorzystanie.


Choć metoda ta jest dosyć zbliżona do tego, co jest nam potrzebne, to

jednak nie do końca. Sposób, w jaki jest ona zaimplementowana, rodzi dwa

problemy:

1. Zwraca ona listę elementów IWebElement, która musi zostać

opakowana obiektem DiscussionHeader. W rzeczywistości mamy

już duplikację między właściwościami Top i Bottom, ponieważ obie

tworzą instancję obiektów DiscussionHeader w celu opakowania

elementów zwróconych przez metodę GetAllTopicRows.


2. Przy każdym jej wywołaniu wywołuje ona metodę Activate, aby

zagwarantować, że nadal znajdujemy się na karcie Latest.

Choć ten pierwszy problem możemy rozwiązać bardzo łatwo, to drugi

wydaje się uniemożliwiać nam współdzielenie tej metody pomiędzy

klasami. Metoda Activate została utworzona, aby zagwarantować, że

przy próbie uzyskania dostępu do dowolnego elementu w klasie

LatestDiscussions, upewnimy się najpierw, że jest to widok

aktywny. Nie jest to jednak coś, co musimy mieć w klasie

CategoryView, ponieważ otwarcie kategorii jest zawsze akcją

proaktywną, która musi zostać jawnie wykonana przed uzyskaniem dostępu

do dyskusji z tej kategorii.

Jednym ze sposobów rozwiązania tego problemu jest uczynienie

Activate metodą wirtualną i zaimplementowanie jej tylko w klasie


LatestDiscussions, a pozostawienie jej pustej w klasie

CategoryView. Nie wygląda to jednak zbyt dobrze – nie jest wcale tak,

że nie musimy nigdy aktywować widoku kategorii, ale nie wydaje się, aby

to klasa CategoryView była odpowiedzialna za automatyczne

wykonywanie tego za każdym razem.

Możemy więc wyciąć wywołanie metody Activate z metody

GetAllTopics i umieścić je na początku właściwości Top i Bottom.


Takie posunięcie będzie bardziej logiczne, mimo że teraz zduplikowaliśmy

wywołanie GetAllTopics w obu tych metodach. Teraz możemy jednak

wyodrębnić metodę GetAllTopics do klasy bazowej i wykorzystać ją

ponownie w klasach LatestDiscussions i CategoryView,


usuwając w ten sposób duplikację pomiędzy tymi klasami. Dodajemy więc

odrobinę duplikacji w jednym miejscu, aby usunąć jej więcej w innym

miejscu.

Przyglądając się temu nieco bliżej i badając użycia właściwości Top


i Bottom, możemy wywnioskować, że upewnienie się, iż znajdujemy się

na właściwej karcie, jest konieczne tylko w przypadku konstruktora klasy

LatestDiscussions, a nie w każdym elemencie tej klasy, tak więc

możemy usunąć również i tę duplikację.

Ostatecznie powinniśmy zmienić nazwę metody GetAllTopicRows


na GetAllDiscussionHeaders, ponieważ nie zwraca ona już jedynie
elementów wiersza, ale pełne obiekty DiscussionHeader. Na listingu

14.12 pokazano klasę LatestDiscussions po wprowadzeniu

powyższych zmian, ale przed wyodrębnieniem metody

GetAllDiscussionHeaders do wspólnej klasy bazowej.


Listing 14.12. Metoda LatestDiscussions.GetAllDiscussionHeaders gotowa

do wyodrębnienia do wspólnej klasy bazowej

Po wykonaniu tej refaktoryzacji należy uruchomić również pierwszy

test, aby się upewnić, że niczego nie zepsuliśmy tymi zmianami.


Teraz musimy wyodrębnić metodę GetAllDiscussionHeaders
do wspólnej klasy bazowej. Jeśli korzystamy z narzędzia ReSharper,

możemy to zrobić automatycznie poprzez ustawienie się na nazwie tej

metody, wciśnięcie Ctrl+Shift+R i wybranie opcji Extract Superclass

(Wyodrębnij superklasę). Jeśli nie używamy ReSharpera, będziemy musieli

ręcznie utworzyć klasę bazową i przenieść do niej tę metodę. Do klasy tej

musimy również przenieść element członkowski _webDriver i oznaczyć

go modyfikatorem protected, dzięki czemu będziemy mogli uzyskać do


niego dostęp zarówno z tej klasy bazowej, jak i jej klas pochodnych.

Nazwiemy tę klasę bazową DiscussionsList i sprawimy, że klasa

CategoryView będzie po niej dziedziczyć. Na listingu 14.13 pokazano

implementację klasy CategoryView dziedziczącej po nowej klasie

DiscussionsList.

public class CategoryView : DiscussionsList


{

public CategoryView(IWebDriver webDriver)


: base(webDriver)
{
}
public IReadOnlyCollection<DiscussionHeader>
Discussions
{
get { return GetAllDiscussionHeaders();
}
}
}
Listing 14.13. Klasa CategoryView zaimplementowana jako klasa

pochodna od klasy DiscussionsList

Implementowanie metody Equals

Kolejne niepowodzenie, które widzimy, wygląda nieco przytłaczająco: Test

method

MVCForumAutomation.SanityTests.DiscussionsCanBeFilteredByCate

gory threw exception:

OpenQA.Selenium.StaleElementReferenceException: stale element

reference: element is not attached to the page document. Wyjątek

StaleElementReferenceException oznacza, że znaleziony

element nie jest już dostępny w modelu DOM. Jeśli jednak spojrzymy na

kod testu i ślad stosu, wszystko będzie jasne: próbujemy odczytać tytuł

dyskusji z obiektu Discussion, który reprezentuje otwartą stronę

dyskusji, podczas gdy obecnie przeglądamy listę dyskusji w kategoriach.

Mimo że tytuł dyskusji możemy zobaczyć przy użyciu tej listy, to jednak

sposób wyodrębniania tej wartości z modelu DOM jest inny. Tak czy

inaczej, powracając do uwagi z początku tego rozdziału dotyczącej

właściwości CategoryView.Discussions, zamierzaliśmy przenieść

właściwość Title do wspólnej klasy bazowej i zaimplementować w niej

metodę Equals. Powinno to rozwiązać nasz problem, ponieważ

właściwość Title również należy inicjalizować w konstruktorze

i przechowywać w pamięci, zamiast za każdym razem odczytywać

z ekranu. Takie postępowanie uchroni nas przed pojawieniem się wyjątku

StaleElementReferenceException, który właśnie otrzymaliśmy.


Wyodrębnienie klasy bazowej z właściwością Title
i zaimplementowanie metody Equals spowoduje również, że test będzie
kończył się sukcesem! Listing 14.14 pokazuje nową klasę bazową

DiscussionIdentifier wraz z jej implementacją metody Equals.

Listing 14.14. Metoda DiscussionIdentifier.Equals

Ponieważ teraz test kończy się sukcesem, powinniśmy sprawdzić, czy

pierwszy test również zakończy się pomyślnie. Na szczęście tak właśnie się

dzieje.

Podsumowanie procesu dodawania drugiego testu


W czasie implementowania drugiego testu utworzyliśmy wiele nowych klas

i metod, ale też skorzystaliśmy z kilku istniejących. Najtrudniejsze sytuacje

polegały na tym, że mieliśmy podobną implementację w różnych miejscach

i trzeba było zrefaktoryzować kod, aby usunąć tę duplikację. W kilku

przypadkach osiągnęliśmy to poprzez utworzenie hierarchii klas. Jak

widzimy, pisanie automatyzacji łatwej w utrzymaniu nie jest prostym

zadaniem, z którym może poradzić sobie każdy początkujący deweloper.

Wręcz przeciwnie, wymaga to zaawansowanych umiejętności

w projektowaniu i programowaniu.

Wprowadzanie dodatkowych usprawnień

Gdy test jest już ukończony, jest to dobry moment na usprawnienie rzeczy,

które nie miały bezpośredniego wpływu na jego pomyślny rezultat.

Tworzenie bardziej zrozumiałych identyfikatorów

Przykładowo jedną z rzeczy, która denerwowała mnie podczas tworzenia

ostatniego testu – ale nie była ona wystarczająco istotna, aby przerwać ten

proces i ją zmienić – była trudność w rozróżnianiu identyfikatorów różnego

rodzaju jednostek, ponieważ dla wszystkich użyłem identyfikatorów GUID.

Denerwowało mnie to zwłaszcza podczas czytania dziennika lub

komunikatów błędów. Jeśli jednak do każdego takiego identyfikatora

dodamy prefiks zawierający rodzaj danej jednostki, to nasze życie stanie się

dużo prostsze.

Jak zawsze, zamiast duplikować ten kod, utwórzmy specjalną klasę

i metodę, która to zrobi. Nazwiemy je odpowiednio UniqueIdentifier


i For, więc gdy ich użyjemy, będzie to wyglądać w poniższy sposób:
var discussionId =
UniqueIdentifier.For("Discussion");

Teraz musimy już tylko znaleźć wszystkie odniesienia do metody

Guid.NewGuid i zamienić je na wywołanie naszej nowej metody. Na

listingu 14.15 pokazano klasę UniqueIdentifier.

public static class UniqueIdentifier


{
public static string For(string entityType)
{
return $"{entityType}-{Guid.NewGuid()}";
}
}

Listing 14.15. Klasa UniqueIdentifier

Organizowanie kodu w foldery

Jeśli spojrzymy na panel Solution Explorer, to zobaczymy, że wszystkie

utworzone przez nas klasy mają postać jednej długiej listy 28 plików

z kodem, a przecież dopiero zaimplementowaliśmy 2 testy… Z tego

powodu przeszukiwanie i zrozumienie podstawy kodu może być bardzo

trudne dla początkujących deweloperów. Jeśli znamy nazwę klasy lub

chociaż jej część, to znalezienie jej nie będzie dla nas stanowić problemu

(możemy posłużyć się polem wyszukiwania Search w panelu Solution

Explorer lub, w przypadku korzystania z narzędzia ReSharper, wcisnąć

Ctrl+T, aby szybko znaleźć dowolny symbol). Jeśli jednak nie wiemy

dokładnie, jakiej klasy powinniśmy szukać albo po prostu chcemy


zrozumieć ogólną strukturę kodu, to będzie to bardzo trudne, ponieważ

wszystkie pliki znajdują się na tej jednej długiej i płaskiej liście.

Gdybyśmy spróbowali to zrobić wcześniej, nie wiedzielibyśmy jeszcze,

jakiego rodzaju klas będziemy potrzebować. Ale teraz możemy spojrzeć na

te klasy i spróbować wyszukać dla nich wspólne kategorie. Następnie, dla

każdej takiej kategorii, możemy utworzyć osobny folder i poprzenosić do

nich odpowiednie klasy. Pierwszymi najbardziej oczywistymi kategoriami

są Tests i PageObjects. Oczywiście na razie mamy tylko jedną klasę

testową, ale wkrótce będziemy mieć ich więcej. Możemy również

zidentyfikować kategorię dla jednostek (Entities), takich jak Category,


Role i PermissionTypes, oraz czwartą kategorię dla całego

generycznego kodu infrastruktury, jak w przypadku klas VisualLogger,


SeleniumExtensions czy UniqueIdentifier. Nazwą tego

czwartego folderu będzie Infrastructure. Zwróćmy uwagę, że niektóre

klasy nie pasują dobrze do żadnej z tych kategorii i nie ma z tym żadnego

problemu. Możemy albo umieścić je w jednym z pozostałych folderów,

albo też pozostawić je poza folderem głównym. Na przykład klasa

FormPage jest klasą bazową dla dowolnego obiektu strony opartego na

formatce, może zatem ona znaleźć się w folderze PageObjects lub

w Infrastructure. Klasa TestDefaults również jest powiązana

z infrastrukturą, ale jest również ściśle związana z aplikacją MVCForum,

więc może lepiej pasować do folderu Entities. Tę ostatnią klasę

pozostawiamy jednak bezpośrednio w folderze głównym naszego projektu.

Wyodrębnianie klasy bazowej dla testów

Jeszcze jednym takim usprawnieniem jest wyodrębnienie wspólnej klasy

bazowej dla wszystkich testów. W rzeczywistości może się to obecnie

wydawać niepotrzebne, ponieważ mamy teraz tylko dwie metody testowe,


które znajdują się w pojedynczej klasie testowej. Jednak nawet na tym

etapie możemy zauważyć, że klasa SanityTests ma dwa różne zadania:


po pierwsze, jest ona kontenerem dla metod testowych, a po drugie, zawiera

ona sporą ilość kodu infrastruktury, która jest powiązana z biblioteką

MSTest i odpowiada głównie za inicjalizowanie i oczyszczanie testów. Jest

to więc wystarczająco dobry powód, aby wyodrębnić wszystkie rzeczy

związane z infrastrukturą w tej klasie do osobnej klasy

MVCForumTestBase, po której klasa SanityTests będzie

dziedziczyć. Oczywiście gdy będziemy mieć więcej klas testowych,

powinny one wszystkie dziedziczyć po klasie MVCForumTestBase, aby

uniknąć duplikacji. Aby zachować spójną strukturę folderów, umieścimy tę

nową klasę wewnątrz folderu Infrastructure. Dodatkowo musimy również

oznaczyć tę klasę bazową za pomocą atrybutu [TestClass], aby

atrybuty [AssemblyInitialize] i [TestInitialize] mogły

zacząć działać. Zmiany te zawiera tag Git o nazwie ExtractBaseClass.

Obsługa wielu użytkowników i przeglądarek

Teraz testy świetnie działają na naszej maszynie, ale aby uzyskać z nich

więcej korzyści, musimy umożliwić ich uruchamianie również innym

deweloperom, przed tym jak zaewidencjonują oni swoje zmiany.

Umożliwienie innym uruchamiania testu może również przyczynić się do

ujawnienia pewnych ukrytych założeń, które dotyczą wyłącznie naszej

maszyny. Jeśli tak się stanie, będziemy musieli to poprawić. Wkrótce

będziemy chcieli również uruchamiać testy w ramach kompilacji nocnej lub

kompilacji CI, co oznacza uruchamianie testów na innej maszynie (patrz

rozdział 15 dotyczący ciągłej integracji). Jeśli rozwiążemy ten problem dla


innych użytkowników, to dodawanie testów do kompilacji będzie znacznie

prostsze.

W rzeczywistości nie użyliśmy żadnych trwale zakodowanych ścieżek,

tak więc obsługa innego środowiska nie powinna stanowić problemu. Jeśli

jednak spróbujemy uruchomić nasz test na maszynie innego dewelopera, to

zobaczymy, że jego serwer sieci Web zamiast portu 8080, jak w naszym

przypadku, używa portu 8090. Inni deweloperzy mogą korzystać

z protokołu HTTPS zamiast HTTP, z certyfikatem wydanym dla konkretnej

nazwy domeny, co uniemożliwi użytkownikom korzystanie z nazwy

localhost lub adresu IP jako nazwy hosta. Możemy obsłużyć wszystkie te

scenariusze, jeśli cały adres URL wyodrębnimy do pliku konfiguracyjnego.

Wskazówki w zakresie korzystania z plików


konfiguracyjnych testów

Jak widzimy, pliki konfiguracyjne są czasem niezbędne do tego, aby

umożliwić innym użytkownikom (deweloperom) automatyzacji

uruchamianie testów, lub w celu obsługi innych środowisk. Ważne jest

jednak, aby utrzymywane przez nas pliki konfiguracyjne były krótkie

i proste, gdyż w przeciwnym wypadku narobimy sobie bałaganu. Oto kilka

wskazówek dotyczących korzystania z tych plików konfiguracyjnych:

Ponieważ zależy nam na tym, aby testy były jak najbardziej spójne

i można je było wykorzystywać ponownie, w plikach tych powinniśmy

uwzględniać możliwie najmniej szczegółów, które mogą być różne dla

różnych środowisk. Z tego powodu należy dodawać do nich tylko te

wartości, które naszym zdaniem inne osoby będą chciały zmienić.

Zwykle jedynymi niezbędnymi ustawieniami są adres URL, ścieżka do

pliku wykonywalnego itd. Czasami nazwa i hasło dla użytkownika

domyślnego również mogą być przydatne, jeśli system nie wczytuje się
od zera przy każdym uruchomieniu. Jednak posiadanie identyfikatorów

wielu jednostek, które są wykorzystywane przez różne testy, nie jest

najlepszym zastosowaniem pliku konfiguracyjnego. Techniki izolacji,

które tworzą dane, są zwykle lepsze i znoszą konieczność

konfigurowania wielu jednostek w jakimś zewnętrznym pliku.

Pamiętajmy, że pliki te mają być wykorzystywane przez ludzi, a nie

tylko przez oprogramowanie. Oznacza to, że powinny być one tak łatwe

w użyciu i konfiguracji, jak to tylko możliwe, więc gdy tylko ktoś

zechce uruchomić testy na nowej maszynie lub w nowym środowisku,

powinien być w stanie zrobić to w możliwie najprostszy sposób. Poza

przechowywaniem jak najmniejszej liczby wpisów, możemy dodatkowo

uprościć tworzenie i edytowanie tych plików poprzez utworzenie pliku

XSD (XML Schema Definition), który opisuje strukturę pliku

konfiguracji. Większość nowoczesnych środowisk IDE (w tym Visual

Studio i Eclipse) dostarcza funkcję automatycznego uzupełniania

elementów w pliku XML, z którym powiązany jest plik XSD. Plik XSD

może być również używany do dodawania adnotacji i uniemożliwienia

użytkownikowi wprowadzania nieprawidłowych wartości.

Kolejną rzeczą, która upraszcza proces konfiguracji, jest posiadanie

domyślnych wartości dla większości kluczy. Dzięki temu nie musimy

myśleć i decydować o tym, jak skonfigurować coś, co nie jest

oczywiste. Jednak my, jako deweloperzy, którzy wybierają wartości

domyślne, jesteśmy odpowiedzialni za ustalenie takich wartości

domyślnych, które są odpowiednie dla większości użytkowników.

Wartości domyślne są szczególnie istotne, jeśli chcemy wprowadzić

jakiś nowy klucz konfiguracji, gdy pliki konfiguracyjne są już

zdefiniowane i używane. W takim wypadku wartość domyślna powinna

sprawić, że system będzie się zachowywał dokładnie w taki sam


sposób, jak przed wprowadzeniem tego nowego klucza. Dodawanie

nowych kluczy bez wartości domyślnej zmusza każdą osobę do

wprowadzenia takiego klucza konfiguracji z odpowiednią wartością.

Jeśli nie możemy określić odpowiedniej wartości domyślnej i sądzimy,

że każdy użytkownik jawnie ustawi tę wartość, wówczas należy

najpierw sprawdzić w kodzie, który ją odczytuje, czy wartość ta istnieje,

czy nie. Gdy nie istnieje, powinniśmy dostarczyć jasny komunikat

z informacją, który klucz należy dodać i w jaki sposób wybrać jego

wartość. W ten sposób, gdy dodamy wymagany klucz, to przy

pierwszym uruchomieniu testu użytkownicy zostaną poinformowani

o tym, co dokładnie muszą zrobić, aby pomyślnie kontynuować

uruchamianie. Ta sama reguła ma również zastosowanie, gdy po raz

pierwszy wprowadzamy obsługę dla pliku konfiguracyjnego: najpierw

sprawdzamy, czy plik ten istnieje, a jeśli nie istnieje, to dajemy jasny

komunikat opisujący proces jego tworzenia. Dobrą praktyką jest

dostarczenie gotowego szablonu lub przykładowego pliku

i poinstruowanie użytkownika, w jaki sposób można go zmodyfikować

pod kątem własnych potrzeb.

I w końcu, ponieważ zawartość plików konfiguracyjnych każdego

dewelopera powinna być różna, nie należy ich wprowadzać do systemu

kontroli wersji. Jak sugerowaliśmy wcześniej, możemy zamiast tego

dostarczyć szablon lub przykładowy plik wraz z jasnymi instrukcjami,

jak taki plik utworzyć, aby przy pierwszym uruchomieniu testu

użytkownik został dokładnie poinstruowany o sposobie jego tworzenia.

Zwróćmy uwagę, że jeśli chodzi o środowiska kompilacji, to w ramach

procesu kompilacji powinniśmy sprawdzić, czy plik został skopiowany

z odpowiednią wartością z predefiniowanej lokalizacji, albo w jakiś

sposób utworzyć go przed uruchomieniem testów. Jeśli korzystamy


z systemu Git, powinniśmy dodać ten plik do pliku .gitignore

(większość innych systemów kontroli wersji oferuje podobne

rozwiązanie pozwalające na ignorowanie zmian w pliku).

Aby uprościć odczytywanie i parsowanie kodu XML z pliku

konfiguracyjnego, skorzystaliśmy z biblioteki

TestAutomationEssentials.Common. Więcej informacji na temat tej

biblioteki można znaleźć w dodatku B.

Obsługiwanie wielu przeglądarek

Czasami witryny zachowują się nieco inaczej w poszczególnych

przeglądarkach, tak więc istotne jest, aby uruchamiać nasze testy w wielu

różnych przeglądarkach. Selenium pozwala nam na uruchamianie tych

samych testów w wielu przeglądarkach już przy wprowadzeniu bardzo

małej zmiany w naszej podstawie kodu.

W kompilacji centralnej będziemy zwykle chcieli uruchamiać testy na

wszystkich przeglądarkach równolegle. Zanim jednak będziemy

dysponować jakąkolwiek kompilacją, korzystne byłoby wcześniejsze

uruchomienie naszych testów w konkretnej przeglądarce poprzez określenie

rodzaju przeglądarki w pliku konfiguracyjnym. Gdy już utworzymy

kompilację, po prostu skorzystamy tej samej możliwości i wywołamy test

kilka razy, używając przy tym różnych plików konfiguracyjnych, po

jednym dla każdej przeglądarki.

Aby poinformować testy, jakiej przeglądarki mają użyć, skorzystamy

z tego samego pliku konfiguracyjnego, który utworzyliśmy w poprzednim

kroku. Do tego pliku (i odpowiadającego mu pliku XSD) dodamy nowy

klucz BrowserType, który wskazuje przeglądarkę, jaka ma zostać użyta.

Teraz obsłużymy przeglądarki Chrome (która będzie wartością domyślną),


Firefox i Edge. Listing 14.16 pokazuje zmianę w konstruktorze

MVCForumClient oraz nową metodę CreateDriver, która tworzy

instancję odpowiedniej implementacji interfejsu IWebDriver zgodnie

z użytym plikiem konfiguracyjnym.


Listing 14.16. Tworzenie instancji właściwego obiektu IWebDriver zgodnie

z konfiguracją

Gdy po raz pierwszy uruchomimy test po wprowadzeniu tych zmian,

otrzymamy następujący komunikat błędu: Assert.Fail failed.

Configuration file TestEnvironment.xml not found. In order to create

one, copy the file TestEnvironment.Template.xml from the project

folder to TestEnvironment.xml (in the same folder), edit it according to

the contained instructions and rebuild the project (Metoda Assert.Fail

zakończyła się niepowodzeniem. Nie znaleziono pliku konfiguracyjnego

TestEnvironment.xml. Aby utworzyć ten plik, skopiuj zawartość pliku

TestEnvironment.Template.xml z folderu projektu do pliku

TestEnvironment.xml (w tym samym folderze), edytuj go zgodnie

z zawartymi w nim instrukcjami i ponownie skompiluj projekt). Jeśli

wykonamy zawarte w nim instrukcje, test powinien zakończyć się

sukcesem. Jeśli otworzymy plik TestEnvironment.xml w Visual

Studio, będziemy mogli skorzystać z funkcji automatycznego uzupełniania,

która podpowie nam prawidłowe klucze i wartości, jakie możemy

wprowadzić. Nie zapomnijmy o ponownym skompilowaniu projektu lub

rozwiązania, aby zmiany te odniosły skutek. Wszystkie zmiany

wprowadzone w celu zapewniania obsługi pliku konfiguracyjnego możemy

zobaczyć w zatwierdzeniu kodu Git oznaczonym jako Listing 14.16 oraz

w poprzednim zatwierdzeniu.

Uwaga

Alternatywnym rozwiązaniem dla testowania w wielu

przeglądarkach jest użycie narzędzia Selenium Grid lub


skorzystanie z usług jednego z dostawców chmury testowania,

takich jak BrowserStack lub SourceLabs, które hostują Selenium

Grid na swoich serwerach i dają nam dostęp do szerokiego

zakresu przeglądarek i systemów operacyjnych. Z punktu

widzenia kodu nie ma istotnej różnicy między zaprezentowanym

powyżej rozwiązaniem a narzędziem Selenium Grid, ale

Selenium Grid daje nam również możliwość zarządzania różnymi

środowiskami, zaś dostawcy chmury udostępniają nam szerszy

zakres przeglądarek i systemów operacyjnych. Jednak szczegóły

dotyczące korzystania z narzędzia Selenium Grid wykraczają

poza zakres tej książki.

Dodatkowe możliwości usprawniania

Gdy mamy już kilka działających testów, możemy poeksperymentować

z dodatkowymi usprawnieniami. Stałe uruchamianie testów pozwala nam

dowiedzieć się, czy wprowadzone przez nas poprawki niczego nie popsuły.

Nie musimy wszystkiego implementować ani nawet próbować wszystkich.

Możemy utworzyć listę prac (backlog) z pomysłami dotyczącymi

usprawnień i przeplatać je tworzeniem dodatkowych testów. Podczas

nadawania tym zadaniom odpowiednich priorytetów, zwłaszcza gdy

musimy raportować nasz postęp menedżerowi, pomyślny o tym, ile czasu

zaoszczędzimy dzięki temu na pisaniu, debugowaniu, utrzymywaniu

i diagnozowaniu testów, oraz jaki wpływ może to mieć na wiarygodność

naszych testów. Nie czekajmy, aż menedżer powie nam, aby wprowadzić te

usprawnienia – po prostu wyjaśnijmy, dlaczego są one istotne i jak wiele


czasu pozwolą nam zaoszczędzić lub po prostu wykonujmy je po drodze,

jeśli są one wystarczająco małe.

Automatyczne ponowne tworzenie bazy danych

Obecnie zakładamy, że uruchamiamy nasze testy na dziewiczej bazie

danych. Jak wspomnieliśmy wcześniej, czasem trzeba usuwać i ponownie

tworzyć taką bazę danych, aby zagwarantować to założenie. Jeśli robimy to

wyłącznie okazjonalnie i ręcznie, nie zajmuje to zbyt wiele czasu, ale jeśli

inni ludzie używają tych testów i nie stosują tej praktyki, mogą potencjalnie

pracować z zanieczyszczoną bazą danych, dlatego w pewnym momencie

ich testy bez żadnego konkretnego powodu będą kończyć się

niepowodzeniem, bijąc ostatecznie w wiarygodność automatyzacji. Poza

tym, gdy integrujemy testy w procesie kompilacji, to i tak musimy to robić.

Istnieje kilka opcji rozwiązania tego problemu, ale jak zwykle każda z nich

ma swoje własne wady i zalety. Wyboru najlepszego podejścia należy

jednak dokonać samodzielnie. Oto kilka takich opcji, które można wziąć

pod uwagę:

Kod, który automatycznie będzie tworzył ponownie bazę danych,

piszemy w metodzie [AssemblyInitialize]. Choć będzie to


trwało zaledwie kilka sekund, to gdy pracujemy nad nowym testem lub

debugujemy test, uruchamiając szybko test raz za razem, takie

oczekiwanie na inicjalizację może zacząć nas drażnić. Ponadto, aby

utworzyć ponownie bazę danych, musimy w pliku

TestEnvironment.xml określić pewne dodatkowe parametry, takie jak

nazwa bazy danych, nazwa użytkownika, hasło do bazy danych itd.

Choć szczegóły te są zawarte w pliku web.config samej aplikacji, to

jednak test nie ma do niego bezpośredniego dostępu, tak więc musimy


przynajmniej określić ścieżkę do pliku web.config i przetworzyć

parametry połączenia z tego pliku.

Aby zaoszczędzić czas potrzeby na ponowne tworzenie bazy danych

przy każdym uruchomieniu podczas debugowania, możemy w pliku

TestEnvironment.xml dodać nowy klucz typu Boolean o nazwie

RecreateDatabase, który określa, czy baza danych ma zostać


utworzona ponownie w metodzie AssemblyInitialize, czy też
nie.

Ponieważ zwykle chcemy ponownie tworzyć bazę danych tylko raz na

jakiś czas, dodanie tej flagi Boolean do pliku TestEnvironment.xml nie

jest wygodnym rozwiązaniem. Jeśli ustawimy ją na wartość true, to

przed kolejnym uruchomieniem testu będziemy ją zapewne chcieli

zmienić z powrotem na false, ale trzeba będzie o tym pamiętać za

każdym razem… Zamiast tego możemy wyodrębnić funkcjonalność

ponownego tworzenia bazy danych do jakiegoś zewnętrznego

programu, pliku wsadowego lub skryptu (np. PowerShell w systemie

Windows lub Bash w systemie Linux) i uruchamiać go tylko wtedy, gdy

będziemy chcieli. Oczywiście w kompilacji centralnej będziemy

uruchamiać ten skrypt przed każdym uruchomieniem. Skrypt ten musi

również przyjmować parametry lub plik konfiguracyjny zawierający

nazwę i poświadczenia dla bazy danych, ale rzadko kiedy będziemy

musieli go zmieniać, więc może on być przechowywany oddzielnie od

danych pliku TestEnvironment.xml.

Oczyszczanie

Jeśli tworzymy ponownie bazę danych przy każdym uruchomieniu lub co

kilka uruchomień, prawdopodobnie nie mamy powodu, aby czyścić każdą


tworzoną przez nas jednostkę, taką jak kategoria czy dyskusja. Jednak

w innych sytuacjach, gdzie ponowne tworzenie bazy danych z jakiegoś

powodu nie jest możliwe, możemy oczyścić te jednostki, aby uchronić się

przed wprowadzeniem do bazy danych sporej ilości „śmieci” tworzonych

przez automatyzację testów. Przypadki te zwykle wskazują również, że

część danych w bazie danych nie została utworzona przez automatyzację

testów. Przykładowo, starsze systemy często opierają się nie tylko na

schemacie bazy danych, ale również na konkretnych danych, jednak

zależności te nie są wystarczająco dobrze udokumentowane i nikt tak

naprawdę nie wie, która funkcja wymaga których danych. Z tego powodu

ponowne tworzenie samego schematu jest niewystarczające, ale niemożliwe

jest również utworzenie pustej bazy danych i dodanie do niej minimalnej

ilości potrzebnych danych, ponieważ zwyczajnie nie wiemy, jakie to dane.

Z tego powodu w takich przypadkach musimy używać kopii rzeczywistej

bazy danych (zwykle istniejącego środowiska „testowego”), której

ponowne utworzenie od zera jest niemożliwe.

Istnieją w zasadzie dwa podejścia do oczyszczania danych tworzonych

przez automatyzację. Jeśli chcemy, możemy je połączyć, jak zostało to

opisane w kolejnych tematach.

Oczyszczanie wszystkich jednostek utworzonych przez


automatyzację testów w procesie wsadowym

Możemy utworzyć plik wsadowy lub program, który oczyszcza wszystkie

jednostki utworzone przez automatyzację i uruchamiać go

w zaplanowanym terminie lub przed rozpoczęciem wykonywania testu.

Aby to zrobić, musimy w jakiś sposób rozróżniać jednostki tworzone przez

automatyzację od innych danych, których nie powinien on modyfikować.

Jeśli określone rodzaje jednostek tworzone są wyłącznie przez


automatyzację testów, to nie będzie to stanowić problemu, ale jeśli baza

danych zawiera jednostki tego samego rodzaju, z których część została

utworzona przez automatyzację, a część nie, to musimy je jakoś od siebie

odróżnić, aby uniemożliwić programowi usuwanie tych, których nie

powinien usuwać. Możemy to zwykle osiągnąć poprzez dodanie jakiegoś

znanego przedrostka lub przyrostka do identyfikatora lub nazwy (np. tytułu)

jednostki.

Jeśli środowisko może być wykorzystywane jednocześnie przez

różnych użytkowników automatyzacji (łącznie z procesem kompilacji), to

istotnym problemem, który musimy rozwiązać w przypadku wyboru tego

podejścia, jest to, aby nie usuwać jednostek testów, które są aktualnie

wykonywane. Jeśli nie rozwiążemy tego problemu, testy mogą

nieoczekiwanie zacząć kończyć się niepowodzeniem i będzie nam dosyć

trudno zdiagnozować powód takiego zachowania. Jeśli jednostki zawierają

wbudowane pole, które przechowuje czas ich utworzenia, możemy użyć go

do filtrowania jednostek, które zostały utworzone niedawno i mogą nadal

być w użyciu, np. przez ostatnie 24 godziny. Jeśli nie zawierają one takiego

pola, to możemy dodać taką informację do identyfikatora lub nazwy, przy

czym filtrowanie takich jednostek bezpośrednio z poziomu języka SQL

może być trudne i mało wydajne.

Pierwszą opcją, choć nieco bardziej skomplikowaną, jest utworzenie

oddzielnej tabeli, do której test będzie zapisywał informacje przy każdym

utworzeniu jakiejś jednostki. Tabela taka zawierać będzie identyfikator

jednostki oraz czas jej utworzenia. Za każdym razem, gdy test tworzy jakąś

jednostkę, zapisuje on w tej tabeli jej identyfikator i rodzaj wraz z bieżącym

czasem. Proces wsadowy może złączyć tę tabelę z tabelą jednostek i w ten

sposób łatwo przefiltrować rekordy, które nadal mogą być w użyciu.


Oczyszczanie po zakończeniu testu

Drugą opcją jest utworzenie mechanizmu oczyszczania, podobnego do

tego, który został omówiony w rozdziale 7 i opisany w dodatku B.

Mechanizm taki oczyszcza każdą jednostkę utworzoną przez test, gdy test

zostanie ukończony. W takim wypadku nie musimy używać żadnego

specjalnego identyfikatora ani nazwy do rozróżniania jednostek

utworzonych przez automatyzację i innych jednostek, ponieważ każda akcja

oczyszczająca usuwa tylko jedną konkretną jednostkę, jaka została

utworzona. Oczywiście nie mamy żadnego problemu z usuwaniem

jednostek, które są w użyciu, ponieważ usuwamy jednostki zawsze wtedy,

gdy nie są już one dłużej wykorzystywane.

Pewną wadą tego podejścia jest jednak to, że gdy wykonywanie testu

zostanie nagle przerwane, mechanizm oczyszczania nie zadziała, tak więc

jednostki utworzone podczas ostatniego testu nie zostaną oczyszczone. Jest

to szczególnie istotne, gdy debugujemy testy w środowisku IDE

i zatrzymamy debuger, ponieważ takie zatrzymanie jest jak nagłe

zatrzymanie procesu testowania, bez uruchamiania jakiegokolwiek kodu

oczyszczającego. Z tego powodu pomocne może okazać się połączenie tych

dwóch podejść.

Uwaga

Można rozważyć zastosowanie tego mechanizmu jako techniki

izolacji między poszczególnymi testami, przy jednoczesnym

zastosowaniu bardziej solidnej techniki izolacji przed każdym

uruchomieniem, takiej jak ponowne tworzenie całej bazy danych.

Może to być szczególnie przydatne, jeśli mamy wiele testów

i ograniczone zasoby.
Poprawienie wydajności

Obecnie wykonanie każdego z napisanych przez nas testów będzie trwało

od 30 do 60 sekund. Nie jest to dużo jak na test kompleksowy, ale nadal nie

jest to dość szybko. Biorąc pod uwagę, że takich testów będziemy mieć

setki, powoli staje się to istotnym problemem. W dzienniku testu widzimy,

że znaczną część czasu wykonywania zajmuje metoda TestInitialize


oraz proces tworzenia jednostek, takich jak użytkownicy czy kategorie.

Wykonywanie tych operacji z poziomu interfejsu użytkownika nie przynosi

testom zbyt dużej, a w zasadzie nawet żadnej wartości. Możemy spróbować

dokonywać tych operacji bezpośrednio za pośrednictwem bazy danych lub

poprzez warstwę dostępu do danych. Oczywiście w ten sposób

wprowadzamy kolejną zależność do automatyzacji testów, dlatego trzeba

rozważyć, czy jest to tego warte, czy też nie, ale jest to jakaś opcja możliwa

do wykonania. Kolejną rzeczą wartą przemyślenia jest to, czy interfejs

użytkownika administratora zmienia się częściej niż warstwa dostępu do

danych lub schemat bazy danych. Rozważania te są opisane bardziej

szczegółowo w rozdziale 6.

Dodawanie kolejnych testów

Idąc dalej, im więcej testów dodajemy, tym więcej powinniśmy mieć

wdrożonej infrastruktury. Jeśli zwracamy uwagę na usuwanie duplikacji, to

nasz kod powinien być łatwy w utrzymaniu i powinno nam być łatwo

przystosować go do zmiany dowolnej funkcji lub implementacji. Zazwyczaj

dodatkowe testy po ukończeniu testów poprawności (której składają się

zwykle z kilkudziesięciu testów) powinny różnić się od istniejących tylko


drobnymi szczegółami. W większości przypadków nadal trzeba będzie

dodać trochę kodu infrastruktury do obsługi nowych przypadków, ale

powinno to być dosyć proste. Jednak od czasu do czasu będziemy musieli

pokryć w całości jakąś nową funkcję, które nie była wcześniej pokryta

przez zestaw testów poprawności, co może zmusić nas do utworzenia

nowego kodu infrastruktury testów.

Testy sterowane danymi

Podczas gdy większość nowych testów nadal wymaga od nas dodawania

kodu infrastruktury, istnieją przypadki, w których testy te różnią się jedynie

wartościami, jakie przekazywane są do pewnych metod lub używane są

jako oczekiwane rezultaty w instrukcjach Assert. W takich sytuacjach

cała logika metody testowej jest duplikacją, zaś sposobem na jej usunięcie

jest skorzystanie z funkcji oferowanej przez większość bibliotek testowania,

nazywanej testami sterowanymi danymi (data-driven tests, DDT). Różne

biblioteki implementują tę funkcję w różny sposób, ale sama koncepcja

pozostaje taka sama: definiujemy metodę testową raz, po czym

dostarczamy dane dla testu z poziomu tabeli zewnętrznej. Ta zewnętrzna

tabela może mieć formę atrybutów (adnotacji w Javie) nad metodą testową,

lub zewnętrznego źródła danych, takiego jak plik CSV, plik Excela, tabela

bazy danych itd. Gdy test zostaje uruchomiony, biblioteka traktuje go jak

szereg testów i uruchamia metodę testową raz dla każdego wiersza w tabeli.

W ten sposób każdy wiersz w tabeli staje się oddzielnym przypadkiem

testowym.

Załóżmy na przykład, że gdy nowy użytkownik rejestruje się w witrynie

naszego testowanego systemu, musi wybrać jakieś hasło. Gdy wprowadza

hasło, system wskazuje, czy jest ono poprawne, czy nie, a jeśli jest, to

pokazuje siłę tego hasła. Zatem system dla każdego podanego hasła
wskazuje, czy jest ono niepoprawne (Invalid), słabe (Poor), średnie

(Medium) lub dobre (Good). Aby przetestować te reguły i warunki,

możemy dostarczyć tabelę zawierającą dwie kolumny: podane hasło oraz

oczekiwane wskazanie. Każdy wiersz jest przypadkiem testowym, który

weryfikuje wskazanie dla podanego hasła. Metoda testowa przechodzi do

formularza rejestracji, wprowadza odpowiednie hasło i weryfikuje, czy

uzyskane wskazanie jest zgodne z oczekiwanym. Listing 14.17 pokazuje

przykład takiego testu dla biblioteki MSTest.

[TestMethod]
[DataRow("", PasswordStrength.Invalid)]
[DataRow("abc", PasswordStrength.Invalid)]
[DataRow("abcdefg1", PasswordStrength.Poor)]
[DataRow("abc123@$#", PasswordStrength.Medium)]
[DataRow("$g62D*&a244dfle*)]",
PasswordStrength.Good)]
public void PasswordValidation(string password,
PasswordStrength expectedInidication)
{

var homePage = WebSite.NavigateToHomepage();


var registrationPage =
homePage.EnterRegistrationPage();
registrationPage.Username =
GenerateRandomUserName();

registrationPage.Password = password;
Assert.AreEqual(expectedInidication,
registrationPage.
PasswordStrength);
}

Listing 14.17. Przykład testu sterowanego danymi

Antywzorce w testach DDT

Choć testy DDT są bardzo przydatne do usuwania duplikacji pomiędzy

podobnymi testami, istnieje kilka typowych antywzorców, które są

stosowane przy testach DDT:

Niektórzy ludzie nadużywają testów DDT, chcąc umożliwić testerom

dodawanie większej liczby scenariuszy bez pisania kodu. Robią to

poprzez dodawanie wielu parametrów (kolumn) do danych i komplikują

logikę metody testowej, aby obsłużyć wszystkie możliwe kombinacje.

Niestety wynikiem takiego podejścia jest problem z utrzymaniem

metody testowej (kodu) oraz danych, które zawierają przez to sporo

zduplikowanych wartości w każdej kolumnie i wielu wierszach.

Czasami testy DDT, zwłaszcza z jakimś zewnętrznym źródłem danych,

są wykonywane z wykorzystaniem dużego zbioru rzeczywistych danych

w celu upewnienia się, że nie ma żadnych regresji. Choć brzmi to jak

całkiem dobry pomysł, to jednak gdy tylko logika zostanie zmieniona

w ramach projektu, będziemy prawdopodobnie musieli zaktualizować

cały zbiór danych. Najbardziej wygodnym i praktycznym sposobem

zaktualizowania całego zestawu danych jest użycie nowych rezultatów

jako nowych oczekiwanych rezultatów. Ale jak tylko to zrobimy,

stracimy zabezpieczenie, którego oczekiwaliśmy od tego testu,


ponieważ nie będziemy mogli stwierdzić, czy te nowe rezultaty są

poprawne, czy nie. Mówiąc bardziej ogólnie, powodem, dla którego jest

to antywzorzec jest to, że większość tych „przypadków testowych”

(wierszy) tak naprawdę weryfikuje tę samą rzecz, i nie jesteśmy

w stanie już określić, które „twierdzenie” dotyczy danego wiersza.

Testy DDT często służą do testowania pojedynczych jednostek (np.

klasy, metody, komponentu itd.) z różnymi parametrami. Wykonanie

tego w formie testu systemu lub w jakimkolwiek zakresie

wykraczającym poza jednostkę, w której zaimplementowany jest dany

kod, sprawi, że test będzie wolniejszy, mniej wiarygodny, trudniejszy

w uruchamianiu, trudniejszy w utrzymaniu itd. W rzeczywistości

dokładnie to samo dotyczy powyższego przykładu, który powinien być

napisany jako test jednostkowy, bezpośrednio wywołujący metodę do

sprawdzania siły hasła.

Podsumowanie

Po ukończeniu jednego testu możemy zacząć w taki sam sposób dodawać

kolejne, przy czym teraz będziemy mieć możliwość ponownego użycia

istniejących fragmentów kodu. Niektóre fragmenty możemy wykorzystać

w niezmienionej postaci, ale inne musimy zrefaktoryzować, aby móc z nich

korzystać bez wprowadzania niepotrzebnych szczegółów do istniejących

testów.

Ponadto, gdy mamy już ukończony jeden lub kilka testów, możemy

wprowadzić dodatkowe usprawnienia w infrastrukturze. Za pomocą

napisanych przez nas testów upewniamy się, że nasze zmiany działają

prawidłowo. Po prostu uruchamiamy je w dowolnych warunkach, które


chcemy zweryfikować i patrzymy, czy kończą się one sukcesem. Jednym

z istotnych usprawnień jest zapewnienie obsługi dla uruchamiania testów

jednocześnie na wielu maszynach. Jest to konieczne, aby umożliwić

deweloperom uruchamianie testów po każdej wprowadzonej przez nich

zmianie, co stanowi największą wartość automatyzacji testów.

I w końcu, nie czekajmy na to, aż ktoś nam powie, że trzeba poprawić

infrastrukturę automatyzacji testów. Weźmy sprawy w swoje ręce i zróbmy

wszystko, co możemy, aby to osiągnąć, informując przy tym, jaką wartość

w ten sposób otrzymamy.


Rozdział 15. Ciągła integracja

Gdy mamy już przynajmniej jeden test i jesteśmy w stanie uruchamiać testy

na więcej niż jednej maszynie, możemy zintegrować nasze testy z procesem

automatycznej kompilacji, a najlepiej z kompilacją ciągłej integracji

(Continuous Integration, CI). Ten proces kompilacji może zostać

rozszerzony, aby umożliwić również wdrożenie skompilowanej wersji

w środowisku testowym lub przejściowym (ciągłe dostarczanie). Proces ten

można nawet skonfigurować pod kątem bezpośredniego wdrożenia

w środowisku produkcyjnym, w celu utworzenia kompletnego potoku

ciągłego wdrażania. W tym rozdziale skupiamy się głównie na części

związanej z ciągłą integracją, ponieważ to tutaj uruchamiane są zwykle

testy automatyczne.

Uwaga

Czasem CI używana jest wyłącznie do uruchamiania testów

jednostkowych, podczas gdy proces ciągłego dostarczania

(Continuous Delivery, CD) wdraża nową wersję w środowisku

testowym, a następnie wykonuje na niej testy integracyjne i testy

systemu. Jednak ciągłe wdrażanie każdej kompilacji


w środowisku testowym w czasie, gdy testerzy mogą wykonywać

testy manualne, nie zawsze jest pożądane. Ponadto, jak

powiedzieliśmy w rozdziale 7, z powodu problemów związanych

z izolacją zalecane jest korzystanie z różnych środowisk dla

testów manualnych i automatycznych. Jeśli to zrobimy, to różnica

pomiędzy CI i CD nie będzie już taka wyraźna. Z tego powodu

terminem CI należy określać dowolny proces kompilacji, który

uruchamia testy automatyczne i dostarcza programistom szybkie

informacje zwrotne, zaś terminem CD – proces, którego celem

jest zagwarantowanie, że ta sama wersja, która została

przetestowana za pośrednictwem potoku CI (a także poza nim),

wdrażana jest poprawnie i bezpiecznie w każdym środowisku,

a nawet w środowisku produkcyjnym. W tym sensie testy

automatyczne są głównie częścią potoku CI, a nie potoku CD.

Czy to naprawdę konieczne?

Posiadanie systemu kontroli wersji połączonego z serwerem kompilacji

gwarantuje, że każdy pracuje na tej samej wersji kodu źródłowego. W ten

sposób nikt nie może posłużyć się niesławnym powiedzeniem „ale na moim

komputerze to działa!”. Jeśli deweloperzy dokonują synchronizacji

(poprzez operacje wyewidencjonowania) jedynie z wersjami, których

kompilacje zakończyły się sukcesem, to wszystko powinno działać

prawidłowo na maszynie każdego dewelopera.

Obecnie większość producentów oprogramowania nie rozpoczyna prac

nad rzeczywistym kodem produkcyjnym przed wdrożeniem takiego serwera

kompilacji. Konfiguracja serwera kompilacji jest stosunkowo prosta i tania,


więc nie ma żadnego konkretnego powodu, dla którego nie moglibyśmy

wdrożyć takiego serwera na wczesnym etapie. Jeszcze kilka lat temu nie

było to wcale takie proste i także dziś zdarzają się przypadki, w których

zespoły nie mają jeszcze serwera kompilacji lub nie mają odpowiedniej

wiedzy i zdolności, aby w bliskiej przyszłości opracować właściwy proces

kompilacji, chociażby z powodu problemów technicznych wynikających

bezpośrednio ze specyfiki ich projektu. W takich przypadkach, jeśli zespół

jest mały, to nadal może czerpać korzyści z automatyzacji testów, jeszcze

przed wdrożeniem procesu kompilacji. Potrzeba do tego nieco dyscypliny

i odpowiedniej współpracy, ale jeśli upewnimy się, że testy mogą być

uruchamiane na różnych maszynach, to każdy członek zespołu będzie mógł

nadal uruchamiać je na swoim własnym komputerze przed

zaewidencjonowaniem swojego kodu. W ten sposób większość korzyści

oferowanych przez automatyzację testów możemy uzyskać nawet bez

serwera kompilacji lub automatycznego procesu kompilacji.

Jednak ludzie popełniają błędy, a gdy znajdują się pod presją, często idą

na skróty i mogą nawet przestać uruchamiać testy, nawet jeśli są to osoby

zdyscyplinowane i skore do współpracy. Staje się to coraz bardziej

zauważalne, gdy rozmiar zespołu przekracza dwie lub trzy osoby, a czas

uruchamiania całego zestawu testów staje się coraz dłuższy.

Tworzenie procesu kompilacji testów

Dokładne szczegóły techniczne dotyczące sposobu tworzenia procesu

kompilacji testów zależą od technologii systemu zarządzania kompilacją –

takiego jak Jenkins, TFS, TeamCity itd. – oraz konkretnej struktury

testowanego systemu i testów, dlatego wykraczają one poza zakres tej

książki. Jednak sama koncepcja tworzenia takiego procesu, omawiana


w dalszej części tego rozdziału, jest w większości przypadków mniej więcej

taka sama. Na szczęście większość narzędzi jest obecnie dosyć łatwa

w użyciu, a jeśli musimy nauczyć się nimi posługiwać, możemy w tym celu

skorzystać z wielu różnych zasobów dostępnych w Internecie.

Wszystkie te technologie pozwalają nam zdefiniować proces, który

złożony jest z konkretnych akcji. Akcje te wykonywane są zwykle

w odpowiedniej kolejności, przy czym niektóre z nich możemy

zrównoleglić, jeśli wymaga tego definicja procesu. Ponadto proces

kompilacji może w razie potrzeby zostać również rozdzielony na kilka

dedykowanych maszyn (agentów).

Typowy proces kompilacji, projektowany pod uruchamianie testów

automatycznych, obejmuje następujące kroki:

1. Uzyskanie najnowszej wersji kodu źródłowego aplikacji i testów

z systemu kontroli wersji.

2. Kompilacja plików wykonywalnych aplikacji i testów.

3. Uruchamianie testów jednostkowych, jeśli istnieją takie, które nie

wymagają żadnych specjalnych wymagań wstępnych – zwykle zaraz po

skompilowaniu projektu.

4. Wdrażanie skompilowanej aplikacji w jednym lub w kilku istniejących

środowiskach. Mogą to być istniejące środowiska, jak na przykład

środowisko testowe, lub też nowe środowiska, które tworzone są

automatycznie przez proces kompilacji. Opcja ta jest łatwiejsza do

zastosowania w przypadku korzystania z technologii kontenerów.

5. Przygotowanie środowiska testowego pod automatyzację testów. Ten

krok zależy od wybranej strategii izolacji, ale może obejmować takie

rzeczy, jak usuwanie danych starszych testów i/lub tworzenie nowych

danych, konfigurowanie aplikacji zgodnie z wymogami testu,

konfigurowanie symulatorów itd.


6. (Opcjonalne) wdrażanie skompilowanych plików wykonywalnych testów

do jednego lub więcej środowisk wykonywania testów (agentów), gdzie

zostaną one uruchomione. W prostych przypadkach testy mogą być

wykonywane bezpośrednio z poziomu agenta kompilacji, więc nie ma

potrzeby wdrażania ich na inną maszynę.

7. Ponowne uruchamianie testów we wdrożonych środowiskach.

8. Zebranie wyników i utworzenie raportu. W zależności od integracji

technologii biblioteki testowania z systemem zarządzania kompilacją,

raport może zostać dołączony i wyświetlony jako część wyniku

kompilacji, lub po prostu dodany do niego w postaci pliku. Ponadto,

jeśli jeden lub więcej testów zakończy się niepowodzeniem, kompilacja

również oznaczana jest jako zakończona niepowodzeniem. Raport taki

może zostać także wysłany do odpowiednich osób za pomocą poczty e-

mail lub w jakiś inny sposób.

Planowanie procesu kompilacji testów

Jeśli wykonanie całego procesu trwa od 15 do 30 minut lub krócej (w

zależności od tego, jak długo nasz zespół jest w stanie czekać na informacje

zwrotne), to zakładając, że testy są stabilne, warto utrzymywać go jako

jeden skonsolidowany proces i uruchamiać go w trybie ciągłej integracji.

Oznacza to, że kompilacja ta wyzwalana będzie automatycznie przy każdej

operacji ewidencjonowania kodu. Jednak gdy proces ten trwa dłużej lub

testy nie są stabilne, może to znacząco obniżyć produktywność zespołu

deweloperów. W takich przypadkach często dokonuje się podziału procesu

kompilacji na dwie części: pierwsza wykonuje jedynie pierwsze trzy kroki

w ramach procesu kompilacji CI, a druga, w oddzielnym procesie

kompilacji, wykonuje całą resztę, w tym również testy automatyczne (z

wyłączeniem testów jednostkowych). Poniżej znajduje się opis typowych


opcji używanych do zaplanowania tej drugiej fazy. W dalszej części tego

rozdziału omawiamy techniki stosowane do stabilizacji zestawu testów

i skracania czasu jego wykonania, tak aby możliwe było uruchamianie

wszystkich tych czynności w ramach procesu ciągłej integracji.

Uruchamianie testów na żądanie

Jednym z podejść do planowania tej drugiej części procesu – stosowanym

zwykle, gdy testy są mniej wiarygodne i gdy zespół zapewniania jakości

uznawany jest za jedynego właściciela automatyzacji testów – jest

uruchamianie testów na żądanie. To „żądanie” ma zazwyczaj miejsce

bezpośrednio przed lub równolegle z trwającym cyklem testowania

manualnego, zwykle przed którymś wydaniem lub kamieniem milowym,

ale również wtedy, gdy jakaś szybka lub ryzykowna zmiana musi zostać

przetestowana bardziej szczegółowo. Zaletą tego podejścia jest to, że

deweloper automatyzacji może w krótszym czasie badać testy kończące się

niepowodzeniem, ponieważ musi on to robić tylko raz na jakiś czas.

Jednocześnie jest to również wadą tego podejścia, gdyż w takim wypadku

stabilizowanie testów staje się coraz trudniejsze. Ponieważ między każdymi

dwoma uruchomieniami testów może zostać wprowadzonych wiele zmian

w testowanym systemie, wiele testów może kończyć się niepowodzeniem

z powodu dozwolonych zmian w aplikacji, co może wydłużyć i obniżyć

efektywność badania pojedynczego uruchomienia.

Kompilacje nocne

Kolejnym podejściem, które jest prawdopodobnie najbardziej typowe dla

testów systemu, jest uruchamianie testów co noc. W takim przypadku

kompilacja zaplanowana pod automatyczne uruchamianie o określonej


godzinie każdej nocy. W porównaniu z poprzednim podejściem, ten sposób

wymaga, aby ktoś – zwykle deweloper automatyzacji – następnego ranka

zbadał uzyskane rezultaty, w razie potrzeby naprawił testy i zgłosił istotne

błędy deweloperom.

Podobnie jak w poprzednim podejściu, tutaj również deweloperzy

rzadko patrzą na same rezultaty testów, a zamiast tego skupiają się głównie

na błędach, które zgłaszają. Pełniejsze omówienie podejść dotyczących

traktowania tych błędów, wraz z konsekwencjami ich stosowania, można

znaleźć w rozdziale 5, w temacie „Obsługiwanie błędów wykrywanych

przez automatyzację”. Zwróćmy uwagę, że w przypadku kompilacji

nocnych deweloperzy uzyskują informacje zwrotne o swoich zmianach

mniej więcej po upływie jednego pełnego dnia od ich wprowadzenia, co

wystarczy do tego, aby w razie potrzeby dokonać znaczącej zmiany

kontekstu z nowej pracy, którą już rozpoczęli. Z kolei w przypadku CI

informacje zwrotne uzyskiwane są w ciągu kilku minut.

Zaraz po kompilacji ciągłej integracji

Trzecia metoda polega na uruchamianiu testów nadal w postaci odrębnej

kompilacji, ale zaraz po tym, jak kompilacja ciągłej integracji (która nie

uruchamia testów systemu) zakończy się sukcesem. Jak wspomnieliśmy

wcześniej, idea ta polega na tym, że testy kończące się niepowodzeniem nie

doprowadzą do niepowodzenia „głównej” kompilacji CI.

W rzeczywistości podejście to jest dosyć bezużyteczne, ponieważ

z jednej strony deweloper automatyzacji prawdopodobnie nie nadąży

z badaniem uzyskanych wyników, a z drugiej strony deweloperzy aplikacji

prawdopodobnie również nie będą starali się ich badać, ponieważ z ich

punktu widzenia sama kompilacja CI zakończyła się sukcesem. Podejście to


może więc być przydatne tylko jako stan przejściowy przed dodaniem

testów do głównej kompilacji CI. Dzięki temu ludzie zyskują większą

pewność i lepszą widoczność, natomiast deweloperzy automatyzacji i osoby

z zespołu DevOps, które utrzymują procesy kompilacji, upewniają się, że

proces działa płynnie. Gdy kompilacja ta zostanie uznana za stabilną, nie

ma już sensu utrzymywać jej osobno poza kompilacją CI.

Uruchamianie testów w ramach ciągłej integracji

Jeśli naprawdę chcemy skorzystać z pełni zalet automatyzacji testów oraz

zapewnianej przez nią szybkiej pętli informacji zwrotnych, musimy

uruchamiać testy w ramach samej kompilacji CI. Z technicznego punktu

widzenia uruchamianie kompilacji przy każdej operacji ewidencjonowania

kodu, zamiast co noc, sprowadza się jedynie do zmiany ustawienia jednego

przełącznika. Jednak z perspektywy procesu możemy to zrobić tylko wtedy,

jeśli testy są wysoce wiarygodne i wystarczająco szybkie, gdyż

w przeciwnym razie uniemożliwi to deweloperom wprowadzanie

prawidłowych zmian lub też będzie to całkowicie ignorowane

i bezużyteczne. Z tego powodu proces dodawania testów do kompilacji CI

jest czymś, co należy wykonywać z głową.

Tworzenie procesu automatycznego wdrażania

Tworzenie pierwszej części kompilacji CI, która jedynie kompiluje

i uruchamia testy jednostkowe, jest zwykle dosyć proste i większość

zespołów deweloperów potrafi to zrobić bez żadnego problemu. Sytuacja

wygląda nieco inaczej, gdy nie mamy żadnych testów jednostkowych:

wtedy gwarantuje nam to jedynie, że deweloperzy nie będą

ewidencjonować kodu, który się nie kompiluje, ale nic poza tym.
Jeśli zostanie zaimplementowana tylko ta pierwsza część kompilacji CI,

to wdrażanie aplikacji będzie prawdopodobnie procesem ręcznym

i podatnym na błędy. Często zespoły znajdujące się w tej sytuacji tworzą

kompilacje nocne, które uruchamiają wyłącznie testy w istniejącym, ręcznie

wdrażanym środowisku. Problemem w takich przypadkach jest to, że

trudno jest ustalić dokładną wersję kodu źródłowego, który został

skompilowany i użyty we wdrożeniu. Ten brak możliwości śledzenia niesie

ze sobą istotne wyzwania podczas badania testów kończących się

niepowodzeniem, przy czym główny problem ma związek

z niezawodnością i zaufaniem.

Z tego powodu ważne jest, aby wdrożyć aplikację automatycznie

w ramach procesu kompilacji i uruchomić testy na wdrożonej wersji.

W przypadku aplikacji lub usług sieci Web wdrażanie aplikacji oznacza

zwykle kopiowanie plików wykonywalnych do istniejącego środowiska.

W przypadku instalowanych aplikacji tradycyjnych lub serwerowych

zazwyczaj oznacza to uruchamianie kreatora instalacji na maszynie

wirtualnej po przywróceniu jej do stanu z czystej migawki lub utworzenie

nowej maszyny wirtualnej. W przypadku aplikacji i usług sieci Web,

zamiast aktualizować istniejące środowisko, możemy użyć kontenerów, co

pozwoli nam za każdym razem wdrażać aplikacje w nowym środowisku

i zapewni również lepszą izolację dla testów.

Aktualizowanie schematu bazy danych

Chociaż w idealnej sytuacji powinniśmy za każdym razem wdrażać

całkowicie nowe i czyste środowisko, to nie zawsze jest to możliwe,

ponieważ tworzenie nowej instancji bazy danych przy każdym

uruchomieniu może być skomplikowane i kosztowne, zwykle z powodu

ścisłego sprzężenia między logiką aplikacji a pewnymi danymi w bazie


danych. Dotyczy to zwłaszcza aplikacji scentralizowanych, takich jak

aplikacje sieci Web lub wewnętrzne kluczowe aplikacje biznesowe. Jednak

nawet bez tworzenia nowej instancji bazy danych, poprawnie poprawne

działanie takiej aplikacji wymaga, aby od czasu do czasu zmodyfikować

schemat bazy danych, a nawet pewne dane statyczne, a także dokonać

odpowiednich modyfikacji w kodzie aplikacji.

Choć wdrażanie nowej wersji plików wykonywalnych w środowisku

testowym lub przejściowym oraz w środowisku produkcyjnym jest

w zasadzie jednakowe, to wdrażanie zmian w bazie danych w środowisku

produkcyjnym jest kwestią znacznie bardziej wrażliwą, ponieważ nie

możemy sobie pozwolić na utratę żadnych istniejących danych,

a jednocześnie chcemy, aby po wdrożeniu nowej wersji aplikacja była

w stanie korzystać z istniejących danych bez żadnych problemów. Aby

jednak upewnić się, że możemy to zrobić bezpiecznie w środowisku

produkcyjnym, należy najpierw sprawdzić, czy możemy to zrobić

bezpiecznie w środowisku testowym lub przejściowym. Oznacza to, że

powinniśmy to stanowić integralną część normalnego procesu wdrażania,

którego używamy również dla środowiska testowego. Jak napisał na swoim

blogu41 Martin Fowler, jeśli coś boli, róbmy to częściej!.

Relacyjne bazy danych są szczególnie trudne do aktualizacji, ponieważ

ich struktura jest dużo bardziej sztywna. Ponieważ czasami może to

stanowić duży problem, często taka aktualizacja jest robiona ręcznie. Jeśli

schemat bazy danych zmienia się dużo rzadziej niż kod i musimy poradzić

sobie z tym problemem, to nie powinno nas to powstrzymać przed

zbudowaniem pozostałej części procesu. Tworzymy ten proces, jak gdyby

schemat bazy danych nigdy się nie zmieniał, i aktualizujmy go ręcznie

wtedy, gdy ulegnie on zmianie. Później możemy rozważyć

zautomatyzowanie również i tego procesu.


Dokładne rozwiązania tego problemu wykraczają poza zakres tej

książki. Świetnym podręcznikiem, w którym omówiono ten temat i podano

przydatne techniki bezpiecznego wprowadzania zmian w bazie danych

w postaci małych kroków, jest książka Refactoring Databases:

Evolutionary Database Design42 autorstwa Scotta Amblera i Pramoda

Sadalage’a. Natomiast poniżej podano ogólne wskazówki pozwalające na

utrzymywanie synchronizacji kodu źródłowego, schematu bazy danych

i wdrożonej aplikacji:

1. Używajmy skryptów zmian SQL: dla każdej zmiany w schemacie lub

danych w bazie danych tworzymy skrypt SQL, który wprowadzi tę

zmianę. Uruchamiamy te skrypty w odpowiedniej kolejności w ramach

procesu wdrażania (najpierw w środowisku testowym, a później

produkcyjnym).

2. Używajmy narzędzi, które porównują bazy danych i tworzą skrypty

zmian automatycznie: niektóre narzędzia mogą porównywać pożądany

schemat ze schematem istniejącym i utworzyć te skrypty zmian

automatycznie. Choć jest to prostsze rozwiązanie, to jednak musimy

być ostrożni i przeglądać tworzone przez nie skrypty, ponieważ nie

zawsze narzędzia te wiedzą, co zamierzamy zrobić z danymi. Jeśli to

konieczne, możemy ręcznie dokonać odpowiednich zmian.

Tworzenie najpierw nowego procesu kompilacji

Jeśli mamy już kompilację CI, która jest regularnie używana, ale nie

uruchamia testów lub uruchamia tylko testy jednostkowe, to nie

powinniśmy jej jeszcze ruszać. Wszystkie nasze eksperymenty

z wdrażaniem i uruchamianiem testów powinniśmy wykonywać w nowym,

oddzielnym procesie kompilacji. Dopiero na końcu, gdy testy działają


stabilnie w nowej kompilacji, wyłączamy oryginalną kompilację

i włączamy nową, aby ją podmienić, lub też, jeśli jest to prostsze,

kopiujemy niezbędne części z nowej kompilacji do istniejącej.

Gdy sądzimy, że nasz proces konfiguruje już wszystko, aby utworzyć

środowiska dla testów, spróbujmy ręcznie uzyskać dostęp do tego

środowiska (np. za pomocą przeglądarki, pulpitu zdalnego lub

w jakikolwiek inny sposób, który jest odpowiedni dla testowanego

systemu). Jeśli nie możemy uzyskać do niego dostępu zgodnie z planem,

wracamy i naprawiamy wszystko, co wymaga naprawy w procesie

kompilacji, a następnie uruchamiamy go ponownie, aż będziemy w stanie

to zrobić. Zwykle problemy związane są z plikami konfiguracyjnymi,

ścieżkami w systemie plików, uprawnieniami, zmiennymi środowiskowymi

itd.

Dodawanie testów do kompilacji

Gdy już zdołamy przygotować środowisko automatycznie za

pośrednictwem procesu kompilacji i możemy użyć go ręcznie, musimy

dodać dwa kroki kompilacji:

1. Najpierw trzeba skompilować pliki wykonywalne testu. Ten krok

powinien nastąpić na początku procesu kompilacji, ponieważ

w przypadku jego niepowodzenia kontynuacja procesu nie ma sensu.

Jeśli mamy już etap kompilacji testowanego systemu, możliwe, że

wystarczy po prostu dodać jakieś parametr, aby skompilować testy.

Czasami nawet to nie jest konieczne, jeśli nasz kod testu znajduje się

w tym samym folderze w repozytorium kontroli wersji.

2. Następnym potrzebnym krokiem jest wykonanie testów we wdrożonym

środowisku.
Uwaga

Gdy pliki źródłowe automatyzacji testów znajdują się w innym

repozytorium kontroli wersji lub w innej lokalizacji niż pliki

testowanego systemu i dlatego nie zostały one jeszcze pobrane,

wówczas przed ich skompilowaniem powinniśmy pobrać również

kod źródłowy automatyzacji testów.

Większość popularnych systemów ma dedykowane wtyczki do

uruchamiania testów napisanych w najbardziej popularnych bibliotekach

testowania. Jeśli nie jesteśmy pewni, czy nasz system kompilacji obsługuje

naszą bibliotekę testowania, lub nie wiemy, jak je ze sobą zintegrować,

najlepiej po prostu poszukać odpowiedzi w sieci. Wystarczy wpisać nazwę

naszego systemu kompilacji i nazwę naszej biblioteki testowania,

a prawdopodobnie znajdziemy wszystkie potrzebne informacje.

W przypadku użycia rzadkiej kombinacji systemu kompilacji i biblioteki

testowania, która nie oferuje specjalnej wtyczki, potrzebna jest możliwość

uruchamiania testów z poziomu wiersza polecenia. Każdy system

kompilacji obsługuje wykonywanie zewnętrznego procesu wiersza

polecenia. Uwzględnianie rezultatów testów może być pewnym

problemem, jeśli nie ma ono dedykowanej obsługi, ale zazwyczaj są one

wypisywane na konsolę i/lub do pliku, który możemy dodać do wyniku

kompilacji. Zwykle biblioteki testowania dostarczają również kod

zakończenia, który wskazuje, czy wszystkie testy zakończyły się sukcesem,

niepowodzeniem lub wystąpił jakiś inny problem. Choć nie to nie

wystarczy, aby zrozumieć, co dokładnie poszło źle, to jednak może to

zostać wykorzystane przez proces kompilacji do ustalenia, w jaki sposób (i

czy w ogóle) kontynuować wykonywanie.


Uruchamianie testów bezpośrednio na serwerze kompilacji lub
zdalnie

W zależności od rodzaju aplikacji, a także od wykorzystania zasobów,

możemy uruchamiać testy albo na samym serwerze kompilacji, albo na

innej dedykowanej maszynie. Na przykład testy automatyczne interfejsu

użytkownika dla aplikacji tradycyjnej muszą być uruchamiane na maszynie,

na której proces kompilacji instaluje tę aplikację, a zwykle nie jest to ta

sama maszyna, na której działa ten proces. Oznacza to, że maszyna, na

której aplikacja jest instalowana, powinna być również skonfigurowana do

uruchamiania testów. Aby odpowiednio skonfigurować maszynę zdalną do

uruchamiania testów w ramach procesu kompilacji, należy zajrzeć do

dokumentacji procesu kompilacji (lub skorzystać z Internetu…). Ta sama

koncepcja ma zastosowanie do aplikacji, które wymagają specjalnego

sprzętu lub oprogramowania. Na przykład, aby móc uruchamiać testy na

fizycznym urządzeniu iOS, musimy dysponować komputerem Mac

z podłączonym urządzeniem iOS. Testy Selenium mogą być uruchamiane

albo na tym samym serwerze kompilacji, ponieważ narzędzie to jedynie

otwiera przeglądarkę, która łączy się z aplikacją wdrożoną gdzieś indziej,

albo na innej maszynie kompilacji.

Eksperymentowanie najpierw z jednym testem

Jeśli mamy wiele testów i uruchamianie ich wszystkich zajmuje sporo

czasu (w porównaniu do całego procesu kompilacji), to powinniśmy

najpierw skonfigurować kompilację pod uruchamianie tylko jednego testu,

dopóki nie zobaczymy, że działa ona stabilnie i zgodnie z oczekiwaniem.

Podczas testowania i stabilizowania tej kompilacji będziemy

prawdopodobnie musieli uruchomić ją wiele razy, a im dłużej trwać będzie


jej wykonanie, tym więcej czasu zajmie nam proces jej stabilizacji. Musi to

być test, który poddaliśmy już weryfikacji i wiemy, że jest stabilny na

różnych maszynach i w różnych środowiskach. Tak jak w przypadku

wdrażania aplikacji, podczas integrowania testów musimy czasem poradzić

sobie również z takimi problemami jak pliki konfiguracyjne, ścieżki

systemu plików, uprawnienia, zmienne środowiskowe itd. W czasie gdy

będziemy się starać doprowadzić to do działania, możemy spróbować

uruchomić testy z poziomu wiersza polecenia maszyny kompilacji (np. za

pomocą Pulpitu zdalnego w systemie Windows lub ssh w systemie

linuksowym), zamiast uruchamiać cały proces kompilacji, który obejmuje

ponowne wdrażanie aplikacji. W większości przypadków w dziennikach

procesu kompilacji będzie można zobaczyć dokładne polecenie, które

wywołuje te testy. To polecenie kopiujemy do wiersza polecenia agenta

kompilacji i w ten sposób dowiadujemy się, co dokładnie jest problemem.

Możemy spróbować uruchomić je na naszym komputerze lokalnym

i zobaczyć różnice. Zwróćmy uwagę, że mogą tutaj występować różnice

w ścieżkach i/lub zmiennych środowiskowych, i to zarówno między

właściwym wykonywaniem kompilacji i uruchamianiem go z poziomu

wiersza polecenia na serwerze kompilacji, jak również między

uruchamianiem go na serwerze kompilacji i lokalnie na naszej maszynie.

Choć zacieranie tych różnic może być dosyć nużącym zadaniem, to zwykle

nie stanowi ono żadnego problemu pod względem technicznym. Pomocne

w tym może okazać się zapoznanie z komunikatami zawartymi

w dzienniku.

Kończenie procesu kompilacji

Gratulacje! Mamy jeden test, który przechodzi kompletny cykl procesu

kompilacji! Naprawdę jest to istotny kamień milowy, gdyż udowadnia on,


że cały proces działa od początku do końca. Teraz musimy dodać nasze

pozostałe testy.

Jeśli wiemy, że cały nasz zestaw testów jest stabilny (lub po prostu nie

mamy zbyt wiele innych testów), możemy spróbować dodać je wszystkie

naraz. W innym przypadku, zwłaszcza gdy zamierzamy wykorzystywać ten

proces kompilacji jako kompilację ciągłej integracji, powinniśmy dodać

jedynie te testy, które uznajemy za stabilne, zaś pozostałe zostawić na

kompilację nocną lub zająć się ich lokalnymi problemami, zanim dodamy je

do kompilacji. Jeśli kompilacja nie będzie wykorzystywana jako kompilacja

CI, możemy dodać jednocześnie wszystkie pozostałe testy, ale wtedy od

razu trzeba rozpocząć proces stabilizacji, aby uniemożliwić „zepsucie”

testów kończących się niepowodzeniem.

Zmiana procesu tworzenia oprogramowania


i kultury

Tworzenie procesu CI (lub nawet potoku CI/CD), jak zostało to opisane do

tej pory, jest zadaniem technicznym. Jednak proces ten sam w sobie nie

stanowi zbyt dużej wartości. Jego rzeczywista wartość ujawnia się po

wcieleniu go do procesu tworzenia oprogramowania oraz kultury. Jak

wspomnieliśmy na początku tego rozdziału, proces i kultura mogą zostać

zmienione, aby możliwe było skorzystanie z zalet testów automatycznych

jeszcze przed ukończeniem procesu CI. Choć posiadanie tego procesu

znacznie ułatwia tę zmianę, to zmiana procesów biznesowych, a tym

bardziej zmiana kultury biznesu, nadal stanowi spore wyzwanie.

Dążenie do „Świętego Graala”


Jeśli cofniemy się do tematu „Osiąganie maksimum korzyści

z automatyzacji testów” z rozdziału 2, to przypomnimy sobie, że naszym

Świętym Graalem, który chcemy osiągnąć, jest posiadanie w pełni

zautomatyzowanego potoku i procesów biznesowych, które zabezpieczą

nas przed wdzierającymi się błędami. W zasadzie do tego właśnie

powinniśmy dążyć, przy czym może to być dosyć długa podróż. Może być

tak, że nasz cel wciąż leży poza naszym zasięgiem i jest dużo bardziej

skromny, ale tak czy inaczej, każdy cel, który zostaje osiągnięty, stanowi

tylko podstawę dla kolejnego celu. Nie ma więc znaczenia, że nasz

faktyczny cel jest inny. Ważne jest to, że wiemy, jakie korzyści możemy

uzyskać z automatyzacji testów i staramy się je osiągnąć.

Co jest potrzebne do zmiany kultury?

Wprowadzanie nowych procesów biznesowych zawsze jest dużym

wyzwaniem, ponieważ większość ludzi jest z natury oporna na zmiany.

Zmiana w kulturze jest nawet jeszcze trudniejsza, ponieważ wymaga ona

zmiany założeń, wartości i przekonań innych osób. Jednak próba zmiany

procesów biznesowych bez przekonywania ludzi, że będzie to dla nich

korzystne, prawdopodobnie spowoduje więcej złego niż dobrego i jest

z góry skazana na niepowodzenie.

Zmiana kultury może wydawać się wielką sprawą, która leży poza

możliwościami zwykłych śmiertelników. Ale w rzeczywistości kultura jest

jak centrum grawitacji wszystkich osobowości w danej grupie osób. Im

mniejsza grupa, tym większy wpływ ma każda osoba na ogólną kulturę.

Ponadto kultura, podobnie jak ludzkie osobowości, ma niezliczoną ilość

aspektów, a każda osoba ma silniejszy wpływ na jedne aspekty, a słabszy na

inne. Ludzie, którzy mają znaczący wpływ nawet na jeden konkretny

aspekt, często uważani są w tym obszarze za „liderów”. Większość osób,


którzy mają mniejszy wpływ na dany aspekt, zwykle po prostu podąża za

liderem. Ale różne osoby mogą być liderami w jednych aspektach lub

obszarach i podążać za liderami w innych obszarach.

Aby można było z powodzeniem korzystać z automatyzacji testów,

zwykle potrzebny jest tylko jeden lider. Nie ma żadnego powodu, dla

którego to my nie moglibyśmy być nim być! Nie musimy być menedżerami

ani mieć wielkiej charyzmy. Jeśli poświęcimy czas na przeczytanie tej

książki, to już jest to znak, że bardzo nam zależy na wykorzystaniu pełnego

potencjału automatyzacji testów – być może nawet bardziej niż

komukolwiek innemu w naszym zespole, z naszym menedżerem włącznie.

Naprawdę musi nam zależeć na uczeniu się i poszerzaniu swojej wiedzy na

ten temat bardziej niż im. Jeśli będziemy to kontynuować, to już niedługo

staniemy się ekspertami, przynajmniej w oczach naszych kolegów

z zespołu. Gdy już będziemy osobami, do których przychodzą inny po rady

na temat automatyzacji testów inni, nasz wpływ będzie stale rosnąć, więc

coraz więcej osób będzie nas słuchać. Prawdopodobnie nie stanie się to

z dnia na dzień i może upłynąć bardzo długi czas, zanim będziemy mogli

sięgnąć po ten Święty Graal, do którego dążymy.

Patrząc jeszcze bardziej realistycznie, w rzeczywistości

prawdopodobnie nigdy nie zdołamy zdobyć tego Świętego Graala. Może to

rozczarować, ale tak naprawdę naszym celem jest samo dążenie do niego –

stopniowe przekonywanie innych ludzi, aby coraz bardziej się do niego

zbliżali. Zapewne i sam Święty Graal będzie się z czasem zmieniał

i ewoluował, więc jeśli nawet kiedyś osiągniemy nasz pierwotny cel, to na

tym etapie będziemy już dążyć do czegoś nowego. Przygotujmy się więc na

długą podróż, która mimo wszystko powinna być dla nas niezwykle

satysfakcjonująca!
Przekonywać i wpływać na inne osoby można na wiele sposobów, które

zależą głównie od naszych mocnych stron i dokonywanych wyborów.

Niektórzy ludzie są bardziej przekonujący, gdy rozmawiają z nimi w sposób

nieformalny. Inni robią to lepiej poprzez pisanie dokumentów

i organizowanie formalnych spotkań, na których przedstawiają jakąś

prezentację. Jednak prawdopodobnie najbardziej efektywnym sposobem

przekonywania innych osób jest pokazanie i dostarczenie im konkretnej

wartości lub rozwiązanie problemu, z jakim próbują się uporać. Podobnie

jak w metodyce (i filozofii) zwinnego tworzenia oprogramowania (Agile),

najlepszym sposobem jest dostarczanie wartości w małych kawałkach.

Dzięki temu możemy zobaczyć, co działa, a co nie. Powinniśmy słuchać

ludzi i starać się zrozumieć, co trapi ich najbardziej, a następnie próbować

odpowiednio rozwiązać następny problem. Kluczem do sukcesu jest tutaj

informowanie naszego menedżera i kolegów o wartości każdej

dokonywanej zmiany, a także o planie działania, jakie zamierzamy

wykonać. W większości przypadków wszystko, co planujemy zrobić,

powinniśmy wcześniej uzgadniać z naszym menedżerem. Jeśli pokażemy

mu potencjalną wartość zmiany oraz jak mały niesie ona ze sobą koszt,

prawdopodobnie nie będzie się on jej sprzeciwiał. Zdarzają się jednak

przypadki, w których menedżer może nie dostrzec natychmiastowej

korzyści, ale inni już tak. Na przykład menedżer zespołu zapewniania

jakości może nie dostrzec wartości, jaką deweloperzy mogą zyskać dzięki

automatyzacji testów. W takich przypadkach możemy powoli i po cichu

próbować pokazać tę wartość kilku kluczowym osobom, które ją

dostrzegają (np. deweloperem) i sprawić, że poproszą oni swojego

menedżera o naszą pomoc. Następnie ich menedżer przyjdzie do naszego

i poprosi go, aby pozwolił nam im pomóc. Jeśli to konieczne, ich menedżer

może nawet zaangażować w to wspólnego menedżera wyższego szczebla,


aby ten przekonał do współpracy naszego menedżera. Gdy ten ostatni

uzyska od swoich kolegów i przełożonych pozytywne informacje o nas i o

naszej pracy, to z pewnością i on dostrzeże wówczas tę wartość.

Kolejnym bardzo ważnym środkiem umożliwiającym zmianę kultury

jest informacja. Informacja ma bardzo duży wpływ na przekonania

większości ludzi, ponieważ pokazuje im obiektywną prawdę (lub

przynajmniej jej jeden aspekt). Brak informacji prowadzi do spekulacji,

tendencyjnych założeń, a nawet osobistych konfliktów, podczas gdy

większa ilość informacji zapewnia transparentność i współpracę.

Informacje mogą mieć postać danych, ale także i wiedzy. W kontekście

informacji jako danych, bardzo istotne są narzędzia i procesy CI, a także

system kontroli wersji i inne narzędzia cyklu życia oprogramowania.

Zapewniają one transparentność i dane bez względu na stan wersji czy

gęstość występowania błędów, pomagają uzasadniać główną przyczynę

problemów itd. Pamiętajmy jednak, że każda metryka może mieć znaczące

efekty uboczne, jeśli będzie wykorzystywana zbyt szeroko lub zbyt

agresywnie do pomiaru wydajności osób. Ludzie starają się zwykle

zmaksymalizować metrykę, na podstawie której są oceniani, ale po drodze

mogą zaniedbać wiele innych istotnych aspektów swojej pracy, co

w niektórych przypadkach może mieć katastrofalne skutki.

W kontekście informacji jako wiedzy istotne mogą być przeglądy kodu,

kursy, książki, blogi czy nawet nieformalne konwersacje.

Określanie punktu wyjścia

Wracając na ziemię, wprowadzanie zmiany do procesów i kultury zależy od

bieżącej sytuacji oraz najtrudniejszego problemu, który staramy się

rozwiązać. Poniżej znajduje kilka typowych sytuacji, w których możemy

się znaleźć, wraz z kilkoma sposobami na dokonanie w nich zmiany. Jak


wspomnieliśmy wcześniej, nie próbujmy zmieniać wszystkiego od razu.

Rozwiązujmy po jednym problemie naraz, w sposób, który zapewnia

największą wartość przy możliwie najmniejszym wysiłku.

Brak automatycznej kompilacji

Jak wspomnieliśmy na początku tego rozdziału, zespół może czerpać

korzyści z automatyzacji testów bez automatycznej kompilacji. Jednak

w opisanej tam sytuacji założyliśmy, że zespół jest mały, ściśle ze sobą

współpracuje i wie, jak uzyskać maksimum korzyści z takiej automatyzacji.

Jeśli jednak jesteśmy samodzielnymi deweloperami automatyzacji, którzy

w ramach zespołu zapewniania jakości pracują z dala od deweloperów, to

istnieje szansa, że potencjalna wartość, jaką zespół uzyska z automatyzacji

testów, będzie daleka od oczekiwanej.

Jeśli nie otrzymamy zgody na utworzenie automatycznej kompilacji,

która będzie uruchamiać testy (mimo że przy obecnych narzędziach nie

powinno być na to żadnej wymówki), możemy po prostu utworzyć

zaplanowane zadanie na naszej maszynie (np. za pomocą Harmonogramu

zadań systemu Windows), które będzie uruchamiać wszystkie testy w nocy.

Przy użyciu kilku wierszy skryptu możemy sprawić, że wyniki będą

wysyłane automatycznie do odpowiednich osób przez e-mail. Ale nawet

i bez tego, samodzielne wysyłanie wyników każdego ranka jest dobrym

punktem wyjścia. Jak powiemy sobie w kolejnym temacie, rezultaty te

powinniśmy również opisywać własnymi słowami.

Rozbieżność oczekiwań

Bardzo często deweloperzy automatyzacji testów piszą raporty do

nietechnicznego menedżera lub do menedżera, który jest zbyt zajęty, aby


mógł zająć się drobnymi szczegółami lub zawiłościami projektu

automatyzacji testów. Menedżerowie ci często mają problem

z dostrzeganiem szerszego obrazu oraz stanu projektu, co powoduje

frustrację i rozbieżności między ich oczekiwaniami a faktycznym stanem

projektu.

Jednym z głównych tego powodów jest to, że rezultaty automatyzacji

testów, a w konsekwencji dostarczane przez nią wartości, są mało

transparentne. Raporty mówią co prawda, jak wiele testów i które z nich

zakończyły się sukcesem lub niepowodzeniem, ale komunikat błędu jest

zbyt techniczny, aby był on zrozumiały dla tych menedżerów. Nawet jeśli

mamy wizualny dziennik i wszystko, co pomaga nam szybko badać wyniki,

to nadal nie jest to coś, co nasz menedżer będzie chciał wykorzystywać dla

każdego niepowodzenia. Zakładając, że dostaliśmy czas na badanie

i naprawianie testów, często nasz menedżer nie wdaje się w szczegóły

techniczne i trudno jest mu zrozumieć, na co właściwie poświęcamy swój

czas (kto nie ma czasu na badania i naprawę testów, powinien przeczytać

temat „Posiadanie wielu niestabilnych testów” w dalszej części tego

rozdziału). Ponieważ istnieją również inne powody niepowodzeń, które nie

są błędami, czasem menedżerowie dostrzegają jedynie wysoki koszt

tworzenia i utrzymywania testów, a bardzo małą wartość w błędach

wykrywanych są przez automatyzację. Po prostu nie mają oni środków

pozwalających na oszacowanie tego, czy jest to opłacalne, czy nie, oraz

gdzie leży główna przyczyna, aby podjąć próbę ich usunięcia.

Zakładając, że badamy już rezultaty każdego ranka, powinniśmy to

kontynuować, pamiętając również o pisaniu raportów własnymi słowami

i wysyłaniu ich do naszego menedżera, a nawet do niektórych z naszych

kolegów. Raport może być dostarczany w formie tabeli lub w bardziej

elastycznym formacie tekstowym – może to być dowolna forma, którą


uznamy za najbardziej odpowiednią, i która naszym zdaniem najlepiej

przekazuje te informacje.

Badamy każdy test kończący się niepowodzeniem i próbujemy

wywnioskować, czy niepowodzenie to powodowane jest przez (a) błąd

w aplikacji, (b) zmianę w produkcie, (c) problem środowiskowy lub (d)

nieoczekiwany warunek w teście (tj. błąd w teście). Dla każdego z tych

przypadków opisujemy problem w jednym lub kilku zdaniach, które nie są

zbyt techniczne, ale wystarczająco przejrzyste. Ponadto opisujemy, co

zrobiliśmy lub co naszym zdaniem powinno zostać zrobione, aby rozwiązać

lub załagodzić ten problem. Znane błędy należy w jakiś sposób odróżnić od

nowych. Jeśli nie jesteśmy w stanie w pełni zidentyfikować głównej

przyczyny niepowodzenia i ustalić, czy powstaje ono w wyniku błędu, czy

innego problemu, to opisujemy, co dokładnie już zbadaliśmy i jakie są

nasze dotychczasowe wnioski. Ponadto piszemy, jakie diagnostyki

dodaliśmy lub jakie powinny zostać dodane (jeśli potrzebujemy do tego

więcej czasu lub pomocy od innych osób), aby ułatwić sobie badanie tego

problemu następnym razem. Podsumowując, powinniśmy zapisywać tylko

istotne szczegóły, ale za to w zwięzły i łatwy do zrozumienia sposób.

Zwykle jednym z pierwszych wykonywanych kroków podczas badania

niepowodzeń jest próba odtworzenia problemu na naszej maszynie lokalnej,

zarówno poprzez uruchomienie tych samych testów automatycznych, jak

również przez próbę otworzenia go ręcznie. Rezultaty tych badań często są

na tyle interesujące, że warto o nich wspomnieć bezpośrednio, ponieważ

mówią nam one dość sporo o naturze tego problemu.

Na koniec w raporcie grupujemy ze sobą identyczne problemy, tak aby

łatwiej nam było skupić się na tych problemach i ich rozwiązaniach,

zamiast na samych testach. Jest to również dobra okazja do podkreślenia

problemów, które muszą zostać rozwiązane – łącznie z problemami


bardziej ogólnymi i długoterminowymi, które przeszkadzają nam

w pracy – oraz do położenia nacisku na dodatkowe usprawnienia w

procesach i kulturze. Taki raport będzie dużo bardziej cenny dla naszego

menedżera niż jakikolwiek inny raport automatyczny, o który może on

poprosić, i z pewnością zostanie on przez niego doceniony.

Posiadanie wielu niestabilnych testów

Wyjaśnimy sobie pewną rzecz: niestabilne testy są bezużyteczne!

W przypadku niestabilnych testów nie możemy ustalić, czy testowany

scenariusz działa, czy nie, ani czy niepowodzenie powodowane jest przez

błąd lub jakiś inny problem. Zamiast pomagać komukolwiek w jakikolwiek

sposób, wprowadza to tylko dodatkowy szum do procesu i marnuje nasz

czas. Z tego powodu testy muszą być zawsze stabilne. Na potrzeby naszej

dyskusji niestabilne testy niekoniecznie muszą być zawsze testami

migoczącymi. Nawet testy, które kończą się sukcesem na jednej maszynie,

a niepowodzeniem na innej maszynie, lub po prostu testy, które stale

kończą się niepowodzeniem, ale nikt nie miał czasu na prześledzenie

i naprawienie ich głównej przyczyny, powinny być uznawane za

niestabilne, mimo że nie są one testami migoczącymi.

Choć nie jest to pożądane, zbyt często zdarzają się sytuacje, w których

zestaw automatycznych testów staje się niestabilny, ponieważ wiele

zawartych w nim testów kończy się niepowodzeniem z niewyjaśnionych

przyczyn. Zwykle sytuacje te powstają głównie z powodu niewiedzy

menedżerów, którzy nie rozumieją potrzeb automatyzacji testów

związanych z jej utrzymaniem i bardziej przejmują się pokrywaniem

kolejnych scenariuszy niż stabilizowaniem tych, które już istnieją

(prawdopodobnie dlatego, że zgodzili się oni na konkretny termin

zaproponowany przez ich własnego menedżera). Jeśli składamy raport


takiemu menedżerowi, to spróbujmy go poinformować (a w razie potrzeby

również jego menedżerowi) o bezużyteczności takich niestabilnych testów

i związanym z nimi koszcie. Wyjaśnijmy, że stabilizowanie testów musi być

stałym wysiłkiem o wysokim priorytecie.

Jak zawsze, informacja – zarówno w formie danych, jak i w formie

wiedzy – jest naszym najlepszym narzędziem. W przypadku informacji jako

wiedzy, komunikowanie kosztów i wartości niestabilnych testów, jak

również różnic pomiędzy testami manualnymi i automatycznymi w tym

odniesieniu, jest bardzo ważne. W kontekście informacji jako danych,

podstawowe źródło informacji stanowią wyniki testów uruchamianych

w kompilacji. Jeśli jednak mamy wiele niepowodzeń, to istnieje szansa, że

nie otrzymaliśmy wystarczającej ilości czasu na szczegółowe badanie

i naprawianie tych problemów, a w miarę upływu czasu problem ten jeszcze

się pogarsza. Ponieważ się pogarsza, będziemy potrzebować jeszcze więcej

czasu na badanie i stabilizowanie testów, więc istnieje szansa, że nasz

menedżer będzie chciał odłożyć to na później, do czasu osiągnięcia jakiegoś

następnego kamienia milowego (a pewnie również i to zostanie odłożone

w czasie).

Choć rozwiązanie, które zamierzamy tu przedstawić, powinniśmy

uzgodnić z naszym menedżerem, to jest ono na tyle oszczędne, że jest

raczej mało prawdopodobne, aby nasz menedżer był mu przeciwny.

Zamiast próbować ustabilizować od razy wszystkie testy, zacznijmy od

tych, które są już dosyć stabilne. Nawet jeśli większość testów jest

niestabilna, to prawie zawsze istnieć będzie kilka testów, które niemal

regularnie będą kończyć się sukcesem. Jeśli nie ma takich testów, to

zacznijmy od tych, które aktualnie piszemy, a gdy tylko zaczną one działać,

uznajmy je za stabilne. Zróbmy listę tych względnie stabilnych testów,

nawet jeśli lista będzie ona bardzo krótka. Każdego ranka, zamiast badać
niepowodzenia wszystkich testów, skupmy się na testach uwzględnionych

na tej liście. Ponieważ lista zawiera testy, które są dość stabilne, powinny

one zwykle kończyć się sukcesem, tak więc czas poświęcony na ich

badanie i naprawianie będzie bardzo krótki. Jeśli jednak część z nich

kończyć się będzie niepowodzeniem, zbadajmy je i naprawmy, aby

ponownie można je było uznać za stabilne. Każdego dnia informujmy

naszego menedżera o bieżącym stanie testów z tej listy, jak to opisaliśmy

w poprzednim temacie. Gdy już wszystkie testy z tej listy będą kończyć się

sukcesem, będziemy mogli powoli dodawać do tej listy po jednym lub po

kilka testów, które wydają nam się dosyć stabilne. Możemy również wybrać

jeden test, który regularnie kończy się niepowodzeniem z tym samym

komunikatem błędu, i spróbować go zbadać i naprawić. Niektóre

z wprowadzonych przez nas poprawek dla testów z tej listy, czy też

poprawek dla testów, które regularnie kończyły się niepowodzeniem,

prawdopodobnie naprawią również inne testy. Do badania i stabilizowania

testów zastosujmy koncepcje wspomniane w rozdziale 13. Stopniowo

rozszerzajmy listę stabilnych testów do momentu, aż wszystkie testy będą

stabilne.

Nawet jeśli ostatecznie pozostanie nam kilka „upartych” testów, które

trudno nam będzie ustabilizować, to będą one raczej stanowić wyjątek niż

normę. Ponieważ testy te wyróżniają się na tle pozostałych, łatwo nam

będzie zauważyć regresje. Takimi problematycznymi testów możemy

chwilowo przestać się zajmować lub nawet całkowicie je usunąć, jeśli

zdamy sobie sprawę, że ich wartość nie jest tak jasna, jak sądziliśmy.

Zauważmy, w jaki sposób to stopniowe podejście pomaga nam uniknąć

potrzeby inwestowania zbyt dużej ilości czasu na badanie wielu

niepowodzeń, przy jednoczesnym zachowaniu stałego postępu w kierunku

stabilnego zestawu testów. Takie stopniowe podejście będzie znacznie


łatwiejsze do zaakceptowania dla naszego menedżera, gdyż czas potrzebny

na badanie i naprawianie błędów nie będzie już tak długi, zaś dostrzegana

przez niego wartość będzie niemal natychmiastowa. Informowanie naszego

menedżera o postępie w stabilizacji testów gwarantuje, że on jest świadomy

tego postępu i dostrzega nasz wkład w sukces bieżącej podróży związanej

z automatyzacji testów.

Przejmowanie starszego zestawu testów

W organizacjach ludzie z czasem przychodzą i odchodzą, a deweloperzy

automatyzacji nie są tu wcale wyjątkiem. Gdy automatyzacja testów zostaje

przekazana w inne ręce – zwłaszcza wtedy, gdy została ona opracowana

i była utrzymywana przez pojedynczą osobę – często nowy deweloper

automatyzacji, który przejął ją po kimś innym, ma problem z jej dalszym

trzymaniem. Ponieważ nowy deweloper automatyzacji jest mniej

zaznajomiony z kodem automatyzacji testów, może nie być świadomy

decyzji i powodów, które doprowadziły do tego, że automatyzacja ta została

napisana w taki, a nie inny sposób. Ponieważ każdy deweloper ma swój

własny styl kodowania i zestaw umiejętności, trudno mu będzie dalej

utrzymywać dotychczasowe testy i prawdopodobnie uzna on odziedziczoną

automatyzację za jakiś stary rupieć, który trzeba wymienić (dotyczy to

prawie każdego dewelopera, który przejmuje kod po innej osobie, a nie

tylko deweloperów automatyzacji). Co więcej, choć wiele z tych powodów

ma charakter subiektywny, to często jest tak, że w czasie, w którym

automatyzacja była rozwijana i utrzymywana, zmieniał się testowany

system, wykorzystywane technologie oraz wiedza dewelopera

automatyzacji. Nawet przy najlepszych intencjach w kodzie

prawdopodobnie istnieć będą ślady starej technologii i niejawnej wiedzy, co

utrudni jego utrzymanie. Może się również zdarzyć, że testy będą


obiektywnie niestabilne lub trudne w utrzymaniu z powodu złych

umiejętności kodowania lub braku izolacji.

Zanim jednak postanowimy wszystko wyrzucić i zacząć od początku,

odpowiedzmy sobie na dwa następujące pytania:

1. Czy możliwe jest zrozumienie celu każdego testu, a przynajmniej

niektórych z nich? Jeśli przynajmniej niektóre z nazw testów, kroków

lub opisów są dla nas zrozumiałe (lub dla ekspertów z danej dziedziny

lub wybranych deweloperów) lub utrzymane są związki pomiędzy

metodami testowymi i ręcznie napisanym przypadkiem testowym, to

istnieje szansa, że będziemy mogli nadal ustabilizować takie testy

i uzyskać z nich jakąś wartość.

2. Czy możemy wskazać jakiś problem związany z izolacją lub

architekturą, który ogranicza wiarygodność tych testów? Jeśli tak, czy

jest możliwe zmodyfikowanie kodu infrastruktury, aby to naprawić, czy

też powinna zostać zmieniona większość testów?

Jeśli testy nie są wiarygodne z powodu jakichś problemów związanych

z izolacją lub architekturą, które możemy precyzyjnie wskazać, ale są one

trudne do naprawienia, to faktycznie może nie być żadnego powodu

dalszego utrzymywania tych testów. Jednak w przypadku, gdy testy te nie

są wiarygodne, lecz nadal możemy zrozumieć ich przeznaczenie,

powinniśmy zastosować technikę stopniowego stabilizowania testów, którą

opisaliśmy w poprzednim temacie.

Jeśli nie możemy precyzyjnie wskazać konkretnego problemu z izolacją

lub architekturą, a cel testów nie jest dla nas zrozumiały, to możemy

kontynuować ich uruchamianie. Jednak gdy będą się one kończyć

niepowodzeniem, to powinniśmy poświęcać na ich badanie tylko rozsądną

ilość czasu. Jeśli można je łatwo naprawić, to zróbmy to, a jeśli dodatkowo

możemy również zmienić nazwę lub opis metody testowej, aby lepiej
opisywała jej przeznaczenie, to jeszcze lepiej. Jeśli uważamy, że test jest

zbyt długi i weryfikuje zbyt wiele rzeczy, możemy podzielić go na kilka

krótszych testów, które razem pokrywają taką samą funkcjonalność jak

oryginalny test. Jednak testy kończące się niepowodzeniem, które są trudne

w utrzymywaniu i nie mają wyraźnego celu, są naprawdę bezużyteczne.

Jeśli deweloperzy funkcji oraz właściciel produktu również tak uważają, to

prawdopodobnie możemy usunąć te testy.

Nasuwa się jednak pytanie, co zrobić z nowymi testami. Pierwszym

impulsem większości deweloperów jest napisanie nowej infrastruktury,

która będzie łatwiejsza w utrzymaniu. Problem polega na tym, że musimy

zachować odpowiednią równowagę między kosztem korzystania

i utrzymywania kodu, który jest dla nas mniej wygodny, a kosztem

tworzenia całej infrastruktury na nowo. W wielu przypadkach możemy

ponownie wykorzystać tylko te części infrastruktury, które mają dla nas

sens, zaś pozostałe części musimy napisać od nowa. Możliwe nawet, że

część tej infrastruktury będziemy chcieli napisać sami, zwłaszcza gdy

zechcemy skorzystać z podejścia omówionego w rozdziałach od 10 do 12,

a poprzednia automatyzacja nie została napisana z jego wykorzystaniem.

Jeśli jednak w starej infrastrukturze jest jakiś użyteczny kod, który możemy

ponownie wykorzystać, to trzeba to zrobić.

Jeśli ostatecznie zdecydujemy się napisać nowy system automatyzacji

od zera, to rozważmy uruchamianie nowych testów obok starych,

przynajmniej do czasu uzyskania właściwego pokrycia i zaufania do

nowego systemu.

Pogoń za programistami aplikacji


Ostatni problem i technika usprawniania, o której sobie powiemy, ma wiele

różnych symptomów:

1. Deweloperzy automatyzacji czują, że ich praca nie jest doceniana przez

zarząd.

2. Błędy, których priorytet nie jest wystarczająco wysoki, aby były one

naprawione od razu, ale mają one wpływ na automatyzację, są

odkładane w czasie, co negatywnie wpływa na wiarygodność

automatyzacji.

3. Niezbędne zmiany w testowanym systemie, które mają sprawić, że

będzie nam go łatwiej przetestować, nie mają odpowiednio wysokiego

priorytetu.

4. Automatyzacja często kończy się niepowodzeniem z powodu

uprawnionych zmian w testowanym systemie, ponieważ deweloper

automatyzacji nie był przygotowany zawczasu na te zmiany. Deweloper

automatyzacji czuje, że musi ganiać za deweloperami aplikacji, aby

zrozumieć, co się zmieniło i dlaczego.

Wszystkie te symptomy są zwykle rezultatem przekonania, że

automatyzacja jest częścią odpowiedzialności zespołu zapewniania jakości

oraz że deweloperzy aplikacji nie powinni się nią zbytnio przejmować.

Przekonanie to zwykle jest powiązane z innym przekonaniem, że głównym

kanałem komunikacyjnym pomiędzy zespołem zapewniania jakości

a zespołami deweloperów jest system śledzenia błędów. Prawie zawsze

w takich sytuacjach testy uruchamiane są wyłącznie w ramach kompilacji

nocnych, a nie CI.

To właśnie w tym miejscu zmiana kultury jest najbardziej potrzebna.

Aby móc rozwiązać te problemy, odpowiedzialność za uruchamianie

i utrzymywanie testów powinna zostać oddana w ręce deweloperów,

a przynajmniej powinni oni być ściśle zaangażowani w prace nad rozwojem


automatyzacji testów. W rzeczywistości powinno to być

odpowiedzialnością zespołu, ale deweloperzy na pewno muszą być w to

aktywnie zaangażowani. Oczywiście problem polega na tym, w jaki sposób

dokonać tej zmiany…

Interesujące jest to, że podczas gdy większość deweloperów nie lubi

szczegółowo testować swoich zmian i nowych funkcji, lub pisać testów dla

swojego kodu, to jednak w wielu przypadkach są oni skłonni sami

uruchamiać automatyczne testy. Gdy damy im automatyczne testy, które

zostały już napisane i które mogą uruchamiać za pomocą pojedynczego

przycisku, poczują się oni bardziej pewnie, że ich zmiany nie wyrządziły

żadnej istotnej szkody. Mimo wszystko deweloperzy nie lubią, gdy w ich

kodzie znajdowane są błędy, ponieważ w ten sposób mają oni więcej pracy

(jak również uderza to bezpośrednio w ich ego…).

Sztuczką, dzięki której może to zacząć działać, jest porozmawianie

z deweloperami (najlepiej najpierw nieoficjalnie) i wyjaśnienie im, co tak

naprawdę automatyzacja robi i na jakiej zasadzie działa. Następnie możemy

pokazać im, w jaki sposób mogą oni uruchamiać jeden, kilka lub wszystkie

testy (w zależności od tego, ile czasu zajmuje taka operacja). Istnieje duża

szansa, że przynajmniej kilku z nich będzie podekscytowanych taką

możliwością. Przeważnie będą to starsi deweloperzy, liderzy techniczni lub

liderzy zespołów, przy czym jest to w dużej mierze uzależnione od

charakteru tych osób. Gdy znajdziemy dewelopera, który zechce

uruchamiać testy i to z własnej woli, zaoferujmy mu pomoc w rozwiązaniu

dowolnego problemu, który może napotkać, i postarajmy się naprawdę

zapewnić mu tę pomoc. Spróbujmy zrobić tę „sztuczkę” na jak największej

liczbie deweloperów, skupiając się właśnie na tych starszych deweloperach

i liderach technicznych. Prawdopodobnie co jakiś czas któryś

z deweloperów napotka błąd i wezwie nas, aby mu w tym pomóc. Gdy tak
się stanie, pokażmy mu, w jaki sposób możemy zbadać takie

niepowodzenie i razem z nim spróbujmy znaleźć jego główną przyczynę.

Jeśli przyczyną jest błąd wprowadzony przez dewelopera, to pod wieloma

względami będzie to wspaniałe zwycięstwo. Pozwólmy mu naprawić ten

błąd i upewnijmy się, że test kończy się sukcesem. Jeśli przyczyną była

uprawniona zmiana w testowanym systemie, to pokażmy mu, w jaki sposób

naprawiamy taki test (zakładając, że naprawa nie trwa zbyt długo, a w

większości przypadków nie powinno tak być, jeśli trzymaliśmy się zasady

unikania duplikacji). Uruchamiamy test ponownie i upewniamy się, że

naprawa zakończyła się sukcesem. W ten sposób stopniowo przenosimy

wiedzę i odpowiedzialność na dewelopera, podczas gdy on widzi wartość,

jaką dają mu szybkie informacje zwrotne uzyskiwane z automatyzacji.

Po pewnym czasie stosowania tej sztuczki, gdy współpracujemy już

w ten sposób z kilkoma deweloperami, kiedy test zakończy się

niepowodzeniem w kompilacji nocnej, spróbujmy ustalić, który

z deweloperów zaewidencjonował kod, który doprowadził do tego

niepowodzenia, spoglądając w tym celu na szczegóły kompilacji i historię

kontroli wersji z ostatniego dnia. Zwróćmy uwagę, że fakt, iż test zakończył

się niepowodzeniem w kompilacji nocnej wskazuje, że deweloper ten nie

uruchomił tego testu przed zaewidencjonowaniem swoich zmian.

Zakładając, że deweloper ten wie już, jak należy uruchamiać testy, i że jest

to test, który naszym zdaniem powinien być uruchomiony, możemy pójść

do tego dewelopera bezpośrednio i pokazać mu, że gdyby uruchomił on test

przed wprowadzaniem swoich zmian, to zdołałby wyłapać ten błąd jeszcze

przed zaewidencjonowaniem kodu do systemu. Notujmy tego rodzaju

przypadki za każdym razem, a po jakimś czasie pokażmy zespołowi (jak

również naszemu menedżerowi) czas, jaki moglibyśmy zaoszczędzić,

gdyby testy uruchamiane były przez deweloperów przed


ewidencjonowaniem przez nich zmian. Nie trzeba dodawać, że powinniśmy

to zrobić bardzo uprzejmie i zasugerować to jako usprawnienie w procesie,

a nie obwiniać deweloperów za to, że tego nie robią. Chcemy więc

wykorzystać te dane w celu zapewnienia transparentności i do pokazania

wartości, jaką mogą przynieść zmiany dokonane w tym procesie.

Wreszcie ktoś już zasugeruje, aby dodać testy (lub ich podzbiór) do

kompilacji CI, żeby uchronić się przed powtórnym pojawianiem się tego

typu regresji. Oczywiście nie powinniśmy bezczynnie czekać na sugestię,

ale od początku zachęcać do tej zmiany. W ten sposób przygotujemy sobie

grunt na ten moment porozumienia i będziemy mogli wcielić ten plan

w życie bez większego sprzeciwu.

Uwaga

Na podstawie własnego doświadczenia i obserwacji zauważyłem,

że większość deweloperów nie lubi tworzyć testów dla swojego

kodu, głównie z powodu braku doświadczenia lub wiedzy o tym,

jak należy to zrobić. Gdy już zyskają pewne doświadczenie i są

w tym trochę lepsi, staje się dla nich oczywiste, że tworzenie tych

testów powinno być nieoderwalną częścią ich pracy. Naturalnie

niektóre osoby dochodzą do tego wniosku szybciej niż inne.

Jednak na początku wielu deweloperów postrzega pisanie testów

automatycznych jako mało techniczne wyzwanie, więc wydaje

się im, że tylko tracą na tym swój cenny czas. W rezultacie

uważają oni, że powinno to być wykonywane przez kogoś

innego, na przykład testera. Typową wymówką stosowaną przez

większość deweloperów jest to, że są zbyt zajęci pisaniem kodu

i nie mają czasu na dodatkowe pisanie testów. Jednak prawda jest


taka, że taka sama ilość pracy podzielona jest na taką samą liczbę

osób, czy to deweloperów aplikacji, czy deweloperów

automatyzacji, bez względu na to, czy wykonują oni oba te

rodzaje zadań, czy też każdy z nich wykonuje swoją pracę. Nie

trzeba dodawać, że im większy narzut komunikacyjny, tym

bardziej jest to czasochłonne, a czas spędzony na wyszukiwaniu,

badaniu i naprawianiu błędów jest ogromnym kosztem, który

możemy zaoszczędzić, gdy każda zmiana w kodzie jest

natychmiast pokrywana przed odpowiednie testy automatyczne.

W kolejnym rozdziale rozmawiamy o zwiększaniu współpracy

między deweloperami aplikacji i deweloperami automatyzacji

w jeszcze szerszym zakresie.

Skracanie czasu wykonywania testów

Zanim doprowadzimy do tego, że deweloperzy będą uruchamiać testy i/lub

dodawać testy do kompilacji CI, powinniśmy zagwarantować, że cały test

nie wykonuje się zbyt długo, gdyż w przeciwnym wypadku nie będą oni

chcieli korzystać z nich regularnie. Niektórzy mówią, że cały przebieg

testowania nie powinien trwać dłużej niż kilka minut czy nawet sekund, ale

większość deweloperów jest w stanie zaakceptować nawet 30-minutowe

wykonywanie testów, jeśli tylko otrzymają oni cenne informacje zwrotne na

temat swoich zmian. Jest tak z dwóch powodów:

1. Mimo że deweloper może kontynuować pracę nad innymi rzeczami, gdy

w wyniku jego operacji ewidencjonowania uruchamiana jest

kompilacja, ale dopiero po jej zakończeniu zyskuje on spokój ducha,

który pozwala mu zająć się czymś nowym. Jest tak, ponieważ gdy
kompilacja zakończy się niepowodzeniem, będzie on musiał dokonać

ponownego przełączenia kontekstu, aby móc naprawić ten błąd.

2. W przypadku dużego zespołu i wysokiej częstotliwości wykonywania

operacji ewidencjonowania, im dłużej trwa kompilacja, tym

prawdopodobnie więcej operacji ewidencjonowania zostanie scalonych

do pojedynczej kompilacji, a tym samym częściej może ona zakończyć

się niepowodzeniem z powodu konfliktów integracyjnych.

Jest to główny powód, dla którego testy jednostkowe są częściej

używane w kompilacjach CI niż testy pełnego systemu. Jednak w wielu

przypadkach opcja uruchamiania testów, które są czymś więcej niż

czystymi testami jednostkowymi, jest często zbyt wcześnie odrzucana.

Istnieje kilka technik, które pozwalają utrzymywać szybkość CI pod

kontrolą nawet przy korzystaniu z bezpieczeństwa testów automatycznych

o szerszym zakresie.

Ulepszanie izolacji

Choć głównym celem izolacji (omawianej szczegółowo w rozdziale 7) jest

poprawienie wiarygodności testów, to może mieć ona również wpływ na

ich wydajność. Współdzielona baza danych z dużą ilością danych sprawia,

że aplikacja działa wolniej. Liczba jednocześnie połączonych

użytkowników może prowadzić do blokowania, co spowalnia ten system,

zaś ilość danych może mieć wpływ na wydajność zapytań, które aplikacja

wykonuje w ramach testów. Jeśli jednak każde środowisko testowe ma

swoją własną małą bazę danych, to usuwamy w ten sposób wąskie gardło

i sprawiamy, że testy są bardziej wiarygodne. Ponadto, jeśli umieścimy

bazę danych razem z aplikacją na tej samej maszynie, to eliminujemy

wówczas narzut związany z komunikacją przez sieć, co również może mieć

znaczący wpływ na wydajność testów.


Poza izolowaniem bazy danych powinniśmy odizolować środowisko

testowe od wszelkich zewnętrznych zależności (np. zewnętrznych usług

sieciowych, zewnętrznego sprzętu itd.). Te zewnętrzne zależności powinny

być albo symulowane (jak zostało to opisane w rozdziale 6), albo

klonowane i używane osobno dla każdego środowiska. W ten sposób

usuwamy opóźnienia i obciążenie z tych usług, co w konsekwencji również

zwiększa wydajność.

Realizowanie wymagań wstępnych za pośrednictwem API

Tworzenie wymaganych przez testy danych za pośrednictwem interfejsu

użytkownika zwykle zajmuje długi czas i może sprawić, że testy będą mniej

wiarygodne i trudniejsze w utrzymaniu, a w dodatku może nie służyć celom

tych testów. Powinniśmy rozważyć tworzenie ich bezpośrednio za

pośrednictwem API lub nawet bezpośrednio w bazie danych.

W przeciwieństwie do interfejsu użytkownika, API projektowane są do

użycia z poziomu kodu. Więcej informacji na temat stosowania różnych

zakresów testowania można znaleźć w rozdziale 6.

Równoległe wykonywanie i wirtualizacja

Gdy dokonaliśmy już optymalizacji pod kątem izolacji, to uruchamianie

wielu testów równolegle powinno być już bardzo proste. To, czy do

bezpiecznego uruchamiania testów równolegle potrzebujemy różnych

wątków, różnych procesów, różnych maszyn lub całkowicie różnych

środowisk, zależy od poziomu izolacji oraz dokładnej architektury.

Kontenery stosowane lokalnie lub w chmurze, zwłaszcza z narzędziem

orkiestracji, takim jak Kubernetes, znacznie ułatwiają tworzenie wielu

środowisk, które mogą być używane do uruchamiania testów równolegle

z odpowiednią izolacją między nimi. Choć kontenery i maszyny wirtualne


świetnie nadają się do tego celu, to jeśli nasza aplikacja nie wpasowuje się

dobrze w tę technologię lub też nie mamy wystarczającej wiedzy lub czasu,

aby nauczyć się z niej korzystać, to nie powinno nas wstrzymywać przed

znalezieniem innej drogi. Izolowane środowiska do równoległego

uruchamiania testów tworzyłem sam na długo przed tym, gdy po raz

pierwszy usłyszałem o kontenerach…

Gdy rozwiążemy już problemy związane ze zrównoleglaniem, możemy

zacząć skalować naszą aplikacją poprzez dodawanie większej ilości sprzętu.

Choć może się to wydawać bardziej kosztowne w odniesieniu do sprzętu

lub zasobów w chmurze, to w zakresie redukowania ogólnego czasu

testowania zrównoleglanie przynosi nam poprawę na poziomie jednego

rzędu wielkości.

Uruchamianie wyłącznie testów poprawności w ramach


ciągłej integracji

Choć usprawnianie izolacji i zrównoleglanie przynosi najwięcej korzyści

w zakresie wydajności, to nie zawsze jest to takie proste do zrobienia. Jeśli

mamy już wiele testów, które uruchamiane są w kompilacji nocnej

i wykonują się przez kilka godzin, a wymagania dotyczące izolacji są zbyt

złożone, to może się wydawać, że używanie tych testów w kompilacji CI

jest niepraktyczne. Cóż, to prawda, w odniesieniu do całego zestawu

testów, ale w przypadku małego podzbioru testów, którego wykonanie nie

zajmuje więcej niż 30 minut, może to być duża różnica. Oczywistym (i

najbardziej właściwym) wyborem takiego podzbioru jest zestaw testów

poprawności. Zestaw ten powinien pokrywać większość istniejących

funkcji, ale tylko niewielki fragment każdej z nich, a nie wszystkie

permutacje i skrajne przypadki. Choć nie dostarcza to największej wartości

z całego zestawu testów, to przynajmniej dosyć wcześnie wyłapuje główne


problemy. Te główne problemy są błędami, które uniemożliwiają aplikacji

jej uruchomienie lub wykonanie jednego z istotnych przypadków użycia

systemu. Takie niepowodzenia mogą zmarnować sporą ilość czasu testera

i innych deweloperów, którzy próbują uzyskać najnowsze zmiany. Co

więcej, dodanie zestawu testów poprawności do CI pomaga rozpocząć

zmianę kulturową w kierunku większego zaangażowania deweloperów

w aspekty związane z jakością i automatyzacją testów.

Tak naprawdę największą korzyść z tego podejścia czerpiemy my:

deweloperzy będą musieli pomagać nam w utrzymywaniu testów

w stabilnym stanie i dostosowywaniu ich do wprowadzanych przez siebie

zmian, zanim jeszcze zdołają one popsuć kompilację. Posiadanie

stabilnego, „zawsze zielonego” zestawu testów poprawności działającego

w kompilacji CI znacząco pomaga w stabilizowaniu i oznaczaniu na

zielono całej nocnej kompilacji. Jeśli błąd zostaje wyłapany w kompilacji

nocnej, to deweloperzy będą bardziej skorzy do współpracy nad jego

naprawą, ponieważ są już zaznajomieni z automatyzacją testów.

Dzielenie potoku CI na etapy

Zamiast czekać, aż kompilacja nocna uruchomi wszystkie testy niebędące

testami poprawności, można utworzyć łańcuch kompilacji, z których każda

uruchamia kolejną po pomyślnym zakończeniu poprzedniej. Niektóre etapy

mogą uruchamiać wiele innych kompilacji, aby były wykonywane

równolegle. Na przykład pierwsza kompilacja kompiluje i uruchamia

jedynie testy jednostkowe (które są bardzo szybkie i nie wymagają

wdrażania). Jeśli etap ten zakończy się sukcesem, wyzwala on następną

kompilację, która wdraża aplikację i uruchamia zestaw testów poprawności.

Jeśli ten etap zakończy się sukcesem, uruchamia on kolejną kompilację,

która wykonuje pozostałe testy, trwające, powiedzmy, trzy godziny.


Ponadto może ona również uruchomić równolegle dwie dodatkowe

kompilacje w oddzielnych środowiskach: jedną, która wykonuje

długotrwałe testy obciążeniowe, i drugą, które testuje instalacje

i aktualizacje. Jeśli jedna z tych kompilacji zakończy się niepowodzeniem,

kolejne nie zostaną uruchomione. I w końcu, powinno być jakieś wskazanie

dotyczące ogólnego stanu wersji oraz powiadomienie, czy cały proces

zakończył się sukcesem, czy nie. Oczywiście cały ten proces można łatwo

rozszerzyć w celu utworzenia potoku ciągłego wdrażania.

Ponieważ w rzeczywistości całkowity czas trwania całego procesu jest

znacznie dłuższy od średniego odstępu między operacjami

ewidencjonowania, to w potoku opartym na uruchamianiu przez każdą

kompilację kolejnej kompilacji może nastąpić przepełnienie kolejki. Aby

się przed tym uchronić, możemy skorzystać z typowej praktyki, w której

każda z tych kompilacji uruchamiana jest w pętli i przyjmuje rezultaty od

ostatniej pomyślnej kompilacji, która ją poprzedza. Jeśli nie ma żadnej

poprzedniej kompilacji zakończonej sukcesem, bieżąca kompilacja po

prostu czeka na jej pojawienie się.

Zaletą tworzenia łańcucha kompilacji jest dostarczanie możliwie

najszybszych informacji zwrotnych dotyczących każdego zestawu testów.

Jego wadą jest jednak to, że proces pracy staje się nieco bardziej

skomplikowany, ponieważ deweloperzy muszą patrzeć na wyniki

wszystkich tych kompilacji, aby ustalić, czy mogą zaewidencjonować

swoje zmiany, czy też nie. Nie mówiąc już o tym, że niepowodzenie

w jednej z dłuższych kompilacji może wymagać dłuższej naprawy, co może

zablokować wszystkich deweloperów, jeśli proces ten jest bardzo

rygorystyczny. W takich przypadkach, przy ustalaniu, kto może dokonać

ewidencjonowania i czego, gdy jedna z tych kompilacji zakończy się


niepowodzeniem, trzeba kierować się zdrowym rozsądkiem oraz stosować

praktyki przyjęte w danej organizacji.

Pisanie głównie testów integracyjnych i jednostkowych

Jak opisano w rozdziale 6, na decyzję związaną z wyborem zakresu testów

(np. testy kompleksowe, integracyjne lub jednostkowe) składa się wiele

czynników, a jednym z nich jest wydajność. Ogólna zasada jest taka, że im

mniejszy zakres testowania, tym szybsze testy. Dlatego, biorąc pod uwagę

wszystkie możliwe czynniki, dobrym pomysłem może być uruchamianie

tylko kilku testów kompleksowych (np. zestaw testów poprawności)

i pozostawienie wszystkich pozostałych jako testów integracyjnych.

Oczywiście im mniejszy staje się zakres, tym mniejszą pracę wykonują one

w obszarze testowania integracji między komponentami, więc musimy

znaleźć właściwą równowagę między tym ryzykiem, szybkością testów, ich

wiarygodnością oraz łatwością ich utrzymywania. Ale ogólnie rzecz biorąc,

skupienie się głównie na testach integracyjnych często stanowi właściwy

kompromis, który umożliwia uruchamianie tysięcy testów w ciągu kilku

minut. Więcej informacji na ten temat można znaleźć w rozdziale 6.

Uruchamianie testów wyłącznie dla konkretnych


komponentów

Dobra architektura to taka, w której różne komponenty (lub nawet

mikrousługi) mają różne i odrębne zadania. Jeśli zadania te odpowiadają

również różnym funkcjom biznesowym, to sensowne wydaje się pisanie

większości testów jako testów komponentów (lub usług) i wykorzystywanie

testów kompleksowych jedynie do testowania kilku funkcjonalności

wielofunkcyjnych. W przeciwieństwie do testów integracyjnych, w których

zakres jest zawężany poprzez pomijanie warstw, testy komponentów mogą


(chociaż nie muszą) wykorzystywać wszystkie warstwy, ale testują

i opierają się na pojedynczym komponencie, który zawiera całą

funkcjonalność określonej funkcji. Testy te mogą wykorzystywać

symulatory lub atrapy naśladujące interakcje z innymi komponentami, ale

powinny być one dosyć rzadkie, ponieważ integracja pomiędzy

komponentami powinna być zwykle testowana przy użyciu szerszego

zakresu.

Jeśli architektura faktycznie jest taka modułowa i modułowość ta

dopasowana jest do funkcji biznesowych, to wydaje się, że większość

testów musi jedynie testować jeden komponent, zaś tylko kilka testów

weryfikujących scenariusze wielofunkcyjne będzie wymagać szerszych

zakresów. W takim przypadku sensowne wydaje się również posiadanie

różnych kompilacji dla każdego komponentu, z których każda uruchamia

tylko testy istotne dla tego komponentu. Ponieważ komponenty są zwykle

od siebie niezależne, to ryzyko, że zmiana w jednym komponencie

spowoduje problem w innym, jest dosyć niskie. W ten sposób, jeśli

deweloper dokona zmiany w jednym komponencie, to nie musi się on

przejmować wszystkimi testami, a jedynie testami tego konkretnego

komponentu. Z tego powodu kompilacje poszczególnych komponentów są

również szybsze. W takim wypadku tych kilka testów dla scenariuszy

korzystających z wielu funkcji można uruchamiać w osobnej kompilacji

w potoku CI.

Dzisiaj, przy rosnącym trendzie w kierunku architektury opartej na

mikrousługach i modułach, staje się to coraz bardziej powszechne. Jednak

fakt, że jakiś system złożony jest z wielu usług, nie gwarantuje, że usługi te

faktycznie są niezależne z perspektywy funkcji biznesowych. Jeśli

większość historyjek użytkownika (które powinny dostarczać prawdziwą

wartość klientowi lub użytkowi końcowemu) wymaga zmian w więcej niż


jednej usłudze, to będzie to prawdopodobnie oznaczać, że usługi te nie są

wystarczająco niezależne dla tego celu. Choć brzmi to dosyć logicznie

i wydaje się proste, to w rzeczywistości trudno jest powiedzieć, że

architektura większości systemów pozwala na dostarczanie wartości przez

większość historyjek użytkownika poprzez zmianę tylko jednego

komponentu lub usługi.

Optymalizowanie wydajności testów

Mimo że optymalizowanie szybkości samych testów (i ich podstawowej

infrastruktury) może brzmieć jak coś, co należałoby rozważyć już na

samym początku, to jednak nie powinniśmy za bardzo się na tym skupiać,

zanim nie zastanowimy się nad podejściami podanymi powyższej.

W rzeczywistości, w kontekście tworzenia obecnego oprogramowania,

optymalizowanie wydajności każdego fragmentu kodu podczas jego pisania

uznawane jest za złą praktykę, ponieważ często komplikuje programowanie

i testowanie, a ostateczne odbija się to na koszcie utrzymania. W roku 1974

Donald Knuth, jeden z prekursorów informatyki, nazwał to zjawisko

mianem „przedwczesnej optymalizacji” i stwierdził, że „przedwczesna

optymalizacja jest źródłem wszelkiego zła”43. Zalecanym podejściem jest

najpierw napisanie kodu w najbardziej czytelny i łatwy w utrzymaniu

sposób, a dopiero potem mierzenie i profilowanie jego wydajności,

identyfikowanie jego wąskich gardeł i optymalizowanie go. Bez

profilowania i mierzenia prawdopodobnie będziemy tylko marnować czas

na optymalizowanie niepotrzebnych rzeczy. Ta sama reguła odnosi się

również do automatyzacji testów.

Jeśli po zakończeniu profilowania aplikacji znajdziemy „winny”

fragment kodu, który trzeba zoptymalizować, być może jego poprawienie

będzie wymagać pewnych ustępstw w zakresie struktury i łatwości


utrzymania kodu. Jednak w innych przypadkach możemy podjąć

uzasadnioną decyzję o ustępstwach w izolacji, aby podnieść wydajność

wszystkich testów. Na przykład, jeśli każdy test otwiera aplikację na nowo

i loguje się do niej, to możemy cały czas utrzymywać tę aplikację otwartą

z zalogowanym użytkownikiem, a jedynie powracać do głównego ekranu

na początku każdego testu, aby zaoszczędzić w ten sposób czas. Choć

zwiększamy w ten sposób ryzyko niestabilności, to możemy zastosować

pewne rozwiązania, które pozwolą nam to ryzyko zmniejszyć. Na przykład,

w przypadku niepowodzenia takiego testu lub wykonania testów, które

wylogowują użytkownika, możemy tak czy inaczej otworzyć taką aplikację

ponownie i zalogować się do niej. Wracając do pierwszego punktu,

uznajmy te opcje jedynie za ostatnią deskę ratunku, z której możemy

skorzystać dopiero po dokonaniu pomiarów czasu, jaki będziemy mogli

zaoszczędzić.

Pokrywanie szerszej macierzy

Jedną z największych zalet automatyzacji testów jest to, że może być ona

uruchamiana w różnych środowiskach, na przykład w celu sprawdzenia,

czy aplikacja działa poprawnie w różnych systemach operacyjnych i ich

wersjach, w różnych przeglądarkach, z różnymi typami baz danych, na

różnym sprzęcie itd. Obecnie aplikacje mobilne często muszą być

testowane na wielu urządzeniach, gdzie istotną rolę odrywają takie usługi,

jak PerfectoMobile, Xamarin Test Cloud czy SauceLabs.

Czysty potok CI (bez ciągłego dostarczania) zwykle uruchamia testy

jedynie na jednej konfiguracji, która uznawana jest za konfigurację

referencyjną. Ponieważ rodzaj systemu operacyjnego, przeglądarki itd. nie

powinien mieć wpływu na większość kodu w aplikacji, to wystarczy, aby


deweloperzy szybko uzyskali informacje zwrotne dotyczące tego, czy nie

zaewidencjonowali oni czegoś, co popsuło istniejące zachowanie.

Posiadanie jednej konfiguracji referencyjnej sprawia, że dużo łatwiej jest

odtworzyć, debugować i analizować większości regresji wyłapywanych

przez proces CI.

Pozostała część macierzy może zostać uruchomiona po zakończeniu CI,

gdyż prawdopodobnie będzie wykonywać się dłużej i/lub będzie bardziej

kosztowna w kontekście zasobów. Może być ona nawet wykonywana

w nocy lub tylko w weekendy, jeśli częstotliwość wykrywanych przez nią

defektów jest dosyć niska, a cykl wydawniczy na to pozwala (np.

w aplikacjach ratowania życia). Ponieważ większość regresji i problemów

jest wykrywana w procesie CI, a do tego testy są za jej pomocą

stabilizowane, to problemy pojawiające się podczas uruchamiania szerokiej

macierzy są najprawdopodobniej ograniczone do problemów związanych są

z konkretną konfiguracją, na której test zakończył się niepowodzeniem.

Dzięki temu badanie jest znacznie prostsze, a ogólny proces łatwiejszy

w stabilizacji.

Błąd popełniany przez wiele zespołów polega na tym, że każdej nocy

uruchamiają oni całą macierz bez konfiguracji referencyjnej, na której testy

są stabilizowane, a defekty obsługiwane są bardziej rygorystycznie

i znacznie częściej. Dlatego dużo trudniej jest odróżnić niestabilne testy od

problemów związanych z konkretną konfiguracją.

Podsumowanie

Aby zestaw automatyzacji testów był efektywny, musimy uruchamiać go

regularnie i tak często, jak to tylko możliwe. Z technicznego punktu

widzenia tworzenie procesu kompilacji jest dosyć proste przy użyciu


większości popularnych narzędzi kompilacji, ale prawdziwym wyzwaniem

jest to, w jaki sposób poprawnie z nich korzystać. Jest to trudniejsze

głównie dlatego, że wymaga przyswojenia przez wiele osób nowych

procesów, a nawet nowego sposobu myślenia.

Powszechne przekonanie, zgodnie z którym tego rodzaju zmiany mogą

zostać wprowadzone wyłącznie w przypadku, gdy doprowadzi do nich

kierownictwo, jest fałszywe. Choć poprowadzenie tych zmian może być

łatwiejsze dla menedżera, to jednak wcale nie musi. Dowolna osoba

z odpowiednią pasją i wystarczającą wiedzą może poprowadzić tę zmianę.

Sztuka polega na tym, aby usprawniać po jednej rzeczy naraz, pokazując

przy tym natychmiastową korzyść, jaka z tego płynie, i uparcie dążyć

w kierunku osiągnięcia naszych celów.

Aby nawiązać współpracę deweloperów, muszą oni zyskać na tym

podstawową wartość, jaką jest szybkie uzyskiwanie informacji zwrotnych

dla wprowadzanych przez nich zmian. Aby można było im tę wartość

dostarczyć, testy nie powinny wykonywać się zbyt długo. W rozdziale tym

zaprezentowaliśmy kilka pomysłów oraz technik, które pomagają nam

optymalizować czas trwania testu, w celu dopasowania go do procesu CI.


Rozdział 16. Tworzenie
oprogramowania sterowane testami
akceptacyjnymi (ATDD)

Dobry potok CI/CD znacznie ułatwia dostarczanie zespołowi deweloperów

szybkich informacji zwrotnych dotyczących regresji. Jest to dosyć istotna

część bycia zwinnym, ale to zbyt mało. Aby zespół był zwinny, musi on

być w stanie szybko reagować na opinie klientów i robić to przez cały czas

życia danego produktu. W tym rozdziale omawiamy metodykę tworzenia

oprogramowania sterowanego testami akceptacyjnymi (Acceptance Test

Driven Development, ATDD), nakreśloną już krótko w rozdziale 5,

i pokazujemy, w jaki sposób pozwala ona zespołom osiągnąć większą

zwinność.

Uwaga

Kanban, jedna z metod zwinnego tworzenia oprogramowania

(Agile methodology), propaguje korzystanie z metryki nazywanej

czasem realizacji (lead time). Metryka ta mierzy czas potrzebny

jest do ukończenia implementacji (tj. użycia w produkcji)

historyjki użytkownika. Zminimalizowanie tego czasu jest


prawdopodobnie najbardziej pożądanym rezultatem zwinności,

ponieważ skraca to cykl informacji zwrotnej z klientami. Z tego

powodu powinna być ona stale utrzymywana na niskim

poziomie. Choć metryka ta jest wykorzystywana głównie

w metodzie Kanban, to skrócenie pętli zwrotnej jest istotnym

celem każdej metody Agile. ATDD również pomaga nam to

osiągnąć.

Omówienie metodyki ATDD

Jak wspomnieliśmy w rozdziale 5, ATDD – czasem nazywane również

tworzeniem oprogramowania sterowanym zachowaniem (BDD) lub

specyfikacją na przykładach (SbE) – jest podejściem, które uzupełnia

metodykę Agile. W podejściu tym zespół, razem z właścicielem produktu,

definiuje kryteria akceptacji dla każdej historyjki użytkownika w formie

kilku reprezentatywnych scenariuszy. Scenariuszy tych używa się zarówno

jako dokumentacji, jak również jako podstawy dla testów automatycznych.

Testy te można zaimplementować nawet przed zaimplementowaniem kodu

aplikacji, w celu obsługi takiej historyjki użytkownika. Gdy aplikacja

przechodzi nowo opracowane testy akceptacyjne, jak również wszystkie

istniejące testy, historyjka użytkownika zostaje uznana za ukończoną.

W dalszej części tego rozdziału opisujemy ten proces bardziej szczegółowo,

ale na razie powinno nam to wystarczyć.

Bycie bardziej zwinnym


Zanim powiemy o tym, co dokładnie sprawia, że zespół jest bardziej

zwinny, wyjaśnimy sobie najpierw pojęcie „długu technicznego”, ponieważ

jest ono niezbędne do zrozumienia tej dyskusji.

Dług techniczny

Termin dług techniczny (technical debt), zaproponowany przez Warda

Cunninghama w krótkim raporcie napisanym przez niego w 1992 roku44,

jest metaforą opisującą ideę, że dziś pozwolimy sobie pójść na skróty

w kontekście programowania lub tworzenia oprogramowania, to

w przyszłości będzie to oznaczać dla nas więcej pracy. Im dłużej będziemy

odkładać naprawę tych skrótów, tym więcej będziemy mieć pracy. Możemy

to więc przyrównać do rosnących odsetek długu finansowego.

Dług techniczny nie ma jednoznacznej i powszechnie akceptowanej

definicji. W swojej publikacji Cunningham mówi o długu technicznym

w kontekście łatwości utrzymania kodu. Możemy jednak postrzegać go

nieco szerzej, jako coś, co odkładamy na potem, a co później wymaga

więcej czasu i większego wysiłku. W tym sensie pisanie kodu, którego nie

można przetestować, również jest długiem technicznym, ponieważ jego

testowanie zajmie nam więcej czasu, a jeśli nie zrobimy tego wystarczająco

dobrze i w środowisku produkcyjnym zostaną znalezione błędy, wówczas

może nas to kosztować jeszcze więcej.

Ale prawdopodobnie najbardziej powszechną formą długu technicznego

są błędy. Im dłużej będziemy odkładać ich wyszukiwanie i naprawianie,

tym bardziej kosztowna będzie ich naprawa.

Co sprawia, że zespół jest zwinny?

Jak wspomnieliśmy wcześniej, aby zespół był zwinny i minimalizował czas

realizacji, musi szybko reagować na informacje zwrotne od klientów oraz


innych akcjonariuszy i robić to przez cały czas życia tworzonego produktu.

Aby było to możliwe, trzeba spełnić kilka warunków:

1. Zespół musi za wszelką cenę unikać długu technicznego. Zespół, który

pracuje nad nowym projektem, może być przekonany o swojej

zwinności, ponieważ szybko wydaje on nowe funkcje i reaguje na

pozyskiwane informacje zwrotne. Będzie to jednak fałszywe

przeświadczenie, jeśli w tym samym czasie zaciąga on spory dług

techniczny. W miarę upływu czasu ten dług techniczny będzie

spowalniał zespół, więc nie będzie on już tak zwinny jak do tej pory

(nawet jeśli stosują się oni do wszystkich obrzędów metodyki „Agile”).

2. Cały zespół, a mamy tutaj na myśli kompletny zespół, łącznie z każdą

osobą, która wnosi wartość do firmy za pośrednictwem tworzonego

produktu – od sprzedawców, poprzez menedżerów produktu,

deweloperów, testerów, członków zespołów operacyjnych, aż po osoby

obsługujące klientów – musi odpowiednio się dostosować

i współpracować, dążąc do tego samego celu biznesowego, który może

się często zmieniać.

Oczywiście zwinność to nie tylko metodyka ATDD, a w niektórych

przypadkach zespół może być bardzo zwinny i spełniać powyższe warunki

bez implementowania ATDD. Jednak jak zobaczymy w dalszej części tego

rozdziału, poprawna implementacja ATDD zwykle przyniesie nam pod tym

względem ogromne korzyści.

Jak być zwinnym a „stosowanie” zwinności

Termin „Agile” (wielka litera „A”) oznacza rodzinę metodyk, która

została zapoczątkowana przez Manifest zwinnego tworzenia

oprogramowania i jego 12 zasad. Do najpopularniejszych metod z tej

rodziny należą kolejno Scrum, Kanban oraz Programowanie


ekstremalne (XP). Każda metodyka (nie tylko Agile) oferuje zestaw

wytycznych dotyczących tego, co i w jaki sposób należy robić.

W ostatnich latach proces adopcji metodyki Agile był bardzo szybki,

a dzisiaj większość organizacji przyjęło przynajmniej niektóre

z wytycznych tych metodyk. Niestety organizacje działające w naszej

branży, które implementują te metodyki – i to nawet te, które starają

się zaimplementować jedną z nich w całości w sposób podręcznikowy

– często za bardzo skupiają się na samych wytycznych i praktykach,

zapominając o ideach i wartościach, które za nimi stoją. Często mówi

się, że firmy te stosują zwinność, ale same nie są zwinne.

Unikanie długu technicznego

Aby zespół mógł pozostać zwinny przez długi czas, powinien unikać długu

technicznego. Oznacza to, że każda nowa funkcja i dokonywana zmiana

powinna być wdrażana do produkcji i traktowana na równi z główną

wersją, łącznie ze wszystkimi istotnymi aspektami cyklu życia

oprogramowania, bez odkładania przy tym niczego – testowania,

bezpieczeństwa, monitorowania, itd – na później.

Oczywiście opracowanie każdej nowej złożonej funkcji w całości

w ramach bardzo krótkiego cyklu jest niemożliwe. Chodzi o to, że prawie

wszystkie złożone funkcje można rozłożyć na wiele mniejszych funkcji,

a sztuka polega na tym, żeby to zrobić w taki sposób, aby każda z nich

nadal stanowiła pewną wartość dla użytkownika. Przykładowo, jedną

z funkcji aplikacji biznesowej może być generator raportów. Zamiast jednak

implementować cały generator raportów w jednym kawałku, możemy go

podzielić na wiele historyjek użytkownika, z których każda obsługuje

jedynie jeden rodzaj raportu lub bardziej zaawansowany sposób

dostosowania istniejącego raportu. Jak wyjaśniliśmy w rozdziale 2, jest to


koncepcja historyjki użytkownika. Istnieje wiele technik do podziału

dużych funkcji na mniejsze historyjki użytkownika, ale jest to również coś,

co wymaga innego sposobu myślenia (i czasem pewnej kreatywności) niż

w przypadku powszechnej praktyki polegającej na pisaniu pełnej

specyfikacji dla jakiejś funkcji jako całości (możemy o tym myśleć jak

o „pionowym”, a nie „poziomym” podziale tworzenia i projektowania).

Więcej informacji na temat tych technik można znaleźć w książce Fifty

Quick Ideas to Improve Your User Stories45 autorstwa Gojko Adzica

i Davida Evansa (2014).

Choć takie dekomponowanie funkcji może przypominać pewną formę

długu technicznego (ponieważ możemy wydać coś jeszcze przed

ukończeniem całej funkcji), to tak długo jak każda z tych mniejszych

historyjek użytkownika będzie miała swoją własną wartość, nie będziemy

mieli do czynienia z długiem technicznym. Jest tak, ponieważ cokolwiek

zostało już opracowane, pozostanie całkowicie użyteczne, a przynajmniej

dla niektórych użytkowników, nawet jeśli plany uległy zmianie i pierwotnie

planowana „duża” funkcja nigdy nie zostanie ukończona.

Jeśli zdecydujemy się unikać długu technicznego, musimy dostosować

się do poniższych wymagań:

1. Kod i projekt muszą być stale utrzymywane w czystym stanie. Nie

możemy uwzględniać w naszym planie czasu na jego późniejsze

oczyszczanie.

2. Nie ma miejsca na żadne nieporozumienia dotyczące wymagań. Takie

nieporozumienia oznaczają, że praca musi zostać wykonana ponownie.

Zwróćmy uwagę, że funkcja opracowana zgodnie z wymaganiami nadal

może mieć zły wpływ na użytkownika, co wymagać będzie jej

ponownego opracowania (w formie innej historyjki użytkownika), ale


przynajmniej będziemy wtedy dysponować bezcenną wiedzą i znać

faktyczne potrzeby klientów.

3. Nowe funkcje powinny być dostarczane, gdy są one już przetestowane

i możliwie całkowicie wolne od błędów.

Wymagania te rodzą pewne nietrywialne wyzwania, jednak wkrótce

zobaczymy, w jaki sposób podejście ATDD pozwala nam się z nimi uporać.

Proces

Oto ogólny opis tego procesu wraz ze szczegółowym wyjaśnieniem

każdego kroku:

1. Zespół, z właścicielem produktu włącznie, tworzy wspólnie historyjkę

użytkownika, definiując jedną lub kilka historyjek jako kryteria

akceptacji dla tej historyjki użytkownika.

2. Dla każdego scenariusza zdefiniowanego w kryteriach akceptacji

piszemy test kończący się niepowodzeniem.

3. Piszemy kod aplikacji, aby nowe testy kończyły się sukcesem. Ponadto

zespół upewnia się, że również wszystkie istniejące testy kończą się

sukcesem.

4. Aplikacja jest dostarczana i zbierane są opinie na jej temat.

Tworzenie historyjki użytkownika

Wiele zespołów traktuje historyjkę użytkownika jako małe funkcje, które

właściciel produktu powinien szczegółowo zdefiniować, tak aby

deweloperzy wiedzieli dokładnie, co mają utworzyć. Istnieje jednak jeszcze

inna szkoła myślenia, zgodnie z którą historyjki użytkownika powinny

jedynie wyrażać konkretną potrzebę, zaś same rozwiązania i ich


specyfikacje powinny być wspólnie tworzone przez zespół deweloperów.

W pierwszym rozdziale książki Fifty Quick Ideas to Improve Your User

Stories, autorzy Gojko Adzic i David Evans mówią o tym w ten sposób:

Należy wyraźnie zaznaczyć, że jeśli zespołowi pasywnie doręczane są

dokumenty, (…), to nie będzie to działać z historyjkami użytkownika.

Organizacje z takim procesem nie uzyskają pełnych korzyści z

wielokrotnego dostarczania.

Zaś w drugim rozdziale podają, że

[…] historyjki użytkownika są świadectwami dyskusji, których cel

dobiega końca po zakończeniu konwersacji.46

To do całego zespołu, wraz z właścicielem produktu, należy

odpowiedzialność w zakresie identyfikowania potrzeb klientów, których

spełnienie dostarcza tym klientom największą wartość (za którą będą nam

oni skłonni zapłacić). To właśnie w taki sposób należy nadawać priorytety

historyjki użytkownika. Potrzeby te zwykle formalizowane są jako

historyjki użytkownika w postaci:

Jako [rola]
Aby móc [rozwiązać problem, zrealizować potrzebę
lub dostarczyć większą wartość]
Chcę [proponowane rozwiązanie]

lub jakimś podobnym wariancie. Gojko i David sugerują nawet, że ten, kto

formalizuje historyjkę użytkownika (przeważnie jest to właściciel

produktu), powinien jedynie określić to, „kto” i „dlaczego” (a więc dwa

pierwsze zdania), ponieważ proponowane rozwiązanie powinno być

wynikiem dyskusji zespołu. W najgorszym wypadku właściciel produktu


powinien jedynie zaproponować jakieś rozwiązanie, ale już decyzja

końcowa musi znajdować się w rękach zespołu.

Tworzenie rozwiązania

Gdy potrzeba jest już zrozumiała i nadano jej właściwy priorytet, zespół

powinien odbyć spotkanie, na którym jego członkowie wspólnie

zaproponują możliwe rozwiązania. Należy podkreślić, że w tym kontekście

problem polega na tym, w jaki sposób spełnić potrzebę klienta, a nie jak

zaimplementować określone już zachowanie. Zatem rozwiązanie powinno

jedynie określać działanie, która ma wpływ na użytkownika, a nie jego

szczegóły techniczne i implementacyjne. Zespół powinien poszukiwać

najbardziej efektywnego rozwiązania danego problemu, które można

szybko dostarczyć i które jednocześnie dostarcza najwyższą możliwą

wartość klientowi (poprzez spełnienie jego głównej potrzeby lub

rozwiązanie jego problemu). Dalsze usprawnienia w zakresie tego

rozwiązania mogą zostać podzielone na inne historyjki użytkownika, które

rozwiązują mniejsze problemy, jakich nie rozwiązuje wybrane rozwiązanie.

Jednak te historyjki użytkownika powinny mieć nadany odpowiedni

priorytet w odniesieniu do wszystkich pozostałych historyjek użytkownika.

Zaleca się, aby uczestnikami tej konwersacji były osoby, którzy będą

pracować nad tą historyjką użytkownika, a jeśli nie jest to możliwe, to

przynajmniej właściciel produktu, jeden deweloper, który będzie

implementował to rozwiązanie, oraz jeden tester lub deweloper

automatyzacji. Każda z tych ról zwykle wnosi coś istotnego do dyskusji:

Właściciel produktu, jako osoba reprezentująca biznes lub klienta,

najlepiej zna potrzeby klienta i może ostatecznie zdecydować, które

rozwiązanie będzie stanowić dla niego najwyższą wartość.


Deweloper zwykle wie najlepiej, jakie rozwiązanie jest możliwe i jak

bardzo będzie ono kosztowne. Może on pomyśleć o możliwych

projektach dla każdego zaproponowanego rozwiązania i oszacować

(nawet bardzo wstępnie) stopień ich skomplikowania oraz ich wpływ na

łatwość utrzymania kodu. Zwróćmy uwagę, że choć nie jest to

spotkanie projektowe, to jednak różne rozwiązania mogą mieć wpływ

na taki projekt, co w efekcie może wpłynąć na czasy dostarczania oraz

łatwość utrzymania w długim okresie czasu.

Tester lub deweloper automatyzacji testów powinien pomyśleć o tym,

jak sugerowane rozwiązanie może zostać przetestowane, oraz jak ile

czasu zajmie jego testowanie. Poza tym testerzy zwykle postrzegają

produkt z szerszej perspektywy i mogą często przewidzieć potencjalne

problemy i konflikty z innymi funkcjami, które mogą być niewidoczne

dla deweloperów i właściciela produktu.

Definiowanie kryteriów akceptacji

Gdy zespół osiągnie już porozumienie dotyczące wybranego rozwiązania,

w kolejnym kroku jego członkowie będą wspólnie definiować jego kryteria

akceptacji. Kryteria te mogą służyć jako:

1. Wytyczne dla deweloperów odnoszące się do tego, co powinni utworzyć.

Innymi słowy, kryteria te służą za lekką specyfikację dla historyjki

użytkownika.

2. Testy akceptacyjne dla utworzonej funkcji lub wprowadzonych zmian

(stąd też nazwa „tworzenie oprogramowania sterowane testami

akceptacyjnymi”). Większość z tych testów akceptacyjnych można

później zautomatyzować (więcej informacji na ten temat można znaleźć

w dalszej części tego rozdziału).


3. Scenariusz dla wersji demonstracyjnej, która może zostać pokazana

klientom i interesariuszom, no i oczywiście właścicielowi produktu.

4. Dokumentacja dla zamierzonego zachowania tworzonej funkcji.

Wspólne definiowanie kryteriów akceptacji w formie przykładów

scenariuszy użycia i ich oczekiwanych wyników ma następujące zalety:

1. Pomaga to wyobrazić sobie rozwiązanie z perspektywy użytkownika

oraz sposób, w jaki rozwiązuje ono konkretny problem. Przyrównajmy

to do sposobu, w jaki ludzie często mówią o tradycyjnych

specyfikacjach, w których zwykle skupiają się na konkretnych

szczegółach i pomijają przy tym szerszy obraz.

2. Gwarantuje to, że każdy ma pewne wspólne zrozumienie dotyczące

rozwiązania. Gdy ludzie rozmawiają, zwykle używają ogólnych

i niejasnych terminów, zakładając przy tym, że są one znane wszystkim

pozostałym uczestnikom rozmowy, podczas gdy często tak nie jest.

Jednak w czasie omawiania konkretnych przykładów i oczekiwanych

wyników często te założenia i niejasności zostają ujawnione.

3. Pomaga to usunąć problemy związane z zaproponowanym

rozwiązaniem. Ponieważ specyfikacje oparte na swobodnym tekście,

a tym bardziej na swobodnych konwersacjach, są podatne na

niejasności i przypuszczenia, to nawet jeśli wszyscy pojmują daną rzecz

w taki sam sposób, może się zdarzyć, że pomijają oni jakiś istotny

szczegół lub problem związany z zaproponowanym rozwiązaniem.

Tutaj również spisywanie konkretnych przykładów pomaga pozbyć się

tych problemów.

4. Ogranicza to zakres rozwiązania. Każdy pomysł może zostać

zinterpretowany na wiele różnych sposobów, od tych najbardziej

minimalnych i „szybkich i brudnych”, aż po te najbardziej

wyczerpujące i nawet nazbyt skomplikowane. Usuwanie tych


niejasności i problemów pomaga zarysować granice minimalnego

opłacalnego rozwiązania. Przykładowo, jeśli historyjka użytkownika

mówi o potrzebnym edytorze tekstowym, jedna osoba może sobie

wyobrażać coś na wzór prostego Notatnika, podczas gdy inna może

mieć na myśli w pełni wyposażony procesor tekstu, taki jak program

Word. Scenariusz użycia wskazuje, jakie jest minimalne rozwiązanie,

które od razu zaspokaja daną potrzebę. Dalsze usprawnienia powinny

zostać wyodrębnione do dodatkowych historyjek użytkownika.

Niektóre historyjki użytkownika nie dotyczą dodawania nowej

funkcjonalności, ale raczej skupiają się na modyfikowaniu lub usuwaniu

istniejących funkcji. W takich przypadkach musimy zidentyfikować

istniejące scenariusze (testy), które muszą zostać zmienione, i odpowiednio

je zmodyfikować, usuwając przy tym również wszelkie scenariusze, które

nie są nam potrzebne. Te zmodyfikowane scenariusze stają się kryteriami

akceptacyjnymi historyjki użytkownika i należy je traktować jako nowe

testy w dalszej części tego procesu.

Pisanie testów automatycznych

Na tym etapie testy akceptacji można już zaimplementować jako testy

automatyczne. Choć pisanie kryteriów akceptacji jako przykładów użycia

pomaga pozbyć się wielu dwuznaczności i niejasności, to jednak część

z nich może nadal istnieć, ponieważ są one pisane w języku naturalnym.

Implementowanie testów w kodzie pozostawia znacznie mniej miejsca na

takie dwuznaczności, ponieważ wszystkie szczegóły techniczne

wywierające wpływ na test, i odpowiednio na użytkownika, muszą być

zdefiniowane, aby test mógł być się wykonać. Proces implementowania

kodu testu może ujawnić wszelkie pozostałe ukryte problemy i niejasności.


Jeśli zespół wykorzystuje różne rodzaje testów automatycznych (np.

testy systemu, integracyjne i jednostkowe), to powinien najpierw

zdecydować o tym, jaki jest najbardziej odpowiedni rodzaj dla każdego

testu. Gdy zostanie to już ustalone, należy utworzyć odpowiednie testy.

Jeśli testy automatyczne i kod aplikacji piszą inne osoby, to tak naprawdę

testy automatyczne nie muszą być pisane wcześniej, gdyż można to robić

równolegle z tworzeniem kodem aplikacji. Osoby te muszą jednak blisko ze

sobą współpracować, aby wypracować szczegóły, które pozwolą testowi na

poprawne oddziaływanie z testowanym systemem. Deweloperzy

automatyzacji i aplikacji powinni się starać uzgadniać te szczegóły tak

szybko, jak to tylko możliwe, aby uniknąć niepotrzebnego przepisywania

w późniejszym czasie.

Jeśli ten sam deweloper pisze kod aplikacji i testy automatyczne, lub też

para (lub więcej) deweloperów pisze wspólnie testy i kod (w ramach

programowania w parze lub w zespole (tzw. mob programming – patrz tekst

uzupełniający), wówczas zaleca się, aby testy pisane były jako pierwsze.

Gwarantuje to, że testowany system będzie pisany w sposób, który

umożliwi jego testowanie i ujawni wszelkie ostateczne dwuznaczności.

Jeśli istnieje więcej niż jeden test, to zespół może zdecydować, czy

przed rozpoczęciem prac nad kodem aplikacji mają zostać utworzone

wszystkie testy, bądź też czy przed napisaniem każdego kolejnego testu

powinien zostać napisany niezbędny kod aplikacji. Każde z tych podejść

ma swoje wady i zalety, ale w ogólnym przypadku odpowiedź na to pytanie

będzie w dużym stopniu zależeć od kontekstu. Pisanie wszystkich testów

przed kodem aplikacji pomaga usunąć wszystkie potencjalne problemy,

przy czym pisanie kodu również może ujawnić pewne ograniczenia, które

zmuszą nas do wprowadzenia odpowiednich poprawek w testach.

W większości przypadków zalecanym podejściem jest zaimplementowanie


kodu dla najprostszego testu i stopniowe przechodzenie do testów coraz

bardziej skomplikowanych. Dzięki temu szybciej dowiadujemy się, czy

zmierzamy w odpowiednim kierunku, zaś deweloperom ułatwia to

ewoluowanie ich projektu zgodnie z potrzebami. Powstrzymuje to również

deweloperów przed tworzeniem niepotrzebnego kodu i sprawia, że

właściwy kod jest bardzo prosty.

Można również wstępnie utworzyć szkielet dla wszystkich testów (jak

zrobiliśmy to w rozdziale 11), następnie utworzyć kod, który sprawi, że

pierwszy test zakończy się sukcesem, po czym przejść do kolejnego testu,

i tak do chwili, aż wszystkie testy będą kończyć się sukcesem.

Programowanie w parze i programowanie w zespole (mob

programming)

Jedną z bardziej kontrowersyjnych koncepcji metodyki ekstremalnego

programowania jest to, że cały kod aplikacji i testów (lub jego

większość) jest pisany w parach. Jeden deweloper obsługuje

klawiaturę i mysz, a drugi spogląda mu przez ramię i recenzuje jego

pracę w czasie rzeczywistym, starając się wyśledzić błędy

i zaproponować lepszy sposób na napisanie tego kodu. Te dwie osoby

powinny od czasu do czasu zamieniać się rolami. Ponadto zaleca się,

aby partnerzy z różnych par zmieniali się ze sobą, aby zapewnić lepszą

współpracę zespołów i wymianę wiedzy. Zaletą tego podejścia jest

szybsze uzyskiwanie informacji zwrotnych i dzielenie się wiedzą,

a także lepsze dopasowanie między zespołami. W rzeczywistości, jeśli

zespół próbuje dokonać wymaganych przeglądów kodu, powinien on

rozważyć skorzystanie z podejścia programowania w parze.

Większość napotkanych przeze mnie zespołów, które starały się

wymusić proces przeglądu kodu (bez zachęcania do programowania


w parze), ostatecznie go nie wymuszała, robiła to pobieżnie lub

naprawdę spędzała znacznie więcej czasu, niż gdyby osoba

sprawdzająca i osoba sprawdzana usiadły razem i zaimplementowały

cały kod. Nie mówiąc już o tym, że transfer wiedzy między tymi

osobami będzie w ten sposób znacznie cenniejszy. Ponadto wielu ludzi

zgłasza, że w przypadku programowania w parze są oni dużo bardziej

skupieni niż podczas samodzielnej pracy.

Główną wadą tego podejścia jest oczywiście to, że utworzenie każdej

funkcji kosztuje prawie dwa razy tyle. W rzeczywistości większość

ekspertów zgadza się, że programowanie w parze powinno być

stosowane tylko w miarę potrzeb, a nie jako wymagana praktyka. Jest

ono najbardziej korzystne, gdy umiejętności obydwu partnerów

wzajemnie się uzupełniają, ale gdy realizowane przez nich zadanie jest

zbyt proste lub gdy partnerzy nie potrafią ze sobą współpracować,

prawdopodobnie nie będzie ono dobrym rozwiązaniem. Ponadto

niektóre osoby czują się lepiej programując w parze, podczas gdy inne

wolą programować samodzielnie.

Jeszcze bardziej kontrowersyjna koncepcja wznosi to podejście na

wyższy poziom i sugeruje, że to cały zespół powinien wspólnie

pracować nad wszystkimi zadaniami programistycznymi (i innymi).

Idea ta jest w zasadzie podobna do programowanie w parze, przy

czym jej zaletą jest to, że wszyscy uczestnicy są ze sobą

zsynchronizowani i biorą udział we wszystkich podejmowanych

decyzjach. Podobnie jak w programowaniu w parze, tutaj również

istnieje osoba „prowadząca” (która stale się zmienia), zaś pozostali

przeglądają kod i proponują rozwiązania w czasie rzeczywistym.

W przeciwieństwie do programowania w parze, osoby przeglądające


kod mogą również korzystać z własnych laptopów do poszukiwania

alternatywnych rozwiązań lub wyszukiwania istniejących fragmentów

kodu, które mogłyby zostać wykorzystane ponownie. Dzięki temu

współpraca jest dużo bardziej efektywna. Samuel Fare napisał na ten

temat bardzo przystępny wpis blogowy47.

Implementowanie testów dla nieistniejącej funkcji

Gdy niektóre osoby słyszą po raz pierwszy o koncepcji polegającej na

pisaniu testów przed pisaniem kodu aplikacji, często wpadają

w konsternację i nie rozumieją, jak przetestowanie czegoś, co nie istnieje,

jest w ogóle możliwe. No cóż, jeśli zadaniem takiego testu jest

zweryfikowanie funkcjonalności, która nie została jeszcze

zaimplementowana, to oczywiście test powinien zakończyć się

niepowodzeniem. Jeśli jako pierwsze będziemy pisać testy jednostkowe

(klasyczne podejście TDD), to kod w ogóle się nie skompiluje. Z kolei

w przypadku automatyzacji interfejsu użytkownika lub testowania API,

napisanie kodu, który się kompiluje, nie powinno być problemem, ale

w czasie uruchamiania taki test zakończy się niepowodzeniem.

Prawdopodobnie jednak osoby te zadają sobie następujące pytanie: „Jak

mogę napisać test, jeśli nie mam wszystkich szczegółów technicznych,

które są mi do tego potrzebne?”. Odpowiedź na to pytanie jest taka, że

zamiast decydować o tych szczegółach w czasie, gdy (my lub ktoś inny)

piszemy kod aplikacji i dostosowujemy test do tych szczegółów, robimy to

w odwrotnej kolejności: decydujemy (razem z deweloperem aplikacji)

o tych szczegółach podczas pisania kodu testu, a następnie pisze on kod

aplikacji w taki sposób, aby pasował on do tych szczegółów.

W ramach prostego przykładu załóżmy, że zamierzamy utworzyć

historyjkę użytkownika w celu dodania przycisku, który w aplikacji


kalkulatora online będzie obliczał pierwiastek kwadratowy z jakiejś liczby.

Być może uważamy, że nie będziemy w stanie napisać odpowiedniego testu

przed napisaniem kodu, ponieważ musimy znać id nowego przycisku, jak

również wynik przeprowadzanych obliczeń. O tym, co powinno być tym

identyfikatorem id, możemy zdecydować podczas pisania testu

i skoordynować to z deweloperem aplikacji, tak aby użył on tego samego

identyfikatora podczas tworzenia tego przycisku. Jeśli zaś chodzi

o oczekiwany wynik, to najpierw powinien on zostać zdefiniowany jako

część kryteriów akceptacji. Na przykład powinniśmy wcześniej wiedzieć,

że pierwiastek kwadratowy z liczby 16 wynosi 4. Ale pytanie nieco się

komplikuje, gdy chcemy przetestować wynik pierwiastka kwadratowego

z liczby 2, ponieważ teraz musimy przedyskutować z deweloperem

problemy związane z dokładnością i ewentualnym zaokrągleniem wyniku.

Jeśli są to pytania istotne z perspektywy biznesu i nie zostały one poruszone

w kryteriach akceptacji, to musimy zaangażować w to właściciela produktu,

a może i nawet cały resztę zespołu, aby wspólnie zdecydować o tym, co

powinno być dokładnym oczekiwanym wynikiem, a następnie napisać test

i kod aplikacji zgodnie z tą decyzją. Jednak w przypadku, gdy nie ma ono

istotnego wpływu na biznes, możemy skorzystać z rozwiązania, które

dostarczane jest przez standardową bibliotekę oferowaną na używanej przez

nas platformie (np. z funkcji Math.Sqrt dostępnej w bibliotece .NET).

W takim wypadku naprawdę trudno jest przewidzieć dokładny wynik (w

tym prostym przykładzie tak nie jest, ale jeśli obliczenia będą nieco

bardziej skomplikowane, to możemy mieć z tym problem). Tak więc albo

piszemy test w taki sposób, aby pozwalał na marginalne błędy

w dokładności, albo piszemy go z pewną ustaloną dokładnością, co

doprowadzi do niepowodzenia nawet w przypadku poprawnego

zaimplementowania kodu aplikacji, a następnie naprawiamy test w taki


sposób, aby dopasować go do dokładnej liczby, której poprawność już

zweryfikowaliśmy. Podejście to nie powinno być stosowane domyślnie, ale

jeśli tylko robi się to z głową (w tym przypadku stała jest tylko dokładna

wartość), to wszystko będzie w porządku. Zwróćmy uwagę, że w tym

przykładzie preferowane jest podejście z tolerowaniem błędu marginalnego,

ponieważ test będzie wtedy bardziej solidny i mniej podatny na

niepowodzenia powodowane drobnymi szczegółami implementacji.

Kolejnym wyzwaniem może być dla nas scenariusz zawierający więcej

niż jeden krok, który nie może działać, dopóki kod aplikacji nie zostanie

ukończony. Problem polega na tym, że nie będziemy mogli zweryfikować

poprawności testu, dopóki aplikacja nie będzie gotowa, ponieważ test

zawsze będzie kończył się niepowodzeniem na pierwszym niekompletnym

kroku, co uniemożliwi nam sprawdzenie reszty kroków

zaimplementowanych w kodzie testu. Istnieje jednak kilka sposobów

rozwiązania tego problemu:

1. Możemy blisko współpracować z deweloperem aplikacji i planować

naszą pracę w taki sposób, aby najpierw zaczął działać ten pierwszy

krok. Dopiero potem przechodzimy do kolejnego kroku i tak dalej, aż

cały test będzie kończył się sukcesem.

2. Możemy blisko współpracować z deweloperem aplikacji, ale zamiast

planować implementację pierwszego kroku w całości, implementujemy

jedynie jego namiastkę, która pozwoli na kontynuowanie działania testu

i zakończenie go niepowodzeniem na ostatniej asercji.

3. Za pomocą debugera omijamy metody, które jeszcze nie działają, lub

tymczasowo oznaczamy te wiersze komentarzem, aby przejść do

kolejnych kroków testu.

Czasem to pierwsze podejście działa bardzo naturalnie, ale

w niektórych przypadkach jest ono niewykonalne bądź też może za bardzo


skomplikować proces tworzenia funkcji. Trzeci sposób jest czymś, co

deweloper automatyzacji może zrobić, aby nabrać pewności do swoich

zmian, ale dla tego problemu nie będzie to dobre metodyczne podejście.

Jest ono również trochę ryzykowne, ponieważ możemy zapomnieć

o odkomentowaniu tych wierszy i sprawić, że test będzie kończył się

sukcesem, mimo że nie będzie on robił tego, co powinien. Dlatego, ogólnie

rzecz biorąc, jeśli pierwsze podejście nie może być zastosowane, to

najbardziej zalecane jest to drugie. Warto tu wspomnieć o dwóch typowych

przypadkach zastosowania drugiego podejścia:

1. W przypadku aplikacji i automatyzacji interfejsu użytkownika (z

interfejsem sieci Web i Selenium włącznie) jako pierwszy może zostać

utworzony podstawowy, niedziałający interfejs użytkownika. Mimo że

na tym etapie interfejs ten nie powinien działać, to identyfikatory (lub

inne identyfikujące atrybuty) – przynajmniej dla elementów, z których

test powinien korzystać – powinny być zdefiniowane. Pozwala to

deweloperowi automatyzacji napisać i uruchomić kod testu, podczas

gdy deweloper może zacząć implementować funkcjonalność, która

sprawi, że test zakończy się sukcesem. Tworzenie w pierwszej

kolejności podstawowego interfejsu użytkownika sprawia, że koncepcja

końcowego rozwiązania jest bardziej zrozumiała. Końcowy styl

interfejsu użytkownika można dopracować później, jeśli tylko

identyfikatory i funkcjonalność pozostają niezmienione.

2. W przypadku usług REST lub SOAP albo dowolnego innego formatu

wymiany komunikatów, jako pierwszą trzeba zdefiniować dokładną

strukturę tych komunikatów. Dzięki temu deweloperzy aplikacji

i automatyzacji mogą rozpocząć pracę nad swoim kodem. Co więcej,

utworzenie usługi, która będzie przyjmować dowolny komunikat

i odpowiadać za pomocą predefiniowanego komunikatu, spełniającego


potrzeby tego testu, z wyjątkiem końcowej asercji, pozwoli

deweloperowi automatyzacji rozpocząć implementowanie testu bez

konieczności czekania na pełną implementację aplikacji. Deweloper

aplikacji może następnie rozpocząć tworzenie funkcjonalności tej

usługi, obejmujące sprawdzanie poprawności żądania i tworzenie

odpowiedzi zgodnie z faktyczną funkcjonalnością. Gdy deweloper

zakończy swoją pracę, test powinien kończyć się sukcesem.

Implementowanie kodu w celu pomyślnego wykonania testu

Uwaga

Ponieważ temat ten dotyczy w większym stopniu deweloperów

aplikacji, zakładamy w nim, że to my jesteśmy tymi

deweloperami.

Gdy kryteria akceptacji są już zaimplementowane w postaci jednego lub

więcej testów automatycznych, nasza praca w roli programisty jest prosta

i przejrzysta. Koniec z wymaganiami, które są niejasne i dwuznaczne. Cel

tego zadania jest bardzo prosty: sprawić, że nowe testy będą kończyć się

sukcesem i nie popsuć przy tym istniejących testów. Jeśli projekt od

samego początku tworzony jest z użyciem metodyki ATDD lub

osiągnęliśmy wystarczająco wysokie pokrycie testów funkcjonalnych, to

powinno to być naprawdę proste. Jeśli pokrycie testów nie jest tak wysokie,

to poza „sprawieniem, że nowe testy będą kończyć się sukcesem, bez

psucia przy tym istniejących testów” trzeba się również postarać, aby nie

popsuć niczego, co już działa, nawet jeśli nie mamy dla tych rzeczy

żadnych testów.
Ponieważ testy zawsze są jedynie konkretnymi przykładami, a nie samą

regułą, nie można za bardzo brać sobie tej rady do serca i sprawić, że

wszystkie testy będą kończyć się sukcesem poprzez zwracanie konkretnych

wartości, jakie są w tych testach oczekiwane. Jasne jest, że nie należy tego

robić. Tak naprawdę, to jeśli nawet to zrobimy, zostaniemy „złapani na

oszustwie” w kolejnym kroku tego procesu. Należy jednak skupić na

scenariuszach zdefiniowanych w kryteriach akceptacji i nie próbować

rozwiązywać wszelkiego rodzaju nietrywialnych przypadków granicznych.

Jeśli zidentyfikujemy przypadki graniczne, które nie są zbyt łatwe

w obsłudze, zgłaszajmy je właścicielowi produktu i odpowiednim

członkom zespołu. Jednak dopóki nie unieważniają one początkowego

rozwiązania, trzeba na razie korzystać z najprostszego rozwiązania,

a rozwiązanie obsługujące te rzadkie przypadki dodać do listy prac

produktu, aby obsłużyć go jako dodatkową historyjkę użytkownika.

Jeśli chodzi o inne przypadki graniczne, które są dosyć trywialne, to

nawet jeśli nie zostały one pokryte bezpośrednio w testach akceptacyjnych,

trzeba je odpowiednio obsłużyć. Jednak przypadki te mogą być zwykle

pokrywane testami jednostkowymi, tak więc zaleca się pisanie dla nich

testów jednostkowych, tak aby działanie to również zostało pokryte

i udokumentowane (w formie testów jednostkowych). Dzięki temu

upewnimy się, że niczego nie popsuliśmy, gdy będziemy wykonywać

większą refaktoryzację lub przepisywać kod.

Refaktoryzowanie kodu w razie potrzeby

Jeśli wszystkie historyjki użytkownika pokrywamy testami akceptacyjnymi

od początku projektu, to możemy swobodnie i w dowolny sposób

modyfikować strukturę naszego kodu. Testy te zapewniają nam solidną

siatkę bezpieczeństwa, na której możemy się wesprzeć, wykonując


wszelkiego rodzaju eksperymenty i zmiany. Powiedzenie „jeśli coś nie jest

zepsute, nie naprawiaj tego” nie ma tutaj zastosowania! Możemy, a nawet

powinniśmy poprawić wszystko, co nie podoba nam się w sposobie, w jaki

kod został napisany, przy założeniu, że wszystkie testy nadal kończą się

sukcesem. Co więcej, ponieważ metodyka Agile pozwala, a nawet zachęca

klientów do zmiany wymagań co jakiś czas, to wczorajsze założenia

projektowe mogą nie być już poprawne, więc projekt musi się zmienić, aby

dostosować się do tych zmian w wymaganiach.

Jeśli rozpoczynamy wprowadzanie metodyki ATDD do już istniejącego

projektu, to refaktoryzacja może nie być dla nas zbyt łatwa, ponieważ nie

mamy siatki bezpieczeństwa w postaci pełnego pokrycia testami

akceptacyjnymi (i prawie pełnego pokrycia kodu). W dalszej części tego

rozdziału wyjaśniamy, w jaki sposób można rozwiązać ten problem.

Należy zawsze starać się dokonywać refaktoryzacji małymi krokami,

a po każdym takim kroku uruchamiać testy i upewniać się, że wszystko

nadal działa poprawnie (tj. wszystkie testy z wyjątkiem testu

akceptacyjnego dla nowej historyjki użytkownika, jeśli jeszcze go nie

ukończyliśmy). Ten sposób wykonywania refaktoryzacji,

w przeciwieństwie do przepisywania dużych fragmentów kodu naraz,

wymaga pewnego doświadczenia, umiejętności planowania i zmiany

sposoby myślenia. Jednak takie podejście zmniejsza ryzyko wpadnięcia

w „króliczą norę” zmian, z której wydostanie się (sprawienie, że kod znów

będzie działał poprawnie) może potrwać kilka dni lub nawet tygodni.

Zwykle rozpoczynamy od wstawienia komentarza // TODO:


zawierającego cel naszej refaktoryzacji, a następnie planujemy, w jaki

sposób możemy to osiągnąć małymi krokami. Do czasu ukończenia tej

refaktoryzacji możemy utworzyć tymczasowe duplikacje i obejścia

w kodzie, aby utrzymać jego poprawne działanie (i kończenie się testów


sukcesem). W pobliżu tych duplikacji i obejść możemy wstawić

odpowiednie komentarze – dzięki temu, gdy w przyszłości my sami lub

ktoś inny spojrzy na ten kod, będzie można zrozumieć, dlaczego powstały

te duplikacje i obejścia, co pozwoli na kontynuowanie tej refaktoryzacji. Po

zakończeniu refaktoryzacji powinniśmy (miejmy nadzieję przed

zaewidencjonowaniem naszych zmian) być w stanie usunąć wszystkie te

duplikacje i obejścia wraz z odpowiadającymi im komentarzami. Na tym

etapie kod powinien wyglądać tak, jak tego oczekiwaliśmy, a więc

powinien być krótszy i bardziej przejrzysty. Oczywiście trzeba się upewnić,

że wszystkie testy nadal kończą się sukcesem.

Należy podkreślić, że powinniśmy unikać rozpoczynania przygody

z refaktoryzacją, jeśli nasz kod nie kompiluje się lub niektóre testy są

tymczasowo popsute. Najpierw doprowadzamy nasz kod do stanu, który

pozwala na jego skompilowanie i pomyślne wykonanie testów, a dopiero

potem rozpoczynamy refaktoryzację. Często najlepiej rozpocząć

refaktoryzację albo przed wykonaniem prac związanych z implementacją

historyjki użytkownika, albo po ich wykonaniu (lub zwyczajnie między

nimi). Czasem jednak zauważamy potrzebę refaktoryzacji w trakcie

prowadzenia tych prac, a implementowanie historyjki użytkownika bez

refaktoryzacji zajmuje więcej czasu.

Identyfikowanie sprzecznych wymagań

Zakładając, że testy są wystarczająco szybkie, to gdy nasz kod kompiluje

się i oczekujemy, że istniejące testy będą kończyć się sukcesem,

uruchamiamy je (jeśli są one zbyt powolne, rozważmy uruchamianie

wyłącznie wybranego podzbioru testów, tak często, jak to tylko możliwe).

Jeśli oczekujemy, że testy zakończą się sukcesem, a tak się nie stanie,

przyczyny mogą być następujące:


1. Wprowadziliśmy jakiś błąd. Biorąc pod uwagę, że spodziewaliśmy się

pomyślnego zakończenia testu, oznacza to, że nasza implementacja (lub

projekt) jest niepoprawna. W takim wypadku trzeba naprawić ten błąd

(poprawiając również projekt, jeśli to konieczne), a najlepiej zrobić to

od razu, jeszcze przed ukończeniem implementacji nowej historyjki

użytkownika. Tak czy inaczej, jeśli tylko jesteśmy w stanie naprawić ten

błąd, powinniśmy to zrobić, zanim uznamy historyjkę użytkownika za

„ukończoną”. To właśnie w tym miejscu automatyzacja testów ma

największą wartość – powstrzymujemy błędy przed wdarciem się do

kodu, zanim jeszcze dokonamy jego zaewidencjonowania. Jeśli nie

możemy poradzić sobie z zaimplementowaniem historyjki użytkownika

bez wprowadzania regresji w jakiejś w innej funkcji, która wcześniej

działała prawidłowo, to prawdopodobnie oznacza to, że nowa żądana

funkcjonalność stoi w sprzeczności z istniejącą, więc nie jest to tylko

zwykły błąd w implementacji, ale raczej jakiś problem z rozwiązaniem,

które zostało wybrane dla tej historyjki użytkownika.

2. Zmieniliśmy pewne szczegóły techniczne, na których opierają się testy

automatyczne. Przykładowo z elementu HTML usunęliśmy nazwę klasy

używaną w naszych testach Selenium. W takim przypadku trzeba

poprawić kod testu i sprawić, że będzie się on kończył sukcesem.

Zwróćmy uwagę, że jeśli kod testów jest dobrze ustrukturyzowany, to

zmiany nie powinny być dokonywane w metodach testowych, ale

wyłącznie w kodzie infrastruktury. Jeśli metoda testowa powinna zostać

zmieniona, może to oznaczać, że zmienił się sam scenariusz,

zaakceptowany po jego napisaniu przez właściciela produktu i zespół,

czyli nie jest to już szczegół techniczny. Jeśli uważamy, że zmiany te są

naprawdę szczegółami technicznymi, ale nadal wymagają one od nas

zmiany metod testowych, to prawdopodobnie metody testowe


pozbawione są jakiejś abstrakcji i powinny zostać zrefaktoryzowane.

Upewnijmy się jedynie, że nie zmieniamy znaczenia testowanych

scenariuszy (najlepiej poprosić kogoś innego o przejrzenie kodu pod

tym kątem).

3. Bieżąca historyjka użytkownika jest sprzeczna z pewnym żądanym

wcześniej działaniem. W dużych i złożonych projektach czasami różne

wymagania mogą ze sobą kolidować, czego nikt nie zauważa, dopóki

jakiś użytkownik, który opiera się na starej funkcjonalności i oczekuje

jej działania, nie zacznie narzekać. W jeszcze gorszych przypadkach,

jak na przykład w witrynach przeznaczonych dla konsumentów,

dotknięci takim problemem użytkownicy mogą po prostu zacząć

odwiedzać witryny konkurencji i wcale przy tym nie narzekać… Jedną

z dużych zalet metodyk ATDD i TDD jest to, że pozwalają nam one

wykrywać te konflikty bardzo wcześnie. Gdy wykryjemy taki konflikt,

w proces poszukiwania dla niego rozwiązania, które będzie w stanie

zadowolić zarówno stare, jak i nowe wymaganie, powinniśmy

zaangażować zarówno samego właściciela produktu, jak i cały zespół.

Jeśli metryki biznesowe (lub nawet oszacowania) pokazują, że stara

funkcjonalność nie jest warta utrzymywania, to popsuty test wraz

z kodem przestarzałej funkcji może zostać usunięty, a my będziemy

mogli dalej zgodnie z planem tworzyć nową historyjkę użytkownika.

Jednak w innym wypadku albo muszą zostać zmienione kryteria

akceptacji dla nowej historyjki użytkownika, albo musi zostać

zmieniony istniejący test i testowana przez niego funkcjonalność.

W pewnych ekstremalnych sytuacjach nowa historyjka użytkownika

może nawet zostać anulowana.

Dostarczanie aplikacji i zbieranie opinii na jej temat


Gdy nowe testy akceptacyjne, jak również wszystkie istniejące testy,

kończą się sukcesem, historyjka użytkownika może zostać uznana za

„ukończoną”. Ale nie oznacza to jeszcze, że wszystko jest idealne i nie ma

żadnych dodatkowych ukrytych błędów. Nawet jeśli nie możemy już

znaleźć żadnych błędów, to nadal nie wiemy, czy zaimplementowane przez

nas rozwiązanie jest użyteczne i faktycznie rozwiązuje problem, który

miała rozwiązać historyjka użytkownika. Aby się tego dowiedzieć, musimy

oddać nasze oprogramowanie w ręce ludzi, którzy będą w stanie dostarczyć

nam informacje na jego temat.

W projektach z zastosowaniem ciągłego wdrażania, sytuacja, w której

zmiany są zaewidencjonowane w systemie kontroli wersji, a wszystkie testy

kończą się sukcesem, wskazuje, że funkcjonalność jest automatycznie

dostarczana przynajmniej części użytkowników. Zwykle preferowane jest,

aby użytkownicy ci byli najpierw wewnętrznymi pracownikami lub

testerami wersji beta, którzy będą w stanie dostarczyć bardziej

konstruktywne opinie na wypadek problemów. Ale nawet bez tego

powinniśmy mierzyć i monitorować faktyczne użycie i informacje

rejestrowania, aby dowiedzieć się, czy użytkownicy w ogóle korzystają

z nowej funkcjonalności i czy mają z nią jakieś problemy.

W bardziej konserwatywnych projektach (głównie w konserwatywnym

i kluczowym przemyśle, np. medycznym lub lotniczym) o dłuższym cyklu

wydawniczym, nowa funkcjonalność należy najpierw udostępnić osobom

związanym z danym projektem, np. testerom manualnym, jak również

właścicielowi produktu oraz innym ekspertom z danej dziedziny. Osoby te

powinny próbować używać tej nowej funkcjonalności i przekazywać

odpowiednie opinie do zespołu deweloperów. Małe błędy można zgłaszać

bezpośrednio do deweloperów, którzy mogą je błyskawicznie poprawić.

Bardziej złożone problemy związane z użytecznością lub biznesem, łącznie


z przypadkami granicznymi, które nie zostały pokryte przez oryginalną

historyjkę użytkownika, powinny najpierw przejść przez właściciela

produktu w celu nadania im odpowiedniego priorytetu. Problemy, które

wymagają więcej czasu i planowania, a nie stanowią przy tym zagrożenia

dla wartości historyjki użytkownika, należy dodać do listy prac produktu

jako nowe historyjki użytkownika.

Ponieważ główne scenariusze zostały już przetestowane automatycznie

jako część kryteriów akceptacyjnych historyjki użytkownika, szansa na

znalezienie krytycznych błędów poprzez ręczne testowanie eksploracyjne

jest dużo mniejsza, a nawet jeśli błąd taki zostanie znaleziony, to całkiem

możliwe, że będzie on łatwy do naprawienia. Ponadto w przypadku zmian

związanych z interfejsem użytkownika, których użyteczności nie są

w stanie zmierzyć testy automatyczne, zaleca się, aby tester manualny lub

nawet właściciel produktu sprawdził, czy wygląd i zachowanie tego

interfejsu jest akceptowalne i nie ma w nim żadnych błędów (takich jak

nieczytelny rozmiar lub kolor fontu, przycięty tekst itp.). Jeśli są jakieś

testy regresji, które nie nadają się do automatyzacji, a mają jakiś związek

z historyjką użytkownika, powinny być uruchamiane przez testera. Tester

manualny powinien też wykonywać przede wszystkim testy eksploracyjne,

aby spróbować znaleźć potencjalne problemy, które nie zostały rozpoznane

na etapie tworzenia historyjki użytkownika. Tutaj również małe błędy

można poprawić błyskawicznie, a bardziej złożone błędy powinny być

zwykle traktowane jako oddzielne historyjki użytkownika, jeśli tylko nie

zagrażają one wartości głównej historyjki użytkownika.

Gdy na końcu każdego sprintu odbywa się spotkanie z prezentacją

produktu (demo), wówczas te same scenariusze akceptacyjne można

wykorzystać na potrzeby prezentacji. Takie spotkanie jest również dobrym

miejscem na uzyskanie informacji zwrotnych od interesariuszy, a poza tym


dobrze jest wraz z nimi wypróbować nowe scenariusze, aby poznać granice

tworzonego rozwiązania, a także zidentyfikować dodatkowe potrzeby

i pomysły na usprawnienia, które należy dodać do listy prac produktu

w formie nowych historyjek użytkownika.

Używanie testów akceptacyjnych jako


dokumentacji

Jeśli opis tekstowy testów akceptacyjnych został zachowany i jest w jakiś

sposób połączony z testami automatycznymi, to opisy te można

wykorzystać w postaci dokumentacji funkcjonalności (lub działania)

systemu. Zaletą tego rodzaju dokumentacji jest to, że jest ona zawsze

aktualna! Z tego powodu często jest nazywana „żywą dokumentacją”.

Każda zmiana jakiejś istniejącej funkcjonalności jest albo dokonywana

świadomie w ramach wypracowywania nowej historyjki użytkownika – i w

takim przypadku powinien zostać zmieniony opis testu, tak aby

odzwierciedlał tę zmianę funkcjonalności – albo powinna zostać ujawnione

mimowolnie, gdy deweloper uruchamia test, jak zostało to opisane

wcześniej (w ramach tematu „Identyfikowanie sprzecznych wymagań”).

Jak już wspomnieliśmy, zespół razem z właścicielem produktu decyduje

wtedy o oczekiwanej zmianie, a opis testu powinien zostać odpowiednio

zaktualizowany.

Choć do opisów testów nadal mogą wdzierać się małe nieścisłości, to

ogólnie rzecz biorąc, łatwiej utrzymywać aktualność tych opisów niż

formalnej dokumentacji. Chociaż scenariusze są bardzo cenne jako

przykłady w kontekście dokumentacji, to nie zawsze są one wystarczające,

więc zaleca się ich łączenie i powiązanie z bardziej tradycyjną


dokumentacją, zwłaszcza gdy dokumentacja ta jest również cenna dla

klientów.

Wiązanie kroków zamiast testów

Wiązanie opisów tekstowych scenariuszy akceptacyjnych z testami

automatycznymi jest samo w sobie bardzo cenne, ale nadal mogą się tu

pojawić pewne niejasności i niespójności pomiędzy opisami i testami. Aby

zminimalizować te niejasności i niespójności, niektóre narzędzia pozwalają

nam tworzyć powiązania między zdaniami scenariuszy i krokami testów.

Najbardziej popularne w tej kategorii są narzędzia, które wykorzystują

składnię języka Gherkin, a należą do nich m.in. Cucumber i SpecFlow.

Istnieją również inne narzędzia, takie jak Robot Framework, które

wykorzystują język o nieco luźniejszym stylu, ale sama ich koncepcja jest

w zasadzie taka sama. Gdy korzystamy z tych narzędzi, wtedy zamiast

tworzyć metody testowe do implementacji całego testu, zapisujemy testy

w specjalnym pliku tekstowym, w formie zdań z mocno ograniczonymi

zasadami syntaktycznymi. Każde takie zdanie powiązane jest z metodą,

która implementuje ten konkretny krok. Każdy krok może być

wykorzystywany przez wiele testów, co pomaga zminimalizować kod

i zmniejszyć duplikację. Ponadto każdą taką metodę można powiązać

z wieloma zdaniami, aby umożliwić tworzenie różnych sformułowań tego

zdania w różnym kontekście. Metody danego kroku są zwykle powiązane

ze zdaniami scenariusza za pomocą atrybutów (w języku C#) lub adnotacji

(w Javie) zawierających ciągi znaków lub wyrażenia regularne, do których

zdanie powinno pasować. Wyrażenie regularne przydaje się zwłaszcza dla

metod, które przyjmują parametry. Każdy parametr powinien być

dopasowany przez grupę w wyrażeniu regularnym, dlatego metody te są

bardziej ogólne i lepsze do ponownego wykorzystywania


Składnia języka Gherkin

W narzędziu Cucumber i jego wersjach dedykowanych innym językom

(łącznie z SpecFlow, czyli wersją dla platformy .NET), testy oraz zdania,

które opisują ich kroki, tworzymy w plikach o rozszerzeniu .feature. Choć

pliki te zawierają tekst w języku naturalnym, to nadal mają one dobrze

zdefiniowaną strukturę oraz kilka słów kluczowych. Struktura ta opisywana

jest za pomocą składni języka Gherkin48.

Każdy plik funkcji w języku Gherkin rozpoczyna się od słowa

kluczowego Feature:, po którym następuje swobodny opis danej

funkcji, który może zajmować kilka wierszy (wszystkie wiersze

z wyjątkiem pierwszego powinny charakteryzować się wcięciem). Ponadto

każdy plik funkcji może zawierać wiele scenariuszy. Każdy scenariusz

oznaczany jest słowem kluczowym Scenario:, po którym w tym samym


wierszu umieszczany jest tytuł tego scenariusza. Wszystkie kolejne wiersze

(z dodanym wcięciem) powinny zaczynać się od jednego ze słów

kluczowych: Given, When, Then, And oraz But, po którym następuje

zdanie w języku naturalnym opisujące dany krok. W przypadku narzędzia

Cucumber wszystkie te słowa kluczowe używane dla kroków są w zasadzie

takie same, ale idea polega na wykorzystywaniu ich do podziału struktury

testu na trzy główne części:

Given – opisuje warunki wstępne dla scenariusza lub to, co powinno

zostać zrobione, aby doprowadzić system do pożądanego stanu przed

wykonaniem operacji, którą chcemy przetestować;

When – opisuje operację, która ma zostać przetestowana;

Then – opisuje oczekiwany wynik.


Słowa kluczowe And i But używane są jako spójniki i semantycznie

kontynuują znaczenie poprzedniego zdania. Jednak z technicznego punktu

widzenia każde z tych pięciu słów kluczowych może pojawić się

w dowolnej kolejności, przy czym porządek zdań w scenariuszach określa

kolejność kroków podczas wykonywania testu. Język Gherkin zawiera

również inne konstrukcje, ale jego główna koncepcja jest właśnie taka.

Listing 16.1 pokazuje przykład takiego pliku funkcji.

Feature: ATM
As a bank manager
In order to increase my income from
commissions
I want to allow my customers to withdraw
cash from ATMs
Scenario: Cash withdrawal charges commission
Given the commission is $1
When I withdraw $50
Then the charged commission should be $1

Listing 16.1. Przykład pliku funkcji języka Gherkin

Aby powiązać pierwsze zdanie z metodą, metoda ta powinna mieć

sygnaturę z atrybutem widocznym na listingu 16.2.

[Given(@"the commission is \$(.*)")]


public void GivenTheCommissionIs(decimal
commission)
{
/* tutaj implementujemy kod, który ustawia
prowizję (commision) na podaną wartość */
}

Listing 16.2. Szablon metody dla pierwszego zdania

Aby pomyślnie uruchomić ten test, trzeba utworzyć podobne metody

dla pozostałych zdań.

Kompromis między możliwością ponownego użycia,


poziomem szczegółów i czytelnością

Wiązanie kroków zamiast testów z ich opisami tekstowymi pomaga

zapewnić, że test faktycznie wykonuje opisane kroki. Nadal jednak może

istnieć różnica między takim opisem jakiegoś pojedynczego kroku a jego

implementacją (tj. reprezentacja tekstowa danego kroku może mówić jedno,

a implementacja powiązanej z nim metody może robić coś innego). Nawet

jeśli test zostanie napisany w całości w kodzie, to i tak może się zdarzyć, że

nazwa metody nie do końca będzie odpowiadać temu, co tak naprawdę ta

metoda robi (w dobrym, czystym kodzie nie powinno to mieć miejsca, ale

mimo wszystko może się to zdarzyć). Jeśli zatem chcemy pójść na całość,

tj. napisać metodę testową w sposób, który nie pozostawia żadnych

dwuznaczności, to musimy opisać każdy szczegół w samej metodzie

testowej. Ale to oczywiście ogranicza możliwość jej ponownego

wykorzystania, a nawet obniża jej czytelność.

Gdy z kolei chcemy, aby nasze testy były tak czytelne, jak to tylko

możliwe, możemy sformułować podobne zdania w nieco inny sposób,

w zależności od ich kontekstu w całym zdaniu (patrząc na cały scenariusz

jak na jedno długie zdanie). Jak wspomnieliśmy wcześniej, z metodą


danego kroku można powiązać więcej niż jedno zdanie, więc

z technicznego punktu widzenia możemy to zrealizować bez wpływu na

możliwość ponownego wykorzystania. Jednak bez spojrzenia kod nie

możemy być pewni, że dwa sformułowania są tak naprawdę powiązane z tą

samą operacją, co wprowadza pewną dwuznaczność.

Jeśli natomiast będziemy chcieli położyć nacisk na ponowne

wykorzystanie (w kontekście samych zdań, a nie tylko kodu) i uniknąć przy

tym dwuznaczności, to zdania mogą czasem dziwnie brzmieć, więc będą

mniej czytelne.

Wniosek: nie ma tutaj jednego właściwego rozwiązania, ale zwykle,

jeśli tylko nie będziemy próbować zbyt radykalnie powiększać żadnej

z tych cech i w naturalny sposób użyjemy języka Gherkin, to w większości

przypadków nie będziemy mieć z tym żadnych problemów. Musimy mieć

jedynie świadomość, że okazjonalnie trzeba będzie pójść na taki

kompromis. Jednakże, choć wiązanie opisów kompletnego scenariusza

z metodami testowymi, zamiast wiązania zdań z metodami kroków,

pozostawia więcej miejsca na niejasności, to daje to również pewną korzyść

w odniesieniu do łatwości utrzymania. Aby to umożliwić, opisy testów

mogą znajdować się w systemie zarządzania testami, takim jak TFS,

MicroFocus Quality Center itd., zaś ich identyfikatory mogą być podawane

na poziomie metod testowych z użyciem jakiegoś atrybutu lub jakiejś

adnotacji. Osobiście skłaniam się bardziej ku temu drugiemu podejściu, ale

nie jest to jednoznaczne.

Wprowadzanie metodyki ATDD do istniejącego


projektu
Ponieważ ATDD wymaga dosyć sporej zmiany w sposobie myślenia,

a także w kulturze, a jej wartość przy istniejących projektach jest nieco

niższa niż w przypadku nowych projektów (z powodu mniejszego pokrycia

kodu), często trudno jest przekonać innych do stosowania tej metodyki.

Poniżej znajduje się kilka wskazówek, które pomogą nam przedstawić tę

ideę naszemu zespołowi (ponadto w rozdziale 15 znajdują się nieco

bardziej ogólne wskazówki dotyczące tego, w jaki sposób zmieniać kulturę

i czerpać w zespole większe korzyści z automatyzacji testów – wskazówki

te mają w większości zastosowanie również tutaj).

Rozpoczynanie bez testów automatycznych

Nawet jeśli nie mamy jeszcze żadnych testów automatycznych, to jako

zespół możemy zacząć korzystać z praktyki polegającej na definiowaniu

kryteriów akceptacji. Na początku (a być może jeszcze przez długi czas) nie

będziemy mieć z tego żadnych testów automatycznych, ale manualne testy

regresji będą znacznie cenniejsze, ponieważ będą one testować wartość,

jaką historyjki użytkownika powinny zapewniać klientom lub firmie.

Wykonywanie samej tej praktyki przynosi również inne korzyści:

Gwarantuje, że testy są wykonywane na wczesnym etapie i mogą mieć

duży wpływ na rozwiązanie i jego jakość.

Usprawnia to komunikację pomiędzy deweloperami, testerami

i właścicielem produktu, a także wspiera wspólne zrozumienie

wymagań i zakresu historyjki użytkownika. Sprzyja to nawet postawie

współdzielonego prawa własności i odpowiedzialności.

Zmusza to zespół do myślenia o możliwości testowania przed

zaimplementowaniem. Pozwala to na łatwiejsze przetestowanie

większej liczby scenariuszy, nawet jeśli z początku są one testowane


tylko ręcznie. Potrzeba opisania kryteriów akceptacyjnych

w jednoznaczny sposób napędza również tworzenie symulatorów do

testowania manualnego, z których później można korzystać przy

automatyzacji testów.

Retrospektywna implementacja automatyzacji

Kontynuując nasz dotychczasowy kurs, jeśli mamy automatyzację testów

w projekcie, ale nie jest ona jeszcze wystarczająco dojrzała i stabilna, aby

mogła być wykorzystana w ramach CI, to nadal możemy być zmuszeni do

retrospektywnej implementacji testów automatycznych i pozwolenie na to,

aby historyjki użytkownika oznaczane były jako „ukończone”, zanim

będziemy mieć dla nich testy automatyczne. Jednak zaimplementowanie

automatyzacji nie później niż przy następnym sprincie (iteracji) może być

sporym krokiem naprzód. Jest to szczególnie istotne, gdy automatyzacja

testów tworzona jest przez oddzielny i niezależny zespół.

Korzyści wspomniane powyżej dla testów manualnych mają również

zastosowanie do testów automatycznych pisanych dla istniejących

projektów. Gdy deweloperzy automatyzacji przyjdą implementować testy

dla funkcjonalności, która została zdefiniowana przy użyciu jasnych

kryteriów akceptacji, wówczas będą oni w stanie wykorzystać te same

kryteria akceptacji jako scenariusze dla swoich testów. Ponieważ zespół

powinien był pomyśleć o możliwości testowania jeszcze podczas

definiowania kryteriów akceptacji, scenariusze te powinny być lepiej

dopasowane do automatyzacji od scenariuszy, które nie były projektowane

z myślą o testowaniu.

Kolejnym krokiem w tym kierunku powinno być zaangażowanie

jednego członka zespołu automatyzacji testów (najlepiej doświadczonego)

w spotkania robocze. Nie tylko pozwoli to zespołowi automatyzacji


przygotować się wcześniej na zmiany, ale da to tej osobie szansę na

dostarczenie dalszych danych dotyczących możliwości testowania

i przejrzystości oczekiwanych wyników.

Od tego momentu aż do pełnej implementacji procesu ATDD powinno

to już właściwie sprowadzać się do przestrzegania zalecenia, które

przedstawiliśmy w poprzednim rozdziale, dotyczącego stabilizowania

testów i przenoszenia odpowiedzialności w ich zakresie na deweloperów.

Gdy testy są już stabilne i szybkie, a jakiś deweloper automatyzacji bierze

udział w spotkaniach roboczych, już nic nie powinno powstrzymywać tego

dewelopera automatyzacji przed natychmiastowym zaimplementowaniem

testów dla nowych historyjek użytkownika, bez czekania na kolejny sprint.

Rozpoczynanie od naprawy błędów

Innym podejściem, w które deweloperzy często łatwiej się angażują, jest

rozpoczynanie od pisania testów dla błędów przed ich naprawieniem. Choć

jest to inny kierunek niż w przypadku poprzedniego podejścia, to jednak nie

stoi ono z nim w sprzeczności, tak więc z powodzeniem możemy je

połączyć i razem zastosować. Jednak to podejście działa najlepiej, jeśli

istnieje już jakaś infrastruktura dla testów automatyzacji, a także

przynajmniej kilka stabilnych testów, które są uruchamiane co noc lub

w ramach ciągłej integracji.

Definiowanie testów dla scenariuszy, które nie zostały jeszcze

zaimplementowane, wymaga sporej zmiany w sposobie myślenia. Ale

w przypadku błędów oczekiwane zachowanie powinno już być określone

w opisie błędu lub przynajmniej być powszechnie znane. Z tego powodu

wielu deweloperów jest w stanie przyswoić to podejście nieco szybciej.

Pomysł polega na zmuszeniu deweloperów do pisania testów

automatycznych, które odtwarzają każdy błąd przed jego poprawieniem (w


przeciwieństwie do większości moich rad, w tym wypadku, głównie

w dużych organizacjach, na początku konieczne może być narzucenie tego

przez menedżerów, ponieważ korzyści z tego podejścia mogą nie być od

razu oczywiste dla deweloperów). Test powinien najpierw zakończyć się

niepowodzeniem z powodu błędu, a deweloper powinien być w stanie

zobaczyć, czy niepowodzenie to pasuje do faktycznego wyniku w raporcie

błędu. Po zbadaniu i poprawieniu tego błędu, może on wykorzystać test do

upewnienia się, że teraz test kończy się sukcesem, co oznacza, że błąd

został odpowiednio poprawiony. Ponadto, podczas gdy deweloper debuguje

i bada błędy, może on posłużyć się testem automatycznym, aby zrobić to

szybciej. Gdy błąd zostanie już usunięty i test zakończy się sukcesem,

deweloper powinien zaewidencjonować kod testu razem z poprawkami

i taki nowy test należy dodać do kompilacji nocnej lub kompilacji CI.

Z czasem deweloperzy powinni nabrać większej swobody w zakresie

pisania automatyzacji testów i korzystania z niej, co ustawi zespół w lepszej

pozycji przed pełnym przyjęciem metodyki ATDD.

Zwiększanie pokrycia regresji

Ponieważ korzyści ze stosowania metodyki ATDD są mniejsze

w przypadku istniejącego projektu o małym lub zerowym pokryciu, który

obsługuje bezpieczne refaktoryzowanie, powinniśmy rozważyć sposób na

zwiększenie pokrycia scenariuszy regresji, aby zmniejszyć ten niedobór.

W przypadku dużych i monolitycznych projektów, osiągnięcie

wysokiego pokrycia może nie być możliwe w krótkim czasie. Strategią,

która pozwala to przezwyciężyć, jest utworzenie „wysp” o wysokim

pokryciu. Na wyspach takich refaktoryzacja może być dokonywana

w bardziej bezpieczny sposób niż poza nimi. Zaleca się zaplanowanie

i nadanie odpowiednich priorytetów w celu pokrycia tych wysp zgodnie


z potrzebami refaktoryzacji. Wyspy te mogą być dopasowane do

komponentów strukturalnych (klas, bibliotek DLL itd.) lub do jakieś funkcji

biznesowej, która może obejmować wiele komponentów. W idealnym

przypadku trzeba również dopasować komponenty strukturalne i funkcje

biznesowe, ale niestety w przypadku starszych, monolitycznych projektów

rzadko kiedy ma to miejsce, tak więc będziemy musieli oszacować, w jaki

sposób wyspy te powinny wyglądać, aby mogły one zmniejszyć większość

ryzyka związanego z refaktoryzowaniem.

Innym sposobem zwiększania pokrycia w nieco mniej planowany

sposób, który jednak w większości przypadków zapewnia dobrą wartość,

jest określenie miejsc, w których należy poprawić pokrycie w ramach

każdej historyjki użytkownika. Innymi słowy, poza definiowaniem

scenariuszy dla kryteriów akceptacji dla historyjki użytkownika, zespół

decyduje, w jaki sposób poprawić pokrycie istniejącej funkcjonalności

wokół obszaru zmiany, aby zmniejszyć ryzyko, że coś zostanie popsute.

Podsumowanie

Według mnie największą wartość z automatyzacji testów uzyskuje się

wtedy, gdy testy automatyczne używane są do pokrywania scenariuszy,

które przynoszą wartość biznesową i poprawiają współpracę i zwinność

poprzez redukowanie czasu realizacji i utrzymywanie go na niskim

poziomie wraz z upływem czasu. Jednym z elementów zapewniających

zwinność jest refaktoryzacja, która wymaga wysokiego pokrycia testów,

aby można było dokonywać jej w bezpieczny sposób. Metodyka ATDD

(znana jest również jako BDD, specyfikacja na przykładach oraz pod

innymi nazwami) dostarcza metodologiczną platformę do dostarczania tych

wartości.
Głównym problemem związanym z tą metodyką jest to, że wymaga ona

znacznej zmiany w sposobie myślenia i kulturze. Na szczęście ten

i poprzedni rozdział dały nam odpowiednie narzędzia, które pozwolą nam

rozwiązać ten problem.


Rozdział 17. Test jednostkowe
i tworzenie oprogramowania
sterowane testami (TDD)

Tworzenie oprogramowania sterowane testami akceptacyjnymi (ATDD)

i tworzenie oprogramowania sterowane testami (TDD) są podejściami,

w których zaleca się pisanie testów przed pisaniem kodu. Jednak ATDD

(lub BDD) zwykle bardziej nadaje się dla dużych zakresów testowania

i scenariuszy opisujących sposób, w jaki użytkownicy używają systemu,

podczas gdy metodyka TDD jest bardziej odpowiednia dla testów

jednostkowych, które wykorzystują najmniejszy zakres testowania (w

postaci pojedynczej klasy lub nawet metody), a tym samym testuje więcej

szczegółów technicznych. Z tego powodu testy jednostkowe i metodyka

TDD uznawane są za praktyki, które powinny być realizowane przez tego

samego dewelopera, który implementuje testowany kod (code under test,

CUT)49. Pod koniec tego rozdziału wyjaśniamy różnicę pomiędzy TDD

i ATDD, ale w tym celu musimy najpierw lepiej zrozumieć testy

jednostkowe i ogólne zastosowanie metodyki TDD.

Przyswajanie testów jednostkowych i TDD


Choć prawie wszyscy zgadzają się, że deweloperzy powinni pisać testy

jednostkowe dla pisanego przez siebie kodu, to w czasie mojej kariery

zauważyłem, że jest znacznie więcej deweloperów, którzy unikają pisania

takich testów lub mają z nimi problem, niż takich, którzy robią to

poprawnie, nie mówiąc już o wykonywaniu tego zgodnie z metodyką TDD.

Nawet wśród deweloperów piszących testy jednostkowe i stosujących się

do podejścia TDD toczą się żarliwe dyskusje na temat tego, jaki jest

najbardziej efektywny sposób pisania takich testów. Świetnym przykładem

takiej debaty jest seria konwersacji wideo zatytułowana Is TDD Dead?50

(Czy metodyka TDD jest martwa?), dostępna na blogu Martina Fowlera.

Spójrzmy więc prawdzie w oczy: to, że ludzie mają z tym problem,

oznacza, że nie jest to najłatwiejsza rzecz do opanowania. Istnieje wiele

świetnych książek na temat testów jednostkowych i podejścia TDD, ale to

nadal za mało. Do opanowania metodyki TDD niezbędne jest ogromne

doświadczenie i lata praktyki, ale też ciągłe przyswajanie wiedzy

(kontrolowana praktyka, jak ćwiczenia kata, czytanie blogów, oglądanie

podcastów, udział w warsztatach i konferencjach itd.), a ta podróż

prawdopodobnie nigdy się nie kończy. Jeśli chodzi o mnie, to od czasu, gdy

po raz pierwszy usłyszałem o metodyce TDD i spróbowałem ją zastosować,

minęło kilka lat, zanim się zorientowałem, że naprawdę ją rozumiem.

Jednak mimo tego nadal uczyłem się i doskonaliłem swoje umiejętności.

W rzeczywistości mój pogląd na testy jednostkowe i metodykę TDD oraz

na to, w jaki sposób związana jest ona z innymi praktykami automatyzacji

kodu i kodowania, zmieniały się w tym czasie wiele razy i prawdopodobnie

nadal będą się zmieniać.

Ćwiczenia kata
Kata jest japońskim słowem zapożyczonym z domeny sztuk walki,

które oznacza zaaranżowane ruchy, jakie wykonywane są w formie

ćwiczeń w celu usprawnienia umiejętności w danej sztuce walki.

W kontekście oprogramowania są to ćwiczenia, które deweloperzy

mogą wykonywać w celu podniesienia swoich umiejętności

programistycznych. Celem ćwiczenia kata nie jest wyłącznie

zaimplementowanie czegoś, co będzie działać, ale także podkreślenie

różnych aspektów i praktyk w zakresie sposobu pisania kodu. Na

przykład ćwiczenie kata może polegać na utworzeniu mechanizmu

obliczeń zliczającego punkty w grze w kręgle, ale z naciskiem na takie

aspekty jak TDD, programowanie funkcyjne, unikanie instrukcji „if”,

a nawet takie rzeczy, jak unikanie korzystania z myszy. Niektóre osoby

wykonują w kółko te same ćwiczenia kata, aby podnieść swoje

umiejętności. Wiele takich ćwiczeń dostępnych jest w Internecie.

Zwróćmy uwagę, że przyswajanie technicznych szczegółów

wymaganych przy pisaniu testów jednostkowych jest bardzo proste. Nawet

przyswajanie metodyki TDD nie jest aż tak trudne. Trudne w tym

wszystkim jest jednak stosowanie tych technik w praktyce. Przyswajanie

metodyki TDD można przyrównać do nauki gry na fortepianie. Możemy

dosyć szybko nauczyć się czytać wszystkie nuty i grać każdą z nich na

fortepianie, wliczając w to bemole i krzyżyki. Możemy również przyswoić

wszystkie symbole specjalne i zagrać staccato, legato, forte i piano, a nawet

używać prawego pedału. Ale nawet znajomość wszystkich tych szczegółów

nie pozwoli nam wcale zagrać muzyki Chopina, nie mówiąc już

o uznawaniu się za pianistów. Do tego potrzebujemy praktyki, praktyki

i jeszcze więcej praktyki. To samo dotyczy TDD. Z tego powodu

w rozdziale tym uczymy się jedynie „czytać nuty”, ale rozmawiamy

również o „wyzwaniach związanych z prawdziwym graniem na


fortepianie” oraz roli fortepianu (tj. testów jednostkowych) w koncercie

nazywanym „tworzeniem oprogramowania”.

Sposoby pisania testów jednostkowych

Rozpoczniemy od opisania sposobu działania bibliotek testów

jednostkowych, a następnie nauczymy się wykorzystywać taką bibliotekę

do napisania testu jednostkowego, jednak najpierw zrobimy to od strony

technicznej.

Mechanizm biblioteki testów jednostkowych

Z biblioteki testów jednostkowych (MSTest) skorzystaliśmy już podczas

tworzenia projektu testu w rozdziale 11. Biblioteka testów jednostkowych

jest tak naprawdę bardzo prostą technologią, ale mimo to jest niezwykle

przydatna51. Istnieje wiele bibliotek testów jednostkowych przeznaczonych

dla wielu języków i środowisk uruchomieniowych, takich jak NUnit

i xUnit.NET dla platformy .NET, JUnit i TestNg dla Javy, PyTest dla

Pythona itd., ale podstawy we wszystkich tych bibliotekach są praktycznie

takie same.

Podczas gdy w zwykłej aplikacji konsolowej mamy tylko jedną metodę

będącą punktem wejścia, którą jest metoda „Main” wywoływana przy

starcie programu, projekt testowania jednostkowego pozwala nam utworzyć

dowolną liczbę takich metod punktu wejścia i wywoływać oddzielnie

dowolną z nich, bądź też sekwencyjnie każdą z nich lub dowolny ich

podzbiór, tak jak sobie tego życzymy. Idea polega na tym, że każdy test

jednostkowy ma swój własny punkt wejścia. Jak widzieliśmy w rozdziale

11, w bibliotece MSTest tworzymy taką metodę poprzez oznaczenie jej


atrybutem [TestMethod]. Inne biblioteki mają inne sposoby na

wskazanie takiej metody, ale sama idea jest zawsze taka sama. Dzięki temu

biblioteka jest w stanie znaleźć metody testowe w projekcie i je wylistować,

co pozwala nam wybrać te metody, które chcemy uruchomić, a następnie je

wykonać. Biblioteka MSTest wymaga również oznaczenia każdej klasy

zawierającej metody testowe atrybutem [TestClass], informując ją

w ten sposób, czy w danej klasie należy poszukiwać metod testowych, czy

też nie. Zauważmy, że niektóre biblioteki nie mają podobnego wymagania.

W czasie gdy Visual Studio uruchamia testy, w panelu Test Explorer

pokazuje stan każdego z nich w postaci sukcesu lub porażki. Sposób, w jaki

określa on, czy test zakończył się sukcesem czy niepowodzeniem jest

bardzo prosty: jeśli test ukończył się bez zgłaszania żadnego wyjątku, test

taki kończy się sukcesem. W przeciwnym wypadku (jeśli został zgłoszony

wyjątek) test kończy się niepowodzeniem. Oznacza to przykładowo, że

pusta metoda testowa zawsze kończy się sukcesem.

Asercje

Ponieważ testy powinny weryfikować faktyczne wyniki (actual results)

testowanego systemu (lub testowanego kodu) na podstawie pewnych

oczekiwanych rezultatów (expected results), każda metoda testowa powinna

zawierać przynajmniej jedną instrukcję podobną do tej: if (expected


!= actual) throw new Exception("...");
Aby uczynić ten powtarzający się kod nieco bardziej eleganckim

i podkreślić specjalne znaczenie tych wierszy w teście, biblioteka MSTest,

a w zasadzie większość z powszechnie stosowanych bibliotek testowania

jednostkowego, dostarcza klasę Assert, z której korzystaliśmy już

w rozdziale 11.
Klasa Assert w bibliotece MSTest, jak zresztą większość z jej

kuzynów w innych bibliotekach, oferuje również metody do

bezpośredniego zakończenia testu niepowodzeniem (Assert.Fail) oraz

do zakończenia testu niepowodzeniem, gdy oczekiwano warunku

o wartościfalse, ale było inaczej (Assert.IsFalse), lub oczekiwano,


że będzie miał on wartość null (Assert.IsNull), a także kilka innych

metod. Biblioteka MSTest oferuje również bardziej specjalistyczne klasy

asercji przeznaczone dla ciągów znaków (StringAssert) i kolekcji

(CollectionAssert). Istnieją również pewne zewnętrzne biblioteki

asercji dla różnych platform, które dostarczają dodatkowe metody asercji,

często z wykorzystaniem płynnego i rozszerzalnego API.

Cykl życia wykonywania testu

Posiadanie wielu punktów wejścia (metod testowych) w tym samym

projekcie lub nawet w tej samej klasie umożliwia łatwiejsze współdzielenie

kodu pomiędzy tymi metodami. Ponieważ jednak wiele testów musi często

wykonać tę samą sekwencję inicjalizującą, pomocna byłaby możliwość

wyeliminowania duplikacji kodu wywołującego tę sekwencję z każdej

metody testowej (nawet jeśli umieścimy całą sekwencję inicjalizującą

w jednej współdzielonej metodzie, to nadal trzeba będzie wywoływać ją

z każdego testu). Ponadto może się zdarzyć, że będziemy chcieli coś

zainicjalizować tylko raz, bez względu na to, czy uruchamiamy tylko jeden

test, wiele testów, czy wszystkie testy w danym projekcie. To samo dotyczy

kodu oczyszczającego (znanego również jako kod zamykający) – zarówno

dla każdego testu, jak i dla każdego wykonania.

Na szczęście wszystkie standardowe biblioteki testowania pozwalają

nam robić te rzeczy i to w dosyć podobny sposób. W projekcie MSTest

wybraną metodę w klasie testowej możemy oznaczyć atrybutem


[TestInitialize], aby poinformować bibliotekę, by ta uruchomiła ją

przed każdą metodą testową w tej klasie, a także atrybutem

[TestCleanup], aby poinformować ją, by uruchomiła ją ona po każdej

metodzie testowej. Ponadto możemy użyć atrybutów

[ClassInitialize] i [ClassCleanup] do powiadomienia

biblioteki, aby uruchomiła ona te metody jeden raz, odpowiednio przed i po

wszystkich metodach testowych w tej klasie, oraz atrybutów

[AssemblyInitialize] i [AssemblyCleanup] do uruchomienia

ich jeden raz odpowiednio przed i po wszystkich metodach w całym

projekcie (zestawie .NET).

Uwaga

Różne metody oczyszczające wywoływane są bez względu na to,

czy testy zakończyły się sukcesem czy niepowodzeniem.

W większości przypadków jest to pożądane, ale wiąże się to

również z pewnymi nietrywialnymi przypadkami. Dodatek B

opisuje mechanizm, który możemy zbudować w celu obejścia

większości tych przypadków, zaś dodatek C opisuje bibliotekę

narzędzi Test Automation Essentials (opracowaną przeze mnie

dla platformy .NET), która zawiera już implementacją tego

mechanizmu.

Aby podsumować kolejność wykonywania tych metod, załóżmy, że

mamy trzy klasy testowe: ClassA, ClassB i ClassC z metodami

testowymi A1, A2 i A3 w klasie ClassA, metodami B1, B2 i B3 w klasie

ClassB oraz metodami C1 i C2 w klasie ClassC. Decydujemy się na


uruchomienie wyłącznie testów A1, A2, A3 i B1. W takim wypadku

kolejność uruchamiania tych metod będzie następująca:

Uwagi:

1. Metody ClassCleanup w bibliotece MSTest nie zawsze muszą być

wykonywane w tej kolejności. W zasadzie zagwarantowane jest jedynie

to, że są one wykonywane dopiero ukończeniu wszystkich metod

testowych i metody TestCleanup w tej klasie, ale przed

wywołaniem metody AssemblyCleanup. W rzeczywistości

biblioteka ta wywołuje metody TestCleanup tuż przed wywołaniem

metody AssembyCleanup.
2. Jak widać, metody ClassC.ClassInitialize oraz

ClassC.ClassCleanup w ogóle nie zostały wywołane, ponieważ

w tym wykonaniu nie uwzględniliśmy żadnej metody testowej z tej


klasy. Gdybyśmy zdecydowali się na uruchomienie wszystkich testów,

wówczas te metody zostałyby wywołane.

3. Wszystkie metody *Initialize i *Cleanup są opcjonalne, tak więc


nie musimy ich implementować, jeśli nie są nam one potrzebne.

Biblioteka MSTest oferuje wiele innych funkcji, tak jak wszystkie

pozostałe biblioteki (przy czym funkcje te w poszczególnych bibliotekach

mogą się między sobą całkowicie różnić). Jednak funkcje te są dużo

bardziej szczegółowe i mniej istotne dla większości przypadków, więc co

wykraczają one poza zakres tej książki.

Sposób pisania testu jednostkowego

Biblioteka testowania jednostkowego nie dostarcza zbyt wielu informacji

na temat tego, w jaki sposób możemy napisać test jednostkowy. Po prostu

wykonuje ona dowolny fragment kodu, który napisaliśmy wewnątrz metody

testowej (oraz wewnątrz metod inicjalizujących i oczyszczających

w odpowiedniej kolejności), i oznacza metody testowe jako wykonane

pomyślnie, jeśli nie zgłaszają one żadnego wyjątku, lub jako zakończone

niepowodzeniem, jeśli to robią. Z punktu widzenia biblioteki testów

jednostkowych, to, co zrobimy wewnątrz tych metod, to już nasza prywatna

sprawa.

Aby jednak napisać poprawny test jednostkowy, musimy wywołać

jedną lub kilka metod na jakiejś klasie wewnątrz testowanego systemu

i zweryfikować ich rezultat. W tym celu nasz projekt testu musi odwoływać

się do projektu (lub jakiejś skompilowanej biblioteki klas) będącego

komponentem testowanego systemu, który zawiera klasę lub klasy do

przetestowania. Jeśli metoda, którą chcemy przetestować, nie jest statyczna,

to musimy najpierw utworzyć instancję obiektu tej klasy, a następnie

wywołać tę metodę. Bezpośrednie wywoływanie metod testowanego


systemu z poziomu testu jest jedną z istotnych właściwości, które

odróżniają testy jednostkowe od testów integracyjnych i testów systemu,

wykorzystujących protokół sieciowy lub technologię automatyzacji

interfejsu użytkownika do komunikacji z testowanym systemem. Ponieważ

często jest tak, że wiele testów w tej samej klasie testowej musi utworzyć

na początku testu instancję obiektu tej samej klasy, taka instancja jest

zwykle tworzona w metodzie TestInitialize (przy czym jeśli różne

testy muszą tworzyć obiekty z różnymi argumentami konstruktora, to nie

ma sensu tego robić). Zwróćmy uwagę, że w ten sposób możemy jedynie

tworzyć instancje klas publicznych i wywoływać ich metody publiczne.

Istnieją techniki pozwalające na wywoływanie wewnętrznych czy nawet

prywatnych metod testowanego systemu z poziomu testów jednostkowych,

ale nie zaleca się tego robić, ponieważ są one uznawane za szczegóły

techniczne, które prawdopodobnie zostaną zmienione (przy czym bywa to

niekiedy przedmiotem różnych sporów).

Ponadto test powinien również weryfikować pewne oczekiwane wyniki,

zwykle za pomocą jednej z metod Assert. Najprostszym wynikiem, jaki

może weryfikować test jednostkowy, jest wartość zwracana z metody

testowej. Jednak niektóre metody nie zwracają żadnej wartości (w swojej

deklaracji zwracają one void) lub po prostu chcemy zweryfikować coś

innego. W takich przypadkach powinniśmy zrozumieć, na co ta metoda ma

wpływ i w jaki sposób możemy to zaobserwować. Na przykład metoda

może zmieniać wartość właściwości lub stan wewnętrzny jakiegoś obiektu,

w wyniku czego zostaje zmodyfikowany rezultat jakiejś innej metody.

W tym ostatnim przypadku musimy wywołać tę drugą metodę

i zweryfikować jej rezultat, aby upewnić się, że pierwsza metoda zmienia

wewnętrzny stan obiektu zgodnie z oczekiwaniem. W dalszej części tego

rozdziału pokazujemy bardziej konkretny przykład.


Tworzenie struktury testu: aranżacja, akcja, asercja (AAA).

Typowa struktura testu jednostkowego składa się z trzech części: aranżacji

(arrange), akcji (act) i asercji (assert). W rzeczywistości części te podobne

są do terminów Given, When i Then opisanych w poprzednim rozdziale.

Jak wskazuje ich nazwa: aranżacja polega na przygotowaniu wymagań

wstępnych dla testu, wraz z tworzeniem instancji testowanej klasy. Akcja

polega na wywoływaniu metody lub sekwencji metod, które chcemy

przetestować. Z kolei asercja to część, w której wywołujemy metody

Assert w celu zweryfikowania wyniku. Jeśli faza aranżacji występuje

w kilku testach, możemy ją przenieść do metody TestInitialize


w celu uniknięcia duplikacji. Czasem również faza akcji może być

współdzielona pomiędzy testami i można ją przenieść do metody

TestInitialize, pozostawiając w metodach testowych jedynie asercje

weryfikujące różne i niezależne wyniki tej samej operacji, ale jest to

znacznie mniej powszechne.

Przykład – prosty kalkulator

Załóżmy na przykład, że tworzymy prosty, standardowy kalkulator.

W naszym projekcie zaimplementowaliśmy klasę o nazwie

CalculatorEngine, która zarządza stanem kalkulatora po kliknięciu

każdego przycisku i dostarcza odpowiednią wartość do wyświetlenia (klasa

ta nie obsługuje interfejsu użytkownika – jest ona jedynie używana przez

warstwę interfejsu użytkownika, jak zostało to opisane). Na listingu 17.1

pokazano publiczne elementy członkowskie tej klasy.


Listing 17.1. Klasa CalculatorEngine

Na listingu 17.2 pokazano klasę testową wraz z kilkoma metodami

testowymi, które testują tę klasę.

[TestClass]
public class CalculatorEngineTests
{
private CalculatorEngine _calculator;
[TestInitialize]
public void TestInitialize()
{
// Aranżacja
_calculator = new CalculatorEngine();
}

[TestMethod]
public void
CalculatorCanAcceptMultipleDigitNumbers()
{
// Akcja
_calculator.ProcessDigit(Digit.Eight);
_calculator.ProcessDigit(Digit.Five);
_calculator.ProcessDigit(Digit.Two);
// Asercja
Assert.AreEqual(852,
_calculator.DisplayedValue);
}

[TestMethod]
public void
CalculatorCanAcceptDecimalFraction()
{
// Akcja
_calculator.ProcessDigit(Digit.Four);
_calculator.ProcessDecimalPoint();
_calculator.ProcessDigit(Digit.Seven);
// Asercja
Assert.AreEqual(4.7,
_calculator.DisplayedValue);
}
[TestMethod]
public void CalculatorCanMultiplyNumbers()
{
// Akcja
_calculator.ProcessDigit(Digit.One);
_calculator.ProcessDigit(Digit.Four);
_calculator.ProcessOperator(Operator.Mul
tiply);
_calculator.ProcessDigit(Digit.Three);
// Asercja
Assert.AreEqual(42,
_calculator.DisplayedValue);
}
}

Listing 17.2. Testy jednostkowe dla klasy CalculatorEngine

Testy jednostkowe i operacje wejścia/wyjścia

Jak na razie wygląda to dosyć prosto. Ale powyższy przykład jest bardzo

elementarny. Przetestowana przez nas klasa jest całkowicie samodzielna,

bez żadnych zależności od jakichkolwiek innych klas, a do tego nie

wykonuje ona żadnych operacji wejścia/wyjścia (Input/Output, I/O). Gdyby

trzeba było utworzyć dla niej interfejs użytkownika, wówczas warstwa tego

interfejsu zwyczajnie wywoływałaby metody dostępne w klasie

CalculatorEngine i aktualizowała ekran zgodnie z wartością

właściwości DisplayedValue, ale sama klasa nie musiałaby niczego

wiedzieć na temat interfejsu użytkownika. W rzeczywistości jednak

większość klas nie jest klasami samodzielnymi. Zależą one od wielu innych

klas i zwykle wykonują jakieś operacje we/wy – związane z interfejsem

użytkownika, bazą danych, systemem plików, siecią itd. – bezpośrednio lub

za pośrednictwem innych klas, od których są one zależne.


Rzecz w tym, że z kilku poniższych powodów powinniśmy zwykle

unikać testowania operacji we/wy w testach jednostkowych:

1. Testy jednostkowe powinny być bardzo szybkie. Prawie wszystkie

operacje we/wy są o rząd wielkości wolniejsze niż czyste operacje

procesora i dostępu do pamięci.

2. Testy jednostkowe powinny się możliwe do uruchomienia wszędzie i w

dowolnym czasie. Wykonywanie operacji we/wy zwykle wymaga

pewnych wymagań wstępnych, które nie zawsze są dostępne na każdej

maszynie.

3. Nawet jeśli wymagania wstępne mogą być spełnione, to kod testu

powinien zająć się synchronizacją i izolacją, co znacznie go

skomplikuje i spowolni, a może nawet sprawić, że będzie on mniej

wiarygodny.

4. Często operacje we/wy używają pewnych ograniczonych zasobów, które

uniemożliwiają równoległe uruchamianie testów. Czyste testy

jednostkowe (które nie wykonują operacji we/wy) mogą być

bezpiecznie uruchamiane równolegle i są tylko ograniczone przez

procesor i pamięć maszyny. Niektóre biblioteki i środowiska IDE (w

tym Visual Studio 2017 i jego nowsze wersje) pozwalają na

uruchamianie testów w tle podczas kodowania. Zastosowanie tego

w przypadku testów wykonujących operacje we/wy raczej nie będzie

działać poprawnie.

Atrapy

Załóżmy, że nasza klasa CalculatorEngine musi również zapisywać

wykonane obliczenia do pliku dziennika, który użytkownik może otworzyć

w standardowym edytorze tekstowym. W jaki sposób moglibyśmy to


przetestować? Jednym ze sposobów może być przeczytanie tego pliku na

końcu testu i zweryfikowanie jego zawartości. Ale nie będzie to zbyt dobry

pomysł ze wspomnianych powyżej powodów. Jeśli zapisywanie do pliku

wykonywane jest bezpośrednio z samej klasy CalculatorEngine, to

nie mamy innego sposobu, chyba że dokonamy refaktoryzacji tego kodu (o

czym powiemy sobie później). Na razie załóżmy, że nasza klasa

CalculatorEngine wywołuje jedynie inną klasę, LogWriter, która

dokonuje zapisu tych treści do dziennika. Innymi słowy, klasa

CalculatorEngine mówi jej, co ma zostać zapisane (wykonane

wyrażenie arytmetyczne, np. „12+3=15”), a klasa LogWriter jedynie

dopisuje ten wiersz w niezmienionej postaci do dziennika. Załóżmy

ponadto, że klasa CalculatorEngine nie odwołuje się do klasy

LogWriter bezpośrednio – ale poprzez interfejs ILogWriter, który

klasa LogWriter implementuje – oraz że klasa CalculatorEngine


przyjmuje tę referencję w swoim konstruktorze. Wszystkie te założenia

mogą wydawać się dosyć problematyczne i możemy się zastanawiać,

dlaczego musimy tak bardzo komplikować rzeczy tylko po to, aby móc

zapisać jakiś wiersz do pliku, ale wkrótce przekonamy się dlaczego tak jest.

Na listingu 17.3 pokazano wymagane deklaracje i fragmenty kodu, które

wyjaśniają te szczegóły oraz związki pomiędzy klasami

CalculatorEngine i LogWriter.

public interface ILogWriter


{
void WriteToLog(string line);
}

public class CalculatorEngine


{
private readonly ILogWriter _logWriter;
public CalculatorEngine(ILogWriter logWriter)
{
_logWriter = logWriter;
}
// ...
public void ProcessEquals()
{
// ...
expression = ...
_logWriter.WriteToLog(expression);
}
}

class LogWriter : ILogWriter


{
private string _filePath;
// ...

public void WriteToLog(string line)


{
// Wykonaj operację we/wy (dopisz wiersz
do pliku)
File.AppendAllLines(_filePath, new[] {
line });
}
}
Listing 17.3. Interakcja pomiędzy klasami CalculatorEngine i LogWriter

Mając powyższy projekt, możemy teraz utworzyć kolejną

implementację interfejsu ILogWriter, której użyjemy wyłącznie w teście


(będzie ona częścią projektu testu) i pozwoli nam ona sprawdzić, czy nasz

kalkulator zapisuje odpowiednie komunikaty do dziennika, bez potrzeby

zapisywania czegokolwiek do jakiegoś pliku. Na listingu 17.4 pokazuje tę

klasę. Jak wskazuje nazwa tej klasy, jest ona czymś, co nazywamy zwykle

atrapą (mock).

class MockLogWriter : ILogWriter


{
public string LineWritten { get; private set;
}
public void WriteToLog(string line)
{
LineWritten = line;
}
}

Listing 17.4. Klasa MockLogWriter

Możemy teraz napisać test, który co prawda nie zweryfikuje, czy

wyrażenia zapisywane są do pliku, ale przetestuje coś podobnego:

sprawdzi, czy klasa CalculatorEngine przesyła właściwe wyrażenie

do klasy LogWriter (która z kolei powinna zapisać je do pliku). Na

listingu 17.5 pokazano, jak wygląda taki test. Zwróćmy uwagę, że

ponieważ dodaliśmy argument do konstruktora klasy


CalculatorEngine, musieliśmy również zmienić naszą metodę

TestInitialize, aby przekazać ten parametr. Użyjemy tej nowej klasy


MockLogWriter jako wartości dla tego argumentu.

[TestClass]
public class CalculatorEngineTests
{
private CalculatorEngine _calculator;
private MockLogWriter _mockWriter;
[TestInitialize]
public void TestInitialize()
{
// Aranżacja
_mockWriter = new MockLogWriter();
_calculator = new
CalculatorEngine(_mockWriter);
}
// ...
[TestMethod]
public void
CalculatorWritesTheExpressionToTheLog()
{
// Akcja
_calculator.ProcessDigit(Digit.Two);
_calculator.ProcessOperator(Operator.Plu
s);
_calculator.ProcessDigit(Digit.Three);
_calculator.ProcessEquals();
// Asercja
Assert.AreEqual("2+3=5",
_mockWriter.LineWritten);
}
}

Listing 17.5. Testowanie, czy klasa CalculatorEngine wysyła

odpowiednie wyrażenie do dziennika

Biblioteki dostarczające atrapy

W kodzie rzeczywistej aplikacji interakcje między klasami są często dużo

bardziej złożone niż w tym przykładzie (przy czym jeśli są one zbyt

skomplikowane, może to wskazywać na problem w projekcie takiej

aplikacji). W takich przypadkach implementowanie atrap, jak to właśnie

zrobiliśmy, może stać się skomplikowane i podatne na błędy. Aby

rozwiązać ten problem, możemy skorzystać z jednej z wielu dostępnych

bibliotek, które pozwalają nam w łatwy sposób tworzyć takie atrapy, bez

konieczności deklarowania i implementowania dla nich specjalnych klas.

Na listingu 17.6 pokazano poprzedni test, ale z użyciem biblioteki

dostarczającej atrapy o nazwie Moq52, zamiast napisanej przez nas ręcznie

klasy MockLogWriter.

[TestClass]
public class CalculatorEngineTests
{
private CalculatorEngine _calculator;
private Mock<ILogWriter> _mockWriter;
[TestInitialize]
public void TestInitialize()
{
// Aranżacja
_mockWriter = new Mock<ILogWriter>();
_calculator = new
CalculatorEngine(_mockWriter.Object);
}

[TestMethod]
public void
CalculatorWritesTheExpressionToTheLog()
{
// Akcja
_calculator.ProcessDigit(Digit.Two);
_calculator.ProcessOperator(Operator.Plu
s);
_calculator.ProcessDigit(Digit.Three);
_calculator.ProcessEquals();
// Asercja
_mockWriter.Verify(
writer => writer.WriteToLog("2+3=5"),
Times.Once);
}

Listing 17.6. Korzystanie z biblioteki Moq zamiast klasy MockLogWriter

Pozorowanie zewnętrznych zależności


Poza pozorowaniem zależności, które wykonują operacje we/wy, zwykle

będziemy chcieli odizolować również biblioteki zewnętrzne, klasy z innych

warstw, a nawet współpracujące ze sobą klasy z tego samego komponentu,

aby przetestować konkretne zachowania pojedynczej klasy. Współpracujące

klasy mogą mieć wypływ na wyniki testowanego kodu w sposób, którego

możemy nie być w stanie łatwo kontrolować z poziomu testu,

a przynajmniej bez tworzenia niepotrzebnego narzutu związanego z łatwym

utrzymaniem. W takich przypadkach możemy również utworzyć atrapy dla

tych zależności testowanego kodu, nawet jeśli nie wykonują one operacji

we/wy, aby zapewnić sobie lepszą kontrolę nad oczekiwanym wynikiem

i łatwością utrzymania testu.

Mechanizm działania TDD

Na tym etapie znamy już cały mechanizm działania testów jednostkowych

oraz techniczne aspekty pisania takich testów. Zanim rozpoczniemy

dyskusję na temat wyzwań wiązanych ze stosowaniem tych technik

w praktyce, spróbujmy zapoznać się z mechanizmem działania TDD.

Czerwone-zielone-refaktoryzacja

Metodyka TDD jest zwykle opisywana jako cykl następujących kroków:

1. Czerwony – piszemy najprostszy test, który kończy się niepowodzeniem

lub nawet się nie kompiluje.

2. Zielony – piszemy najprostszy kod, który sprawia, że test ten, jak i w

wszystkie pozostałe testy, kończy się sukcesem.

3. Refaktoryzacja – refaktoryzujemy kod (zmieniamy jego szczegóły

implementacji) i usuwamy wszelkie śmieci, które pozostawiliśmy


w kodzie w poprzednim kroku. Upewniamy się, że wszystkie testy

nadal kończą się sukcesem.

Widzieliśmy już w rozdziale 11, jak wygląda pisanie kodu dla czegoś,

co jeszcze nie istnieje. Jedyną różnicą jest to, że w rozdziale 11 do

utworzenia kodu infrastruktury testów systemu dla aplikacji MVCClient,

która była już w pełni zaimplementowana, użyliśmy kodu testu, natomiast

w przypadku TDD używamy tej techniki do tworzenia i implementowania

właściwego kodu aplikacji.

Technika ta może również wyglądać podobnie do procesu ATDD

omówionego w poprzednim rozdziale. Faktycznie mają one pewne

elementy wspólne, ale o ile w przypadku ATDD piszemy testy akceptacyjne

dla historyjki użytkownika w cyklu, który trwa zwykle kilka dni, o tyle

TDD zabiera nas w nieco bardziej szczegółową podróż po

implementowaniu kodu, w cyklach trwających zaledwie kilka minut.

Co tak naprawdę oznacza „refaktoryzacja”?

Jestem przekonany, że jednym z powodów, dla którego ludzie często mają

problem z metodyką TDD, jest niejasność związana z krokiem

refaktoryzacji. W książce TDD. Sztuka tworzenia dobrego kodu53 Kent

Beck wyjaśnia, że krok ten oznacza „refaktoryzowanie w celu usunięcia

duplikacji”. Według Becka i przykładów w tej książce, duplikacją może być

wszystko począwszy od „magicznej liczby” pojawiającej się dwukrotnie

w kodzie, aż po całe komponenty kodu, które powtarzają się w różnych

wariantach. W rzeczywistości duplikacja może nawet oznaczać fragmenty

kodu, które wyglądają całkowicie inaczej, ale mają taki sam cel. Na

przykład, jeśli w naszym kodzie bez żadnego konkretnego powodu

zaimplementowaliśmy i użyliśmy dwóch różnych algorytmów sortujących,


to będzie to duplikacja i powinniśmy usunąć jeden z nich. Z kolei kod,

który może wyglądać tak samo, ale używany jest w różnych celach (i

z czasem może ewoluować w różnych kierunkach), nie jest duplikacją.

Ponadto w podawanych przez siebie przykładach Beck wyjaśnia bardzo

istotny aspekt tego kroku, którego wielu ludzi zwyczajnie nie dostrzega:

duplikacja powinna być usuwana nie tylko z testowanego kodu, ale również

z kodu testu, w tym również pomiędzy kodem testu a testowanym

kodem. Gdy czytałem tę książkę, to ostanie zdanie było dla mnie

prawdziwym objawieniem! Duplikacja pomiędzy kodem testu

a testowanym kodem wskazuje, że pomiędzy testowanym kodem a jego

klientami istnieje powiązanie lub ukryta wiedza, co narusza podstawową

zasadę programowania obiektowego: enkapsulację. Może to również

wskazywać, że klasa narusza zasadę pojedynczej odpowiedzialności (Single

Responsibility Principle, SRP) lub po prostu test nie jest dobrze

zdefiniowany, jeśli musi on obliczyć oczekiwany wynik w podobny sposób,

w jaki obliczenia te powinny zostać wykonane przez sam testowany kod.

Tak czy inaczej, duplikacja ta powinna zostać usunięta.

Beck podkreśla również, że kod testu i testowanego systemu powinien

„ujawniać swoje zamiary”. Na pierwszy rzut oka jest to po prostu inny

sposób na powiedzenie tego, że kod powinien być czytelny. Ale słowo

„zamiar” sugeruje, że kod powinien ujawniać, co dokładnie powinien robić,

zamiast tego, w jaki sposób powinien to robić. Dotyczy to zwłaszcza nazw

metod, ale wskazuje również, że większość implementacji metod powinna

zawierać tak mało szczegółów technicznych, jak to tylko możliwe.

Prowadzi to do bardzo krótkich i zwięzłych metod, które wywołują kilka

innych metod o nazwach ujawniających ich zamiary lub ukrywają

wyłącznie jeden szczegół techniczny. Te aspekty projektu – brak duplikacji,


przejrzyste nazwy oraz krótkie metody – mają ogromny wpływ na łatwość

utrzymania kodu.

Na koniec bardzo istotną rzeczą, jaka niezbędna jest do zrozumienia

refaktoryzacji, zwłaszcza w kontekście metodyki TDD, jest to, że powinna

być ona wykonywana w ramach bardzo małych przekształceń, z których

każde utrzymuje kod w stanie poprawnego działania i sprawia, że wszystkie

testy kończą się sukcesem. Automatyczne przekształcenia refaktoryzacji,

które istnieją w większości nowoczesnych środowisk IDE oraz ich

wtyczkach (np. ReSharper), takie jak „Wyodrębnij metodę”, „Dodaj

zmienną”, „Wyodrębnij klasę bazową” itd., wprowadzają spore ułatwienie,

ale nawet bez nich możemy wykonać większość przekształceń

refaktoryzacji w sposób dosyć bezpieczny, w małych i bezpiecznych

krokach. Wymaga to pewnej zmiany w zakresie sposobu myślenia, praktyki

i kreatywności, ale prawie zawsze jest to możliwe. Zgodnie z podstawową

regułą, zamiast zmieniać wszystko na takie, jakie chcemy, tworzymy nową

rzecz, następnie przekierowujemy stare rzeczy, aby korzystały z tej nowej,

a dopiero potem usuwamy tę starą rzecz. Może to oznaczać, że będziemy

musieli utworzyć duplikację w czasie tej operacji, ale potem ją usuwamy.

Załóżmy przykładowo, że mamy klasę CustomerDataWriter z metodą


WriteData, która zapisuje pewne dane o klientach do pliku i przyjmuje

nazwę tego pliku jako parametr. Zauważamy, że gdy metoda ta jest

wywoływana kilka razy na tej samej instancji klasy, zawsze używana jest ta

sama nazwa pliku. Z tego powodu decydujemy się przenieść parametr

filename do konstruktora tej klasy, zachowując jego wartość w polu,

i usuwamy ten parametr z metody. Oto jak w takim przypadku powinna

wyglądać refaktoryzacja, która nie psuje istniejącego kodu:

1. Tworzymy nowe przeciążenie konstruktora, które przyjmuje parametr

filename i zapisuje go w polu _filename. Ponadto w nowym


konstruktorze wywołujemy konstruktor oryginalny (na wypadek, gdyby

istniał i nie był pusty). Ponieważ żaden kod nie wywołuje jeszcze tego

konstruktora, wszystkie testy powinny kończyć się sukcesem.

2. Znajdujemy wszystkie odwołania do starego konstruktora i zamieniamy

je na wywołania nowego przeciążonego konstruktora, dostarczając mu

odpowiedni argument nazwy pliku (wartość tego argumentu możemy

pozyskać z wywołań do metody WriteData tego obiektu). Wszystkie


testy nadal powinny kończyć się sukcesem, ponieważ nowe pole nadal

nie jest używane. Możemy nawet zmienić te odwołania jedno po drugim

i wszystko powinno działać. Zwróćmy uwagę, że utworzyliśmy teraz

nieco duplikacji, ponieważ teraz ten sam parametr jest przekazywany

zarówno do konstruktora, jak i do naszej metody, ale sytuacja ta jest

tylko tymczasowa.

3. Po tym jak wszystkie odwołania do starego konstruktora zamieniliśmy na

odwołania do nowego konstruktora, możemy skopiować zawartość

starego konstruktora do nowego, zamiast go po prostu wywoływać,

a następnie usunąć ten stary konstruktor. Jest to tzw. inlining

(wstawienie kodu w miejsce jego wywołania). Wszystkie testy nadal

powinny kończyć się sukcesem, ponieważ stary konstruktor nie jest już

wywoływany, a nowy robi dokładnie to samo, co robił stary.

4. Tworzymy nowe przeciążenie metody WriteData, bez parametru

filename, i wywołujemy w nim stare przeciążenie z wartością pola

_filename. Wszystkie testy powinny kończyć się sukcesem,

ponieważ nowe przeciążenie nie jest nigdzie wywoływane.

5. Wyszukujemy wszystkie odwołania do starego przeciążenia metody

WriteData i jedno po drugim usuwamy z nich argument, aby

wywołać w ten sposób nowe przeciążenie. Ponieważ zmieniliśmy już

wywołania konstruktora w celu przekazania nazwy pliku i zachowania


jej w polu _filename, a nowe przeciążenie metody WriteData
wywołuje stare z użyciem wartości tego pola, to zachowanie to nie

powinno się zmienić i wszystkie testy nadal powinny kończyć się

sukcesem.

6. Gdy już wszystkie odwołania do starego przeciążenia metody

WriteData, która ma parametr filename, zostaną przeniesione na

nowe przeciążenie (które nie ma tego parametru), możemy wstawić

treść starego przeciążenia do nowego i usunąć to stare przeciążenie.

Ponieważ nikt nie używa już starego przeciążenia tej metody, wszystkie

testy powinny nadal kończyć się sukcesem. Osiągnęliśmy cel naszej

refaktoryzacji: przekazujemy nazwę pliku w konstruktorze, dzięki

czemu nie musimy przekazywać go już w każdym wywołaniu metody

WriteData. Oczywiście samo zachowanie nie zostaje zmienione.


Zwróćmy uwagę, że w krokach 2 i 5 może zostać zmieniony pewien

kod testu, jeśli kod tego testu wywołuje klasę, którą refaktoryzujemy.

Jednak nie stanowi to żadnego problemu, ponieważ wspomnieliśmy już, że

powinniśmy usunąć duplikację z kodu testu, jak również z kodu

produkcyjnego.

Skakanie od kroku do kroku

Choć w teorii te trzy kroki (czerwony, zielony i refaktoryzacji) powinny być

wykonywane w formie ładnej i przejrzystej pętli, to czasem bardziej

praktyczne może okazać się wykonanie jakiejś refaktoryzacji między

dowolnymi dwoma z tych kroków, a nawet podczas wykonywania któregoś

z nich. Zanim to jednak zrobimy, powinniśmy się najpierw upewnić, że kod

poprawnie się kompiluje i że wszystkie testy, z wyjątkiem tego nowego,

kończą się sukcesem. Jednak nigdy nie powinniśmy pozostawiać testów


w popsutym stanie ani też implementować żadnej nowej funkcjonalności,

zanim najpierw nie napiszemy dla niej testu.

Dlaczego najpierw powinniśmy pisać testy?

Rozpoczynanie implementowania jakiejś funkcji od utworzenia dla niej

testu jest dla większości ludzi nieintuicyjne, tak więc dlaczego powinniśmy

to robić?

Kod pisany jest z myślą o możliwości jego przetestowania. Ponieważ

testy jednostkowe powinny testować możliwie najmniejsze jednostki

i unikać wywoływania metod, które wykonują operacje we/wy,

większość testów jednostkowych musi korzystać z atrap, a to powoduje,

że testowany kod musi odwoływać się do swoich zależności za

pośrednictwem interfejsów. Większość z nas zwykle nie pisze kodu

w taki sposób, przez co nie nadaje się on do testowania jednostkowego.

Skupianie się najpierw na testach sprawia, że myślimy i projektujemy

API testowanego kodu w taki sposób, aby było on łatwe w użyciu.

Często jest tak, że gdy ludzie piszą najpierw kod, powstałe w ten sposób

API, zamiast być ładnym i przejrzystym interfejsem, który ukrywa

wszystkie szczegóły techniczne, zostaje ograniczone implementacją.

Sytuacja, w której napisany przez nas test na początku kończy się

niepowodzeniem, a po zaimplementowaniu testowanego kodu kończy

się sukcesem, gwarantuje nam, że napisaliśmy ten test poprawnie (takiej

też odpowiedzi lubię udzielać na często zadawane pytanie: Czy

powinniśmy pisać testy dla naszych testów?). Jeśli przykładowo

zapomnimy napisać instrukcję Assert lub będziemy sprawdzać coś


innego, niż chcieliśmy, to test może zakończyć się sukcesem, zanim
zaimplementujemy testowany kod, a wtedy będzie to wskazywać, że

zrobiliśmy coś nie tak.

Zwykle na deweloperach ciąży presja związana z jak najszybszym

tworzeniem i dostarczaniem efektów ich pracy. Jeśli będziemy pisać test

po napisaniu kodu, prawdopodobnie będziemy robić to pod presją jego

szybkiego ukończenia i możemy wtedy pójść na pewne ustępstwa.

Zwłaszcza gdy nie napisaliśmy naszego kodu w sposób pozwalający na

jego przetestowanie testami jednostkowymi, lecz przetestowaliśmy już

daną funkcjonalność ręcznie, prawdopodobnie będziemy czuć, że na

tym etapie refaktoryzowanie kodu i pisanie „właściwych” testów

jednostkowych nie będzie zbyt efektywne. Gdy z kolei rozpoczniemy

najpierw od pisania testów, to nie będziemy musieli aż tak bardzo iść na

skróty, ponieważ zamiast stanowić jedynie uzupełnienie dla tego kodu,

to właśnie one będą go napędzać, a ponadto testy nie pozwolą nam

wtedy pójść na skróty podczas implementowania testowanego kodu.

Prawdziwe wyzwania w testowaniu


jednostkowym i TDD

Na tym etapie, poza wiedzą w zakresie całej mechaniki, jaka wymagana jest

do pisania testów jednostkowych, znamy już także mechanikę stosowania

metodyki TDD. Wyjaśniliśmy również motywację stojącą za pisaniem

testów przed rozpoczęciem pisania kodu. Ale jak wspomnieliśmy

wcześniej, pozwala nam to jedynie na „czytanie nut”, ale nie pozwala „grać

Chopina” lub w mistrzowski sposób opanować sztuki stosowania TDD.

Oczywiście po przeczytaniu samej tej książki nie będziemy w stanie

opanować metodyki TDD. Najpierw wyjaśnijmy jednak powód, dla którego


stanowi to takie wyzwanie, podając przy tym również kilka wskazówek,

które pozwolą nam osiągnąć ten cel nieco szybciej.

Główne wyzwania związane z testowaniem jednostkowym

Większość systemów oprogramowania, a przynajmniej tych, które są na

tyle istotne, aby warto było dla nich pisać testy, budowana jest z dużej

liczby klas i metod, między którymi istnieje wiele współzależności. Wiele

z tych klas wykonuje również operacje we/wy, zależy od zewnętrznych

bibliotek i często dokonują one różnych założeń co do pozostałych

bibliotek (które są w większości przypadków poprawne, gdyż

w przeciwnym wypadku założenia te byłyby błędami…). Sposób, w jaki

większość programistów pisze oprogramowanie, wliczając w to również

tych doświadczonych i utalentowanych, zwykle nie pozwala na

przetestowanie takiego oprogramowania za pomocą testów jednostkowych.

Prawie każda klasa i metoda, która nie została napisana z myślą o testach

jednostkowych będzie wymagać sporej refaktoryzacji, zanim będziemy

mogli dla niej napisać testy jednostkowe. Ale dokonywanie refaktoryzacji

bez pokrycia testami (jednostkowymi) również jest niebezpieczne. Na

szczęście świetna książka Michaela Feathersa, zatytułowana „Praca

z zastanym kodem. Najlepsze techniki”54, dostarcza mnóstwo praktycznych

technik do bezpiecznego wykonywania małych przekształceń

refaktoryzacji, umożliwiających nam testowanie „starszego” kodu (tj. kodu,

który nie był pisany z myślą o testowaniu). Jednak poza samym

przeczytaniem tej książki, niezbędna będzie nam również odpowiednia

wiedza o tym, z której techniki należy korzystać i kiedy. Poniżej znajdują

się typowe wyzwania związane z pracą ze starszym kodem oraz sposoby

jego refaktoryzowania. Naturalnie książka Feathersa omawia te techniki

znacznie bardziej szczegółowo.


Główne wyzwania związane z podejściem TDD

Choć metodyka TDD obiecuje rozwiązać wiele z tych problemów, to

jednak sama również bywa niekiedy problematyczna:

Jest to bardzo duża zmiana w sposobie myślenia, ponieważ przed

zaimplementowaniem każdej klasy i metody musimy pamiętać

o napisaniu dla niej testu.

Rzadko kiedy rozpoczynamy pracę nad projektem, który dopiero co

powstaje (greenfield project). Oznacza to, że zwykle prawie cały kod

jest „starszym” kodem, który nie był projektowany z myślą o testach

jednostkowych, co przenosi nas z powrotem do wad związanych

z pisaniem testów już po napisaniu kodu. W rzeczywistości większość

nowych projektów rozpoczyna się od szybkich i brudnych weryfikacji

koncepcji (Proof of Concept, POC), które stopniowo ewoluują

w rzeczywisty projekt oprogramowania. Tak więc mamy bardzo małą

szanse na tworzenie nowego projektu od podstaw z wykorzystaniem

metodyki TDD.

Powszechną ideą pozwalającą na rozwiązanie tego drugiego problemu

jest stosowanie tej metodyki wyłącznie dla nowych funkcji, które są

dodawane do istniejącego systemu. W ogólnym przypadku jest to dobry

pomysł, do którego gorąco zachęcam, ale nie jest to również takie proste,

ponieważ nowy kod powinien zwykle integrować się z istniejącym kodem,

a to wymaga zwykle refaktoryzowania istniejącego kodu.

Bardziej szczegółowe wyzwania

Za wspomnianymi powyżej wyzwaniami kryje się wiele bardziej

szczegółowych problemów technicznych. Poniżej znajdują się te, które


moim zdaniem są najczęściej spotykane, wraz z pewnymi wskazówkami

powalającymi na ich rozwiązanie. Jeśli jednak nie jesteśmy programistami,

możemy bez przeszkód pominąć ten temat.

Duże klasy i metody

Często istotne klasy i metody, które są dobrymi kandydatami dla testowania

jednostkowego, są dosyć duże. Gdy projekt był młody, klasy te były

prawdopodobnie małe, ale z czasem dodawano do nich coraz więcej

zachowania, przez co znacznie się one rozrosły. Taka duża klasa lub metoda

często robi wiele rzeczy, co utrudnia przetestowanie tylko jednego z jej

aspektów – jest to często nazywane antywzorcem „boskiego obiektu” (God

Object). Aby ten problem rozwiązać, musimy podzielić taką dużą metodę

lub klasę na kilka mniejszych metod lub klas. Automatyczna refaktoryzacja

„wyodrębnienia metody”, która istnieje w wielu nowoczesnych

środowiskach IDE, pomaga nam to osiągnąć. Ale musimy najpierw

zastanowić się, w jaki sposób podzielić tę dużą metodę na mniejsze części.

Aby jednak w bezpieczny sposób uzyskać pożądany przez nas rezultat,

prawdopodobnie trzeba będzie dokonać większej refaktoryzacji niż tylko

proste wyodrębnienie metody.

Operacje we/wy

Jak zobaczyliśmy w poprzednim rozdziale, aby odizolować operacje we/wy

w naszym kodzie, musimy zrefaktoryzować kod i wyodrębnić je za pomocą

jakiegoś interfejsu, aby można było je zasymulować w naszych testach

jednostkowych. Aby odizolować te operacje, powinniśmy najpierw

wyodrębnić je do ich własnej metody, a następnie wszystkie powiązane

metody, które wykonują operacje we/wy wyodrębnić do ich własnej klasy.


Wtedy będziemy musieli wyodrębnić jakiś interfejs z tej klasy i przekazać

go za pomocą konstruktora testowanego kodu, który zachowuje jego

referencję w polu. Rezultat powinien być więc podobny do tego, co

widzieliśmy na listingu 17.3.

Choć test zawsze przekazuje tę atrapę do konstruktora, to powinniśmy

również naprawić kod produkcyjny, który wykorzystuje te klasę, aby działał

on z tą klasą operacji we/wy. Możemy to zrobić poprzez pozostawienie

domyślnego konstruktora (lub istniejącego konstruktora, który nie

przyjmuje nowego interfejsu w formie parametru) i zmodyfikować go tak,

aby wywoływał ten nowy konstruktor, przyjmujący parametr interfejsu.

Konstruktor ze starą sygnaturą (bez tego parametru) powinien tworzyć

nową instancję klasy, która wykonuje operacje we/wy, i przekazać ją jako

argument do nowego przeciążenia konstruktora.

Alternatywnie (lub też później) – zwłaszcza jeśli testowany kod

instancjonowany jest w kodzie produkcyjnym tylko w jednym miejscu –

możemy usunąć stary konstruktor, utworzyć instancję nowej klasy operacji

we/wy przed wywołaniem konstruktora testowanego kodu i przekazać do

niego tę nową instancję klasy operacji we/wy.

Singletony

Singleton jest wzorcem projektowym, który w klasycznej książce Wzorce

projektowe. Elementy oprogramowania obiektowego wielokrotnego

użytku55 z 1994 roku, znanej również jako książka „Bandy Czterech” (Gang

of Four, GOF), opisany został jako sposób na ograniczenie tworzenia

instancji danej klasy wyłącznie do jednego obiektu. Obecnie jest on często

uznawany za antywzorzec56, niemniej jednak jest on nadal obecny w kodzie

wielu aplikacji. W rozdziale 9 wspomnieliśmy krótko, że singletony


uniemożliwiają ponowne wykorzystanie, ale nie wspomnieliśmy wówczas,

że nie pozwalają również na testowanie, zwłaszcza z użyciem testów

jednostkowych.

Gdy piszemy test jednostkowy dla klasy lub metody, która

wykorzystuje singleton, to wewnętrzny stan tego singletonu zostaje

zachowany pomiędzy różnymi testami jednostkowymi, co narusza izolację.

Oznacza to, że za pomocą metod inicjalizujących i oczyszczających testu

musimy przed każdym testem dokonać w jakiś sposób zresetowania do

jakiegoś znanego stanu. Jednak bardziej istotnym problemem jest to, że

często kod zawierający singletony zawiera ich zbyt wiele, a wiele z nich

jest od siebie wzajemnie zależnych. Te współzależności stanowią żyzny

grunt dla ukrytych założeń, które są „znane wszystkim deweloperom

w zespole” (z wyjątkiem tych, którzy ich nie znają…). Poza wysokim

prawdopodobieństwem wystąpienia błędów w takim kodzie, kod ten często

ma postać spaghetti ściśle powiązanych ze sobą zależności i ukrytych

założeń, przez co jest on bardzo oporny na testowanie.

Ale jeśli mamy tylko jeden lub dwa singletony, które chcemy

odizolować za pomocą atrap, to możemy dosyć łatwo rozwiązać ten

problem. Najpierw wyodrębniamy jakiś interfejs z metod singletonu, które

wykorzystywane są przez testowany kod. Następnie w testowanym kodzie

(który używa singletonu) tworzymy pole z typem tego interfejsu

i inicjalizujemy je w konstruktorze, jak to zrobiliśmy w przypadku

zależności we/wy. Teraz każde wywołanie przez testowany kod jakiegoś

elementu członkowskiego singletonu zamieniamy na wywołanie tego

interfejsu z użyciem nowego pola. Jeśli nie mamy żadnych ukrytych

założeń dotyczących współzależności z innymi obiektami singletonu, to nie

musimy już nic robić.


Zwróćmy uwagę, że jeśli mamy klasy, które wykorzystują

modyfikowalne pola statyczne i nie implementujemy wzorca singeltonu, to

nasza sytuacja jest jeszcze gorsza. Jednak klasę, która używa tych pól,

możemy najpierw zrefaktoryzować pod kątem wewnętrznego

wykorzystania singletonu (zakładając, że są one oznaczone jako private


lub używane są wyłącznie wewnątrz tej klasy), następnie zrefaktoryzować

testowany kod w celu wykorzystania tego singletonu zamiast metod

statycznych, a na koniec zastosować refaktoryzację opisaną wcześniej.

W przypadku, gdy pola te są również oznaczone jako public


i modyfikowane są przez inne klasy, to problem ten będzie znacznie

bardziej skomplikowany, ponieważ kod, który nimi operuje, może być

rozsiany po całej podstawie kodu. W takim wypadku powinniśmy najpierw

znaleźć wszystkie użycia tych pól statycznych, a następnie logikę, która

nimi manipuluje, przenieść i pogrupować do klasy, w której dane pole jest

zadeklarowane. Tylko wtedy możemy uczynić je prywatnymi

i kontynuować refaktoryzację zgodnie z wcześniejszym opisem.

Tworzenie obiektu zależnego w testowanym kodzie

Każdy obiekt musi zostać utworzony za pomocą operatora new oraz

konstruktora tego obiektu, zanim będziemy mogli z niego skorzystać. Jest

to wiadome i robimy tak przez cały czas. Ale jeśli tworzymy instancję

skonkretyzowanej klasy (a nie jakiegoś interfejsu) wewnątrz testowanego

kodu, to nie możemy odizolować jej w teście za pomocą obiektu atrapy.

Ponieważ testowany kod jawnie określa klasę, z której tworzona jest

instancja, test nie ma możliwości wstrzyknięcia obiektu atrapy. Załóżmy na

przykład, że jakaś klasa logiki biznesowej tworzy instancję obiektu

w warstwie dostępu do danych, która pobiera pewne dane z bazy danych,

aby je przetworzyć, a my chcemy odizolować tę klasę, aby przetestować


samą tę klasę logiki biznesowej. Załóżmy, że ten obiekt warstwy dostępu do

danych otwiera połączenie z bazą danych wewnątrz swojego konstruktora

i zamyka je za pomocą metody Dispose. Aby połączenie do bazy danych


było krótkie, obiekt dostępu do danych tworzony jest tylko wtedy, gdy jest

potrzebny, wewnątrz odpowiedniej metody logiki biznesowej, po czym jest

usuwamy zaraz po pozyskaniu odpowiednich danych z bazy danych.

Obecnie nie mamy sposobu na przekazanie atrapy do tej metody,

a zrefaktoryzowanie klasy i przekazanie obiektu warstwy dostępu do

danych w konstruktorze również nie jest zalecane, ponieważ nie chcemy

otwierać połączenia, zanim będziemy chcieli pozyskać te dane.

Aby rozwiązać ten problem, powinniśmy skorzystać z wzorca

projektowego fabryki (factory). Wzorzec fabryki składa się z obiektu, który

zawiera metodę tworzącą i zwracającą kolejny obiekt. Metoda ta

zadeklarowana jest w taki sposób, aby zamiast skonkretyzowanej klasy tego

obiektu zwracała interfejs, jaki powinien implementować tworzony obiekt.

Aby zasymulować obiekt zależny, z obiektu warstwy dostępu do danych

oraz obiektu fabryki, który ten obiekt tworzy, musimy wyodrębnić jakiś

interfejs. Następnie, za pomocą konstruktora, powinniśmy wstrzyknąć tę

fabrykę do testowanego kodu jako interfejs i użyć tego interfejsu fabryki

wewnątrz metody logiki biznesowej do utworzenia instancji klasy. Listing

17.7 pokazuje pseudokod, dzięki któremu możemy dokładnie zobaczyć,

w jaki sposób może to wyglądać. Zauważmy, że w teście musimy utworzyć

atrapę fabryki, która zwraca naszą atrapę obiektu warstwy dostępu do

danych, i przekazać tę fabrykę do konstruktora klasy logiki biznesowej.

public interface IDataProviderFactory


{
IDataProvider CreateInstance();
}

public interface IDataProvider


{
Data RetrieveData();
void CloseConnection();

}
public class BusinessLogicClass
{
private readonly IDataProviderFactory
_dataProviderFactory;
public
BusinessLogicClass(IDataProviderFactory
dataProviderFactory)
{
_dataProviderFactory =
dataProviderFactory;
}
public void DoSomethingImportant()
{
// ...
var dataProvider =
_dataProviderFactory.CreateInstance();
var data = dataProvider.RetrieveData();
dataProvider.CloseConnection();
// ... użyj danych
}
}

Listing 17.7. Pseudokod pokazujący użycie fabryki

Opanowywanie czystego kodu i zasad SOLID

Jak widzimy, testy jednostkowe (bez względu na to, czy pisane są za

pomocą TDD, czy też nie) wymagają, aby prawie żadna klasa nie miała

bezpośredniej zależności od innej skonkretyzowanej klasy. To sprawia, że

klasy te są małe i niezależne, a zatem modułowe i możliwe do ponownego

wykorzystania. Często mówi się, że klasy te są ze sobą luźno powiązane

(loosely coupled). Jedną z największych korzyści metodyki TDD jest

właśnie to: pozwala nam ona pisać dobry, modułowy i obiektowy kod,

który jest zwykle łatwy w utrzymaniu. Z tego powodu często mówi się, że

TDD oznacza tak naprawdę „Projektowanie oprogramowania sterowane

testami” (Test Driven Design), a nie „Tworzenie oprogramowania

sterowane testami” (Test Driven Development).

Ponieważ w większości przypadków starszy kod jest czymś, z czym

musimy po prostu żyć, nie wystarczy pozwolić, aby testy prowadziły nas

w kierunku takiego projektu. Musimy wiedzieć, w jaki sposób pisać luźno

powiązany kod bez względu na stosowanie metodyki TDD. Ponadto

niektórym osobom „udaje się” pisać źle zaprojektowany kod również za

pomocą TDD, przez co nie zostają z trudnym w utrzymaniu kodem, ale

również z wieloma testami jednostkowymi, które wiążą testowany kod

z tym złym projektem i jeszcze bardziej utrudniają jego modyfikowanie.

Choć reguła „usuwania duplikacji” może zapobiec większości z tych

przypadków, to identyfikowanie duplikacji również jest praktyką, którą

trzeba najpierw opanować. Niektóre duplikacje są łatwe do zauważenia, ale

inne są bardziej nieuchwytne.


Wujek Bob (Robert Martin) zdefiniował pięć zasad dostarczających

wskazówek dla luźno powiązanego projektu, które znane są pod nazwą

SOLID (patrz tekst uzupełniający). Zasady te pozwalają łatwiej zrozumieć

sposób pisania luźno powiązanego kodu, jednak opanowanie tych zasad nie

jest proste.

ZASADY SOLID

W pierwszej dekadzie XXI wieku Robert C. Martin, znany szerzej

jako Wujek Bob, wprowadził (w ramach różnych dokumentów)

następujące zasady, które dotyczą zarządzania zależnościami

pomiędzy klasami w projekcie obiektowym i sprawiają, że klasy te są

ze sobą luźno powiązane. Zasady te są następujące:

Zasada jednej odpowiedzialności (Single Responsibility

Principle, SRP) – Klasa powinna mieć jeden i tylko jeden powód

przemawiający za jej zmianą. Innymi słowy: klasy powinny być

małe i skupiać się tylko na jednej rzeczy. Jakiekolwiek dodatkowe

zachowania powinny zostać wyodrębnione do innych

współpracujących klas.

Zasada otwarte-zamknięte (Open/Close Principle, OCP) –

Powinniśmy być w stanie rozszerzyć działanie klasy bez jej

modyfikowania. Rozszerzania działanie klasy bez jej

modyfikowania można dokonać poprzez nadpisanie metod

wirtualnych lub wyłącznie w oparciu o interfejsy, które mogą

zostać dostarczone przez klienta, zwykle za pomocą konstruktora.

Zachowanie można wówczas rozszerzyć poprzez dostarczenie

innych klas, które implementują wymagany interfejs i rozszerzają

oryginalne zachowanie. Kolejnym sposobem na


zaimplementowanie tej zasady jest posłużenie się zdarzeniami:

klasa wyzwala zdarzenie, a jej współpracownicy mogą

zarejestrować się do tych zdarzeń w celu rozszerzenia

oryginalnego zachowania.

Zasada podstawienia Liskov (Liskov Substitution Principle, LSP)

– Musi istnieć możliwość podstawienia klas pochodnych za klasy

bazowe. Innymi słowy: ani klasa bazowa, ani jej klienci nie

powinni być świadomi żadnych konkretnych szczegółów klas

pochodnych lub nawet ich istnienia. Oznacza to, że dodawanie lub

modyfikowanie klas pochodnych powinno działać bez

modyfikowania klasy bazowej lub jej klientów. Poza tym, gdy

uzyskujemy dostęp do obiektu poprzez interfejs, nie powinniśmy

bazować na konkretnych szczegółach implementacji, które istnieją

w implementujących go klasach.

Zasada segregacji interfejsów (Interface Segregation Principle,

ISP) – Tworzymy szczegółowe interfejsy, które są specyficzne dla

każdego klienta. Innymi słowy: jeśli klasa jest przeznaczona do

wykorzystania przez dwóch (lub więcej) różnych klientów

w różnych celach, to klasa ta powinna udostępniać oddzielne

interfejsy, po jednym dla każdego z tych klientów. Na przykład

obiekt strumienia pamięci buforowanej może udostępniać

oddzielne interfejsy dla komponentu odczytującego i zapisującego,

ponieważ komponent odczytujący nie wymaga funkcjonalności

komponentu zapisującego i odwrotnie.

Zasada odwrócenia zależności (Dependency Inversion Principle,

DIP) – Tworzymy zależności od abstrakcji, a nie od konkretnych

implementacji. Oznacza to, że rozważana klasa nie powinna


odwoływać się do innych skonkretyzowanych klas, ale wyłącznie

do interfejsów, które te klasy powinny udostępniać. Na przykład

klasa logiki biznesowej, zamiast odwoływać się do klasy warstwy

dostępu do danych, powinna jedynie odwoływać się

i wykorzystywać interfejs, który udostępnia klasa warstwy dostępu

do danych. To sprawia, że obie te klasy zależą od interfejsu, ale

żadna z nich nie zależy od drugiej, więc mogą one zostać

zmodyfikowane, rozszerzone lub nawet zamienione niezależnie od

tej drugiej klasy.

Witryna Wujka Boba (wspomniana w przypisie dolnym) zawiera łącza

do pełnych dokumentów opisujących szczegółowo każdą z tych zasad.

Ponadto w 2011 roku napisałem serię wpisów blogowych57 na temat

związków pomiędzy tymi zasadami i metodyką TDD.

Opanowywanie umiejętności refaktoryzowania

Nawet jeśli opanujemy zasady SOLID i będziemy w stanie dostrzec nawet

najbardziej nieuchwytne duplikacje, to nadal nie będzie to wystarczające.

Aby opanować TDD i testowanie jednostkowe, musimy również zdobyć

odpowiednie umiejętności w zakresie refaktoryzacji. Jak wspomnieliśmy

wcześniej, proces refaktoryzacji najlepiej realizować jako serię małych

i bezpiecznych kroków, które zawsze pozostawiają kod w poprawnym

stanie. Martin Fowler napisał na ten temat całą książkę (Refaktoryzacja.

Ulepszanie struktury istniejącego kodu58), aby jednak być w tym naprawdę

efektywnymi, musimy również opanować dostępne narzędzia refaktoryzacji

dostarczane przez nasze środowisko IDE. Ponadto, gdy już opanujemy te

techniki, możemy opracować nasze własne techniki refaktoryzacji, a nawet


być w stanie rozszerzyć nasze środowisko IDE, tak aby wykonywało ono te

przekształcenia automatycznie, jak robią to jego wbudowane funkcje.

Największe wyzwanie: co testować?

Wszystkie omówione do tej pory wyzwania mają dość techniczny

charakter. Ale prawdopodobnie największym wyzwaniem w przypadku

stosowania metodyki TDD będzie podjęcie decyzji o tym, co powinniśmy

przetestować. Jeśli naprawdę będziemy testować każdą klasę oddzielnie,

wyodrębniając ją ze wszystkich współpracujących z nią klas za pomocą

interfejsów (i używając atrap w testach), to możemy skończyć z mnóstwem

testów, z których znaczną część stanowić będą testy bardzo techniczne,

niezwiązane bezpośrednio z historyjką użytkownika. Podczas gdy TDD

i testy jednostkowe prowadzą do projektu, w którym klasy są ze sobą luźno

powiązane i łatwe w utrzymaniu, te testy techniczne (np. testy, które

weryfikują, że określone argumenty zostały dostarczone do konkretnej

metody na konkretnym interfejsie, lub testy dla klasy, która obsługuje

niskopoziomową strukturę danych), stają się ściśle powiązane ze

szczegółami wielu interfejsów, które tworzone są w celu zaspokojenia

możliwości przetestowania tych klas. Gdy chcemy zrefaktoryzować

oddziaływania pomiędzy kilkoma klasami, musimy zmienić interfejsy

między nimi (i prawdopodobnie niektóre z ich implementacji).

W konsekwencji będziemy również musieli zmienić większość z testów

tych klas, które je implementują lub symulują. Możemy wówczas

dowiedzieć się, że te szczegółowe testy jednostkowe nie tylko nam nie

pomagają, ale nawet utrudniają nam utrzymywanie!

Oznacza to, że pisanie testów jednostkowych o zakresie testowania na

poziomie pojedynczej klasy nie zawsze jest efektywne i czasem lepiej jest

po prostu przetestować wspólnie kilka klas. Nasuwa się jednak pytanie:


jaką decyzję podjąć? Krótka odpowiedź jest taka, że zależy to głównie od

naszego doświadczenia. Poniżej jednak znajdują się pewne wskazówki,

które mogą nam pomóc rozwiązać ten problem.

Choć wszystkie te wyzwania mogą wyglądać dosyć przytłaczająco, to

opanowanie wszystkich wyżej wymienionych wyzwań sprawi, że będziemy

lepszymi programistami, nawet jeśli nie będziemy stosować TDD. Ale

metodyka TDD staje się potencjalnie również najpotężniejszym narzędziem

w naszym zestawie, ponieważ umożliwia nam ona pisanie czystszego

i łatwiejszego w utrzymaniu kodu, który możemy również łatwo

zweryfikować i upewnić się, że robi to, czego od niego oczekujemy.

Używanie metodyki TDD w celach, do jakich


była projektowana

W rzeczywistości żadna ze znanych mi formalnych definicji testów

jednostkowych, zwłaszcza definicja propagatorów programowania

ekstremalnego, takich jak Kent Beck i Martin Fowler, nie mówi o tym, że

test jednostkowy (lub dowolny test wykonywany w stylu TDD) musi być

testem pojedynczej klasy lub metody, ale jednak w jakiś sposób stało się to

pewnym powszechnym przekonaniem. W swoim wpisie blogowym

zatytułowanym „UnitTests”, oraz we wspomnianej wcześniej serii „Czy

metodyka TDD jest martwa?”, Martin Fowler wyjaśnia, że odkąd stworzyli

koncepcję testów jednostkowych i metodykę TDD, nie przejmowali się już

tak bardzo izolowaniem każdej klasy i zwykle testowali współpracę

pomiędzy kilkoma klasami. Dublerów testowych (atrap) używali oni tylko

od czasu do czasu, gdy testowanie tych klas z ich prawdziwymi

współpracownikami było kłopotliwe.


Uwaga

Testy pojedynczej klasy, które dla wszystkich pozostałych klas

wykorzystują atrapy, Martin Fowler nazywa testami

osamotnionymi (solitary tests), a osoby promujące ten styl testów

nazywa „testerami xunit w stylu mockistycznym” (mockist style

xunit testers). Z kolei testy zawierające kilka klas nazywa testami

towarzyskimi (sociable tests), a ich promotorów nazywa

„testerami xunit w stylu klasycznym” (classic style xunit testers).

W rzeczywistości, gdy zniesiemy ograniczenie mówiące o tym, że dany

test jednostkowy może testować wyłącznie jedną klasę, zobaczymy

wówczas, że metodyka TDD z „towarzyskimi” testami jednostkowymi jest

bardzo podobna do metodyki ATDD, może z wyjątkiem poniższych

niuansów:

1. W podejściu TDD osoba, która pisze testy jest zwykle tym samym

programistą, który implementuje kod. Ponieważ jednak zwolennicy

metodyki TDD często promują programowanie w parze, możemy zatem

powiedzieć, że dwie osoby, które piszą testy, są tymi samymi osobami,

które implementują wspólnie kod.

2. W metodyce TDD tendencja polega na wybieraniu nieco mniejszych

zakresów niż w przypadku ATDD. Ponadto jest tutaj większy nacisk na

szybsze testy i bardziej ścisłą pętlę przyczynowo-skutkową.

Podejście „z zewnątrz do środka” kontra podejście „od


środka na zewnątrz”

Ten drugi niuans jest również związany z innym stylem stosowanym przez

niektórych praktyków: podejście „z zewnątrz do środka” polega na tym, że


zaczynamy od testów o szerszym zakresie, które pokrywają scenariusz

bliższy perspektywie użytkownika, a następnie wypełniamy dodatkowe

szczegóły za pomocą testów jednostkowych (testów o mniejszym zakresie).

Testy o szerszym zakresie mogą kończyć się niepowodzeniem do momentu,

aż wszystkie testy o mniejszym zakresie będą kończyć się sukcesem.

Podejście „od środka na zewnątrz” polega na tym, że powinniśmy

rozpocząć od testu jednostkowego o wąskim zakresie, który pokrywa

jedynie istotę (logikę biznesową) tego scenariusza, a następnie pisać więcej

testów, które rozciągają zakres tego testowania, aż zostanie

zaimplementowana cała historyjka użytkownika. W rzeczywistości

możemy zrefaktoryzować taki kod testu o wąskim zakresie, wyodrębnić

wszystkie wywołania testowanego kodu do metod wirtualnych w klasie

testowej, a następnie utworzyć nową pochodną klasę testową dla testu

o szerszym zakresie i nadpisać te metody wirtualne w celu wywołania tego

samego zachowania z szerszego zakresu (np. za pomocą API REST lub

nawet za pośrednictwem narzędzia Selenium). Jest to tak naprawdę

implementacja wzorca abstrakcyjnego zakresu testowania, omówionego

w rozdziale 6.

Bazując na moim własnym doświadczeniu, uważam, że większość

funkcji powinna rozpoczynać się od historyjki użytkownika, której

zachowanie jest stosunkowo proste i która w zasadzie przenosi tylko pewne

dane z jednego miejsca do innego, potencjalnie wykonując pewne proste

operacje na tych danych, podczas gdy kolejne historyjki użytkownika

powinny poszerzać logikę biznesową tej funkcji, czyniąc ją coraz bardziej

złożoną. W takich przypadkach nie ma zbyt wielkiego usprawiedliwienia

dla rozpoczynania od testów jednostkowych o wąskim zakresie, ponieważ

testowany kod będzie dosyć prosty (zwłaszcza gdy nie ma w nim zbyt

wielu manipulacji danymi). Test o większym zakresie będzie tu


uzasadniony, ponieważ może pokazać, że dane przepływają przez system

we właściwy sposób. Ponieważ dalsze historyjki użytkownika dotyczące tej

samej funkcji sprawiają, że jej wewnętrzna logika biznesowa jest bardziej

złożona, a ponadto będziemy mieć już testy weryfikujące przepływ danych,

możemy skupić się na pokrywaniu tej logiki biznesowej za pomocą testów

jednostkowych o mniejszym zakresie. Jeśli zrobimy to w ten sposób, to

w większości przypadków w efekcie końcowym uzyskamy wiele testów

jednostkowych i mniejszą liczbę testów integracyjnych i testów systemu, co

będzie zgodne z paradygmatem piramidy testów. Nie oznacza to jednak, że

zawsze tak będzie i że nie powinniśmy celować w utworzenie takiej

piramidy, ale odpowiedni zakres powinniśmy wybierać głównie na

podstawie konkretnych przypadków.

Podsumowanie

Jeśli nie będziemy pisać naszego kodu z myślą o testowaniu jednostkowym,

to istnieje szansa, że w takiej postaci nie będzie można go przetestować za

pomocą takich testów. Aby takie testowanie było możliwe, musimy

zrefaktoryzować ten kod i wyodrębnić z niego zewnętrzne zależności

i operacje we/wy do zewnętrznych klas, a następnie odwoływać się do nich

za pośrednictwem interfejsów. Właściwe wykonanie tego wymaga pewnych

umiejętności, które trzeba będzie nabyć. Wykorzystywana przez nas

metodyka TDD może pokierować nas we właściwym kierunku, ale nie

rozwiąże wszystkich naszych problemów, a poza tym do jej opanowania

wymagana jest pewna praktyka i doświadczenie. Gdy już jednak

opanujemy te techniki oraz samą metodykę TDD, nasz kod będzie znacznie

łatwiejszy w utrzymaniu i uczyni z nas lepszych programistów. Jeśli


zrobimy to w odpowiedni sposób, to prawdopodobnie zdamy sobie sprawę,

że tak naprawdę metodyki TDD i ATDD są dokładnie tym samym.


Rozdział 18. Inne rodzaje testów
automatycznych

Wszystkie dotychczasowe rozdziały dotyczyły jedynie testów

funkcjonalnych. Istnieje jednak kilka innych rodzajów testów

automatycznych, a ten rozdział omawia te najczęściej stosowane.

Testy wydajności

Celem testów wydajności jest mierzenie czasu potrzebnego systemowi na

ukończenie różnych operacji. Zwykle powodem wykonywania takich

testów jest to, że wydajność systemu w znacznym stopniu wpływa na

doświadczenie użytkownika, a czasem nawet bezpośrednio przekłada się na

uzyskiwany przychód – przykładem tego mogą być witryny handlu

elektronicznego, do których użytkownicy mogą przenieść się do

konkurencji, jeśli mają złe doświadczenie z naszą witryną.

Uwaga

Niektórzy ludzie mylą testowanie wydajności z testowaniem

obciążenia, które sprawdza sposób, w jaki system obsługuje


wielu jednoczesnych użytkowników. Choć obydwa te rodzaje

testów w pewnym stopniu są ze sobą powiązane, to jednak służą

one innym celom i wykorzystują inne techniki. Testowanie

obciążenia jest omawiane w dalszej części tego rozdziału.

Mierzenie wydajności w środowisku


produkcyjnym

Większość aplikacji sieci Web, zwłaszcza tych hostowanych w chmurze,

może wykorzystywać paradygmat wydań kanarkowych (omówiony

w rozdziale 5), w ramach którego każde wydanie jest najpierw

dystrybuowane tylko do niewielkiej liczby użytkowników (może to być

wyróżniona grupa użytkowników, jak w przypadku ochotniczych testerów

wersji beta, lub też losowa grupa zwykłych użytkowników wybieranych

przez komponent równoważenia obciążenia), po czym stopniowo wypiera

starsze wydanie. W takim wypadku nie musimy wykonywać kosztownych

testów wydajności, ponieważ możemy bezpośrednio mierzyć faktyczną

wydajność użytkowników. W przypadku aplikacji biznesowych możemy

najpierw udostępnić aplikację jedynie w formie pilotażu dla jednego

zespołu, w ramach którego możemy mierzyć jej wydajność i stopniowo ją

poprawić, a dopiero potem rozszerzyć dystrybucję tej aplikacji na całą

naszą organizację.

Zwróćmy uwagę, że w przypadku aplikacji internetowych i aplikacji

typu klient/serwer czas potrzebny na wykonanie jakiejś operacji jest tak

naprawdę sumą: czasu, w jakim klient przetwarza akcję użytkownika

i przygotowuje komunikat do wysłania do serwera, czasu potrzebnego na

przesłanie komunikatu od klienta do serwera, czasu, w jakim serwer


przetwarza i obsługuje to żądanie i przygotowuje odpowiedź, czasu

potrzebnego na przesłanie tej odpowiedzi z powrotem do klienta, oraz

czasu, w jakim klient przetwarza tę odpowiedź i wyświetla wynik na

ekranie. Jest to dosyć uproszczony opis, ponieważ pomiędzy

poszczególnymi uczestnikami może być przesyłanych kilka takich

komunikatów, a niektóre odstępy czasowe mogą na siebie zachodzić lub

być względem siebie całkowicie równoległe. Ponadto te odstępy czasowe

mogą zostać podzielone na mniejsze interwały. Rysunek 18.1 pokazuje

interwały prostej operacji klient/serwer.

Rysunek 18.1. Interwały operacji klient/serwer

Jeśli chcemy zbierać statystyki wydajności dotyczące operacji

wykonywanych przez użytkownika w środowisku produkcyjnym, a czas

przetwarzania po stronie klienta może być wystarczająco duży, aby można

go było zmierzyć, to musimy wyposażyć klienta w mechanizm mierzenia

i zbierania tych danych oraz wysłania ich z powrotem do serwera. Obecnie

istnieje wiele złożonych narzędzi do monitorowania, które pozwalają nam

analizować, wykonywań zapytania i zagłębiać się w wydajność aplikacji –

zarówno po stronie klienta, jak i po stronie serwera. Dzięki nim możemy

nie tylko dowiedzieć się, które z operacji wykonywane są przez długi czas,
ale również poznać dokładne scenariusze, w których taka sytuacja ma

miejsce, wraz z częstotliwością ich występowania.

Ale nie wszystkie aplikacje nadają się tak samo dobrze na wydania

kanarkowe. Niektóre aplikacje muszą być wydajne już od ich pierwszego

wydania lub też mogą nie mieć wystarczającej liczby użytkowników

potrzebnej do zebrania odpowiednich statystyk. Przykładowo aplikacje

medyczne, które przed wejściem do produkcji powinny otrzymać certyfikat

Agencji Żywności i Leków, nie są uprawnione do wykonywania tych rund

pomiarów wydajności i usuwania wąskich gardeł w środowisku

produkcyjnym. Kolejnym przykładem, w którym mierzenie wydajności za

pomocą testów może być nadal istotne, są aplikacje, które powinny być

używane głównie w rzadkich sytuacjach awaryjnych. W większości

przypadków wszędzie tam, gdzie wydania kanarkowe są nieosiągalne,

przed wydaniem aplikacji niezbędne będą standardowe dedykowane testy

wydajności.

Czego nie robić?

Typowym błędnym przekonaniem dotyczącym mierzenia wydajności jest

to, że możemy wykorzystać istniejące testy funkcjonalne do mierzenia

wydajności operacji, które wykonują. Z technicznego punktu widzenia

faktycznie jest to możliwe, a czasem nawet i bardzo proste, zwłaszcza

w przypadku testów opartych na API HTTP, gdzie czas pomiędzy każdym

żądaniem i odpowiedzią może być mierzony automatycznie

i bezproblemowo, a także zapisany do bazy danych. Choć w ten sposób

zbieramy sporą ilość danych dotyczących wydajności, to jednak wcale nie

ułatwia nam to podejmowania decyzji w zakresie wydajności systemu.

Ponadto, gdy pomiar nie jest tak transparentny dla testu, jak w przypadku

testów opartych na API HTTP, mieszanie ze sobą testów funkcjonalnych


i wydajności często sprawia, że kod jest bardziej złożony i trudniejszy

w utrzymaniu.

Głównym powodem, dla którego mierzenie wydajności w ramach

testowania funkcjonalnego nie jest zbyt korzystne, jest to, że testy

funkcjonalne często są wykonywane w odizolowanych, sterylnych

środowiskach, które nie odzwierciedlają prawdziwego środowiska

produkcyjnego. Aby naprawdę testować, a nie zbierać po prostu danych,

musimy zdefiniować oczekiwany wynik – podobnie jak w testach

funkcjonalnych. Różnica polega jednak na sposobie definiowania tego

oczekiwanego wyniku.

Ludzie często myślą, że nadal mogą wykorzystywać dane wydajności

mierzone przez testy funkcjonalne i definiować oczekiwany rezultat

(dotyczący wydajności) jako próg procentowej degradacji wydajności

w porównaniu do poprzedniej kompilacji lub wersji. Innymi słowy, testy

ostrzegą ich, gdy wydajność każdej operacji była gorsza niż w poprzedniej

kompilacji. Podejście to ma jednak kilka wad:

1. Może istnieć wiele czynników, które mają wpływ na mierzony czas

wykonywania operacji. Jeśli nie weźmiemy pod uwagę wszystkich tych

czynników w testach, to mogą pojawić się wyniki fałszywie dodatnie,

które sprawią, że dane staną się niewiarygodne.

2. Same testy można zmienić tak, aby robiły pewne rzeczy w inny sposób,

aby usprawnić wiarygodność lub łatwość utrzymania ich aspektów

funkcjonalnych, nie biorąc przy tym pod uwagę wpływu na pomiary

wydajności. Wzięcie pod uwagę tych efektów może doprowadzić do

powstania konfliktu z testami funkcjonalnymi w zakresie ich łatwości

utrzymania i wiarygodności, tak więc nie jest to dobre rozwiązanie.

Definiowanie oczekiwanego rezultatu


Aby zdefiniować oczekiwany rezultat, powinniśmy najpierw zdecydować

o tym, którą operację chcielibyśmy tak naprawdę mierzyć i w jakim

scenariuszu. Zwróćmy uwagę, że ta sama operacja, jak na przykład operacja

realizacji zamówienia (check-out) w witrynie handlu elektronicznego, może

być wykonywana szybciej lub wolniej, w zależności od liczby i rodzaju

przedmiotów w koszyku zakupowym oraz innych podobnych czynników.

Po zdefiniowaniu operacji, którą chcemy mierzyć, oraz scenariusza,

w którym jest ona wykonywana, powinniśmy zdefiniować warunki

środowiskowe, w ramach których test powinien być uruchamiany: z jakim

profilem sprzętowym (liczba rdzeni procesora, ilość pamięci, rodzaj dysku

twardego itd.), profilem sieci, rozmiarem bazy danych itd. Choć najlepsze

dla dokładności pomiaru jest korzystanie ze środowisk odzwierciedlających

środowisko produkcyjne, to nie zawsze jest to możliwe. Może to być

spowodowane tym, że sprzęt wykorzystywany w środowisku

produkcyjnym jest zbyt kosztowny, aby można go było zduplikować

jedynie na potrzeby testowania wydajności. Innym powodem może być to,

że aplikacja instalowana będzie na sprzęcie klientów (w formie aplikacji

serwerowej, tradycyjnej lub mobilnej), który może mieć wiele różnych

profilów sprzętowych i możliwości, nad którymi nie mamy kontroli.

W takich przypadkach możemy nawet utworzyć wiele środowisk w celu

dokonywania pomiarów wydajności w ramach kilku takich profilów

sprzętowych.

Zwróćmy uwagę, że maszyny wirtualne zwykle współdzielą zasoby

fizyczne z innymi maszynami wirtualnymi na tym samym hoście. Często

wprowadza to spory „szum” do pomiaru, ponieważ inna maszyna wirtualna

może nagle zacząć wykorzystywać dużą ilość zasobów, pozostawiając

testowanej aplikacji jedynie niewielką ich porcję, co będzie miało wpływ na

jej wydajność. Z tego powodu preferowane jest konfigurowanie maszyny


wirtualnej z minimalną ilością przydzielonych zasobów, które nie mogą być

współdzielone z innymi maszynami wirtualnymi.

I wreszcie, trzeba zdefiniować progi dla oczekiwanej wydajności.

W przeciwieństwie do testów funkcjonalnych, w których każdy test

uruchamiamy raz i ustalamy, czy zakończył się sukcesem, czy

niepowodzeniem, w testach wydajności zwykle musimy uruchamiać ten

sam test po kilka razy albo też utworzyć pętlę wewnątrz naszego testu,

która będzie wywoływać testowaną operację i zbierać dla niej po kilka

wyników. Jeśli przedstawimy te rezultaty na wykresie (np. za pomocą

programu Excel), to powinniśmy uzyskać coś podobnego do krzywej

dzwonowej (właściwie krzywej rozkładu gamma, o której mówiliśmy

w rozdziale 13, ale nie musimy się tutaj zbytnio przejmować matematyką).

Oczekiwane rezultaty powinny być zdefiniowane w kontekście progów,

w których odpowiednio wysoki procent rezultatów nie przekracza

określonego czasu trwania. Na przykład możemy zdefiniować, że jeśli co

najmniej 90% rezultatów zostało osiągniętych w czasie poniżej pięciu

sekund, wówczas test powinien zakończyć się sukcesem. Możemy również

obliczyć średnią lub medianę, ale wartości te są zwykle mniej istotne. Przy

okazji, w taki sam sposób definiowane są zwykle umowy

o gwarantowanym poziomie świadczenia usług (service-level agreement,

SLA). Jeśli nasza firma zobowiązuje się do przestrzegania konkretnej

umowy SLA w zakresie wydajności aplikacji, powinniśmy po prostu

skorzystać z tych liczb. Jeśli nie, możemy zdefiniować te progi razem

z właścicielem produktu (i naszym zespołem) i uczynić je naszymi

wewnętrznymi „umowami SLA”, na podstawie których decydować

będziemy, czy powinniśmy już wydawać oprogramowanie, czy nie. Jeśli

mamy potok ciągłego dostarczania, to możemy uczynić ten test częścią jego

bramek.
Ponowne wykorzystywanie kodu pomiędzy testami
funkcjonalnymi i testami wydajności

Choć odradzane jest używanie tych samych testów dla testów wydajności

i testów funkcjonalnych, to zaleca się powtórne wykorzystywanie ich

wspólnego kodu. W rzeczywistości, ponieważ testy wydajności zwykle

wykonują operacje, które używane są również w testach funkcjonalnych,

ponowne wykorzystywanie ich istotnych części wspólnych wydaje się więc

mieć sens.

Badanie wąskich gardeł w wydajności

Istnieje wiele książek dotyczących wyłącznie tego tematu, ale w ramach

wprowadzenia do niego warto wspomnieć, że dostępne są narzędzia

profilowania, które pozwalają nam analizować i przyglądać się temu, ile

czasu zajęło każde wywołanie metody, ile trwała transakcja bazy danych (i

dlaczego), jakie było opóźnienie sieci itd. Czasem analizowanie tych

rezultatów może być bardzo proste, ale czasem jest ono bardzo złożone.

Tak czy inaczej, uważajmy na przedwczesną optymalizację. Przypomnijmy

sobie cytat Donalda Knutha z rozdziału 15: przedwczesna optymalizacja

jest źródłem wszelkiego zła. Nie zakładajmy, że wiemy, gdzie znajduje się

wąskie gardło, dopóki nie dokonamy pomiarów i nie przeprowadzimy

analizy głównej przyczyny problemu.

Wydajność postrzegana a wydajność rzeczywista

W kontekście wydajności warto wspomnieć, że eksperci często rozróżniają

wydajność rzeczywistą i wydajność postrzeganą. Podczas gdy wydajność

rzeczywista jest łatwiejsza do zmierzenia, wydajność postrzegana jest tym,

co jest istotne w kontekście doświadczenia użytkownika. Jeśli na przykład


aplikacja potrzebuje pięciu sekund na pobranie pewnych danych, a później

pokazuje je wszystkie użytkownikowi w tym samym momencie, to będzie

się mu wydawać, że musi czekać dłużej, niż gdyby operacja taka trwała

łącznie siedem sekund, ale najbardziej istotne dane prezentowane były już

po dwóch sekundach, natomiast reszta tych danych wyświetlała się po

dodatkowych pięciu.

Testy obciążeniowe

Choć testy obciążeniowe powiązane są z testami wydajności (i z tego

powodu wiele osób myli je ze sobą), to mierzą one zupełnie inną rzecz.

Testy obciążeniowe mierzą to, ile zasobów sprzętowych serwer

wykorzystuje dla określonej liczby jednoczesnych użytkowników. Test

może zasymulować maksymalną liczbę oczekiwanych użytkowników, jeśli

nie jest ona zbyt wysoka, lub tylko jakiś procent tej liczby, który potem

możemy ekstrapolować w celu oszacowania, czy środowisko produkcyjne

ma wystarczające zasoby do obsługi pełnego obciążenia, lub też jak wiele

zasobów (np. ile serwerów) jest potrzebnych do takiej obsługi. Zauważmy,

że testowanie jedynie jakiegoś procentu maksymalnego obciążenia

i ekstrapolowanie uzyskanych wyników jest mniej dokładne, ale czasami

jest to jedyny możliwy sposób. Testy obciążeniowe powinny również

gwarantować, że na przykład z powodu złej architektury nie istnieje żadne

wąskie gardło, które można wyeliminować poprzez dodanie większej ilości

zasobów sprzętowych. Zwróćmy uwagę, że testy obciążeniowe mają

znaczenie jedynie dla serwerów, ponieważ klienci nie muszą obsługiwać

wielu użytkowników jednocześnie.

Podobnie jak w przypadku testów wydajności, ten rodzaj testu również

staje się mniej istotny w erze chmury, ponieważ zasoby mogą być
dynamicznie pobierane lub zwalniane zgodnie z wykonywanymi na żywo

pomiarami zasobów wykorzystywanych w środowisku produkcyjnym.

Nadal jednak istnieją pewne scenariusze, w których testy te są ważne, np.:

Gdy wdrażanie witryny nie może być wykonywane stopniowo,

ponieważ witryna musi zostać uruchomiona pod konkretne wydarzenie.

Gdy nie używamy chmury lub nawet chmury prywatnej, lub

korzystamy ze specjalnych zasobów sprzętowych. Jest to szczególnie

istotne w przypadku starszych, „monolitycznych” aplikacji.

W rzeczywistości w tym pierwszym przypadku testy obciążeniowe

mogą zostać wykonane jednorazowo, bez konieczności dodawania ich

do potoku ciągłej integracji/ciągłego dostarczania. Jednak same

koncepcje pozostają już w zasadzie takie same.

Jak działają testy obciążeniowe

Istnieje wiele różnych narzędzi do testowania obciążenia (z czego

najbardziej znanymi są JMeter, LoadUI Pro i LoadComplete firmy

SmartBear, Visual Studio Load and Performance, LoadRunner i Gatling).

Każde z nich ma inne funkcje i zalety, ale ich podstawowa idea jest zawsze

taka sama. Nagrywamy lub piszemy testy, które wysyłają żądania do

serwera, a narzędzie testowania obciążenia symuluje wielu jednoczesnych

użytkowników, którzy działają równolegle, poprzez uruchomienie tych

testów z poziomu różnych wątków lub procesów na tej samej maszynie.

Zwróćmy uwagę, że sama maszyna testowania powinna być

wystarczająco wydajna, aby mogła uruchamiać tak wiele wątków

i procesów, natomiast po przekroczeniu określonej liczby jednoczesnych

symulowanych użytkowników (zależnie od mocy maszyny testowania),

możemy zostać zmuszeni użyć więcej niż jednej takiej maszyny (agenta) do
zasymulowania pożądanego obciążenia. Obecnie często agenci testowania

uruchamiani są w chmurze, aby możliwe było zasymulowanie obciążenia

z różnych regionów geograficznych. Zauważmy, że sama testowana

aplikacja nie musi działać w środowisku chmury, aby mogła

wykorzystywać chmurę do uruchamiania agentów testowania obciążenia.

Kolejną rzeczą, jaką warto podkreślić, jest to, że nie ma sensu testować

aplikacji klienta, ponieważ będzie ona jedynie pobierać zasoby z maszyny

testowania, nie generując przy tym dodatkowego obciążenia. Z tego

powodu lepiej będzie, jeśli test będzie wysyłał żądania i otrzymywał

odpowiedzi bezpośrednio, a nie za pośrednictwem klienta (np. za pomocą

narzędzia Selenium). Czasem jednak koszty pisania i utrzymywania testów

oddzielnie dla strony serwera, wyłącznie w celu przetestowania obciążenia,

będą nieuzasadnione, zaś ponowne wykorzystywanie testów

funkcjonalnych działających po stronie klienta, lub przynajmniej ich

infrastruktury, będzie mimo wszystko preferowanym wyborem.

Definiowanie oczekiwanego rezultatu

Jak zawsze, przed zaimplementowaniem jakiegokolwiek rodzaju testów

automatycznych, musimy najpierw zdefiniować nasz oczekiwany rezultat.

Oczekiwany rezultat testów obciążeniowych jest zwykle zdefiniowany jako

możliwość obsłużenia przez serwer maksymalnej oczekiwanej liczby

jednoczesnych użytkowników. Mamy również jednak wiele innych

możliwości:

Którą z dostępnych operacji użytkownicy wykonują najczęściej?

Wykonywania jakich innych operacji oczekujemy od użytkowników

i jaki jest ich rozkład w danym momencie? Możemy przykładowo

powiedzieć, że w witrynie handlu elektronicznego oczekujemy, iż


w danym momencie 60% użytkowników wyszukuje i przegląda

produkty, 25% z nich dokonuje procesu realizacji zamówienia, 10%

stanowią nowi użytkownicy rejestrujący się na stronie, zaś pozostałe

5% użytkowników wysyła żądania „skontaktuj się z nami”.

Jak długo typowy użytkownik czeka między kolejnymi operacjami?

Jaki jest oczekiwany czas odpowiedzi na każde żądanie?

Jaki procent odpowiedzi stanowiących błąd lub przekroczenie czasu

żądania jest akceptowalny? Oczywiście pożądaną odpowiedzią na to

pytanie będzie zawsze 0, ale rzadko kiedy możemy to osiągnąć. Z tego

powodu ważne jest realistyczne definiowanie tego progu.

I tak dalej.

Zauważmy, że wszystkie te parametry dotyczą użytkowników lub

interakcji serwera z klientem, ale podobnie jak w przypadku testów

wydajności, powinniśmy również zdefiniować cechy serwera (lub

serwerów), który powinien obsłużyć obciążenie. Mówiąc ogólnie,

kryteriami sukcesu dla testu obciążeniowego jest to, że serwer kontynuuje

działanie i obsługuje użytkowników w oczekiwanym przez nas czasie

odpowiedzi i procencie błędów.

Definiowanie progów

Ponieważ nie zawsze możliwe (lub zbyt kosztowne) jest posiadanie

środowiska testowania obciążenia, które pozwala na zasymulowanie

maksymalnego obciążenia, jakie jest oczekiwane w środowisku

produkcyjnym, zwykle obciążenie testowane jest na mniejszym, mniej

zaawansowanym środowisku z proporcjonalnie mniejszym obciążeniem.

Co więcej, będziemy często uruchamiać test przez określony okres czasu


(zwykle kilka godzin), aby upewnić się, że wykorzystanie zasobów przez

system stabilizuje się, a nie zwiększa pod stałym obciążeniem. Najbardziej

powszechnym rodzajem błędu, który powoduje zwiększenie wykorzystania

zasobów, są wycieki pamięci, czyli sytuacje, w których wykorzystanie

pamięci zwiększa się wraz z upływem czasu bez żadnego uzasadnionego

powodu. Wyciek taki może dotyczyć również innych zasobów (dysku,

sieci, procesora itd.). Z tego powodu zwykle nie ograniczamy się jedynie do

kryteriów wspomnianych powyżej, ale chcemy również mierzyć

wykorzystanie zasobów, takich jak procesor, pamięć i inne metryki stanu

zdrowia, i upewnić się, że w miarę upływu czasu nie przekraczają one

określonego progu. Niektóre z narzędzi do testowania obciążenia mają te

metryki wbudowane, ale standardowo środowiska produkcyjne

i odzwierciedlające je środowiska do testowania aplikacji, tak czy inaczej

powinny korzystać z narzędzi do monitorowania stanu zdrowia

i diagnostyki.

Ponieważ agenci testowania obciążenia sami poddawani są obciążeniu

podczas wykonywania testu, ważne jest monitorowanie także ich własnych

metryk stanu zdrowia. Jeśli zobaczymy, że zbliżają się one do

wyznaczonych limitów, to powinniśmy rozważyć dodanie kolejnych

agentów lub zwiększenie zasobów sprzętowych, aby uchronić tych agentów

przed awarią.

Tworzenie testów i ich środowisk

Aby dowiedzieć się, jakie powinny być te progi, musimy najpierw zmierzyć

je przynajmniej raz w celu ustalenia jakiegoś punktu odniesienia. Jak

wspomnieliśmy wcześniej, czasem chcemy po prostu uruchomić testy

obciążeniowe tylko raz, aby dokonać pomiaru tych wartości. Ale nawet

wtedy, jeśli spróbujemy uruchomić test po raz pierwszy ze wszystkimi


wspomnianymi powyżej kryteriami i przy maksymalnym obciążeniu, to

prawdopodobnie szybko zakończy się to niepowodzeniem. Istnieje wiele

czynników ograniczających skalę, jaką test próbuje osiągnąć, tak więc

musimy najpierw zidentyfikować i usunąć te limity. Przykładowo, jeśli test

został utworzony poprzez zarejestrowanie ruchu sieciowego lub też

wykorzystuje on narzędzie Selenium, to może wysyłać żądania do pewnych

usług zewnętrznych. Żądania te są potrzebne w rzeczywistej aplikacji (np.

witryna musi pozyskać fonty z usługi Google Fonts), ale ponieważ w teście

wysyłane są one z tej samej maszyny (agenta testowania obciążenia) wiele

razy w bardzo krótkim czasie, te usługi zewnętrzne mogą identyfikować je

jako ataki typu Denial-of-Service (DoS) i blokować dalsze żądania, co

spowoduje niepowodzenie testów. Podobne problemy mogą powodować

zapory sieciowe oraz inne mechanizmy bezpieczeństwa stosowane

w organizacji, błędy w testach, ograniczenia w środowisku testowym lub

produkcyjnym itd. Z tego powodu powinniśmy zwykle próbować

uruchamiać testy dla małej liczby użytkowników i przez krótki okres czasu,

a gdy już zidentyfikujemy i naprawimy te problemy, możemy stopniowo

dodawać coraz więcej użytkowników i wydłużać czas trwania testu.

Czasem podczas wykonywania tego procesu w testowanym systemie może

zostać zidentyfikowane wąskie gardło, które jest tak naprawdę błędem

uniemożliwiającym dalsze skalowanie i musi być naprawiony.

Jeśli pożądane jest dodanie testów obciążeniowych do potoku ciągłej

integracji/ciągłego dostarczania, to po tym, jak test po raz pierwszy

zakończy się sukcesem, a środowisko zostanie odpowiednio przygotowane,

możemy ponowne uruchomić ten sam cykl testowania dla nowych

kompilacji. Jeśli jednak uruchomimy najpierw test w środowisku

produkcyjnym lub jakimś środowisku przedprodukcyjnym, którego

będziemy chcieli użyć w innym celu, to będziemy musieli najpierw


skonstruować dedykowane środowisko dla testów obciążeniowych.

Środowisko to może być mniej zaawansowane od środowiska

produkcyjnego, ale w takim wypadku trzeba proporcjonalnie dostosować

progi i oczekiwane obciążenie. Zwróćmy uwagę, że podobnie jak testy

funkcjonalne, testy te będą musiały być utrzymywane w miarę

ewoluowania aplikacja.

Łączenie testów wydajności z testami obciążeniowymi

Gdy po raz pierwszy osiągniemy pożądane przez nas obciążenie, warto

otworzyć przeglądarkę i spróbować skorzystać ze strony ręcznie, aby

doświadczyć wpływu tego obciążenia na postrzeganą wydajność. Ale jeśli

chcemy dodać testy obciążeniowe do przepływu ciągłej integracji/ciągłego

dostarczania, to przydatne może okazać się uruchamianie testów

wydajności obok testów obciążeniowych, aby upewnić się, że cechy

wydajności nadal mieszczą się w oczekiwanym progu nawet przy wysokim

obciążeniu systemu.

Wydajność danej operacji w aplikacji typu klient/serwer (w tym

aplikacji sieci Web) wyliczana jest na podstawie czasu przetwarzania

w kliencie + czasu komunikacji + czasu przetwarzania na serwerze. Trzeba

zrozumieć, że przetwarzanie po stronie klienta nie jest w żaden sposób

uzależnione od obciążenia serwera, ani też nie powinno mieć wpływu na

czas komunikacji, jeśli tylko przepustowość serwera nie jest nasycona (w

przypadku test obciążeniowy powinien zakończyć się niepowodzeniem).

Wobec tego, jeśli z architektury naszej aplikacji możemy jasno

wywnioskować, że jej wydajność uzależniona jest głównie od

przetwarzania po stronie klienta, wówczas nie ma sensu uruchamiać testów

wydajności pod obciążeniem. Jeśli jednak wiemy lub podejrzewamy, że

czas, jaki zajmuje wykonanie danej operacji zależy przetwarzania po


stronie serwera, to możemy bardzo wiele zyskać na uruchamianiu tych

testów. Jeśli przetwarzanie po stronie klienta jest pomijalne lub stałe i nie

oczekujemy żadnej zmiany w przyszłości z powodu planowanych

modyfikacji, to wystarczy uruchomić testy wydajności wyłącznie po stronie

serwera.

Uruchamianie testów w środowisku


produkcyjnym

Jeszcze kilka lat temu, gdy ktoś sugerował mi uruchamianie testów

w środowisku produkcyjnym, pytałem go: „Ale po co? Jeśli test zakończył

się sukcesem w ramach ciągłej integracji, to niby dlaczego miałby

zakończyć się niepowodzeniem w środowisku produkcyjnym?”. Jednak

z czasem zdałem sobie sprawę, że istnieją ku temu pewne ważne przesłanki.

Testowanie wdrożenia

Teoretycznie nasz potok CI/CD powinien być w pełni zautomatyzowany

oraz wiarygodny i powinien uchronić nas przed wdrożeniem kompilacji,

która jest skazana na niepowodzenie. Ale rzeczywistość jest zawsze

bardziej złożona. Nawet jeśli potok CI/CD jest w pełni zautomatyzowany,

to nowe funkcje mogą wymagać nowych zależności, zmianie mogły ulec

same skrypty potoku, a aktualizacje schematu bazy danych zawsze

stanowią trudną część, która może być podatna na błędy. Ponadto

w systemach składających się z kilku niezależnie wdrożonych usług lub

w systemach, które muszą komunikować się z innymi systemami, wersja

jednej z tych usług lub systemów używanych w środowisku testowym może


różnić się od wersji używanej w środowisku produkcyjnym. Może się tak

zdarzyć z kilku powodów:

Środowisko testowe ma nowszą wersję powiązanej usługi, która nie

została jeszcze wdrożona w środowisku produkcyjnym.

Środowisko testowe wykorzystywało tę samą wersję powiązanej usługi,

która została użyta w środowisku produkcyjnym podczas wykonywania

testu, ale potem do środowiska produkcyjnego wdrożono nowszą wersję

(przetestowaną w innym środowisku), zanim został wdrożony w nim

nasz system.

Zaktualizowana została jakaś zewnętrzna usługa. Mimo że takie

zewnętrzne usługi powinny standardowo utrzymywać wsteczną

kompatybilność, to możemy natknąć się na skrajny przypadek,

w którym kompatybilność ta została przerwana.

Tym samym uruchomienie kilku testów bezpośrednio po wdrożeniu

nowej kompilacji może dać nam pewność, że system funkcjonuje

poprawnie i nic nie zostało uszkodzone podczas wdrożenia.

Testowanie stanu zdrowia środowiska produkcyjnego

Drugim powodem, dla którego powinniśmy uruchamiać testy w środowisku

produkcyjnym, jest monitorowanie stanu zdrowia aplikacji i środowiska

produkcyjnego. Istnieje wiele narzędzi monitorujących i diagnostycznych,

które osobom pracującym w zespole DevOps (lub po prostu Ops) dają

dosyć jasny obraz dotyczący zdrowia systemu w środowisku

produkcyjnym. Choć pomoc tych narzędzi jest nieoceniona, to jednak nie są

one świadome oczekiwanego zachowania funkcjonalnego naszego systemu.

Możemy je zwykle dostosować do pomiaru pewnych parametrów, które to


zachowanie wskazują, ale może nie być to aż tak wiarygodne i łatwe jak

uruchamianie testu, który rutynowo wykonuje wybrany, znany nam

scenariusz biznesowy i raportuje błąd w przypadku jego niepowodzenia.

Które testy uruchamiać?

Jeśli chcemy zacząć planować nowy zestaw automatyzacji testów

i rozważamy wykorzystanie go w środowisku produkcyjnym, to nie

powinniśmy mieszać tych problemów. Dla ciągłej integracji powinniśmy

najpierw wziąć pod uwagę te aspekty izolacji, które mogą nie wpasować się

zbyt dobrze w środowisko produkcyjne, takie jak użycie symulatorów lub

kontrolowanie konfiguracji systemu. Jednak w przypadku testów

produkcyjnych musimy się upewnić, że testy nie wyrządzą żadnej szkody

rzeczywistym danym lub nie wywołają procesów biznesowych, których nie

chcemy wywoływać, ponieważ mogą one mieć niepożądane konsekwencje

w prawdziwym życiu, jak na przykład zamawianie większych ilości dóbr od

dostawców.

Mimo wszystko, czasami wymagania izolacji istniejących testów

funkcjonalnych sprawiają, że są one odpowiednie również dla środowiska

produkcyjnego w swojej obecnej postaci. Powinniśmy jednak wybrać tylko

kilka z nich i upewnić się, że są one bezpieczne dla tego środowiska. Jeśli

nie jesteśmy tego pewni, chcemy być po bezpiecznej stronie lub też wiemy,

że wymagania izolacji naszych testów funkcjonalnych nie pasują do

środowiska produkcyjnego, to powinniśmy napisać dedykowany zestaw

testów specjalnie do tego celu. Oczywiście możemy wykorzystać ponownie

pewną część infrastruktury testów.

Podobnie jak w przypadku testów wydajności, nie będziemy raczej

chcieli wysyłać alertu do osoby z zespołu DevOps po jednokrotnym

niepowodzeniu testu. W przeciwieństwie do ciągłej integracji, gdzie nasze


testy powinniśmy uruchamiać w sterylnym środowisku, tutaj będziemy

raczej chcieli dać testowi jedną lub dwie dodatkowe szanse w przypadku

jego niepowodzenia i dopiero wtedy wysłać taki alert.

Oczyszczanie danych testu

Większość testów podczas swojego działania tworzy pewne dane. Patrząc

od strony przestrzeni magazynowej może to nie mieć zbyt wielkiego

znaczenia, ale ponieważ testy te wykonywane są okresowo, dane te mogą

mieć wpływ na statystyki i raporty dotyczące użytkowników. Jednym ze

sposobów oczyszczania tych danych jest skorzystanie z mechanizmu

oczyszczania opisanego w dodatku B. W przeciwnym wypadku lepiej

utworzyć zadanie wsadowe, które usuwa te dane raz na jeden lub kilka dni.

Aby jednak usunąć te dane w prosty sposób, musimy odróżnić je od innych

danych prawdziwego użytkownika. Możemy zdecydować się na

zastosowanie znanego wzorca, takiego jak poprzedzanie wszystkiego

prefiksem „AUTO”, ale musimy też wiedzieć, z których tabel usuwać te

dane. Innymi słowy, może to być wykonalne, ale musimy to odpowiednio

przemyśleć, aby móc zrobić to prawidłowo.

Kolejną rzeczą, na którą musimy zwracać uwagę, jest upewnienie się,

że po uruchomieniu zadania usuwającego dane, nie usunie ono danych

obecnie wykonywanego testu, gdyż inaczej test ten prawdopodobnie

zakończy się niepowodzeniem. Możemy to osiągnąć poprzez

zsynchronizowanie tego zadania z zadaniem, które uruchamia testy: po

zakończeniu testu zatrzymujemy zadanie testu, uruchamiamy zadanie

usuwające dane, a następnie wznawiamy zadanie testu. Alternatywnie

możemy sprawić, że zadanie usuwać będzie tylko dane utworzone

poprzedniego dnia lub jeszcze starsze, aby zagwarantować w ten sposób, że

nie będzie mieć ono wpływu na żaden test, który jest obecnie wykonywany.
Testowanie wizualne

Ostatnimi czasy coraz większe zainteresowanie zaczęło budzić testowanie

wizualne. W ogólnym przypadku testowanie wizualne jest sposobem na

przetestowanie konkretnego obrazu widocznego na ekranie – na wzór tego,

jak widzi go użytkownik – poprzez porównanie go z uprzednio zapisanym

szablonem. Chociaż Selenium i inne narzędzia do automatyzacji interfejsu

użytkownika testują aplikację na poziomie warstwy interfejsu użytkownika,

to nie weryfikują one, czy interfejs ten wyświetla się prawidłowo. Kolory,

rozmiary, kształty i inne cechy elementów nie są testowane, mimo że mają

one znaczący wpływ na użyteczność i środowisko użytkownika.

Oczywiście powodem, dla którego narzędzia do automatyzacji interfejsu

użytkownika nie bazują na dokładnej lokalizacji, rozmiarze czy kolorach

tych elementów jest to, że właściwości te mogą się bardzo często zmieniać

i doprowadzać do niepowodzenia testu. Czasem jednak zweryfikowanie

wyglądu interfejsu użytkownika może stanowić cenny dodatek do

standardowych testów funkcjonalnych.

Obecnie dominującym produktem w tym obszarze jest Applitools Eyes.

Projekt open source o nazwie Sikuli wykorzystuje wizualne rozpoznawanie

obiektów, ale stosuje on je raczej do identyfikowania elementów niż do

testowania wizualnego. Kolejnym narzędziem w tym obszarze jest

Chromatic, przy czym ono również przyjmuje inne podejście, oparte na

porównywaniu wyglądu poszczególnych komponentów, a przy tym jest

specjalnie dostosowane do biblioteki React.

Uwaga
Ponieważ ścisłe porównywanie obrazu piksel po pikselu jest

bardzo podatne na błędy z powodu problemów związanych

chociażby z antyaliasingiem czy rozdzielczością, narzędzia te

wykorzystują bardziej inteligentne techniki porównywania

obrazów, które mogą kompensować te ograniczenia.

Przepływ pracy testowania wizualnego

Testowanie wizualne zapewnia API, które możemy zintegrować z naszymi

testami funkcjonalnymi w celu wykonywania zrzutów ekranu (dla całej

strony lub konkretnego elementu) w odpowiednich momentach w teście.

Przy pierwszym uruchomieniu testów obrazy te są zapisywane są jako

referencyjne.

Przy kolejnych uruchomieniach testów zrzuty ekranu porównywane są

z odpowiadającymi im obrazami referencyjnymi, a znalezione między nimi

różnice są raportowane. Test zaznacza również dokładnie lokalizacje,

w których te różnice występują. Dla każdej z tych różnic możemy

zdecydować się wykluczyć jej region z kolejnego porównywania,

zaktualizować obraz referencyjny bądź też, jeśli różnica taka jest błędem,

zdecydować się na zachowanie oryginalnego obrazu referencyjnego bez

zmian (i poprawić ten błąd w naszej aplikacji).

Zwróćmy uwagę, że ilość poświęcanej uwagi i pracy związanej

z utrzymaniem (badanie różnic i aktualizowanie linii bazowej), jakie są

wymagane przez ten przepływ pracy, może się różnić w zależności od

częstotliwości zmian dokonywanych w interfejsie użytkownika oraz liczby

zrzutów ekranu robionych przez test. Choć jak już wiemy z rozdziału 2,

jeśli coś się nie zmienia, to nie ma większego sensu tego testować.

W przyszłości narzędzia te powinny być już wystarczająco inteligentne, aby


móc rozpoznać ten sam rodzaj zmian w wielu miejscach, więc gdy

zdecydujemy się podjąć stosowne kroki w stosunku do jednej z tych różnic,

wówczas narzędzie zasugeruje nam wykonanie takiej samej czynności dla

wszystkich pozostałych różnic, znacznie zmniejszając w ten sposób koszty

związane z utrzymaniem. Ale nawet wtedy może się zdarzyć, że jakaś

całościowa zmiana w wyglądzie strony (która może zostać dokonana przez

niewielką zmianę w arkuszu CSS) może mieć wpływ na wszystkie obrazy

referencyjne, przez co wszystkie one będą musiały zostać wykonane na

nowo.

Testowanie wizualne i testowanie w wielu


przeglądarkach/na wielu platformach

Jednym z obszarów, w którym testowanie wizualne radzi sobie bardzo

dobrze, jest testowanie w wielu przeglądarkach i na wielu platformach.

Liczba dostępnych obecnie przeglądarek, systemów operacyjnych, a przede

wszystkim urządzeń mobilnych jest ogromna. Ręczne wykonywanie

testowania wizualnego na całej macierzy testów i platform w krótkich

cyklach wydawniczych jest w zasadzie niemożliwe. Choć aplikacje, które

projektowane są do obsługi wielu przeglądarek i wielu platform, powinny

wyglądać wszędzie tak samo, to niestety nie zawsze tak jest. Te różne

przeglądarki i systemy operacyjne mają różne silniki renderowania, które

mogą mieć wpływ na ostateczny wygląd stron. Czasami można się

spodziewać pewnych drobnych rozbieżności, ale zdarza się, że te różnice

w silnikach renderowania są na tyle duże, iż stają się przyczyną

rzeczywistych błędów. Na szczęście Applitools Eyes i inne podobne

narzędzia umożliwiają nam kontrolowanie poziomu akceptowalnych różnic,

co z jednej strony pozwala nam przezwyciężać problemy powodowane


przez różnice między przeglądarkami, a z drugiej strony nadal wyłapywać

bardziej istotne problemy w zakresie renderowania.

Testy instalacji

Niektóre aplikacje, które projektowane są w taki sposób, aby były

instalowane na maszynie klienta, wyposażone są w złożony, dedykowany

program instalacyjny. Choć zapotrzebowanie na takie programy

instalacyjne stopniowo maleje (ponieważ większość aplikacji można dziś

instalować poprzez zwykłe skopiowanie folderu lub wyodrębnienie

zawartości z pliku Zip), to nadal istnieją aplikacje, które wymagają takiego

instalatora. Są to zwykle aplikacje klienckie lub serwerowe, które

komunikują się ze specjalnym sprzętem lub oprogramowaniem i wymagają

instalacji sterowników do urządzeń lub złożonych procesów konfiguracji.

Podejścia dla testów instalacji

Te programy instalacyjne możemy postrzegać jako osobne programy, które

również trzeba przetestować. Problem polega na tym, że dane wyjściowe

oraz cel takiego programu instalacyjnego same w sobie nie stanowią żadnej

wartości (tj. nikt nie wykorzystuje programu instalacyjnego w żadnym

innym celu niż do zainstalowania aplikacji). Istnieje kilka różnych podejść

do testowania programów instalacyjnych.

Testowanie bezpośredniego wyniku

Bezpośrednim wynikiem programu instalacyjnego jest to, że odpowiednie

pliki zostają skopiowane lub wyodrębnione do właściwych lokalizacji, zaś

pewne konfiguracje zapisywane są w pliku konfiguracyjnym, rejestrze lub


podobnym miejscu. Z tego względu możemy napisać test, który wywołuje

program instalacyjny, a następnie weryfikuje, czy odpowiednie pliki

i ustawienia zostały zapisane do ich właściwych lokalizacji.

Takie podejście ma jednak dosyć istotną wadę: utrzymywanie listy

wymaganych plików i ustawień może być problematyczne i podatne na

błędy, a także sprawić, że test będzie bardzo kruchy. Fakt, iż test zakończył

się sukcesem, nie oznacza wcale, że aplikacja zainstalowała się poprawnie.

Może się zdarzyć, że wszystkie oczekiwane pliki zostały skopiowane,

a ustawienia zostały zapisane, ale aplikacja nie może się uruchomić,

ponieważ wymaga ona jakiegoś dodatkowego pliku lub ustawienia, którego

test nie był świadomy. W podobny sposób, test może zakończyć się

niepowodzeniem z powodu brakującego pliku, podczas gdy plik ten nie jest

tak naprawdę potrzebny.

Instalowanie aplikacji przed uruchomieniem wszystkich


testów

Drugim podejściem jest jednorazowe zainstalowanie aplikacji

w środowisku testowym przed uruchomieniem testów. Preferowanym

sposobem wykonania tego jest skorzystanie z maszyny wirtualnej z czystą

migawką. Zanim test zostanie uruchomiony, przywraca on maszynę

wirtualną do stanu z czystej migawki, następnie uruchamia program

instalacyjny, a dopiero później rozpoczyna wykonywanie testów. Jeśli

instalacja nie zakończyła się pomyślnie, to prawdopodobnie wszystkie

testy, lub przynajmniej większość z nich, zakończą się porażką.

Niepowodzenie nastąpi zwykle bardzo szybko, ponieważ aplikacja nie

zostanie nawet uruchomiona, ale możemy również uzależnić uruchomienie

większości testów od sukcesu kilku pierwszych testów poprawności. Jeśli

wszystkie testy lub ich większość zakończy się sukcesem, to


prawdopodobnie będzie to oznaczać, że aplikacja została pomyślnie

zainstalowana.

Wadą tego podejścia jest to, że sprawdza ono jedynie jeden pozytywny

scenariusz programu instalacyjnego. Nie weryfikuje wszystkich

parametrów lub ich kombinacji, błędnych warunków (np. brak miejsca na

dysku), różnych platform, istnienia lub nieistnienia wymagań wstępnych

itd.

Testowanie, czy aplikacja działa prawidłowo po każdej


instalacji

W ostatniej metodzie testowanie aplikacji instalacyjnej traktuje się jak

testowanie każdej innej aplikacji, przy czym w celu zweryfikowania, czy

instalacja ta zakończyła się pomyślnie, wykonywany jeden funkcjonalny

test poprawności. Niektóre bardziej dokładne testy programu instalacyjnego

mogą wymagać również uruchomienia bardziej szczegółowego testu

funkcjonalnego jako weryfikacji. Jeśli na przykład program instalacyjny

pozwala nam skonfigurować serwer poczty wykorzystywany do wysyłania

raportów, to możemy zrobić tak, aby test instalacji uruchomił test

funkcjonalny, który zweryfikuje, czy raport został wysłany (i odebrany) za

pośrednictwem poczty e-mail. Naturalnie testy zakończone

niepowodzeniem nie powinny wykonywać żadnych testów funkcjonalnych,

ale muszą weryfikować, czy wyświetlany jest odpowiedni komunikat błędu,

i upewnić się, że w przypadku niepowodzenia instalacji folder instalacyjny

aplikacji zostanie usunięty.

To podejście pozwala na przetestowanie programu instalacyjnego

z różnymi parametrami i warunkami wstępnymi. Podobnie jak

w poprzedniej metodzie, dla tych testów również należy używać maszyny

wirtualnej z czystą migawką, ale dodatkowo można skorzystać z różnych


maszyn wirtualnych w celu przetestowania instalacji na różnych systemach

operacyjnych i używać migawek do testowania różnych warunków

wstępnych. Możemy nawet skorzystać z kilku grup maszyn wirtualnych do

przetestowania różnych topologii sieciowych, w których przykładowo baza

danych i serwer znajdują się na różnych maszynach.

Choć teoretycznie jest to preferowane podejście, to jego napisanie

będzie najbardziej skomplikowane i kosztowne, a uruchomienie będzie

zajmować najwięcej czasu i zasobów obliczeniowych. Oczywiście możemy

również łączyć i dopasowywać do siebie różne podejścia, aby uzyskać

optymalną równowagę między obniżaniem ryzyka i redukcją kosztów.

Testowanie instalacji za pośrednictwem interfejsu


użytkownika lub instalacji dyskretnej

Większość programów instalacyjnych można uruchamiać interaktywnie,

w ramach typowego kreatora instalacji, jak również uruchamiać je bez

interfejsu użytkownika, gdzie wartości parametrów podawane są jako

argumenty w wierszu polecenia lub za pośrednictwem dedykowanego

pliku. Uruchamianie instalacji w sposób dyskretny nie wymaga żadnej

technologii automatyzacji interfejsu użytkownika, dlatego jest ona zwykle

szybsza i łatwiejsza w utrzymaniu. Ale czasami możemy przetestować

również interfejs instalacji, a wtedy musimy posłużyć się automatyzacją

interfejsu użytkownika.

Testowanie programu deinstalacyjnego

Zwykle ten sam program, który został użyty do zainstalowania jakiejś

aplikacji jest również wykorzystywany do jej odinstalowania. Testowanie

funkcji odinstalowania jest dosyć podobne do testowania samej instalacji,

przy czym trzeba najpierw zainstalować taką aplikację, zanim będzie


można ją odinstalować. Oczekiwanym wynikiem operacji odinstalowania

powinno być zwykle usunięcie wszystkich plików tej aplikacji, ale niekiedy

może to być nieco bardziej skomplikowane. Czasem pliki, które zostały

utworzone lub zmienione przez użytkownika (np. pliki konfiguracji), muszą

pozostać nietknięte. Bywa również, że w momencie ich usuwania pliki są

zablokowane i można je usunąć dopiero po ponownym uruchomieniu

systemu. Prawidłowe przetestowanie tego ostatniego scenariusza (przy

czym inne scenariusze również mogą tego wymagać) wymaga

uruchomienia testu z innej maszyny niż maszyna instalacyjna, aby można ją

było uruchomić ponownie, a następnie sprawdzić, czy po ponownym

uruchomieniu wszystkie pliki zostały usunięte.

Testy aktualizacji

Jeśli testy instalacji wydają się skomplikowanie, to testy aktualizacji są

jeszcze bardziej złożone. W porównaniu do testów instalacji, testy

aktualizacji mają dwa dodatkowe wymiary w macierzy testowania:

1. Wersja kodu źródłowego – aktualizacja nie zawsze jest wykonywana

z ostatniej dostępnej wersji. Użytkownicy mogą pominąć jedną, dwie

lub więcej wersji podczas aktualizowania. Ponadto każda duża wersja

może mieć kilka mniejszych wersji, a nawet kilka wersji beta.

2. Dane użytkownika – gdy użytkownik pracował ze starą wersją,

prawdopodobnie utworzył on jakieś dane (w formie plików, rekordów

w bazie danych itd.). Mógł on również zmienić pewne dane

konfiguracji, aby dostosować system do własnych preferencji. Podczas

dokonywania aktualizacji będzie on oczekiwał, że będzie w stanie użyć

tych danych w nowej wersji i zachować w ten sposób dotychczasowe


wartości konfiguracyjne (zakładając, że nadal mają one zastosowanie

w nowej wersji).

Podejścia dla testów aktualizacji

Podobnie jak w przypadku testów instalacji, testy aktualizacji można

testować przy użyciu podanych niżej podejść.

Testowanie bezpośredniego rezultatu

Jak w przypadku odpowiednika tego podejścia dla testu instalacji, po

zainstalowaniu starej wersji i opcjonalnie utworzeniu lub zmodyfikowaniu

pewnych danych, test powinien uruchomić program dokonujący

aktualizacji, a następnie sprawdzić, czy nowe pliki zostały skopiowane.

Podobnie, jeśli powinny być dodane nowe wpisy konfiguracyjne, to

również trzeba to zweryfikować. Można również przetestować, czy dane,

które zostały utworzone lub zmienione po zainstalowaniu starej wersji,

nadal znajdują się w nienaruszonym stanie (lub zostały przekształcone do

nowszego formatu).

Aktualizowanie aplikacji przed uruchomieniem wszystkich


testów

Tutaj również jak w przypadku odpowiednika tego podejścia dla testu

instalacji, wykonujemy aktualizację tylko raz przed uruchomieniem

wszystkich testów. Możemy albo jawnie zainstalować jakąś starszą wersję,

uruchomić pewne testy funkcjonalne w celu utworzenia pewnych danych,

a następnie zaktualizować i uruchomić ponownie testy funkcjonalne, albo

też możemy zachować środowisko z poprzednich kompilacji lub wersji

i bezpośrednio zaktualizować w nim aplikację i uruchomić testy. Zwróćmy


uwagę, że jeśli dokonamy aktualizacji z poprzednich kompilacji, i pewne

testy (lub też sama instalacja) zakończą się niepowodzeniem, to środowisko

takie nie będzie już czyste, dlatego możemy nie wiedzieć, czy nasze

niepowodzenia po aktualizacji są skutkiem poprzednich czy też nowych

błędów. Takie testowanie ma wady swojego odpowiednika dla testów

instalacji, a poza tym zwyczajnie mija się z celem testowania aktualizacji

danych. Ponieważ testy funkcjonalne zwykle tworzą niezbędne dane (na

potrzeby izolacji), to nigdy nie weryfikują, czy możliwe jest dalsze

korzystanie z danych utworzonych w poprzedniej wersji.

Jawne testy aktualizacji

Podejście to zbliżone jest do podejścia „testowania, czy aplikacja działa

poprawnie po każdej instalacji”, ale tutaj nie wystarczy jedynie uruchomić

test poprawności, aby móc zweryfikować, czy nowa wersja została

poprawnie zainstalowana. Większość testów musi utworzyć lub

zmodyfikować pewne dane przed aktualizacją i uruchomić bardziej

szczegółowy test po aktualizacji, aby zweryfikować, czy dane nadal są

możliwe do wykorzystania. Ponadto w testach instalacji mogliśmy

wykorzystać ponownie istniejące testy funkcjonalne, natomiast tutaj

prawdopodobnie nie będziemy w stanie tego zrobić, ponieważ test

uruchomiony po aktualizacji musi zweryfikować dane, jakie zostały

utworzone przez inny test przed wykonaniem tej aktualizacji.

Podsumowując temat testów aktualizacji: macierz testowania oraz

złożoność procesu właściwej implementacji tych testów są ogromne. Z tego

powodu decyzja o tym, co należy przetestować, wymaga pewnego

kompromisu podyktowanego analizą ryzyka i kosztów.


Testowanie algorytmów statystycznych,
niedeterministycznych i sztucznej inteligencji

Obecnie termin „sztuczna inteligencja” (Artificial Intelligence, AI) staje się

coraz bardziej popularny, aby nie powiedzieć, że jego użycie jest dzisiaj po

prostu modne. Sam termin AI, pełen wielu filozoficznych znaczeń

i implikacji, jest zbyt szeroki, aby używać go w jakiejkolwiek efektywnej

dyskusji. Nieco bardziej szczegółowym terminem jest „uczenie

maszynowe” (machine learning), co oznacza, że maszyna może „sama się

uczyć” – niebędąc przy tym zaprogramowana pod kątem rozwiązania

konkretnego zadania – poprzez spoglądanie na poprzednie przykłady lub

poszukiwanie pewnych wzorców w danych. Jedną z typowych technik

uczenia maszynowego, która ostatnimi czasy zyskała na popularności, jest

„głębokie uczenie” (deep learning). Głębokie uczenie jest oparte na

technice sztucznych sieci neuronowych (Artificial Neural Networks, ANN),

znanej jest od kilku dekad i szeroko stosowanej, choćby w obszarach

rozpoznawania pisma odręcznego czy mowy. Ale głębokie uczenie

wykorzystuje również ogromną moc obliczeniową i dostęp do wielkiej

ilości danych dostępnych w Internecie, co umożliwia rozwiązywanie

problemów, które wcześniej uznawane były za niemożliwe do rozwiązania

przez komputery. Dobrym przykładem tej technologii może być

oprogramowanie do rozpoznawania obrazów oferowane przez dużych

dostawców chmury (głównie Microsoft Azure, Google Cloud Platform

i AWS), które może rozpoznać treść dowolnego obrazu i powiedzieć, co ten

obraz przedstawia. Wspólną rzeczą wszystkich technik AI i głębokiego

uczenia jest to, że w dużej mierze bazują one na statystyce.

Można powiedzieć, że z algorytmami testowania, które oparte są na

statystyce, związane są dwa główne wyzwania:


1. Do uzyskania sensownych wyników potrzebujemy dużych ilości danych.

2. Dokładne wyniki mogą różnić się już po wprowadzeniu niewielkich

zmian (i usprawnień) do algorytmu.

Ponadto niektóre z tych algorytmów wykorzystują liczby losowe, które

czynią je algorytmami niedeterministycznymi.

Sposoby testowania algorytmów statystycznych

Poniżej znajduje się kilka podejść z użyciem algorytmów statystycznych,

które warto rozważyć w przypadku testowania aplikacji. Jak zawsze,

w razie potrzeby podejścia te możemy ze sobą łączyć.

Pozorowanie algorytmu w całości

Większość systemów z użyciem algorytmów statystycznych zawiera

algorytm w jednym konkretnym komponencie. Chociaż komponent ten

mieści podstawową wartość produktu, to jest on zwykle opakowany

wieloma innymi komponentami, w których znajduje się normalny kod

aplikacji i logika biznesowa. Gdy chcemy przetestować tę logikę

biznesową, to często napotykamy problem podczas próby zdefiniowana

oczekiwanego rezultatu, jeśli rezultat ten opiera się na wyniku tego

algorytmu.

Z tego powodu, gdy chcemy przetestować rezultat logiki biznesowej

aplikacji, często dobrym rozwiązaniem jest zasymulowanie komponentu,

który zawiera taki algorytm. Zauważmy, że atrapa tego komponentu nie

musi symulować logicznego zachowania aplikacji, i może nawet zwracać

absurdalne wyniki. Załóżmy na przykład, że nasza aplikacja umożliwia

użytkownikowi przesłanie kilku obrazów i określenie nazwy obiektu do

wyszukania w tych obrazach, po czym informuje użytkownika, ile obrazów


zawiera określony obiekt. Na przykład użytkownik może przesłać cztery

obrazy, z czego trzy przedstawiają kota (wraz z innymi obiektami), a jeden

samochód. Jako obiekt do znalezienia w tych obrazach użytkownik

wprowadza wartość „cat” (kot). W takim wypadku użytkownik powinien

uzyskać rezultat „3”, ponieważ kot znajduje się tylko na trzech obrazach.

Załóżmy teraz, że używany przez nas algorytm akceptuje wyłącznie jeden

obraz naraz i zwraca listę obiektów, które zostały na nim rozpoznane. Dla

atrapy tego algorytmu nie ma znaczenia, co tak naprawdę znajduje się na

obrazie, więc możemy jej przesyłać zawsze ten sam obraz złożony

z jednego czarnego piksela, a jedynie powiadomić ją o tym, aby za każdym

razem zwracała ona inny wynik. W teście możemy określić wyniki, jakie

atrapa ta powinna zwracać, np. [cat, table] przy pierwszym wywołaniu,

[cat] przy drugim wywołaniu, [bottle, cat] przy trzecim oraz [car] przy

czwartym. W ten sposób możemy przetestować logikę biznesową naszej

aplikacji bez konieczności posługiwania się rzeczywistym algorytmem i nie

martwić się o niepoprawne wyniki.

Oczywiście w ramach takiego podejścia nie testujemy samego

algorytmu, a jedynie otaczający go kod, ale w wielu przypadkach jest to

właśnie to, czego nam potrzeba.

Korzystanie z absurdalnie prostych wyników

Jeśli chcemy przetestować aplikację kompleksowo, wraz z samym

algorytmem, ale nie chcemy, aby jego błędne wyniki miały wpływ na

wiarygodność naszych testów, to możemy również skorzystać z absurdalnie

trywialnych przypadków. Kontynuując nasz poprzedni przykład, zamiast

korzystać z atrapy i zamiast używać słowa „cat” jako terminu

wyszukiwania, możemy po prostu posłużyć się terminem wyszukiwania

„black rectangle” (czarny prostokąt) i dostarczać do algorytmu proste


obrazy narysowane w programie Microsoft Paint, które zawierają czarne

prostokąty i opcjonalnie inne kształty geometryczne w różnych kolorach na

białym tle. Rozpoznanie tych kształtów powinno być znacznie „łatwiejsze”

dla algorytmu i nie powinniśmy uzyskiwać od niego żadnych

nieprzewidzianych wyników (jeśli skorzystamy z prawdziwych zdjęć

kotów, to niewielkie zmiany w algorytmie lub jego danych szkoleniowych

mogą spowodować, że przykładowo jeden z kotów identyfikowany będzie

zgodnie z jego rasą i zamiast „cat” otrzymamy „Ragdoll”).

Korzystanie z progów

Większość algorytmów „głębokiego uczenia”, jak również inne algorytmy

statystyczne, klasyfikują swoje dane do zbioru możliwych wyników,

zwracając jeden konkretny rezultat. Ale inne algorytmy zwracają jedną lub

więcej wartości, jak na przykład procenty, szacunkowe ilości, datę i godzinę

itd. Niewielkie zmiany w tych algorytmach lub użycie wartości losowych

mogą generować inne rezultaty przy każdym uruchomieniu. Bez względu

na to, czy chcemy kompleksowo przetestować sam algorytm, czy też całą

aplikację, powinniśmy rozważyć zdefiniowanie oczekiwanego wyniku

w formie zakresu. W niektórych sytuacjach zakres ten może być taki sam

dla wszystkich przypadków, ale niekiedy powinien on być inny dla każdego

przypadku. Załóżmy na przykład, że poprzedni algorytm rozpoznawania

obrazów zwraca również poziom pewności jego wyników, co również

chcemy przetestować. Wtedy dla czarnego prostokąta na białym tle

możemy założyć, że poziom pewności powinien być bardzo wysoki (np.

99%) i dla naszych testów możemy użyć węższego zakresu (np. 98–100%).

Ale dla konkretnego obrazu ze słabo rozpoznawanym kotem, gdzie poziom

ufności wynosi 65%, będziemy prawdopodobnie chcieli zastosować szerszy


zakres, np. 50–90%, aby umożliwić wprowadzanie przyszłych zmian

w algorytmie.

Progi można stosować również do innych wyników, które nie są

wyrażane w procentach. Na przykład możemy użyć algorytmu, który

próbuje przewidzieć liczbę uczestników konkretnego wydarzenia. Załóżmy,

że w pewnych okolicznościach przewiduje on, że w wydarzeniu tym

weźmie udział 120 osób. Jeśli ustalimy oczekiwany wynik na poziomie

dokładnie 120 osób, wówczas drobne zmiany w algorytmie

prawdopodobnie doprowadzą do niepowodzenia naszego testu. Możemy

więc bezpiecznie użyć zakresu od 110 do 150 osób. Dokładny zakres trzeba

skonsultować z właścicielem produktu, ponieważ czasem górna lub dolna

granica nie powinna znacznie odbiegać od bieżącego wyniku, natomiast ta

druga może się różnić w większym stopniu.

Korzystanie z zestawów testów lub danych regresji

Jeśli chcemy przetestować sam algorytm, to zwykle będzie nam potrzebny

odpowiedni zbiór danych, wraz z ich oczekiwanymi rezultatami. Algorytmy

stosowane do uczenia maszynowego zalicza są przeważnie do jednej

z dwóch kategorii: uczenia nadzorowanego i uczenia nienadzorowanego

(nazywanego czasem uczeniem półnadzorowanym, ale to już wykracza

poza temat tej książki). W przypadku uczenia nadzorowanego do algorytmu

dostarcza się zestaw danych uczących, które składają się zarówno z danych,

jak i z oczekiwanych wyników tego algorytmu (często nazywanych

„danymi oznakowanymi”). Na przykład dla algorytmu rozpoznawania

pisma odręcznego te oznakowane dane mogą zawierać duży zbiór obrazów

z literami napisanymi pismem odręcznym wraz z odpowiadającymi im

prawidłowymi literami (znakami). Typową praktyką jest podzielenie

dostępnych danych oznakowanych na trzy części:


Zbiór uczący – ten zbiór danych wykorzystywany jest przez algorytm

do „nauczenia się” wzorców.

Zbiór walidujący – ten zbiór danych jest używany przez inżyniera

algorytmu (tj. osobę zajmującą się analizą danych) do sprawdzenia

dokładności algorytmu i dostosowania jego parametrów w celu

poprawienia jego wyników.

Zbiór testowy – ten zbiór danych używany jest do zweryfikowania

ostatecznej efektywności tego algorytmu. Powodem, dla którego ten

krok jest istotny, jest konieczność uniknięcia zjawiska nazywanego

nadmiernym dopasowaniem lub przeuczeniem (overfitting), w którym

algorytm poprawnie może zidentyfikować dane w zestawie

szkoleniowym i zestawie weryfikującym, ale dużo gorzej identyfikuje

jakiekolwiek inne dane.

Tę ostatnią część można zaimplementować jako test sterowany danymi

(data-driven test, DDT) lub jako pojedynczy test, który przechodzi po

danych i wywołuje na nich algorytm. Test ten może stanowić test regresji

dla algorytmu. Zwróćmy jednak uwagę, że w wielu przypadkach nie

oczekujemy, iż 100% wierszy w zestawie danych testu będzie kończyć się

sukcesem. Należy ustawić jakiś próg dla liczby lub procentu niepowodzeń,

aby można było określić kryteria sukcesu tego algorytmu.

W przypadku uczenia nienadzorowanego, a także algorytmów, które nie

są algorytmami klasyfikującymi, prawdopodobnie nie będziemy mieć

predefiniowanych danych testowych. W takich przypadkach można

wykorzystać jakieś dane historyczne, dostarczyć je do algorytmu i zapisać

uzyskane z niego wyniki w formie oczekiwanych rezultatów, do których

będzie można odnosić się w kolejnych uruchomieniach testów. Jak

wspomnieliśmy wcześniej, powinniśmy również ustawić pewne rozsądne


progi dla każdego rezultatu lub jakiś stały próg dla wszystkich wyników,

i użyć tych danych w ramach testu sterowanego danymi. Zakładając, że

nasz bieżący algorytm dosyć dobrze wykonuje swoją pracę i że nasze progi

są odpowiednio ustawione, to dane te powinny w dobry sposób

wykorzystywać testowanie regresji dla algorytmu.

Testowanie aplikacji, które wykorzystują liczby losowe

Pomijając już samą sztuczną inteligencję, aplikacje wykorzystują liczby

losowe do różnych celów. Oczywiście najlepszym przykładem są tutaj gry,

jednak niektóre aplikacje biznesowe również z nich korzystają. Jest kilka

metod rozwiązywania problemów związanych z testowaniem takich

aplikacji.

Pozorowanie generatora liczb losowych

W większości przypadków najlepszym podejściem jest wykorzystanie

atrapy dla generatora liczb losowych, ponieważ daje nam ono pełną

kontrolę nad „losowo” generowanymi wartościami. Jeśli zechcemy użyć

takiej atrapy na zewnątrz testów jednostkowych lub testów komponentu, to

musimy mieć jakiś sposób na wstrzyknięcie tej atrapy do procesu

testowanego systemu. Można to zrobić za pomocą mechanizmów

wstrzykiwania zależności, dedykowanej flagi konfiguracyjnej itd.

W rzeczywistości będziemy to robić w sposób podobny do tego, w jaki

zasymulowaliśmy systemową datę i godzinę w rozdziale 6.

Korzystanie ze znanego inicjatora losowego

Generatory liczb losowych wykorzystują specjalny algorytm do

generowania ciągu liczb, które mają mniej lub bardziej jednakowy rozkład.
Aby ciąg ten był za każdym razem inny, zwykle jako wartość początkową

dla tego ciągu wykorzystują one liczbę milisekund lub cykli systemowych,

jakie zostały naliczone od momentu uruchomienia komputera bądź też

zostały uzyskane z bieżącej daty i godziny. Ta wartość początkowa

nazywana jest inicjatorem losowym (seed) i zwykle możemy ją dostarczyć

bezpośrednio do generatora, zamiast opierać się na zegarze systemowym.

Dostarczenie tego samego inicjatora w różnych wywołaniach w aplikacji

powoduje, że generowane są zawsze te same ciągi liczb.

Z tego powodu możemy dodać do naszej aplikacji „ukrytą” opcję

dostarczania wartości tego inicjatora z pliku konfiguracyjnego lub

jakiegokolwiek innego źródła zewnętrznego i używać go w testach w celu

zapewnienia spójnych i deterministycznych wyników.

Mimo że podejście to wymaga również zmiany w testowanym systemie,

to jednak zmiana ta jest zwykle znacznie prostsza. Nie daje nam to jednak

kontroli nad tym, jakie wartości są generowane, co może nam być

potrzebne do zasymulowania pewnych skrajnych przypadków – gwarantuje

nam to jedynie, że ciąg jest zawsze spójny. Co więcej, niewielkie zmiany

w kodzie aplikacji, wliczając w to refaktoryzację, która nie powinna

wpływać na zachowanie aplikacji, mogą tak czy inaczej spowodować, że

aplikacja będzie generować różne rezultaty z tym samym inicjatorem. Jeśli

na przykład jakaś gra musi zainicjalizować gracza w losowej lokalizacji na

ekranie, to powinna ona uzyskać dwie losowe wartości: jedną dla

współrzędnej X i drugą dla współrzędnej Y danego gracza. To, czy

aplikacja pobierze najpierw wartość X, a potem Y, czy odwrotnie, z punktu

widzenia użytkownika nie będzie mieć żadnego znaczenia (ponieważ obie

są losowe), ale gdy zmienimy tę kolejność, wówczas test zakończy się

niepowodzeniem, jeśli za każdym razem do wygenerowania tej samej

lokalizacji używa on inicjatora.


Testowanie aplikacji analityki biznesowej

Termin analityka biznesowa (Business Intelligence, BI) jest stosowany do

opisu technologii i aplikacji, które pomagają organizacji analizować dane

przechowywane w ich systemach, aby uzyskać informacje ułatwiające

podejmowanie decyzji biznesowych. Choć jest to bardzo szeroki temat,

który czasem dotyczy również uczenia maszynowego, w wielu przypadkach

składa się on głównie z kilku procesów ETL i odpowiadających im

raportów. ETL jest skrótem od Extract, Transform and Load

(wyodrębnianie, przekształcanie i ładowanie) i oznacza on po prostu, że

bierzemy (wyodrębniamy) jakieś dane z jednego miejsca, odpowiednio je

przekształcamy w celu dopasowania ich do naszych potrzeb, ewentualnie

wyodrębniamy z nich tylko istotne informacje, a następnie ładujemy je do

innej bazy danych, z której możemy je wykonywać na nich zapytania. Choć

zwykle te procesy ETL i raporty działają na dużych zbiorach danych, to

w większości przypadków nie wykorzystują one złożonych algorytmów

statystycznych do produkowania tych wyników. Tym samym w celu

przetestowania procesów ETL lub aplikacji biznesowych możemy po

prostu potraktować je jak dowolne inne aplikacje i skorzystać z niewielkiej

ilości danych, które będą odpowiednie do potrzeb każdego testu. Bazę

danych można przez każdym przywrócić do czystej kopii zapasowej testem

i wypełnić jedynie tymi danymi, które są potrzebne i istotne dla tego testu.

Na przykład, jeśli jakiś raport powinien pokazywać procent

pracowników danej firmy, sklasyfikowanych według ich stażu pracy

w zakresach 0–1, 1–3, 3–5, 5–10, 10–20 i ponad 20 lat, to dla każdej z tych

kategorii możemy wygenerować od 1 do 3 rekordów pracowników (co

łącznie da nam przykładowo 10 rekordów) i odpowiednio zweryfikować te

rezultaty.
Chociaż większość aplikacji BI jest dosyć złożona, wykonuje wiele

przekształceń oraz umożliwia użytkownikowi podział danych w raportach

na różne sposoby, to możemy zwykle przetestować każdą z tych funkcji

oddzielnie, jak byśmy to robili w przypadku standardowych aplikacji.

Podsumowanie

W rozdziale tym zobaczyliśmy inne zastosowania automatyzacji testów

poza normalnymi testami funkcjonalnymi. Dla każdego z tych przypadków

omówiliśmy problemy, jakie są z nimi związane. Dla każdego z nich

zobaczyliśmy jeden lub więcej sposób ich rozwiązania. Ale to tylko ich

niewielki fragment. Istnieje wiele innych szczególnych zastosowań

automatyzacji testów, a każde z nich związane jest z wieloma innymi

problemami. Ostatecznie trzeba samodzielnie znaleźć odpowiednie

rozwiązanie konkretnego problemu. Miejmy jednak nadzieję, że dzięki

informacjom przedstawionym w tym rozdziale będziemy w stanie poradzić

sobie z dowolnym takim problemem.


Rozdział 19. Co dalej?

Przede wszystkim chciałbym bardzo podziękować za przeczytanie tej

książki do końca! Jednak nasza przygoda związana z poznawaniem

automatyzacji testów nie kończy się w tym miejscu, a dopiero się zaczyna.

Tak naprawdę można powiedzieć, że nigdy się ona nie skończy. Oto kilka

ogólnych porad, z których warto skorzystać w czasie trwania tej przygody.

Popełniaj błędy

Podobnie jak z innymi istotnymi rzeczami w życiu, tak samo jest

z automatyzacją testów: nie istnieje tylko jeden właściwy sposób jej

realizacji, a nawet jeśli istnieje, to możemy nie wiedzieć, jak on wygląda.

Jeśli jednak będziemy bali się popełniać błędy, to nigdy do niczego nie

dojdziemy. Błędy i niepowodzenia stanowią bezcenne źródło nauki.

Z czasem zaczynamy uczyć się na własnych błędach i jesteśmy w stanie się

poprawić. Będziemy prawdopodobnie popełniać nowe błędy, ale miejmy

nadzieję, że nie będziemy już powtarzać tych, które popełniliśmy

wcześniej.

Ważną wskazówką w tym zakresie jest to, że jeśli jesteśmy w stanie

wykonać szybki eksperyment, który powie nam, czy zmierzamy we


właściwym kierunku, czy też nie, to powinniśmy taki eksperyment

przeprowadzić! Znacznie lepiej jest szybko ponieść porażkę, niż trwać

w błędzie przez długi czas.

Słuchaj, konsultuj się i zasięgaj porad

Ponieważ dla większości rzeczy nie ma jednej właściwej drogi, możemy

sporo nauczyć się na błędach, doświadczeniach, a nawet pomysłach innych

osób. Konsultujmy się z ludźmi, których opinie cenimy najbardziej, ale

słuchajmy uważnie również tych osób, których opinie nie są dla nas aż tak

istotne. Nawet jeśli nie akceptujemy ich opinii, to przynajmniej lepiej

poznamy ich odmienny punkt widzenia, który również może być bardzo

cenny.

Ponadto nie bójmy się prosić innych o pomoc, prosić o opinie lub

zadawać pytania. Pamiętajmy: jedynym głupim pytaniem jest to, które nie

zostało zadane. Recenzje (przeglądy kodu, przeglądy projektów lub nawet

przeglądanie pomysłów) są bezcenną metodą nauki, której nie sposób

przecenić. Starajmy się uzyskać pomoc zawsze wtedy, gdy jej

potrzebujemy. Pomoc taką możemy zawsze znaleźć i uzyskać w sieci,

również w witrynie tej książki

(www.TheCompleteGuideToTestAutomation.com), ale czasem, gdy

czujemy, że brakuje nam wystarczającej wiedzy w określonej dziedzinie,

powinniśmy rozważyć zatrudnienie kogoś do pomocy, czy to w roli

konsultanta na pół etatu, czy też jako pracownika na pełny etat. Oczywiście

jako konsultanta można również zatrudnić także mnie, autora tej książki (po

skontaktowaniu się ze mną za pośrednictwem witryny LinkedIn, pod

adresem https://www.linkedin.com/in/arnonaxelrod/).
Zanim jednak poprosimy kogoś innego o pomoc, to przynajmniej

w przypadku konkretnych pytań technicznych, powinniśmy najpierw sami

spróbować znaleźć odpowiedź, czy to poprzez czytanie dokumentacji, czy

też wyszukiwanie informacji w sieci, a nawet lepiej – w ramach własnych

eksperymentów. Jeśli samodzielnie znajdziemy taką odpowiedź, to będzie

nam ją łatwiej zapamiętać, a do tego będziemy mogli lepiej zrozumieć dany

problem.

Poznaj i dostosuj się do celów swojego biznesu

W ostatecznym rozrachunku automatyzacja testów jest po prostu środkiem

prowadzącym do określonego celu. Narzędzie to, jeśli tylko jest poprawnie

używane, zwykle pomaga organizacjom dużo szybciej i przez długi okres

wydawać stabilne wersje oprogramowania. Ale każda organizacja ma swoje

własne cele i sposoby ich uzyskiwania, a na różnych etapach może mieć

inne cele lub strategie ich realizacji. Zrozumienie potrzeb i celów naszej

organizacji powinno prowadzić nas przez planowanie, tworzenie

i korzystanie z automatyzacji testów, tak aby najlepiej służyła ona bieżącym

celom naszej firmy.

Poznaj swoje narzędzia

Bez względu na to, w jaki sposób zbudowaliśmy swoje rozwiązanie

automatyzacji, to na pewno korzystamy z jakiegoś zestawu narzędzi.

Naszymi podstawowymi narzędziami mogą być przykładowo Selenium,

MSTest, Visual Studio i C#. Z tymi narzędziami, z których korzystamy

najczęściej, starajmy się zapoznać bardziej szczegółowo i poszerzyć swoją


wiedzę na ich temat. Ludzie bardzo często używają jakiegoś narzędzia

przez długi czas, ale znają i wykorzystują jedynie niewielką część jego

możliwości i funkcji. Dobra znajomość narzędzia, z którego regularnie

korzystamy, może znacznie zwiększyć naszą produktywność!

Podstawowym sposobem na dogłębne poznanie jakiegoś narzędzia jest

zapoznanie się z jego dokumentacją. Kolejnym przydatnym sposobem

pozyskiwania nowej wiedzy o wykorzystywanych przez nas narzędziach

jest po prostu odkrywanie dostępnych w nich funkcji. Funkcje bibliotek

kodu (takich jak Selenium lub MSTest) możemy odkrywać za pomocą

funkcji uzupełniania kodu i etykietek narzędzi, zintegrowane środowiska

programowania możemy poznawać, nawigując po ich menu itd. Jeśli

napotkamy coś, o czym wcześniej nie wiedzieliśmy, warto poświęcić trochę

czasu na poeksperymentowanie z tą rzeczą lub przynajmniej poczytać nieco

więcej na jej temat. W przypadku projektów open source możemy nawet

sklonować ich repozytoria i zajrzeć bezpośrednio do wnętrza ich kodu.

Ponadto możemy także zasubskrybować odpowiednie tematy

w witrynie StackOverflow59, aby uzyskać w ten sposób dostęp do

konkretnych pytań dotyczących narzędzi, z których korzystamy najczęściej.

Czytanie pytań zadawanych przez inne osoby, nawet tych pozbawionych

odpowiedzi, pozwala nam zapoznać się z innymi funkcjami i problemami,

których istnienia nie byliśmy wcześniej świadomi. Próba udzielenia

odpowiedzi na niektóre z tych pytań zmusza nas do sprawdzenia naszej

odpowiedzi i sformułowania jej w prosty i przejrzysty sposób, co również

wzmacnia nasze zrozumienie danego problemu (jak mówi stare przysłowie:

nauczanie jest najlepszą metodą nauki). Jeśli dodatkowo poświęcimy trochę

czasu na zbadanie tych pytań, na które nie znamy odpowiedzi,

prawdopodobnie zdołamy nauczyć się czegoś zupełnie nowego.


Poszerzanie dotychczasowej wiedzy na temat wykorzystywanego

narzędzia nie tylko pozwoli nam zwiększyć naszą produktywność, ale

ostatecznie sprawi, że to my staniemy się osobami, których zaczną radzić

się inni, dzięki czemu nasza wiedza na jego temat będzie jeszcze lepsza. Co

więcej, jeśli znamy już dogłębnie jedno narzędzie, będziemy lepiej

rozumieć sposób jego działania. Kiedy przyjdzie czas na naukę kolejnego

narzędzia, działającego w podobny sposób (np. inna biblioteka

automatyzacji testów, inna technologia automatyzacji interfejsu

użytkownika, kolejny język programowania itd.), będziemy w stanie szybko

znaleźć podobieństwa i różnice w tych narzędziach, więc bardzo szybko

nauczymy się tej nowej technologii. Im więcej narzędzi będziemy znać,

tym szybciej będziemy uczyć się kolejnych.

Doskonalenie umiejętności programistycznych

Jak już wielokrotnie podkreślaliśmy w tej książce, automatyzacja testów

polega głównie na programowaniu. Z tego względu powinniśmy traktować

siebie jak deweloperów oprogramowania. Dogłębnie poznajmy swój język

programowania i zintegrowane środowisko programistyczne, prośmy

o przeglądy kodu, przyjmujmy zlecenia dotyczące programowania, uczmy

się nowych języków i paradygmatów programowania (np. programowanie

funkcyjne, model aktorów itd.), bierzmy udział w konferencjach

programistycznych itd. W dodatku D zostały przedstawione pewne

wskazówki i praktyki pozwalające podnieść naszą produktywność

programisty, takie jak efektywna praca z klawiaturą, poznawanie niektórych

funkcji języka, pisanie kodu odpornego na błędy, właściwa obsługa

wyjątków itd.
W książce tej odnosimy się do kilku innych podręczników

i paradygmatów dotyczących programowania, jakie powinien znać każdy

programista, wliczając w to podręcznik „Czysty kod”, zasady SOLID,

wzorce projektowe, refaktoryzację i tworzenie oprogramowania sterowane

testami.

Aby zostać dobrymi deweloperami automatyzacji testów, powinniśmy

spędzić kilka lat na stanowisku dewelopera aplikacji. Dzięki temu nie tylko

podniesiemy swoje umiejętności programistyczne, ale będziemy w stanie

lepiej zrozumieć pracę deweloperów i problemy, z jakimi się borykają –

zarówno od strony technicznej, jak i od strony takich aspektów, jak procesy

organizacyjne i przepływy pracy. Gdy wrócimy z powrotem do tworzenia

testów automatycznych, będziemy na nie patrzeć z zupełnie innej

perspektywy.

Doskonalenie umiejętności w zakresie


zapewniania jakości

Choć często podkreślamy, że automatyzacja polega głównie na

programowaniu, to zdecydowanie dotyczy ona również zapewniania jakości

(Quality Assurance, QA). Analityczne i krytyczne myślenie są kluczowe

przy planowaniu testów i badaniu niepowodzeń, ale przede wszystkim

powinniśmy się skupić na jakości, a nie na automatyzacji testów samej

w sobie. Pamiętajmy, że automatyzacja testów jest jedynie narzędziem, że

testowanie znacznie wykracza poza samą automatyzację, a QA znacznie

wykracza poza testowanie. Starajmy się uzyskać szerszy obraz testowanej

aplikacji, jej użytkowników oraz cyklu rozwoju oprogramowania, aby być

w stanie oszacować, gdzie jest prawdziwe ryzyko i jakie są najbardziej

skuteczne sposoby jego wyeliminowania. Spróbujmy zdefiniować, co tak


naprawdę jakość oznacza dla naszej organizacji i użytkowników, a także

znaleźć sposoby efektywnego jej pomiaru, aby móc dokonać prawdziwej

zmiany.

Podobnie jak w przypadku doskonalenia swoich umiejętności

programistycznych, powinniśmy również rozszerzać swoje umiejętności

w zakresie QA poprzez czytanie o nowych paradygmatach zapewniania

jakości, rozmowy z ludźmi, słuchanie podcastów i udział w konferencjach.

Oczywiście spędzenie kilku lat w roli testera QA pomoże nam lepiej poznać

stawiane przed nimi problemy, ich punkt widzenia oraz sposób myślenia.

Ale zamiast poświęcania kilka lat każdej z tych ról, prawdopodobnie

najlepiej będzie popracować lat w zespole, w którym każdy robi wszystkie

te rzeczy (tworzenie, testowanie, DevOps itd.) lub przynajmniej w zespole

multidyscyplinarnym, który działa na zasadzie ścisłej współpracy.

Poszerzaj swoje horyzonty

W naszej codziennej pracy mamy styczność jedynie z konkretnymi

dziedzinami problemów, określonymi strukturami organizacyjnymi,

procesami, architekturą itd. Jeśli naprawdę chcemy być profesjonalistami,

to powinniśmy wiedzieć, co dzieje się w innych miejscach, dziedzinach,

czy technologiach. Dobrym sposobem na pozyskanie tej wiedzy jest

uczestniczenie w konferencjach i lokalnych spotkaniach (tzw. grupy

użytkowników), ale również czytanie i udzielanie się na forach i grupach

w mediach społecznościowych, czytanie blogów, słuchanie podcastów

i podobnych rzeczy. Czytanie książek również jest świetnym sposobem na

poszerzenie swojej wiedzy, a także pogłębienie jej w obszarach, z którymi

jesteśmy już zaznajomieni.


Raz na kilka lat możemy również poszukać i podjąć się realizacji

nowych projektów wewnątrz naszej organizacji (lub poza nią, jeśli

wewnątrz nie znajdziemy tego, czego szukamy), co również może pozwolić

nam znacznie poszerzyć nasze horyzonty.

Dzielenie się wiedzą

Jak tylko dowiemy się czegoś nowego na jakiś temat, to powinniśmy się tą

wiedzą podzielić z innymi, nawet jeśli wydaje się nam, że to nic ważnego

i prawdopodobnie inni już to wiedzą. Będziemy zaskoczeni tym, jak wielką

wartość ta wiedza będzie mieć dla innych osób. Dzisiaj bardzo łatwo jest

dzielić się swoją wiedzą z innymi za pośrednictwem witryny LinkedIn lub

własnego blogu. Próba opisania jakiejś rzeczy w celu jej objaśnienia innym

sprawi, że będziemy lepiej ją rozumieć. Z kolei otrzymywanie komentarzy

od społeczności może dostarczyć nam wielu pomysłów na rozwój i dosyć

szybko uczynić z nas ekspertów w konkretnym obszarze wiedzy.

Poza pisaniem bloga możemy również dzielić się swoją wiedzą jako

prelegenci na konferencjach lub spotkaniach. Lokalne spotkania są zwykle

bardziej przystępne dla mniej doświadczonych prelegentów, jednak

stanowią one świetny sposób na rozpoczęcie tej działalności i pozwalają

nam zdobyć cenne doświadczenie oraz pewność siebie, które są niezbędne

do przemawiania na dużych konferencjach.

Dzielenie się swoją wiedzą z innymi członkami społeczności (na

blogach, spotkaniach, konferencjach lub w jakikolwiek inny sposób) może

sprawić, że staniemy się rozpoznawalni wśród naszych kolegów

i przyszłych pracodawców lub klientów, a co za tym idzie, przyczynić się

do szybkiego rozwoju naszej kariery. Nawet jeśli nasze blogi będą

stosunkowo anonimowe, to wskazywanie ich naszym potencjalnym


pracodawcom może zapewnić nam dużą przewagę nad innymi kandydatami

i to jeszcze zanim weźmiemy udział w rozmowie kwalifikacyjnej.

Dziel się własnymi narzędziami

Innym bardzo pomocnym sposobem dzielenia się wiedzą jest udostępnianie

kodu i narzędzi, które stworzyliśmy sami, w formie projektów open source

(lub w postaci narzędzi, które można pobrać lub używać online). Jeśli kod

ten lub narzędzie faktycznie okaże się przydatne dla innych, to z pewnością

społeczność doceni nas wtedy jeszcze bardziej. Ponadto zarządzanie

popularnym projektem open source może mieć takie same korzyści dla

naszej kariery, jak samo dzielenie się wiedzą.

Tak właśnie postąpiłem z moim własnym projektem Test Automation

Essentials (opisywanym w dodatku C). Po prostu wszystko, co zbudowałem

i co może zostać wykorzystane ponownie w innych projektach, zebrałem

w jedną całość i udostępniłem w witrynie GitHub.

Bawmy się dobrze!

Możemy skorzystać z dowolnej z rad przedstawionych w tym rozdziale lub

w ogóle w tej książce, ale najważniejsze jest to, abyśmy zawsze byli

zadowoleni, bez względu na to, co robimy. Aha, i pamiętajmy o kremie

przeciwsłonecznym.
Dodatek A. Rzeczywiste przykłady

W rozdziale 6 mówiliśmy o tym, w jaki sposób projektować automatyzację

testów, aby pasowała do architektury konkretnej aplikacji. Ten dodatek

opisuje trzy przykłady oparte na rzeczywistych aplikacjach, w których

brałem udział przy projektowaniu rozwiązań automatyzacji testów.

Zwróćmy uwagę, że przykłady te jedynie bazują na rzeczywistych

przypadkach. Niektóre szczegóły zostały zmienione, dla zachowania

większej przejrzystości, ochrony własności intelektualnej lub zapewnienia

anonimowości. Niektóre z przedstawionych tu pomysłów nie zostały

zrealizowane w rzeczywistych projektach, głównie z powodu zmienionych

priorytetów, polityki wewnętrznej itd., ale przynajmniej techniczne

możliwości każdego z nich zostały dowiedzione.

Przykład 1 – system monitorowania


wodomierzy

Jeden z moich klientów produkuje elektroniczne wodomierze i tworzy

system do kontrolowania i monitorowania tych wodomierzy w miastach

i powiatach. Same wodomierze, wraz z elektroniką i wbudowanym

oprogramowaniem, są tworzone i testowane niezależnie od tego systemu.


Firma ta poprosiła mnie o pomoc przy utworzeniu infrastruktury

automatyzacji testów dla systemu kontrolowania i monitorowania. System

ten jest witryną sieci Web, która poza prezentowaniem informacji

o wodomierzach i ich stanie w postaci tabel, formularzy i wykresów,

wyświetla je również za pomocą widżetu Google Maps. Architekturę tego

systemu przedstawiono na rysunku A.1.

Rysunek A.1. Schemat architektury dla systemu monitorowania

wodomierzy

Poniżej znajduje się opis poszczególnych komponentów oraz interakcji

między nimi:

Klient sieci Web jest stroną HTML z plikami JavaScript i CSS, które

razem wykorzystywane są przez przeglądarkę do wyświetlania interfejsu

i odziaływania z użytkownikiem. Jak w przypadku każdej innej witryny,


wszystkie strony HTML i powiązane z nimi pliki, w tym również dane

do wyświetlenia, pobierane są z serwera sieci Web. Strona główna

wyświetla również widżet Google Maps i wykorzystuje API JavaScript

usługi Google Maps do dodawania na mapie znaczników w miejscach

odpowiadających lokalizacjom wodomierzy.

Po stronie serwera znajdują się następujące komponenty:

Serwer sieci Web jest częścią serwerową witryny, która dostarcza do

przeglądarek klientów pliki HTML, JavaScript i CSS, a także API

sieci Web dostarczającej dane do wyświetlenia. Serwer sieci Web

zwykle pobiera dane i otrzymuje zdarzenia dotyczące istotnych

aktualizacji z warstwy danych. Ponadto może on wysyłać polecenia

do wodomierzy, co również realizowane jest za pośrednictwem

warstwy danych.

Warstwa danych zarządza dostępem do danych i dostarcza do

wyższych warstw odpowiednie powiadomienia związane

z aktualizacją danych. Serwer sieci Web wykorzystuje tę warstwę do

pozyskiwania danych z bazy danych, odbierania powiadomień

i wysyłania poleceń, podczas gdy agregator danych wykorzystuje ją

do zapisywania i aktualizowania danych w bazie danych i odbierania

poleceń wysyłanych do wodomierzy.

Komponent agregatora danych otrzymuje strumień niemal

rzeczywistych danych od wszystkich wodomierzy w danym powiecie

za pośrednictwem serwera komunikacji. Agreguje on i przekształca

te dane do postaci struktury wymaganej przez warstwę danych.

Dodatkowo wysyła on polecenia do wodomierzy zgodnie

z żądaniami warstwy danych. Przed wysłaniem polecenia agregator


danych najpierw przekształca je z wewnętrznej struktury danych

aplikacji na protokół zrozumiały dla wodomierzy.

Serwer komunikacji, który został opracowany dla mojego klienta przez

podwykonawcę, zachowuje się jak specjalistyczny router między

aplikacją a wodomierzami. Serwer komunikacji nie odczytuje i nie

zmienia treści komunikatów, a jedynie wysyła je do i odbiera je od

konkretnych wodomierzy za pośrednictwem sieci komórkowej.

Same wodomierze są fizycznymi urządzeniami elektromechanicznymi,

które mierzą przepływ wody, zgłaszają ewentualne problemy i przyjmują

polecenia, takie jak „otwórz dopływ” lub „zamknij dopływ”. Urządzenia

te są wyposażone w anteny komórkowe do komunikowania się

z serwerem komunikacji. Mimo że mają one w sobie również

oprogramowanie, to jest ono tworzone przez inny zespół w firmie

w niezależnym tempie. Z tego powodu na potrzeby testowania tego

systemu do kontroli i monitorowania, oprogramowanie dostępne na tych

urządzeniach możemy uznać za komponent zewnętrzny, który nie jest

integralną częścią systemu.

Gdy zespół zapewniania jakości poprosił mnie o pomoc

w ustabilizowaniu platformy dla testów automatycznych, zapytałem ich, co

tak naprawdę chcą przetestować. Ich wstępną odpowiedzią było: „wszystko,

od początku do końca”. Gdy jednak zagłębiliśmy się w to bardziej, okazało

się, że tak naprawdę chodziło im o interfejs użytkownika i komponenty

serwerowe. Elementy te stanowią całe oprogramowanie, które tworzą oni

sami w tym zespole, natomiast serwer komunikacji i oprogramowanie

wodomierzy stanowią dla nich mniejsze zainteresowanie, ponieważ uważają

je oni za stabilne i niezawodne.


Symulowanie serwera komunikacji

Potem zapytałem ich, w jaki sposób wykonali oni dzisiaj swoje testy

manualne. Okazało się, że wykorzystują oni bazę danych, która jest kopią

bazy jednego z ich klientów i wykonują wszystkie testy za pośrednictwem

interfejsu użytkownika klienta sieci Web. Dopiero na naszym spotkaniu

zespół ten zdał sobie sprawę, że w ogóle nie testuje komponentu agregatora

danych, mimo że znajduje się w nim spora część logiki. Gdy zaczęliśmy

dyskutować o tym, w jaki sposób powinniśmy do tego podejść

z perspektywy automatyzacji testów, doszliśmy do wniosku, że musimy

stworzyć symulator dla wodomierzy i serwera komunikacji. Jest to jedyny

sposób pozwalający na przetestowanie komponentu agregatora danych

i pozwalający utworzyć wiarygodne testy, które nie będą mogły na siebie

wzajemnie wpływać.

Na początku wyglądało to na niewykonalne zadanie. Zespół zapewniania

jakości oraz ich menedżer podkreślali, że z jednej strony, ponieważ tylko

programiści mają wiedzę na temat wykorzystywanego protokołu, to właśnie

oni powinni tworzyć symulator. Jednak z drugiej strony, deweloperzy są zbyt

zajęci i w najbliższej przyszłości nie będą mieli na to czasu. Wtedy

zasugerowałem, że deweloperzy będą musieli jedynie dostarczyć

odpowiednie dokumenty dotyczące tego protokołu i przekazać odrobinę

swojej wiedzy, natomiast sam symulator będzie tworzony przez

deweloperów automatyzacji. Tak więc menedżer zespołu zapewniania

jakości wezwał do siebie odpowiedniego dewelopera i zapytał go, czy

będzie nam mógł w tym pomóc, na co ten niezwłocznie się zgodził! Okazało

się, że dysponuje bardzo szczegółową dokumentacją dotyczącą tego

protokołu, a jeśli informacje te nie będą dla nas wystarczająco jasne lub

aktualne, bardzo chętnie nam w tym pomoże. Na rysunku A.2 pokazano

końcową architekturę automatyzacji testów.


Rysunek A.2. Końcowa architektura dla systemu monitorowania

wodomierzy

Praca z usługą Google Maps

Innym architektonicznym wyzwaniem był komponent Google Maps. Jak

w przypadku każdej innej strony zawierającej komponent Google Maps,

architektura tego systemu wyglądała jak na rysunku A.3.

Z oczywistych powodów do automatyzacji interfejsu użytkownika

wybraliśmy narzędzie Selenium. Problem polega na tym, że w przypadku

komponentu graficznego takiego jak Google Maps, praca z tym narzędziem


nie jest już taka prosta. Komponent Google Maps oparty jest na elemencie

SVG (Structured Vector Graphics) języka HTML 5, który zawiera wszystkie

surowe dane reprezentujące linie i kształty wymagane do wyświetlenia

mapy. Selenium ma co prawda dostęp do tych danych, jednak nie jesteśmy

w stanie ich zrozumieć i zinterpretować tego, co widzi użytkownik. Na

szczęście usługa Google Maps wykorzystuje również za kulisami API

JavaScript, które dostarcza nam bardziej znaczące informacje i operacje.

Selenium może wysyłać dowolny kod JavaScript, jaki podamy mu do

wykonania bezpośrednio w przeglądarce, i otrzymać z powrotem rezultat.

Dzięki temu możemy użyć narzędzia Selenium do wykonywania zapytań

i modyfikowania API JavaScript usługi Google Maps i w ten sposób

uzyskać szersze pojęcie o tym, co widzi użytkownik.

Rysunek A.3. Architektura klienta sieci Web z komponentem Google Maps


Rysunek A.4 pokazuje architekturę tego rozwiązania w odniesieniu do

komponentu Google Maps.

Rysunek A.4. Architektura testu dla klienta sieci Web z komponentem

Google Maps

Przykład 2 – system do handlu na rynku Forex

Do klienta z mojego drugiego przykładu przyszedłem po tym, jak jego

zespół rozpoczął już implementowanie automatycznego testowania, ale


zwrócili się do mnie, ponieważ mieli z tym pewne problemy, związane

głównie ze stabilnością testów. Po zbadaniu i naprawieniu kilku oczywistych

problemów, rozpoczęliśmy dyskusję o procesach biznesowych, które

wykorzystują przy automatycznych testach. Dopiero po jakimś czasie

zdałem sobie sprawę, że nie wykonują oni testów w niedziele. Dla wielu

osób może się to wydawać oczywiste, ale jeśli weźmiemy pod uwagę to, że

w Izraelu tydzień roboczy trwa od niedzieli do czwartku (tj. niedziela jest po

prostu kolejnym zwykłym dniem roboczym), to łatwo zrozumieć moje

zdziwienie. Dla ludzi, z którymi rozmawiałem, było czymś oczywistym, że

testy nie mogą być uruchamiane w niedziele, ponieważ giełdy nie operują

w niedziele. Oczywiście rozumiałem, dlaczego rzeczywisty system nie może

działać w niedziele, ale nadal nie mogłem pojąć, dlaczego tego dnia nie

mogą być uruchamiane same testy – zarówno testy manualne, jak

i automatyczne. Ostatecznie zdałem sobie sprawę, że zależą one od

zewnętrznej usługi, która w czasie rzeczywistym dostarcza dane z różnych

giełd z całego świata, a gdy handel się nie odbywa, wiele funkcji aplikacji

jest po prostu niedostępnych. W przypadku systemu w środowisku

produkcyjnym ma to sens, ale okazało się, że ta sama usługa

wykorzystywana jest również w środowisku testowym, co uniemożliwia

testowanie w niedziele.

Choć usługa ta sama w sobie była dosyć stabilna, to zależność od niej

powodowała również, że testy miały pewne dziwne zależności od

konkretnych, rzeczywistych symboli akcji giełdowych, a od czasu do czasu

akcje wykorzystywane przez testy trzeba było podmieniać na inne z powodu

zmian w niektórych właściwościach tych akcji na prawdziwym rynku. Tak

więc testy nie tylko były uzależnione od istnienia określonych akcji, ale

również od ich właściwości. Mimo że same akcje prawie nigdy nie

przestawały istnieć, to już ich właściwości od czasu do czasu zmieniały się,

co wymagało wprowadzenia odpowiedniej zmiany w testach lub wybrania


innej akcji do przetestowania. Ponadto w pewnych sytuacjach konkretne

akcje były tymczasowo niedostępne z powodu prawdziwych ograniczeń

w handlu. Oczywiście sam test nie miał nad tym żadnej kontroli, a było to

coś, co miało znaczący wpływ na niestabilność testów.

Co więcej, okazało się, że nie można sprawdzić najbardziej podstawowej

funkcjonalności systemu, ponieważ test nie miał kontroli nad

najważniejszymi danymi tego systemu, jakimi są dane pozyskiwane z tej

usługi handlowej. Przykładowo nie można było wstępnie ustalić straty lub

zysku na transakcji handlowej, ponieważ nie dało się przewidzieć,

zachowania akcji.

Po tej rozmowie zdałem sobie sprawę, że muszę lepiej poznać

architekturę tego systemu, tak więc poprosiłem ich o pokazanie schematu

jego architektury. Schemat architektury był zbliżony do schematu

pokazanego na rysunku A.5.


Rysunek A.5. Architektura systemu handlu na rynku Forex

Rozwiązanie

Na podstawie wniosków z tej dyskusji zdecydowaliśmy się

zaimplementować symulator dla serwera handlu (i jego serwera proxy).

W ten sposób zyskaliśmy lepszą kontrolę nad tym, co dzieje się w testach

i mogliśmy zasymulować scenariusze (również te bardzo proste!), których

wcześniej nie dało się przetestować. Oczywiście dzięki temu testy stały się

bardziej wiarygodne, a do tego mogły być wykonywane dowolnego dnia,

w tym również w niedziele.

Niestabilność powodowana przez CRM

Kolejnym komponentem, który miał wpływ na stabilność testów, była

aplikacja CRM. Główny system podłączony był do systemu Microsoft

Dynamics CRM, który przechowywał informacje o użytkownikach.

Aplikacja Microsoft Dynamics CRM była odpowiednio rozszerzona

i dostosowana do potrzeb organizacji („Komponenty CRM” na schemacie).

Zespół, który pracował nad tą częścią systemu, był oddzielony od zespołu

tworzącego główny serwer sieci Web, ale środowisko testowe głównego

serwera sieci Web skonfigurowane było do pracy z tym samym

środowiskiem testowym zespołu CRM. Z powodu ograniczeń technicznych

programiści komponentów CRM wykorzystywali swoje środowisko testowe

nie tylko do samego testowania, ale również do debugowania podczas

tworzenia oprogramowania. Oznacza to, że aplikacja CRM była w dużej

mierze niestabilna. W rezultacie zawsze gdy główny serwer sieci Web

musiał użyć komponentu CRM, komunikował się z niestabilną aplikacją

CRM, która z kolei sprawiała, że testy serwera sieci Web nie były

wiarygodne.
Izolowanie środowisk

Ponieważ komponenty CRM są ściśle powiązane (zgodnie z projektem)

z aplikacją Microsoft Dynamics CRM, a celem zespołu było kompleksowe

przetestowanie systemu wraz z komponentami CRM, zdecydowaliśmy się

nie symulować części CRM jak w przypadku usługi handlu. Aby

ustabilizować testy, zdecydowaliśmy się na odłączenie środowiska

testowego serwera sieci Web od środowiska testowego/rozwojowego

komponentów CRM i utworzenie kolejnej instalacji CRM wewnątrz

normalnego środowiska testowego. Została utworzona automatyczna

kompilacja (która uruchamiała się co noc, ale mogła być również wyzwalana

ręcznie) do kompilowania kodu w systemie kontroli wersji, a w przypadku

pomyślnego zakończenia, również wdrożenia do środowiska testowego

serwera sieci Web oraz komponentów CRM. Pozwoliło to deweloperom

komponentów CRM na debugowanie ich kodu w ich oryginalnym

środowisku oraz na ewidencjonowanie kodu w systemie kontroli wersji

dopiero po zweryfikowaniu wprowadzonych zmian, dzięki czemu

środowisko testowe do uruchamiania testów automatycznych zawsze było

czyste i stabilne. Na rysunku A.6 pokazano końcową architekturę

środowiska testowego.
Rysunek A.6. Końcowa architektura środowiska testowego

Testowanie aplikacji mobilnej z użyciem abstrakcyjnego


zakresu testowania

Poza normalną aplikacją sieci Web, rozpoczęto także opracowywanie

aplikacji mobilnej. Aplikacja mobilna oferowała praktycznie taką samą

funkcjonalność, lecz interfejs użytkownika zorganizowany był inaczej, aby

lepiej pasował do mniejszego ekranu smartfonu. Technologia interfejsu

użytkownika, która została użyta dla aplikacji mobilnej, miała charakter


aplikacji hybrydowej, co oznacza, że była ona niczym przeglądarka

internetowa wbudowana wewnątrz powłoki natywnej aplikacji. Oznaczało

to, że z technicznego punktu widzenia mogliśmy skorzystać z Selenium

również do przetestowania aplikacji mobilnej, ale ponieważ interfejs

użytkownika zorganizowany był inaczej, nie mogliśmy ponownie

wykorzystać istniejących testów w ich dotychczasowej postaci.

Z tego powodu, aby obsłużyć testowanie zarówno zwykłej aplikacji sieci

Web, jak i aplikacji mobilnej, zdecydowaliśmy się zrefaktoryzować kod

testu w taki sposób, aby korzystał z wzorca abstrakcyjnego zakresu

testowania, który został opisany w rozdziale 6: same metody testowe nie

musiały się zmieniać, natomiast dla każdej klasy i metody reprezentującej

funkcjonalność biznesową (która korzystała z regularnej aplikacji sieci Web)

wyodrębniliśmy zbiór interfejsów, a następnie utworzyliśmy nowy zestaw

klas, które implementowały te interfejsy, ale z użyciem aplikacji mobilnej.

Dodaliśmy również odpowiednie ustawienie do pliku konfiguracyjnego,

które określało, czy powinniśmy korzystać z normalnej aplikacji sieci Web,

czy z aplikacji mobilnej. Zgodnie z tym ustawieniem automatyzacja testów

tworzyła instancje odpowiednich klas.

Przykład 3 – zarządzanie sklepem detalicznym

Nasz trzeci przykład jest aplikacją typu klient/serwer do zarządzania

sklepami detalicznymi. W przeciwieństwie do dwóch poprzednich

przykładów, które są centralnymi witrynami sieci Web z pojedynczym

wdrożeniem, aplikacja z tego przykładu jest gotowym produktem, który

sprzedawcy mogą kupić, zainstalować i uruchomić na ich własnym sprzęcie.

Ponieważ oprogramowanie to było gotowym produktem, którego

sprzedawcy używali do prowadzenia swoich biznesów, ważne było, aby


oprogramowanie to było wysoce konfigurowalne i rozszerzalne. Ponadto

oprogramowanie to obsługiwało kilka różnych opcji wdrożenia, aby łatwiej

dopasować się do sprzedawców o różnej wielkości i potrzebach. Rysunek

A.7 pokazuje architekturę pełnego wdrożenia tego systemu.

Opis architektury

Choć architektura ta na pierwszy rzut oka może nieco przytłaczać, to wiele

jej komponentów jest po prostu różnymi instancjami innych identycznych

komponentów. Zanim wyjaśnimy sobie każdy z tych komponentów,

omówmy najpierw ogólną architekturę przedstawioną na diagramie.


Rysunek A.7. Architektura aplikacji przy pełnym wdrożeniu

W konfiguracji pełnego wdrożenia, która jest odpowiednia dla dużej

sieci sklepów detalicznych, aplikacja wdrożona jest na wszystkich kasach

fiskalnych, na serwerze sklepu w każdym sklepie (oddziale) oraz na


serwerze centralnym zlokalizowanym w siedzibie głównej. Jak to pokazano

na schemacie, komponenty serwera sklepu oraz serwera w siedzibie głównej

mogą być wdrożone na oddzielnych maszynach. Ponadto, w celu

zapewnienia nadmiarowości, można wdrożyć więcej niż jedną instancję

serwera siedziby głównej, ale to leży już poza zakresem tego schematu.

W szczególności, każda instancja wdrożenia składa się co najmniej z:

Serwera logiki biznesowej – jest to serwer REST/HTTP, który obejmuje

całą logikę biznesową. W opcji pełnego wdrożenia każda kasa fiskalna

zawiera swój własny lokalny serwer i bazę danych w celu zapewnienia

skalowalności i dostępności na wypadek awarii sieci. Wszystkie serwery

logiki biznesowej, łącznie z serwerami centralnych sklepów i siedzib

głównych, są w dużej mierze identyczne. API REST przeznaczone jest

dla zaawansowanych klientów, którzy mogą za jego pomocą opracować

adaptery do innych systemów lub nawet tworzyć swoje własne interfejsy

użytkownika.

Baza danych – baza zawierająca wszystkie dane, których potrzebuje

serwer. Każda instancja zawiera między innymi dane dotyczące

wszystkich produktów dostępnych w sklepie wraz z ich cenami oraz

wszystkie dane dotyczące transakcji sprzedaży, które powiązane są

z konkretną maszyną: kasy fiskalne zwykle przechowują transakcje

obsłużone w danej kasie podczas tego samego dnia, baza danych sklepu

przechowuje kopię transakcji, które zostały obsłużone przez wszystkie

kasy fiskalne w tym sklepie (oddziale), zaś baza danych w siedzibie

głównej przechowuje kopię wszystkich transakcji całego łańcucha.

W przypadku serwerów siedziby głównej i sklepu, baza danych może

zostać wdrożona na osobnych maszynach.

Usługa synchronizacji danych – ten komponent jest odpowiedzialny za

synchronizację danych między różnymi serwerami. W ogólnym


przypadku większość modyfikacji danych (np. aktualizacje produktów

i cen) jest przesyłana z wyższych warstw (siedziba główna, sklep) do

niższych warstw (kasy fiskalne), podczas gdy dzienniki transakcji

sprzedaży przesyłane są w górę tej hierarchii. Usługa synchronizacji

danych zawiera dedykowaną logikę biznesową, która pozwala jej

decydować o tym, które dane i do którego serwera powinna przesłać. Na

przykład niektóre produkty i ceny mogą mieć zastosowanie wyłącznie do

wybranych sklepów.

Ponieważ w konfiguracji pełnego wdrożenia serwer logiki biznesowej

jest wdrożony w kasach fiskalnych, na poziomie sklepów i na poziomie

siedziby głównej, wdrożenie to nazywane było również wdrożeniem

„trójwarstwowym”.

Poza komponentami istniejącymi w każdej instancji, kasy fiskalne mają

dedykowaną aplikację kliencką z interfejsem użytkownika, z której

korzystają kasjerzy. Komponent ten jest w większości przypadków cienką

warstwą interfejsu użytkownika, która komunikuje się z serwerem lokalnym

zainstalowanym na tej samej maszynie.

Można również wdrożyć serwer zarządzania, który łączy się z serwerami

sklepu i siedziby głównej. Serwer ten zapewnia interfejs sieci Web do

zarządzania wszystkimi danymi, łącznie z produktami i cenami, oraz

pozwala na przeglądanie i wykonywanie zapytań do dzienników transakcji

sprzedaży oraz pewnych analitycznych pulpitów nawigacyjnych.

Wdrożenie minimalne

Jak wspomnieliśmy wcześniej, aplikacja obsługuje różne konfiguracje

wdrożenia, w zależności od rozmiaru i potrzeb konkretnego klienta (sklepu).


Powyżej została wyjaśniona najbardziej złożona konfiguracja, natomiast

teraz opiszemy najprostszą z możliwych.

Minimalne wdrożenie składa się wyłącznie z jednego serwera logiki

biznesowej, bazy danych oraz jednego klienta, a wszystko to na jednej

maszynie będącej kasą fiskalną. Zwykle będzie tam również jeden serwer

zarządzania, ale nawet on nie jest wymagany, ponieważ zamiast tego dane

mogą być przesyłane z poziomu systemu zarządzania innego dostawcy za

pomocą API REST. Rysunek A.8 pokazuje konfigurację minimalnego

wdrożenia.

Rysunek A.8. Konfiguracja minimalnego wdrożenia aplikacji

Wewnętrzne struktury aplikacji serwera i kasy fiskalnej były bardzo

zbliżone do typowej architektury typu klient/serwer, o której wspominaliśmy

na początku rozdziału 6. Należy jednak zwrócić uwagę na kilka rzeczy:


1. Warstwa usług używana była jako publiczne API REST. Klienci

wykorzystywali to API głównie w celu integracji tego systemu z innymi

systemami i automatyzacji procesów biznesowych.

2. Warstwa logiki biznesowej również była udostępniona jako API .NET,

głównie po to, aby umożliwić klientom rozszerzanie, dostosowywanie

i nadpisywanie domyślnego zachowania systemu. Ta warstwa

zbudowana była z wielu komponentów, z których część miała

współzależności.

3. Każda jednostka danych (np. produkt, cena, transakcje sprzedaży, klienci,

kasjerzy itd.), jaka musiała zostać przesłana do innych serwerów

z użyciem usługi synchronizacji danych, miała zaimplementowany

interfejs do serializacji i deserializacji siebie z użyciem formatu XML.

O konsekwencjach takiego rozwiązania powiemy sobie później.

Struktura organizacyjna

Aby zrozumieć czynniki przemawiające za różnymi rozwiązaniami

automatyzacji testów, należy dobrze zapoznać się ze strukturą organizacyjną

(związki pomiędzy strukturą organizacyjną, architekturą i automatyzacją

testów omawiane są w rozdziale 8). Ta aplikacja została opracowana przez

grupę złożoną z około 200 osób: około połowa z nich to deweloperzy, zaś

reszta to głównie testerzy i menedżerzy produktu. Istniał także dedykowany

zespół dla aplikacji zarządzania, kolejny zespół dla klienta kasy fiskalnej

i jeszcze jeden dla usługi synchronizacji danych. Serwer logiki biznesowej

opracowany został przez kilka innych zespołów, odpowiadających mniej

więcej różnym komponentom w warstwie logiki biznesowej, ale każdy

z nich był również odpowiedzialny za odpowiednie fragmenty warstw usług

i dostępu do danych. Projekt zarządzany był z wykorzystaniem metodyki

Scrum.
Rozwiązania automatyzacji testów

Ponieważ warstwa logiki biznesowej udostępniona była jako publiczne API,

istotne było (i naturalne), aby większość testów była napisana w formie

testów komponentów. Dla każdego takiego komponentu jego testy

symulowały inne komponenty. Ponadto testy te symulowały warstwę

dostępu do danych, podczas gdy inne testowały warstwę dostępu do danych

każdego komponentu z osobna. Naturalnie, ponieważ testy te były bardzo

lekkie i szybkie, były uruchamiane w ramach kompilacji CI (patrz rozdział

15). Kompilacja ta kompilowała i uruchamiała razem wszystkie testy dla

wszystkich komponentów.

Jednak z powodu wzajemnych zależności między komponentami,

a także tego, że warstwa usług również była udostępniona jako API REST,

ważne stało się regularne testowanie integracji całego serwera. Dlatego

utworzona została również infrastruktura dla testów integracyjnych.

Ogólne zalecenie było takie, aby poza kilkoma testami komponentów, które

tak czy inaczej były wykonywane, zespół napisał co najmniej jeden test

integracyjny dla każdej historyjki użytkownika. Testy te testowały cały

serwer i bazę danych jako jedną jednostkę, komunikując się z nią wyłącznie

poprzez API REST. Infrastruktura tych testów tworzyła dedykowaną, pustą

bazę danych przed uruchomieniem testów w celu zapewnienia izolacji (patrz

rozdział 7 dotyczący izolacji) i wykorzystywała tę bazę danych podczas

testów. Dzięki temu deweloperzy mogli uruchamiać te testy na ich

maszynach lokalnych przed zaewidencjonowaniem kodu. Testy te były

również dodawane do kompilacji CI poprzez instalowanie nowej wersji

serwera na dedykowanej maszynie testowej i uruchamianie na niej

wszystkich testów.

Mimo że testy te były wolniejsze od testów komponentów, to nadal były

one stosunkowo szybkie. Podczas gdy dziesiątki testów komponentów


wykonywało się w ciągu sekundy, większość testów integracyjnych

zajmowała od 0,5 do 1 sekundy. Na początku nie stanowiło to problemu, ale

gdy liczba testów urosła do tysięcy, uruchomienie ich wszystkich zajmowało

od 30 do 40 minut, co powoli zaczynało być uciążliwe. Mimo że nie wydaje

się to bardzo długim okresem, jednak gdy programiści musieli szybko

wydać jakąś nową funkcję, często pomijali uruchamianie wszystkich testów

przed zaewidencjonowaniem kodu, po czym, głównie z powodu konfliktu

z jakąś zmianą dokonaną przez innego programistę, proces kompilacji

kończył się niepowodzeniem. Od tamtej pory nikt (ze 100 programistów!)

nie miał przyzwolenia na ewidencjonowanie swoich zmian, dopóki

kompilacja nie została naprawiona. Stanowiło to olbrzymie wąskie gardło

i marnowało tylko czas. Dodajmy do tego fakt, że sam proces kompilacji

trwał jeszcze dłużej z powodu narzutu związanego z kompilowaniem

i uruchamianiem testów komponentów (co sumowało się do około 50 minut)

i od razu widać, jak bardzo mogło to być frustrujące.

Rozwiązaniem było umożliwienie równoległego wykonywania się

testów. Na maszynach deweloperów testy były dzielone pomiędzy 4 wątki

(co było liczbą rdzeni na maszynie każdego dewelopera). W kompilacji

podzieliliśmy testy na 10 różnych maszyn, z których każda wykonywała 4

wątki! Pozwoliło nam to zredukować czas lokalnego uruchamiania do około

10 minut, zaś całkowity czas kompilacji, łącznie z kompilacją i testami

komponentów, do około 15 minut! Oczywiście każdy taki wątek musiał

pracować z oddzielną instancją aplikacji oraz osobą instancją bazy danych.

Ponieważ testy zawsze rozpoczynają działanie z pustą bazą danych, nie

stanowiło to większego problemu.

Symulator daty i godziny


Było kilka funkcji, które uzależnione były od daty i godziny. Na szczęście,

głównie z powodu wymagań dotyczących rozszerzalności, system zawierał

mechanizm wstrzykiwania zależności (dependency injection, DI). Ponadto,

częściowo dzięki testom komponentów, ale również właściwym decyzjom

architektonicznym, które zostały podjęte na wczesnym etapie, wszystkie

użycia klasy System.DateTime w bibliotece .NET zostały

wyabstrahowane za pomocą interfejsu, który został zaimplementowany

przez obiekt singletonowy. Pozwoliło nam to opracować komponent

symulujący datę i godzinę, który został wprowadzony do systemu za

pomocą mechanizmu wstrzykiwania zależności, i kontrolować go w testach

za pośrednictwem specjalnie utworzonego punktu końcowego REST. Na

rysunku A.9 pokazano architekturę testów integracyjnych z uwzględnieniem

symulatora daty i godziny.


Rysunek A.9. Architektura testów integracyjnych z uwzględnieniem

symulatora daty i godziny

Testy dla trzech warstw

Zespoły tworzące internetową aplikację zarządzania, klienta kasy fiskalnej

i usługę synchronizacji danych pisały testy jednostkowe, ale bez testów

o szerszym zakresie. Jednak testerzy manualni często znajdywali błędy,


które zdarzały się jedynie w pełnym (trójwarstwowym) wdrożeniu. Było tak

głównie z powodu specjalnego kodu serializacji i deserializacji, który

powinien być zaimplementowany oddzielnie dla każdej jednostki i nie był

używany ani w testach integracyjnych pojedynczej warstwy, ani w testach

komponentów.

Na szczęście infrastruktura testów integracyjnych rozróżniała żądania

REST, które standardowo pochodziły od kasy fiskalnej, od żądań, które

zwykle pochodziły od aplikacji zarządzania. Pozwoliło nam to wprowadzić

niewielką zmianę w infrastrukturze testów, aby umożliwić ich uruchamianie

w konfiguracji trójwarstwowej. Zamiast kierować wszystkie żądania pod ten

sam adres URL, zapytania, które standardowo pochodziły od aplikacji

zarządzania, kierowane były do serwera siedziby głównej, a żądania, które

zwykle pochodziły od kasy fiskalnej, kierowane były do serwera kasy

fiskalnej. Ponieważ trzy warstwy były skonfigurowane i połączone za

pośrednictwem usługi synchronizacji danych, istniejące testom mogły

działać w tej konfiguracji, więc można było przetestować usługi

synchronizacji danych i cały kod serializacji i deserializacji, bez

konieczności modyfikowania tych testów! Można to uznać za formę

abstrakcyjnego zakresu testowania, jak to omówiliśmy wcześniej, ale nawet

bez konieczności implementowania dwóch oddzielnych zestawów klas dla

każdej konfiguracji.

W rzeczywistości zmiana była nieco bardziej skomplikowana od

zwykłego przekierowania żądań pod różne adresy URL, ponieważ trzeba

było czekać na rozpropagowanie danych w innych warstwach. Do tego celu

wykorzystaliśmy specjalne API monitorowania usługi synchronizacji

danych, które pozwoliło nam dowiedzieć się, kiedy zakończył się proces

propagacji danych. Dzięki temu musieliśmy czekać tylko określoną ilość

czasu i ani sekundy dłużej. Ponieważ jednak testy te były dużo wolniejsze
(około 1 minuty na test, zamiast 1 sekundy), uruchamianie ich w CI nie

miało sensu, więc stworzyliśmy inny proces kompilacji, który uruchamiany

był w nocy.

Testy kompleksowe

Jeśli chodzi o aplikacje zarządzania oraz aplikacje klienta kasy fiskalnej, to

mimo że były one pokryte przez testy jednostkowe, nie wystarczyło tylko

upewnić się, że działają one poprawnie we współpracy z serwerem.

Okazjonalnie zdarzały się różnice między zawartością żądań wysyłanych

przez klientów a tym, czego oczekiwały serwery, lub też między

odpowiedziami wysyłanymi przez serwer a tym, czego oczekiwał klient.

Z tego powodu w pewnym momencie zdecydowaliśmy, że nie możemy

unikać automatycznych testów kompleksowych. Dla klienta kasy fiskalnej

testy te wykorzystywały bibliotekę Microsoft Coded UI, zaś do

oddziaływania z internetową aplikacją zarządzania korzystały z Selenium,

a przy tym działały również w dedykowanym nocnym procesie kompilacji.

W większości przypadków testy te pisane były przez dedykowany zespół

(raportujący do menedżera zespołu zapewniania jakości). Ponieważ testy

integracyjne testowały już wdrożenie trójwarstwowe, wystarczyło, aby testy

kompleksowe testowały pojedynczy serwer, ale w połączeniu z serwerem

zarządzania i klientem kasy fiskalnej. W ten sposób pojedynczy serwer dał

nam brakujące pokrycie, którego potrzebowaliśmy.


DODATEK B. Mechanizm
oczyszczania

Jak wyjaśniliśmy w rozdziale 7, pisanie solidnego kodu oczyszczającego

test nie jest łatwe i może być podatne na błędy, przy czym można utworzyć

mechanizm, który rozwiązuje większość tych problemów. Ten dodatek

wyjaśnia, w jaki sposób krok po kroku utworzyć taki mechanizm oraz jak

należy z niego korzystać. Zwróćmy uwagę, że mechanizm ten jest już

wbudowany w projekt Test Automation Essentials (patrz dodatek C).

W przypadku biblioteki MSTest możemy więc zacząć używać go niemal od

razu, natomiast do innych bibliotek platformy .NET (np. NUnit lub xUnit)

trzeba odpowiednio go dostosować. Kierując się objaśnieniami zawartymi

w tym dodatku, powinniśmy być w stanie zaimplementować podobny

mechanizm w dowolnym języku spoza platformy .NET (takiego jak Java,

Python, JavaScript itd.).

Wywołania zwrotne i delegaty

Mechanizm oczyszczania oparty jest na pojęciu wywołań zwrotnych

(callback). Różne języki programowania implementują wywołania zwrotne

na różne sposoby i przy użyciu innej składni, jednak wszystkie języki


ogólnego zastosowania w jakiś sposób je implementują. Wywołanie

zwrotne pozwala na odwołanie się do funkcji lub metody i zachowanie tego

odwołania do późniejszego użycia. W języku C# można to osiągnąć za

pomocą delegatów, w Javie za pomocą interfejsu z pojedynczą metodą

(zwykle Consumer lub Function), zaś w językach JavaScript i Python

możemy po prostu przypisać funkcję do zmiennej bez wywoływania tej

funkcji (tj. bez dopisywania nawiasów po nazwie funkcji). Po zachowaniu

referencji do funkcji w zmiennej, możemy wywołać tę funkcję za

pośrednictwem tej zmiennej. Pozwala nam to przypisywać różne funkcje do

zmiennej w zależności od okoliczności, a następnie wywoływać wybrane

funkcje bezpośrednio poprzez wywoływanie tego, do czego odwołuje się

zmienna. Niektóre języki oferują nawet krótszą składnię do deklarowania

krótkich anonimowych funkcji bezpośrednio w wierszu kodu, nazywanych

często wyrażeniami lambda, dzięki czemu nie musimy tworzyć

i deklarować całej nowej metody, jeśli chcemy jedynie zachować referencję

do tej prostej metody w zmiennej wywołania zwrotnego.

W języku C# predefiniowany typ delegata System.Action


zdefiniowany jest następująco:

public delegate void Action();

Definicja ta oznacza, że zmienna typu Action może odwoływać się do


dowolnej metody, która nie przyjmuje argumentów i nie zwraca żadnej

wartości (czyli zwraca void), w formie wywołania zwrotnego.


Na listingu B.1 pokazano przykłady delegatów i wyrażeń lambda

w języku C#, z użyciem typu delegatu Action.

private static void Foo()


{
Console.WriteLine("Foo");
}
public static void Main()
{
// Poniższy wiersz nie wywołuje metody Foo,
ale po prostu zachowuje
// wywołanie zwrotne w celu późniejszego
użycia.
Action x = Foo;

// Wykonaj jakąś pracę...

// Natomiast poniższy wiersz wywołuje metodę


Foo
x();
// Przypisz wyrażenie lambda (anonimową
funkcję zdefiniowaną
// bezpośrednio w wierszu kodu) do zmiennej x
w celu późniejszego
// użycia. Puste nawiasy oznaczają, że metoda
ta nie przyjmuje żadnych
// argumentów, natomiast kod zawarty pomiędzy
nawiasami klamrowymi
// { oraz } jest treścią tej metody.
x = () => { Console.WriteLine("This is
a lambda expression"); };

// Wykonaj jakąś pracę...


// Teraz poniższa instrukcja wywołuje treść
wyrażenia lambda.
x();
}

Listing B.1. Przykłady wywołań zwrotnych z wykorzystaniem delegatów

i wyrażeń lambda w języku C#

Budowanie mechanizmu oczyszczania

Wróćmy teraz do naszego problemu oczyszczania. Opiszemy krok po kroku

pełne rozwiązanie, aby wyjaśnić potrzebę istnienia każdego z niuansów

ostatecznego rozwiązania. Ale zanim omówimy to rozwiązanie,

przypomnijmy sobie, jaki dokładnie problem staramy się rozwiązać.

Problem

Jeśli przykładowo test wykonuje pięć operacji, które powinny zostać

cofnięte (np. tworzy jednostki, które należy usunąć), a czwarta operacja nie

powiedzie się, to będziemy chcieli, aby tylko trzy pierwsze operacje zostały

cofnięte, ponieważ operacje czwarta i piąta nie zostały tak naprawdę

wykonane. Spójrzmy teraz na bardziej konkretny przykład: załóżmy, że

budujemy witrynę handlu elektronicznego i chcemy sprawdzić, czy jeśli

kupimy trzy przedmioty i wprowadzimy kupon zniżkowy, to system

odpowiednio obniży użytkownikowi cenę. Aby przetestować ten

scenariusz, powinniśmy najpierw dodać do katalogu trzy produkty, a także

zdefiniować szczegóły dotyczące kuponu. Na listingu B.2 pokazano

oryginalną metodę testową. Pierwsze cztery instrukcje wykonują operacje,

które po których chcemy posprzątać po zakończeniu testu.


[TestMethod]
public void CouponGivesPriceReduction()
{
var usbCable = AddProductToCatalog("USB
Cable", price: 3);
var adapter = AddProductToCatalog("Car AC to
USB adapter", price: 7);
var phoneHolder =
AddProductToCatalog("SmartPhone car
holder", price: 20);
var coupon = DefineCouponDetails("12345",
percentsReduction: 20);

var shoppingCart = CreateShoppingCart();


shoppingCart.AddItem(usbCable);
shoppingCart.AddItem(adapter);
shoppingCart.AddItem(coupon);
var totalBeforeCoupon =
shoppingCart.GetTotal();
Assert.AreEqual(30, totalBeforeCoupon,
"Total before coupon added should be 30
(3+7+20)");
shoppingCart.AddCoupon(coupon);
// Oczekuj 20-procentowej zniżki ceny
decimal expectedTotalAfterCoupon =
totalBeforeCoupon * (1 - 20 / 100);
var totalAfterCoupon =
shoppingCart.GetTotal();
Assert.AreEqual(expectedTotalAfterCoupon,
totalAfterCoupon,
"Total after coupon");
}

Listing B.2. Oryginalna metoda testowa, bez kodu oczyszczającego

Proste rozwiązanie

Aby pozostawić środowisko w czystym stanie, musimy usunąć produkty,

które dodaliśmy do katalogu oraz definicję kuponu. Ale jak już

powiedzieliśmy, jeśli jedna z operacji nie powiedzie się, to nie chcemy

wykonywać kodu oczyszczającego związanego z tą operacją lub

operacjami, które nie zostały jeszcze wykonane. Prostym rozwiązaniem

może być zatem zarządzanie listą wywołań zwrotnych (delegatów

Action), z których każde wykonuje niepodzielną operację oczyszczania.

Zaraz po pomyślnym wykonaniu niepodzielnej operacji tworzącej jednostkę

lub zmieniającej stan środowiska, powinniśmy dodać do tej listy delegat dla

metody (lub wyrażenie lambda), która cofa tę konkretną operację.

W metodzie oczyszczającej biblioteki testującej przechodzimy w pętli po

liście delegatów i wywołujemy je jeden po drugim. Ponieważ korzystamy

z biblioteki MSTest, robimy to w metodzie, która ma przypisany atrybut

[TestCleanup] (podobną metodę mają praktycznie wszystkie biblioteki


testów jednostkowych dla innych języków). Zdefiniujmy najpierw ogólny

mechanizm oczyszczający wewnątrz naszej klasy testowej, jak to pokazano

na listingu B.3.
private readonly List<Action> _cleanupActions =
new List<Action>();
private void AddCleanupAction(Action
cleanupAction)
{
_cleanupActions.Add(cleanupAction);
}

[TestCleanup]
public void Cleanup()
{
foreach (var action in _cleanupActions)
{
action();
}
}

Listing B.3. Prosty mechanizm oczyszczający

Teraz możemy wykorzystać go wewnątrz naszej metody testowej, jak to

pokazano na listingu B.4.

[TestMethod]
public void CouponGivesPriceReduction()
{
var usbCable = AddProductToCatalog("USB
Cable", price: 3);
AddCleanupAction(() =>
RemoveProductFromCatalog(usbCable));
var phoneHolder =
AddProductToCatalog("Smartphone car
holder", price: 20);
AddCleanupAction(() =>
RemoveProductFromCatalog(phoneHolder));
var adapter = AddProductToCatalog("Car AC to
USB adapter", price: 7);
AddCleanupAction(() =>
RemoveProductFromCatalog(adapter));
var coupon = DefineCouponDetails("12345",
percentsReduction: 20);
AddCleanupAction(() =>
RemoveCouponDefinition(coupon));

var shoppingCart = CreateShoppingCart();


shoppingCart.AddItem(usbCable);
shoppingCart.AddItem(adapter);
shoppingCart.AddItem(coupon);

var totalBeforeCoupon =
shoppingCart.GetTotal();
Assert.AreEqual(30, totalBeforeCoupon,
"Total before coupon added should be 30
(3+7+20)");

shoppingCart.AddCoupon(coupon);
// Oczekuj 20-procentowej obniżki ceny
decimal expectedTotalAfterCoupon =
totalBeforeCoupon * (1 - 20 / 100);
var totalAfterCoupon =
shoppingCart.GetTotal();
Assert.AreEqual(expectedTotalAfterCoupon,
totalAfterCoupon,
"Total after coupon");
}

Listing B.4. Korzystanie z mechanizmu oczyszczającego wewnątrz metody

testowej

Jeśli przykładowo operacja dodawania produktu „smartphone car

holder” (uchwyt do telefonu) do katalogu (trzecia instrukcja) nie powiedzie

się, wówczas jedyną akcją oczyszczającą, jaką dodaliśmy do tej pory,

będzie akcja usuwania „USB Cable” (kabel USB) w drugiej instrukcji, tak

więc jest to jedyna akcja oczyszczająca, która zostanie wywołana przez

metodę Cleanup. Zwróćmy uwagę, że wywołania metod

AddCleanupAction w kodzie testu nie wywołują metod

RemoveProductFromCatalog i RemoveCouponDefinition.
Metoda AddCleanupAction jedynie dodaje referencję do tych metod do

listy _cleanupActions, która używana jest do wywoływania ich

wyłącznie w metodzie Cleanup.


Takie podejście do oczyszczania ma jednak dwie wady:

1. Metoda testowa jest teraz zagracona dużą ilością kodu technicznego

i dlatego jest ona mniej czytelna.


2. Zawsze gdy chcemy wywołać metodę AddProductToCatalog lub

DefineCouponDetails, musimy pamiętać o dodaniu odpowiedniej


akcji oczyszczającej. Jest to wysoce podatne na błędy i wprowadza

duplikację, ponieważ po każdym wywołaniu metody

AddProductToCatalog lub DefineCouponDetails powinno

następować wywołanie metody AddCleanupAction z odpowiednią

metodą wywołania zwrotnego oczyszczania.

Na szczęście rozwiązanie obu tych problemów jest bardzo proste: po

prostu przenosimy każde wywołanie metody AddCleanupAction do

wnętrza odpowiedniej metody tworzącej jednostkę, po której musimy

posprzątać. W naszym przypadku powinniśmy przenieść wywołania tej

metody do metod AddProductToCatalog


i DefineCouponDetails. Na listingu B.5 pokazano, w jaki sposób

możemy dodać wywołanie metody AddCleanupAction do metody

AddProductToCatalog:

private Product AddProductToCatalog(string name,


decimal price)
{
Product product = ... /* w tym miejscu
powinien znaleźć się kod metody
AddProductToCatalog (np. wyślij żądanie HTTP
lub dodaj dane bezpośrednio do bazy danych,
i zwróć nową instancję obiektu, który
reprezentuje ten nowy produkt */

AddCleanupAction(() =>
RemoveProductFromCatalog(product));
return product;
}

Listing B.5. Dodawanie wywołania metody AddCleanupAction do metody

AddProductToCatalog

Teraz możemy już przywrócić metodę testową z powrotem do stanu,

w jakim znajdowała się ona na listingu B.2, ale nadal musimy zadbać o to,

aby kod oczyszczający był wykonywany odpowiednio po zakończeniu

testu.

Ponowne wykorzystywanie mechanizmu oczyszczania

Ponieważ mechanizmu tego będziemy potrzebować w większej części lub

nawet we wszystkich naszych klasach testowych, sensowne wydaje się

wyodrębnienie tego działania do wspólnej klasy bazowej. Na listingu B.6

pokazano mechanizm oczyszczający w osobnej klasie bazowej.

[TestClass]
public abstract class TestBase {
private List<Action> _cleanupActions = new
List<Action>();
public void AddCleanupAction(Action
cleanupAction)
{
_cleanupActions.Add(cleanupAction);
}

[TestCleanup]
public void Cleanup()
{
foreach (var action in _cleanupActions)
{
action();
}
}
}

Listing B.6. Przenoszenie mechanizmu oczyszczania do wspólnej klasy

bazowej

Teraz musimy sprawić, że wszystkie klasy testowe będą dziedziczyć po

klasie TestBase.

Obsługiwanie zależności pomiędzy akcjami oczyszczającymi

Dopóki zmiany dokonywane przez nas w środowisku będą od siebie

niezależne, wszystko będzie w porządku. Zmodyfikujmy nieco nasz

przykład: załóżmy, że chcemy obsłużyć inny rodzaj kuponu – taki, który

powiązany jest z konkretnym produktem i który przyznaje zniżkę tylko

wtedy, gdy dany produkt kupowany jest n razy. W takim scenariuszu

musimy zdefiniować kupon, który odnosi się do danego produktu, a zatem

najpierw trzeba utworzyć produkt. Następnie definiujemy kupon, podając

poprzednio utworzony produkt, który ma z nim zostać powiązany. Jeśli

aplikacja wymusza integralność referencyjną między definicją kuponu

i produktem, to przy próbie usunięcia produktu, z którym powiązana jest

definicja kuponu, powinniśmy otrzymać błąd. Scenariusz ten pokazuje kod

testowy z listingu B.7. Zwróćmy uwagę, w jaki sposób obiekt usbCable


przekazywany jest jako argument do metody

DefineCouponDetailsForProduct, która wiąże go z nowym

kuponem. Zakładając, że metoda

DefineCouponDetailsForProduct dodaje akcję oczyszczającą

w celu usunięcia tego kuponu, kod ten zgłosi błąd w metodzie Cleanup,
mówiący, że produkt „USB Cable” nie może zostać usunięty, gdyż nadal są

z nim powiązane istniejące definicje kuponów. Wiersz, który jest

odpowiedzialny za zgłoszenie tego wyjątku, nie jest widoczny na listingu,

ale możemy wywnioskować, że wyjątek ten powinien zostać zgłoszony

z poziomu metody RemoveProductFromCatalog (pokazanej

wcześniej na listingu B.5), która wywoływana jest niejawnie z metody

Cleanup (widocznej na listingu B.6) za pomocą delegatu, ponieważ

metoda ta zostałaby wywołana przed wywołaniem delegatu, który usuwa

kupon.
Listing B.7. Metoda testowa, która powinna zakończyć się

niepowodzeniem z powodu kolejności akcji oczyszczających

Na szczęście rozwiązanie tego problemu również jest bardzo proste:

musimy jedynie wywołać metody oczyszczające w kolejności odwrotnej

do tej, w jakiej były dodawane. Może się wydawać, że rozwiązanie to jest

przypadkowe i ma zastosowanie jedynie w naszym przykładzie. Jednak

zawsze gdy tworzymy zależność pomiędzy jednostkami, to albo tworzymy

najpierw jednostkę niezależną (tj. jednostkę, od której zależą inne

jednostki), a dopiero potem tworzymy jednostkę zależną, albo też

tworzymy obie jednostki niezależnie (w dowolnej kolejności), a dopiero

potem tworzymy między nimi zależność w postaci odrębnej operacji.

W tym pierwszym przypadku, który widzieliśmy w naszym przykładzie,

usuwanie jednostki w odwrotnej kolejności usuwa najpierw jednostkę

zależną (która eliminuje również samą zależność), a następnie usuwa

pierwszą (niezależną) jednostkę, dla której nie mamy już żadnej zależności.

W drugim przypadku, gdy tworzymy zależności między istniejącymi

jednostkami, trzeba dodać akcję oczyszczającą, która usuwa tę zależność

(bez usuwania żadnej z jednostek). Gdy akcje oczyszczające zostaną

wywołane w odwrotnej kolejności, wówczas sama zależność zostanie

usunięta jako pierwsza, a dopiero potem usunięte zostaną same jednostki.

Na listingu B.8 pokazano bardzo prosty przykład implementacji tego

rozwiązania.

[TestCleanup]
public void Cleanup()
{
_cleanupActions.Reverse();
foreach (var action in _cleanupActions)
{
action();
}
}

Listing B.8. Odwracanie kolejności akcji oczyszczających w celu

rozwiązania problemu zależności

Teraz oczyszczanie będzie wykonywane w odpowiedniej kolejności,

a dzięki temu nie zostanie zgłoszony żaden wyjątek.

Uwaga

Oczywiście możemy zaimplementować mechanizm czyszczenia

za pomocą stosu zamiast listy i uniknąć w ten sposób

wywoływania metody Reverse. Prawdopodobnie będzie to

nawet nieco bardziej wydajne, ale nie na tyle, aby się tym

przejmować.

Obsługiwanie wyjątków w akcjach oczyszczających

Podstawową ideą mechanizmu czyszczenia jest wykonywanie

odpowiednich akcji oczyszczających, bez względu na to, czy test zakończył

się niepowodzeniem, czy też nie, a jeśli tak, to nie ma znaczenia, w którym

wierszu. Co jednak stanie się, jeśli jedna z akcji oczyszczających sama

zakończy się niepowodzeniem? Niestety nic nie może być w 100%


bezpieczne, tak więc akcje oczyszczające również mogą zgłaszać wyjątki.

Przykładowo, jeśli test zmienia jakieś globalne ustawienie, które ma wpływ

na wszystkie testy, a akcja oczyszczająca, która powinna przywrócić to

ustawienie do początkowego stanu, zakończy się niepowodzeniem,

wówczas może dojść do sytuacji, w której pozostałe testy również zakończą

się niepowodzeniem. Jednak zazwyczaj tak nie jest, tak więc możemy

bezpiecznie kontynuować uruchamianie kolejnych testów. Ponadto czasem

niepowodzenie jednej akcji oczyszczającej może spowodować, że inne

akcje oczyszczające również zakończą się niepowodzeniem. Ponieważ nie

możemy być tego pewni, jednak warto próbować wywoływać pozostałe

akcje oczyszczające.

W przypadku niepowodzenia musimy zaraportować wszystkie

szczegóły wyjątku, aby móc zbadać i naprawić główną przyczynę tego

niepowodzenia (jak to omówiono w rozdziale 13). Gdy wyjątek zgłaszany

jest w metodzie Cleanup, biblioteka testów jednostkowych automatycznie


zgłosi go jako część wyniku testu. Ponieważ chcemy kontynuować

uruchamianie innych akcji oczyszczających, musimy przechwycić każdy

z tych wyjątków. Z tego powodu trzeba zebrać wszystkie wyjątki, które

zostały zgłoszone z poziomu akcji oczyszczających i zgłosić je ponownie

na końcu metody Cleanup, opakowane razem w wyjątek zbiorczy

AggregateException, jeśli jest ich więcej niż jeden. Jeśli był tylko

jeden taki wyjątek, wówczas możemy go zgłosić w dotychczasowej postaci.

Na listingu B.9 pokazano metodę Cleanup z odpowiednią obsługą

wyjątków.

[TestCleanup]
public void Cleanup()
{
_cleanupActions.Reverse();
var exceptions = new List<Exception>();

foreach (var action in _cleanupActions)


{
try
{
action();
}
catch (Exception ex)
{
exceptions.Add(ex);
}
}
if (exceptions.Count == 1)
throw exceptions.Single();

if (exceptions.Count > 1)
throw new AggregateException(
"Multiple exceptions occurred in
Cleanup.", exceptions);
}

Listing B.9. Metoda Cleanup z obsługą wyjątków

Uwaga
W przypadku platformy .NET zaleca się wykorzystywanie listy

obiektów klasy ExceptionDispatchInfo zamiast listy

wyjątków typu Exception, aby zachować ich oryginalne ślady

stosu. Nie zrobiliśmy tego w tym przykładzie, aby niepotrzebnie

go nie komplikować. Implementację tego mechanizmu można

znaleźć w projekcie Test Automation Essentials w witrynie

GitHub i zobaczyć, w jaki sposób wykorzystywana jest klasa

ExceptionDispatchInfo. Więcej informacji na temat

projektu Test Automation Essentials można znaleźć w Dodatku

C.

Podsumowanie

Jak powiedzieliśmy w rozdziale 7, jedną z technik, która pomaga nam

osiągnąć izolację, jest usuwanie po zakończeniu testu wszystkiego, co

zostało w nim utworzone. Powiedzieliśmy sobie również, dlaczego trudno

jest napisać kod oczyszczający działający poprawnie we wszystkich

sytuacjach. Opisany tutaj mechanizm czyszczenia gwarantuje, że wywołane

zostaną właściwe akcje oczyszczające, bez względu na to, czy test

zakończył się niepowodzeniem, czy nie, ani na miejsce, w którym nastąpiło

to niepowodzenie. Gwarantuje to również, że jednostki, które są od siebie

zależne, będą oczyszczane w odpowiedniej kolejności, aby uniknąć

problemów związanych integralnością danych oraz zgłaszaniem wyjątków.

Na koniec upewniliśmy się, że jakiekolwiek wyjątki, które mogą wystąpić

wewnątrz akcji oczyszczających, nie powstrzymają uruchamiania innych

akcji oczyszczających i że raportowane będą wszystkie szczegóły


wyjątków, więc będziemy mogli zbadać i naprawić główną przyczynę

niepowodzeń.
Dodatek C. Projekt „Test
Automation Essentials”

Ten dodatek opisuje utworzony przeze mnie projekt open source o nazwie

Test Automation Essentials, który zawiera różne przydatne narzędzia do

tworzenia automatyzacji testów w języku C#.

Kontekst

Gdy rozpocząłem swoją pracę na stanowisku konsultanta i zacząłem

opracowywać infrastruktury do automatyzacji testów dla różnych klientów,

zauważyłem, że wiele rzeczy, które zaimplementowałem dla jednego

klienta, było również przydatnych dla pozostałych klientów. Jak można się

łatwo zorientować po przeczytaniu tej książki, nie cierpię pisać

zduplikowanego kodu, więc szukałem sposobu na to, aby jakoś udostępnić

ten kod moim klientom. Ponieważ naturalnie moi klienci nie współdzielą

między sobą tego samego repozytorium kontroli wersji, postanowiłem

utworzyć w tym celu projekt open source, udostępniany w witrynie GitHub,

który będzie służył nam wszystkim. Z czasem, gdy tylko napisałem coś, co

mogło być przydatne dla moich klientów lub jakieś innej osoby, dodawałem
to do projektu. Kod źródłowy tego projektu dostępny jest pod adresem

http://github.com/arnonax/TestEssentials.

Większość publicznych klas i metod dostępnych w jego bibliotekach

zawiera dosyć wyczerpujące komentarze XML (podobne do komentarzy

JavaDoc w języku Java). Po najechaniu na te klasy i metody wskaźnikiem

myszy w Visual Studio można zobaczyć komentarze wyświetlane w formie

etykietek narzędziowych, co ułatwia ich użycie. Stopniowo staram się

pokryć komentarzami XML resztę klas i metod, dla których jeszcze tego

nie zrobiłem.

Struktura projektu

Ponieważ moi klienci oraz projekty, w które byłem zaangażowany,

korzystają z różnych technologii automatyzacji testów, podzieliłem ten

projekt na kilka mniejszych i bardziej modułowych bibliotek. Dzięki temu

każdy będzie mógł używać jedynie tych bibliotek, które pasują do jego

potrzeb i wykorzystywanych technologii.

Rozwiązanie to składa się zatem z następujących projektów C#:

TestAutomationEssentials.Common – ten projekt zawiera bardzo

ogólne narzędzia wielokrotnego użytku. Nie jest on zależny od żadnej

konkretnej technologii (z wyjątkiem samej platformy .NET) czy

biblioteki, a co za tym idzie, może być używany w dowolnym

projekcie. W rzeczywistości wiele z dostępnych w tym projekcie

narzędzi nie jest ograniczonych jedynie do automatyzacji testów i mogą

one obsługiwać dowolny projekt .NET. Większość z pozostałych

projektów tego rozwiązania jest zależnych od tego projektu.


TestAutomationEssentials.MSTest – ten projekt dostarcza narzędzia,

które są przydatne dla projektów pisanych z wykorzystaniem biblioteki

MSTest w wersji 1, która była wykorzystywana aż do wydania

programu Visual Studio 2015.

TestAutomationEssentials.MSTestV2 – ten projekt dostarcza taką

samą funkcjonalność jak projekt TestAutomationEssentials.MSTest,

z tym że dla biblioteki MSTest V2 (MSTest w wersji 2), która jest

używana od czasu wydania programu Visual Studio 2017 i dostępna

w postaci pakietu NuGet dla projektów tworzonych za pomocą

programu Visual Studio 2015. W rzeczywistości projekt ten nie zawiera

żadnych własnych plików źródłowych C#, lecz dołącza wszystkie pliki

źródłowe projektu TestAutomationEssentials.MSTest. Z tego powodu

projekty te są praktycznie zawsze identyczne, z wyjątkiem wersji

biblioteki MSTest, do której się odnoszą.

TestAutomationEssentials.CodedUI – ten projekt dostarcza narzędzia

dla projektów opartych na bibliotece Coded UI (bibliotece do

automatyzacji interfejsu użytkownika tworzonej przez Microsoft)

TestAutomationEssentials.Selenium – ten projekt oferuje narzędzia

przydatne dla projektów z użyciem Selenium.

Poza tymi pięcioma projektami, które dostarczają narzędzia przydatne

dla dowolnego projektu, rozwiązanie to zawiera również następujące

projekty wewnętrzne:

TestAutomationEssentials.UnitTests – zawiera testy jednostkowe (i

pewne testy integracyjne) dla projektów

TestAutomationEssentials.Common

i TestAutomationEssentials.MSTest.
TestAutomationEssentials.MSTectV2UnitTests – zawiera testy

jednostkowe i integracyjne dla projektu

TestAutomationEssentials.MSTestV2.

TestAutomationEssentials.TrxParser – wewnętrzny projekt, który jest

wykorzystywany przez projekty testów jednostkowych.

Wskazówka

Przyglądając testy jednostkowe, można czasem lepiej zrozumieć

działanie pewnych narzędzi. Ponadto interesujące może okazać

się również przeglądanie i debugowanie testów w klasie

TestBaseTest (i jej klasie bazowej) oraz próba zrozumienia

sposobu ich działania. Ponieważ testy te sprawdzają integrację

klasy TestBase w projekcie

TestAutomationEssentials.MSTest z samą biblioteką

MSTest, generują one test w locie, kompilują go i uruchamiają

skompilowany kod za pomocą biblioteki MSTest – a wszystko to

wewnątrz samego testu. Jest to dosyć skomplikowane

i podchwytliwe, ale fajne…

Uwaga dotycząca testów jednostkowych i komentarzy XML

Większość kodu w tym projekcie została najpierw napisana jako część

rzeczywistych projektów, a dopiero później wyodrębniona z tych projektów

do rozwiązania Test Automation Essentials. Ponieważ wszystkie oryginalne

projekty były projektami testowymi, kod był pokryty (pośrednio) przez

testy aplikacji mojego klienta. Po przeniesieniu odpowiedniego kodu do

rozwiązania Test Automation Essentials próbowałem zmodernizować testy

jednostkowe dla większości tego kodu, aby upewnić się, że nie popsuję
kompatybilności, gdy będę coś modyfikował. Niestety zajmuje to trochę

czasu i w niektórych przypadkach jest to dosyć trudne. Z tego powodu do

tej pory pokryłem testami tylko projekty Common i MSTest (w tym

MSTestV2), ale pracuję również nad pokryciem projektu Selenium.

Podobnie postępuję z komentarzami XML, przy czym zwykle jest to

łatwiejsze i szybsze niż pisanie testów jednostkowych.

Pakiety NuGet

Aby móc korzystać z tych narzędzi, nie musimy pobierać kodu źródłowego

z witryny GitHub (mimo że można to oczywiście zrobić). Możemy

natomiast skorzystać z tych bibliotek w formie pakietów NuGet (podobnym

sposobem, w jaki dodaliśmy bibliotekę Selenium WebDriver do projektu

w rozdziale 12). Dostępnych jest pięć bibliotek, po jednej dla każdego

projektu, które możemy łatwo dodać do naszego własnego projektu,

i zacząć korzystać z tego, co jest nam potrzebne.

Funkcje i narzędzia

Poniżej znajduje opis głównych funkcji i narzędzi zawartych w każdej

z tych bibliotek.

TestAutomationEssentials.Common

Ta biblioteka zawiera różne pomniejsze, ale bardzo przydatne metody

narzędziowe (głównie metody rozszerzenia, które zostały wspomniane

w rozdziale 13), plus kilka większych funkcji:

1. Obsługa dla plików konfiguracyjnych. Zostało to wyjaśnione

i wykorzystane w rozdziale 14.


2. Klasa Logger, która implementuje koncepcję zagnieżdżonego

rejestrowania, opisana w rozdziale 13.

3. Ogólna implementacja mechanizmu czyszczenia opisanego w dodatku B.

4. Klasa Wait (podobna do klasy WebDriverWait w Selenium), która

dostarcza kilka metod do oczekiwania na spełnienie określonego

warunku.

Poniżej znajduje się bardziej szczegółowe wyjaśnienie dla tych

najbardziej interesujących klas i metod z tej biblioteki.

Klasa ExtensionMethods

Ta klasa zawiera wiele metod rozszerzenia ogólnego przeznaczenia, które

mogą nieco uprościć nasz kod i ułatwić jego czytanie. Na listingu C.1

pokazano kilka przykładów użycia tej klasy.

// Przykład metody SubArray:


string[] cartoons = {"Mickey", "Minnie", "Goofy",
"Pluto", "Donald" };
string[] dogs = cartoons.SubArray(2, 2);

// Przykład metody Dictionary.AddRange:


var numbers = new Dictionary<int, string>
{

{1, "One" },
{2, "Two" },
{3, "Three" }

};
var moreNumbers = new Dictionary<int, string>()
{
{4, "Four"},
{5, "Five"},
{6, "Six"}
};
numbers.AddRange(moreNumbers);

// Przykład metody IEnumerable<T>.IsEmpty():


IEnumerable<int> values = GetValues();
if (values.IsEmpty())
{
// Zrób coś...
}
DateTimeExtensions

Listing C.1. Przykłady użycia klasy ExtensionMethods

Ta klasa również zawiera przydatne metody rozszerzenia, ale związane

głównie z operowaniem datą i godziną. Zwłaszcza interesujące wydają się

te metody rozszerzenia, które sprawiają, że kod czyta się płynniej. Na

przykład zamiast pisać: var duration =


TimeSpan.FromSeconds(3); możemy po prostu napisać: var
duration = 3.Seconds();. Różnica jest dosyć mała, a do tego

niektóre osoby mogą uznać taką składnię za niejasną, ale gdy już do niej

przywykniemy i zaczniemy czytać kod, będzie nam to szło dużo płynniej.

TestExecutionScopeManager
Ta klasa implementuje mechanizm czyszczenia, który opisywany jest

w dodatku B, ale nie jest ona ograniczona do żadnej konkretnej biblioteki

testowania jednostkowego, a ponadto pozwala nam na zagnieżdżanie

zakresów wykonywania w celu obsługi wielu poziomów czyszczenia, jak

[TestCleanup], [ClassCleanup] i [AssemblyCleanup]. Jeśli

korzystamy z biblioteki MSTest, to nie ma potrzeby używać tej klasy

bezpośrednio, ponieważ projekt TestAutomationEssentials.MSTest (oraz

MSTestV2) wykorzystuje już tę klasę wewnętrznie i dostarcza pojedynczą

metodę AddCleanupAction. Z klasy tej możemy jednak skorzystać,

jeśli potrzebujemy jej dla innych bibliotek testowania jednostkowego lub

w dowolnym innym celu.

Ta klasa wykorzystuje również klasę Logger do zapisywania

momentów rozpoczęcia i zakończenia zakresu wykonywania.

Wait

Ta klasa udostępnia kilka metod statycznych do oczekiwania na spełnienie

określonego warunku. Metody te zgłaszają wyjątek TimeoutException


w przypadku, gdy warunek nie został spełniony po upływie określonego

czasu. Warunek podawany jest jako delegat lub wyrażenia lambda

(wyjaśnione w dodatku B), które zwraca wartość logiczną wskazującą, czy

warunek został spełniony, czy też nie.

Klasa ta ma kilka przeciążonych metod o nazwie Wait.Until


i Wait.While, które zgodnie ze swoją nazwą odpowiednio oczekują do

momentu spełnienia warunku oraz dopóki warunek jest spełniony (tj. do

momentu, aż warunek nie będzie już spełniony). Niektóre z przeciążeń tych

metod przyjmują komunikat błędu do wykorzystania w wyjątku

TimeoutException, przy czym jeśli używamy wyrażenia lambda,


możemy wykorzystać kolejne przeciążenie, które nie przyjmuje

komunikatu błędu i automatycznie parsuje wyrażenie, aby go użyć

w opisie. Na listingu C.2 pokazano dwa przykłady użycia klasy Wait.

// Przykład metody Wait.Until


Wait.Until(() => batchJob.IsCompleted,
2.Minutes());

// Przykład metody Wait.While z komunikatem błędu


Wait.While(() => batchJob.InProgress, 2.Minutes(),
"Batch job
is still in progress after 2 minutes");

Listing C.2. Przykłady użycia klasy Wait

Poza metodami Until i While, istnieją także metody If oraz

IfNot. Metody te są bezpośrednimi odpowiednikami metod While


i Until, z tym że nie zgłaszają wyjątku TimeoutException po

przekroczeniu określonego czasu. Zamiast tego zwyczajnie kontynuują one

swoją pracę. Może to być przydatne w sytuacjach, gdy chcemy czekać na

wystąpienie jakiegoś zdarzenia, ale możemy równie dobrze je przeoczyć.

Jako przykład rozważmy przypadek, w którym kliknięcie jakiegoś

przycisku rozpoczyna operację mogącą potrwać kilka sekund. W tym czasie

powinien pojawić się komunikat „Proszę czekać”, a my chcemy zaczekać

na zakończenie operacji. Czasem jednak ta sama operacja może być

wykonana bardzo szybko, więc komunikat ten pojawi się jedynie przez

ułamek sekundy. Nasz test może więc nawet wcale nie zauważyć tego

komunikatu. Aby więc uchronić nasz test przed niepowodzeniem na

wypadek takiej sytuacji, możemy skorzystać z metody Wait.IfNot do


wstępnego oczekiwania na pojawienie się komunikatu, podając niską

wartość przekroczenia czasu (np. 1 sekunda), po czym za pomocą

Wait.Until poczekać na zniknięcie komunikatu, z większą wartością

przekroczenia czasu (np. 1 minuta). Jeśli test pominął komunikat, ponieważ

był on widoczny zbyt krótko, wówczas metoda Wait.IfNot będzie na

próżno czekać na pojawienie się komunikatu, ale tylko przez okres 1

sekundy i bez zgłaszania wyjątku. Z kolei metoda Wait.Until dokona

błyskawicznego powrotu, ponieważ komunikat nie jest już wyświetlany.

TestAutomationEssentials.MSTest

Najważniejszą klasą dostarczaną przez tę bibliotekę jest klasa TestBase.


Klasa ta została zaprojektowania do wykorzystywania jako klasa bazowa

dla wszystkich naszych testów. Jeśli mamy już hierarchię klas testów, po

prostu dziedziczymy najniższe klasy (nasze własne testowe klasy bazowe)

z tej klasy.

Klasa ta dostosowuje metody inicjalizujące i oczyszczające z biblioteki

MSTest do klasy TestExecutionScopeManager z projektu

TestAutomationEssentials.Common. Dodatkowo udostępnia ona metodę

wirtualną, którą możemy nadpisać i która wywoływana jest tylko wtedy,

gdy test kończy się niepowodzeniem (OnTestFailure). Jest to coś,

czego biblioteka MSTest domyślnie nie dostarcza. Przydaje się to do

dodawania wszelkich informacji, które mogą nam pomóc w badaniu

i diagnozowaniu niepowodzeń. W rzeczywistości, jeśli zaimportujemy

przestrzeń nazw TestAutomationEssentials.MSTest.UI,


wówczas otrzymamy nieco inną implementację klasy TestBase, której

domyślna implementacja metody OnTestFailure wykonuje zrzut

ekranu w przypadku niepowodzenia (zrzut ten wykonywany jest na całej


powierzchni ekranu, a nie tylko w obrębie strony przeglądarki, jak ma to

miejsce w przypadku narzędzia Selenium).

LoggerAssert

Poza klasą TestBase, biblioteka ta zawiera również kilka innych

użytecznych klas. Jedną z nich jest klasa LoggerAssert, która zapisuje

komunikat w dzienniku za każdym razem, gdy ewaluowana jest asercja, i to

bez względu na to, czy kończy się ona sukcesem, czy porażką. Choć

w ogólnym przypadku na końcu każdego testu nie powinno znajdować się

więcej niż kilka asercji, to istnieją przypadki, w których może się przydać

kilka asercji znajdujących się w środku testu (np. wewnątrz pętli, które

również nie są zalecane w testach, ale istnieją od tego wyjątki…). W takich

sytuacjach warto mieć również w dzienniku asercje kończące się sukcesem,

a nie tylko te, które kończą się niepowodzeniem. Zwróćmy uwagę na to, że

poprawne korzystanie z tej klasy wymaga formułowania komunikatów

w formie oczekiwań (np. „x powinno mieć wartość 3”), a nie w postaci

komunikatów błędu („x nie miało wartości 3”), ponieważ komunikaty te

będą zapisywane w dzienniku również w przypadku sukcesu.

TestAutomationEssentials.CodedUI

Podstawowym celem tej biblioteki jest sprawienie, aby nasza praca

z biblioteką Coded UI za pośrednictwem kodu i bez plików UIMap (patrz

rozdział 3) była znacznie prostsza. Biblioteka

TestAutomationEssentials.CodedUI udostępnia interfejs API, który

przypomina interfejs Selenium WebDriver. Na listingu C.3 pokazano

przykład użycia tej biblioteki.

var customerDetailsPanel =
mainWindow.Find<WinPane>
(By.AutomationId("CustomerDetails"));
var customerNameTextBox =
customerDetailsPanel.Find<WinText>
(By.Name("CustomerName"));
var text = customerNameTextBox.DisplayText;

Listing C.3 Przykład użycia biblioteki TestAutomationEssentials.CodedUI

Ponadto dostarcza ona kilka użytecznych metod rozszerzenia, takich

jak: myControl.RightClick(),
myControl.DragTo(otherControl) oraz bardzo przydatną metodę
myControl.IsVisible(), której z jakiegoś powodu nie dostarcza

biblioteka CodedUI.

TestAutomationEssentials.Selenium

Ta biblioteka opakowuje narzędzie Selenium WebDriver, aby w większości

przypadków uprościć jego wykorzystywanie.

Uwaga

Obecnie biblioteka ta jest zbyt „uparta”, co utrudnia jej dodanie

do już istniejących projektów. Rozważam zmianę lub

przynajmniej złagodzenie niektórych z tych ograniczeń, ale na

razie nie mogę podać żadnych konkretnych szczegółów.

WaitForElement
Aby korzystać z większość funkcji tej biblioteki, musimy utworzyć

instancję klasy Browser, która opakowuje nasz obiekt IWebDriver.


Korzystając z tego obiektu (a co za tym idzie, innych klas, które dziedziczą

po klasie ElementsContainer, o czym powiemy za chwilę), możemy

wyszukiwać elementy za pomocą metody WaitForElement. Metoda ta

jest podobna do znanej nam już metody FindElement w Selenium, ale są


między nimi dwie znaczące różnice:

1. Metoda ta zawsze czeka na pojawienie się elementu (a nie wymaga tylko

jego istnienia). Możemy określić wartość przekroczenia czasu lub

skorzystać z domyślnych 30 sekund.

2. Przyjmuje ona argument description, który wykorzystywany jest do


automatycznego rejestrowania w dzienniku operacji kliknięcia, pisania

itd. Podanie opisowej nazwy w tym argumencie sprawi, że dziennik

będzie dużo bardziej czytelny.

Browser.WaitForElement zwraca obiekt BrowserElement,


który z jednej strony implementuje interfejs IWebElement z Selenium,

a z drugiej strony dziedziczy również po klasie ElementsContainer,


co oznacza, że za pomocą metody WaitForElement możemy

wyszukiwać wewnątrz niego elementy podrzędne, tak samo jak

w przypadku standardowej metody FindElement.

Obsługiwanie ramek i okien

Biblioteka ta jest według mnie najbardziej przekonująca w przypadku

aplikacji sieci Web, które wykorzystują wiele okien lub elementów iframe

(będących stronami sieci Web wyświetlanymi wewnątrz innej strony).

Zwykle przy Selenium musimy używać funkcji

driver.SwitchTo().Window() oraz
driver.SwitchTo().Frame(), aby korzystać z innych okien

i ramek. Ale najbardziej denerwujące jest to, że jeśli chcemy użyć

elementu, który już znaleźliśmy w jednym kontekście (np. w oknie lub

ramce, ze stroną główną włącznie), to nie będziemy mogli korzystać z tego

elementu po zmianie kontekstu, dopóki nie przełączymy się z powrotem na

początkowy kontekst. Jeśli tego nie zrobimy, otrzymamy wyjątek

StaleElementReferenceException (patrz rozdział 14), ale

zarządzanie aktywnym kontekstem może być problematyczne

i skomplikować nasz kod. Problem ten pokazano na listingu C.4 problem.

// Przejdź do witryny zawierającej element iframe


webDriver.Url = "http://www.some-
site.com/outer.html";

// Znajdź element na zewnętrznej stronie


var outerButton =
webDriver.FindElement(By.Id("outerButton"));

// Przełącz się na ramkę wewnętrzną


webDriver.SwitchTo().Frame("frame1");

// Znajdź jakiś element w ramce wewnętrznej


var innerButton =
webDriver.FindElement(By.Id("innerButton"));

// Klikanie w wewnętrzny przycisk działa normalnie


innerButton.Click();

// (Patrz następny komentarz)


//webDriver.SwitchTo().DefaultContent();
// Kliknięcie przycisku na zewnętrznej stronie
spowodowałoby teraz
// zgłoszenie wyjątku
StaleElementReferenceException, ponieważ
zewnętrzna
// strona nie jest bieżącym kontekstem. Aby
uniknąć tego wyjątku, musisz
// odkomentować powyższą instrukcję, która ustawia
kontekst z powrotem
// na zewnętrzną stronę
outerButton.Click();

Listing C.4. Problem z metodą SwitchTo

Zwróćmy uwagę, że w tym przykładzie problem nie wygląda na

poważny, ale w bardziej skomplikowanych sytuacjach zarządzanie

bieżącym kontekstem może sprawić, że kod będzie problematyczny

i podatny na błędy.

Rozwiązanie problemu SwitchTo

Klasy, które dziedziczą po klasie ElementsContainer (w tym

Browser i BrowserElement) zawierają metodę GetFrame, która

wyszukuje ramkę i zwraca odpowiedni obiekt Frame (będący częścią

biblioteki TestAutomationEssentials.Selenium). Klasa Frame również

dziedziczy po ElementsContainer, tak więc możemy wyszukiwać

zagnieżdżone ramki wewnątrz ramki, którą ten obiekt reprezentuje (tj.

wyszukiwać elementy iframe wewnątrz innych elementów iframe). Ponadto

klasa Browser zawiera metodę OpenWindow, która wywołuje podaną


przez nas operację (np. kliknięcie przycisku), co powinno w rezultacie

otworzyć nowe okno i zwrócić obiekt BrowserWindow, reprezentujący


to nowe okno. Jak można łatwo zgadnąć, BrowserWindow również

dziedziczy po klasie ElementsContainer. Przyjemną rzeczą jest to, że


biblioteka TestAutomationEssentials zarządza bieżącym kontekstem za nas,

tak więc nie musimy się nim przejmować. Gdy tylko chcemy użyć

elementu, który znaleźliśmy w dowolnym oknie lub ramce, powinniśmy

być w stanie to zrobić, nawet jeśli nie znajduje się on w aktywnym

kontekście (zakładając oczywiście, że element ten nadal istnieje). Test

Automation Essentials wykona za nas całą pracę związaną z przełączaniem

kontekstu. Na listingu C.5 pokazano, jak wyglądałby poprzedni przykład po

zastosowaniu biblioteki Test Automation Essentials.

// Utwórz instancję klasy Browser, która opakowuje


nasz obiekt
webDriver object var browser = new
Browser(webDriver, "Site with iframe");

// Przejdź do witryny zawierającej element


browser.Url = @"http://www.some-
site.com/outer.html";

// Znajdź element na zewnętrznej stronie


var outerButton =
browser.WaitForElement(By.Id("outerButton"),
"Outer button");

// Znajdź wewnętrzną ramkę


var frame = browser.GetFrame("frame1", "inner
frame");

// Znajdź jakiś element w wewnętrznej ramce


var innerButton =
frame.WaitForElement(By.Id("innerButton"), "Inner
button");
// Klikanie wewnętrznego przycisku działa
normalnie
innerButton.Click();

// Klikanie zewnętrznego przycisku działa teraz


bezproblemowo
outerButton.Click();

Listing C.5. Praca z ramkami z użyciem biblioteki Test Automation

Essentials

Pomoc w tworzeniu projektu i przenoszenie na


inne języki

Dobrą stroną projektów open source jest to, że każdy może przyczynić się

do ich tworzenia i mieć wpływ na kierunek tego rozwoju. Ze względu na

naturę tego projektu, dodawałem do niego głównie te rzeczy, których sam

potrzebowałem, tak więc może w nim nadal brakować wielu rzeczy, które

powinny się w nim znaleźć. Ponadto, mimo że projekt ten jest bardzo

modułowy, to nie ma on zdefiniowanych jasnych granic tego, co powinno

się w nim znaleźć, a co nie. Można zatem zawrzeć w nim każdy dobry

i przydatny dla innych pomysł. Oczywiście, jak w przypadku każdego


innego oprogramowania, projekt ten może również zawierać błędy, które

muszą zostać poprawione. W ten sposób możemy na wiele różnych

sposobów przyczynić się do rozwoju tego projektu.

Przed podjęciem decyzji o wysłaniu żądania ściągnięcia (pull-request,

patrz rozdział 13), należy to najpierw przedyskutować, zamieszczając

konkretny problem w witrynie GitHub lub kontaktując się bezpośrednio ze

mną. Zwróćmy uwagę, że zamieszczenie problemu nie musi koniecznie

oznaczać zgłoszenia błędu, ponieważ może to być również pomysł na jakieś

usprawnienie lub żądanie zaimplementowania nowej funkcji. Jeśli jednak

zdecydujemy się wysłać żądanie pull-request, to upewnijmy się, że zawiera

ono przejrzyste komentarze XML dla swojego publicznego API.

Przynajmniej w przypadku bibliotek Common i MSTest oczekuję, że

prawie wszystko będzie pokryte testami jednostkowymi i integracyjnymi.

Rozważam również przeniesienie niektórych rozwiązań do innych

języków programowania, głównie do Javy, ale może również do Pythona

lub jakiegoś innego języka, jeśli będzie taka potrzeba. Prawdopodobnie

zrobię to, gdy będę miał jakiś rzeczywisty i istotny projekt automatyzacji

testów, który będzie tego wymagał, ale każdy może to zrobić. Zwróćmy

uwagę, że niektóre rzeczy, zwłaszcza w bibliotece Common, są ściśle

przeznaczone dla języka C#, natomiast inne narzędzia mogą być przydatne

dla innych języków.


Dodatek D. Wskazówki i praktyki
zwiększające produktywność
programisty

Pracując jako konsultant z wieloma deweloperami automatyzacji testów,

zauważyłem, że wielu z nich jest spragnionych wskazówek i praktyk

zapewniających bardziej efektywną pracę i pozwalających usprawnić ich

umiejętności programistyczne. W tym dodatku zebrałem wybrane

wskazówki i praktyki, które powinny być cenne dla większości osób.

Niektóre z tych wskazówek skierowane są do wszystkich programistów,

nie tylko do deweloperów automatyzacji testów, a inne dotyczącą bardziej

automatyzacji testów, a nawet samego narzędzia Selenium.

Preferuj korzystanie z klawiatury

Jeśli chcemy, aby nasza praca z kodem była bardziej wydajna, powinniśmy

częściej korzystać z klawiatury niż myszy. Przyzwyczajenie się do

korzystania z klawiatury wymaga czasu, ale gdy już tak się stanie,

będziemy dużo bardziej produktywni podczas pisania i czytania kodu! Oto

kilka porad, które pomogą nam przywyknąć do klawiatury. Większość


z nich zakłada, że korzystamy z systemu Microsoft Windows. W przypadku

korzystania z systemu macOS, linuksowego lub dowolnego innego systemu

operacyjnego, prawdopodobnie w systemach tych istnieć będą

odpowiedniki tych skrótów, ale mogą być one również całkiem inne.

Stosowne informacje o odpowiednikach tych skrótów w tych systemach

operacyjnych można prawdopodobnie znaleźć w sieci.

1. Ponieważ niezwykle trudno zmienić dotychczasowe nawyki, musimy

najpierw nałożyć na siebie pewne ograniczenia, aby przyzwyczaić się

do korzystania z klawiatury zamiast myszy. Z tego powodu trzeba

położyć mysz nieco dalej niż do tej pory i sięgać po nią jedynie wtedy,

gdy nie potrafimy zrealizować jakiegoś zadania za pomocą klawiatury,

zaś po jego wykonaniu odstawić ją z powrotem.

2. Wciśnięcie lewego klawisza Alt spowoduje ukazanie skrótów dla menu

i przycisków, poprzez wyświetlenie znaku podkreślenia pod literą

danego skrótu. Aby aktywować taki skrót, należy wcisnąć kombinację

klawiszy Alt + podkreślona litera. Przykładowo, aby otworzyć menu

File (Plik) praktycznie w dowolnej aplikacji, należy wcisnąć Alt + F60.

3. Po otwarciu wybranego menu zobaczymy skróty przypisane

poszczególnym elementom tego menu. Gdy menu jest otwarte, możemy

po prostu wcisnąć podkreśloną literę, bez konieczności wciskania

klawisza Alt. Na przykład, po wciśnięciu kombinacji Alt + F w celu

otwarcia menu File, wystarczy wcisnąć klawisz O, aby aktywować

element menu o nazwie Open… (Otwórz…).

4. Gdy menu jest już otwarte, możemy nawigować po jego elementach za

pomocą strzałek na klawiaturze. Wciśnięcie klawisza Enter spowoduje

wybranie podświetlonego menu. Klawisze strzałek są również

przydatne do nawigowania po większości kontrolek drzewa. Na


przykład, aby rozwinąć folder w Eksploratorze plików, wystarczy

wcisnąć klawisz strzałki skierowanej w prawo.

5. Wiele elementów menu ma również dodatkowe skróty klawiszowe,

wyświetlane po ich prawej stronie, zwykle w postaci kombinacji Ctrl +

jakiś inny klawisz. Skróty te mogą być wywoływane bezpośrednio, bez

konieczności uprzedniego otwierania menu. Na przykład, aby otworzyć

nowy plik, wystarczy w dowolnym momencie wcisnąć klawisz Ctrl +

O.

6. Na większości kontrolek (elementów) interfejsu użytkownika

w większości aplikacji możemy ustawiać fokus, co oznacza, że

kontrolka taka ma pierwszeństwo w pozyskiwaniu danych z klawiatury.

W danym momencie fokus może być ustawiony tylko na jednej

kontrolce. Koncepcja fokusa jest najbardziej zauważalna w przypadku

pól tekstowych, ale inne kontrolki, takie jak przyciski, pola wyboru,

rozwijane listy itd., również mogą przyjmować fokus, co pozwala nam

sterować nimi za pomocą klawiatury. Aby przenieść fokus z jednego

elementu na inny w danym oknie, należy użyć klawisza Tab. Za

pomocą kombinacji Shift + Tab możemy odwrócić kierunek

przemieszczania się fokusa. Wciśnięcie klawisza Enter lub Spacja

spowoduje wciśnięcie wybranego przycisku. Ponadto Spacja umożliwia

również zaznaczanie i odznaczanie pól wyboru, lub wybieranie

elementów z list wielokrotnego wyboru. Klawisz Esc imituje wciśnięcie

przycisku Cancel w oknach dialogowych.

7. Większość klawiatur zawiera klawisz menu kontekstowego (zwykle

ulokowany między prawymi klawiszami Alt i Ctrl), który odpowiada

operacji wciśnięcia prawego przycisku myszy. Zwróćmy jednak uwagę,

że w przypadku korzystania z myszy, prawy przycisk otwiera menu

kontekstowe odpowiadające lokalizacji, w której znajduje wskaźnik


myszy, podczas gdy wciśnięcie klawisza menu kontekstowego na

klawiaturze otwiera menu kontekstowe w miejscu, w którym obecnie

znajduje się fokus.

8. Za pomocą kombinacji Alt + Tab i Alt + Shift + Tab możemy

przełączać się pomiędzy otwartymi oknami. W wielu aplikacjach,

w tym w programie Visual Studio, możemy używać skrótu Ctrl + Tab

oraz Ctrl + Shift + Tab do przełączania się pomiędzy otwartymi

dokumentami. Podczas przytrzymywania klawisza Alt (lub Ctrl)

będziemy mogli podejrzeć listę otwartych aplikacji lub dokumentów.

9. W edytorze tekstu lub polu tekstowym użycie kombinacji Ctrl +

i Ctrl + ← spowoduje przemieszczenie karetki (kursora wprowadzania

tekstu) o jedno słowo w przód lub w tył. Klawisze Home oraz End

pozwalają przenieść się na początek lub na koniec bieżącego wiesza.

Z kolei kombinacje Ctrl + Home oraz Ctrl + End umożliwiają

przejście na początek oraz na koniec dokumentu.

10. Za pomocą kombinacji klawiszy Shift + , Shift + ← lub Shift +

dowolna kombinacja opisana powyżej będziemy mogli zaznaczyć tekst

między bieżącą pozycją a miejscem do którego przeniesie nas

zastosowana kombinacja. Przykładowo kombinacja Shift + Ctrl +

spowoduje zaznaczenie następnego słowa.

11. Wciśnięcie klawiszy Shift + Del lub Shift + Backspace usuwa następne

lub poprzednie słowo (lub tekst od środka słowa do jego końca lub

początku).

12. Warto przeszukać sieć pod kątem listy skrótów dla naszego ulubionego

środowiska programowania lub innej aplikacji, z której często

korzystamy, a następnie wydrukować ją i umieścić bezpośrednio przed

sobą. Warto również poszukać w sieci konkretnych skrótów

klawiszowych dla czynności, dla których nie możemy znaleźć skrótu.


Jeśli korzystamy z narzędzia ReSharper, możemy wyszukać w sieci

frazę „ReSharper Default Keymap PDF”, wydrukować docelowy

dokument i położyć go w pobliżu klawiatury. Od czasu do czasu

powinniśmy powracać do tego dokumentu, wyszukiwać w nim

przydatne skróty i starać się ich często używać, aż w końcu zaczniemy

używać ich w naturalny sposób. Dzięki tej liście prawdopodobnie

nauczymy się również dodatkowych funkcji narzędzia ReSharper (lub

naszego środowiska programowania).

13. Przypisanie klawiszy w wielu aplikacjach, zwłaszcza środowiskach

programistycznych, można dostosowywać. Tak więc możemy przypisać

bezpośrednie skróty klawiszowe do tych akcji, które wykonujemy

często, a które nie mają domyślnie zdefiniowanych takich skrótów.

Poka-Yoke

Właściwość MVCForum zadeklarowaliśmy w rozdziale 11 jako właściwość


tylko do odczytu, a nie właściwość standardową (z publiczną metodą

ustawiającą). Deklarowanie właściwości tylko do odczytu jest bardziej

restrykcyjne niż deklarowanie jej jako pełnoprawnej właściwości, dlatego

może być uznawane za gorszą opcję. Nam jednak chodziło o to, aby nikt

nie mógł nigdy zmienić tej właściwości z zewnątrz jej klasy (zwróćmy

uwagę, że zmiana wartości typu referencyjnego oznacza zamianę referencji

na odwołanie do innego obiektu, a nie dokonywanie zmian na istniejącym

obiekcie). Istnieje wiele podobnych decyzji projektowych, które

programista może podjąć, aby ograniczyć lub zezwolić na wykorzystanie

pewnej konstrukcji kodu, ale niektórzy programiści mają błędne

przekonanie, że lepiej jest zezwolić na więcej. Dlaczego więc powinniśmy


stosować te ograniczenia? Aby odpowiedzieć na to pytanie, wyjaśnimy

sobie teraz, czym jest „poka-yoke” i dlaczego termin ten jest tak istotny.

Poka-yoke jest japońskim terminem oznaczającym „uodparnianie na

pomyłki” lub zapobieganie przypadkowym błędom. Termin ten jest częścią

filozofii produkcji Lean, opracowanej przez Toyotę w połowie XX wieku.

Poka-yoke jest dowolnym mechanizmem, który pomaga operatorowi

sprzętu uniknąć (yokeru) pomyłek (poka), gdyż jak się okazuje,

zapobieganie pomyłkom jest często znacznie prostsze i tańsze niż

późniejsze badanie i naprawa defektów. Choć pierwotnie termin ten, jak

i sama koncepcja używane były w produkcji przemysłowej, to obecnie ma

ona również zastosowanie w projektowaniu oprogramowania.

Istnieją niezliczone przykłady funkcji języka i technik w projektowaniu

oprogramowania, które pozwalają na zastosowanie koncepcji poka-yoke.

Jedną z nich jest pierwsza zasada projektowania obiektowego –

enkapsulacja. Enkapsulacja jest tym, co pozwala nam określać, czy element

członkowski klasy będzie prywatny (private) i dzięki temu tylko inne

metody tej samej klasy będą miały do niego dostęp, czy też publiczny

(public), jeśli element ma być wykorzystywany przez metody

zdefiniowane również w innych klasach. Korzystanie z silnego typowania

(tj. konieczność jawnego określania dokładnego typu) jest bardzo mocnym

mechanizmem Poka-yoke w językach, które go obsługują (np. C# i Java).

Ponadto korzystanie z silnie typowanych kolekcji, które wykorzystują

generyki jest lepszym sposobem unikania błędów od zwykłych kolekcji

obiektów. Kolejną techniką, która pozwala nam zapobiegać błędom, jest

powstrzymywanie się od korzystania z wartości null jako poprawnej

wartości, o czym powiemy sobie za chwilę.

Mimo że korzystanie z koncepcji poka-yoke ogranicza czasem

elastyczność, ja jednak zalecam, aby elastyczność była dopuszczona tylko


w wybranych miejscach i w wybrany przez nas sposób, a nie jak ma to

miejsce – domyślnie. To właśnie dlatego nasza właściwość MVCForum jest


właściwością tylko do odczytu.

Unikaj wartości Null

Większość języków obiektowych pozwala na to, aby zmienne obiektów

(lokalne, statyczne, pola itd.) przyjmowały specjalną wartość null, która

wskazuje, że zmienna ta nie odwołuje się do żadnego obiektu. Jest to

zwykle również domyślna wartość zmiennej obiektu, która nie została

zainicjalizowana. Prawdopodobnie najbardziej powszechnymi

nieoczekiwanymi wyjątkami (które reprezentują tak naprawdę problem

w kodzie, lub mówiąc wprost: błędy) są w przypadku platformy .NET

wyjątki NullReferenceException (NullPointerException

w Javie), spowodowane użyciem wartości null. Błędy te mogą być bardzo


trudne do zbadania, zwłaszcza jeśli traktujemy wartości null jako

prawidłowe wartości, ponieważ ich przyczyna często leży w miejscu innym

niż to, w którym zgłaszany jest wyjątek. Co więcej, przyczyną tych błędów

jest również to, że nie doszło do przypisania rzeczywistego obiektu! Próba

udzielenia odpowiedzi na pytanie, dlaczego do czegoś nie doszło jest

znacznie trudniejsza niż próba udzielenia odpowiedzi na pytanie, dlaczego

coś nastąpiło… Zwróćmy uwagę, że gdy unikanie stosowania wartości

null jest naszą podstawową regułą, a mimo to nadal otrzymujemy wyjątek


NullReferenceException, to zwykle bardzo łatwo nam będzie
znaleźć zmienną, której zapomnieliśmy zainicjalizować.

Właśnie dlatego wiele nowoczesnych języków programowania

(zwłaszcza języków funkcyjnych, takich jak F# i Scala) celowo

uniemożliwia domyślne wykorzystywanie wartości null. W niektórych


z tych języków nadal można używać wartości null, ale tylko wtedy, gdy

jawnie zadeklarujemy zmienną w sposób, który na to pozwoli.

Ponieważ większość z nas nadal używa „standardowych” języków

obiektowych (C#, Java, Python, Ruby itd.), nasz kompilator nie

powstrzymuje nas przed używaniem wartości null. Jednak przy

zachowaniu odrobiny samodyscypliny, możemy sami zacząć ich unikać.

Wystarczy zainicjalizować każdą zmienną od razu w chwili jej

deklarowania (lub w konstruktorze) i unikać przypisywania wartości null


lub zwracania wartości null z metody. Jeśli pozyskamy wartość

z „zewnątrz”, która może być wartością null (z miejsca, nad którym nie

mamy żadnej kontroli, jak w przypadku argumentów w publicznym API lub

wartości zwracanej z metody zewnętrznego dostawcy), wówczas

powinniśmy sprawdzić ją pod kątem wartości null tak szybko, jak to

tylko możliwe i odpowiednio ją obsłużyć. Jeśli nie możemy jej obsłużyć, to

możemy zgłosić wyjątek lub „przetłumaczyć” ją na inny obiekt, który

reprezentuje pusty obiekt (tzw. „wzorzec obiektu pustego”. Prostym tego

przykładem jest pusta lista zamiast wartości null).


Dostosowanie się do tych reguł pomoże nam uniknąć wielu

potencjalnych błędów!

Unikaj przechwytywania wyjątków

Większość początkujących i średnio doświadczonych programistów zbyt

intensywnie wykorzystuje bloki try/catch, ponieważ myślą, że dzięki

temu ich kod jest bardziej solidny i niezawodny. Cóż, prawda jest taka, że

gdy przechwytujemy wyjątek, uniemożliwiamy jego propagację do naszego

obiektu wywołującego (i jeśli nasz kod jest metodą „main” lub metodą

testową, to zapobiegamy sytuacji, w której wyjątek doprowadza do awarii


naszej aplikacji lub niepowodzenia testu), jednak daje to jedynie złudne

wrażenie, że kod jest bardziej solidny. Powodem tego jest fakt, że jeśli coś

poszło nie tak – a my nie wiemy dokładnie co i dlaczego, i po prostu to

zignorujemy – to prawdopodobnie zostaniemy trafieni rykoszetem tego

problemu w późniejszym czasie. Jeśli zignorujemy wszystkie złapane

wyjątki, to program nigdy nie ulegnie awarii, ale może również nie robić

tego, co powinien! Przechwytywanie wyjątku jedynie po to, aby go

zignorować (tj. używanie pustej klauzuli catch) jest często nazywane

„połykaniem wyjątków” i w 99% przypadków jest to bardzo zły pomysł…

Co więcej, jeśli połkniemy jakiś wyjątek, to nasze życie będzie znacznie

trudniejsze, kiedy przyjdzie nam zdebugować lub zdiagnozować jakiś

problem. Jeszcze gorzej, jeśli będzie się to działo jedynie okazjonalnie

w środowisku produkcyjnym! Z tego powodu najbardziej naiwnym

podejściem dla tego problemu jest po prostu zapisanie wyjątku w pliku

dziennika. Jest to znacznie lepsze niż całkowite połykanie wyjątku, a w

niektórych sytuacjach jest to nawet dobry pomysł. Jednak w ogólnym

wypadku, biorąc pod uwagę użytkownika (użytkownika końcowego lub

wywołującego metodę), będzie to miało taki sam efekt jak połknięcie

wyjątku, ponieważ zwykle nie będziemy zaglądać do dziennika, dopóki

użytkownik nie doświadczy i nie zgłosi problemu. Jeśli zdecydujemy się

zapisać wyjątek w dzienniku, upewnijmy się, że zarejestrowaliśmy

wszystkie informacje o tym wyjątku, łącznie ze śladem stosu oraz

oryginalnym wyjątkiem, jeśli różni się od tego, który został wychwycony

(InnerException w .NET lub getCause() w Javie). Na platformie

.NET najlepiej jest używać metody ToString(), ponieważ zwracany

przez nią ciąg znaków zawiera już wszystkie istotne informacje.

W jaki sposób powinniśmy zatem obsługiwać wyjątki? W większości

przypadków poprawną odpowiedzią jest: „nie powinniśmy”! W przypadku


wyjątków, których wystąpienia się spodziewamy (np. „nie znaleziono

pliku”, gdy aplikacja próbuje odczytać z pliku, który mógł zostać usunięty

lub nazwa którego mogła zostać zmieniona przez użytkownika),

powinniśmy unikać takiej sytuacji poprzez wcześniejsze jej sprawdzanie

i odpowiednie jej obsłużenie (np. sprawdzenie, czy plik istnieje, a jeśli nie,

to poinformowanie użytkownika o tym, co należy zrobić). Dla wyjątków

ogólnych, których nie da się przewidzieć ani też podać przyczyny ich

wystąpień, lub gdy nie możemy z nimi nic zrobić (np. „brak pamięci”),

pozwólmy im propagować się w dół stosu. Jeśli nasz program od razu

ulegnie awarii (lub sprawi, że test zakończy się niepowodzeniem z powodu

wyjątku), automatycznie otrzymamy większość informacji, które pozwolą

nam zbadać główną przyczynę. Jeśli zrobimy to we właściwy sposób,

prawdopodobnie znajdziemy błąd i szybko go poprawimy.

Oczywiście, gdyby zawsze był to taki zły pomysł, to konstrukcja

try/catch nie zostałaby zaimplementowana w żadnym z nowoczesnych

języków. Kiedy więc powinniśmy przechwytywać wyjątki? Jest kilka takich

przypadków:

Jesteśmy w stanie określić, co dokładnie poszło nie tak i elegancko to

obsłużyć, ale możemy to zrobić wyłącznie po fakcie. W wielu

przypadkach warunki uruchomienia mogą się zmieniać podczas

wykonywania operacji (np. plik jest usuwany w momencie, gdy

próbujemy do niego zapisać). W innych przypadkach mamy sposób na

sprawdzenie warunków przed rozpoczęciem operacji, ale jest to po

prostu nieopłacalne, przykładowo ze względu na wydajność (tj.

sprawdzenie, czy możemy zrobić coś, co długo trwa, może oznaczać, że

sprawdzenie to samo w sobie może zająć tyle samo czasu). Inne

przypadki mogą być spowodowane ograniczeniami technicznymi (np.


nie mamy API, które mogłoby nam zawczasu powiedzieć, czy operacja

powiedzie się, czy też nie).

W takich wypadkach powinniśmy przechwytywać najbardziej

szczegółowy typ wyjątku oraz zawęzić zakres bloku try wyłącznie do


konkretnej operacji, która naszym zdaniem może zgłosić ten wyjątek.

W ten sposób unikamy przechwytywania wyjątków, których się nie

spodziewamy i przechwytujemy jedynie te, których oczekujemy. Blok

catch powinien wykonywać konkretną oczekiwaną obsługę, aby


rozwiązać dany problem lub zasugerować użytkownikowi lub

wywołującemu sposób jego rozwiązania.

Dodanie globalnej obsługi błędów, która prezentuje użytkownikowi

wszystkie nieoczekiwane niepowodzenia w specjalny sposób i/lub

załącza dodatkowe informacje do wyjątku. Na przykład niektóre

zespoły preferują wykorzystywanie dla wyników testów

własnościowych mechanizmów raportowania i chcą, aby w raportach

tych zapisywane były wszystkie informacje o niepowodzeniu, wraz

z dokładną datą i godziną. Ponadto możemy dodać do nich pewne

informacje systemowe lub konkretne informacje o testowanym

systemie.

W takim wypadku powinniśmy mieć tylko jeden blok try/catch, ale


w przeciwieństwie do poprzedniego przypadku, tutaj blok try
powinien obejmować cały program (lub przypadek testowy), a my

powinniśmy przechwytywać wszystkie wyjątki. W tym przypadku

należy pamiętać o tym, aby raportować wszystkie posiadane przez nas

informacje o wyjątku, które pozwolą na jego solidne zbadanie.

Powinniśmy również pomyśleć o zapasowej obsłudze wyjątków

w przypadku, gdy wyjątek wystąpił wewnątrz kodu naszej normalnej


obsługi błędów. Na przykład, jeśli nie uda nam się dokonać zapisu do

niestandardowego raportu, powinniśmy awaryjnie wypisać to

niepowodzenie na konsolę lub do normalnej obsługi błędów,

dostarczanej przez bibliotekę testowania. Zwykle obsługa taka powinna

po prostu zgłosić nowy wyjątek zawierający zarówno wyjątek

oryginalny, jak też informacje o drugim wyjątku, który wystąpił

wewnątrz kodu obsługującego wyjątki. W ten sposób będziemy mogli

zbadać zarówno powód oryginalnego niepowodzenia, jak również

problem powstały w bloku obsługi błędów. Oczywiście drugi blok

obsługi błędów powinien być znacznie prostszy od pierwszego, tak aby

szansa na jego niepowodzenie była dużo mniejsza.

Posiadamy cenne informacje do dodania do wyjątku, które mogą być

przydatne podczas jego badania. Na przykład mamy złożoną metodę,

która wykonuje wiele operacji w bazie danych, wywołuje zewnętrzne

usługi REST itd. W takiej metodzie wiele rzeczy może pójść nie tak, ale

natywne wyjątki, które mogą zostać zgłoszone, mogą mieć zbyt niski

poziom, aby były dla nas przydatne i nie powiedzą nam, w jaki sposób

odtworzyć błąd. W takiej sytuacji możemy przechwycić wszystkie

wyjątki (lub bardziej szczegółowy, ale nadal ogólny wyjątek) i zgłosić

ponownie nowy wyjątek, który opakowuje wyjątek oryginalny, ale

dodaje informacje o parametrach przekazywanych do metody lub jakiś

wewnętrzny stan bieżącego obiektu.

Tego rodzaju obsługę wyjątków bardzo rzadko powinniśmy pisać

z wyprzedzeniem. W większości przypadków trzeba najpierw

zrealizować normalną obsługę wyjątków, a dopiero wtedy, gdy

widzimy, że brakuje nam pewnych istotnych informacji, niezbędnych

do ustalenia ich przyczyny, powinniśmy dodać dodatkowe informacje

do wyjątku na wypadek kolej takiej sytuacji. Naturalnie trzeba


zachować wszystkie informacje o wyjątku oryginalnym, a już na pewno

jego ślad stosu.

Wykonujemy jakąś operację na dużej liczbie wierszy (lub innej formie

danych zawierających niezależne jednostki) i nie chcemy, aby

niepowodzenie w jednym wierszu zatrzymało proces i uniemożliwiło

jego wykonanie się na pozostałych wierszach. W takim przypadku

powinniśmy opakować tę operację dla pojedynczego wiersza w bloku

try, zaś wewnątrz bloku catch dodać wyjątek do listy wyjątków.


Gdy pętla się zakończy, trzeba zgłosić wyjątek zawierający wszystkie

przechwycone wyjątki (.NET oferuje w tym celu klasę

AggregateException) lub zaraportować niepowodzenia


użytkownikowi w jakiś inny sposób.

Typowym przypadkiem użycia obsługi błędów jest ponawianie

wykonania operacji, która zakończyła się niepowodzeniem. Jest to dobry

powód do zastosowania konstrukcji try/catch i mieści się w pierwszej

wymienionej powyżej kategorii. Jednakże powinniśmy dokładnie

zrozumieć dlaczego i co będziemy powtarzać. Na przykład, jeśli będziemy

próbować odczytać dane z pliku, który nie istnieje, to ponawianie tej

operacji bez ingerencji człowieka nic nam nie da! Jeśli przechwycimy zbyt

ogólne wyjątki i ponowimy całą operację, powstaną wówczas dwa nowe

problemy:

1. Marnujemy mnóstwo czasu na ponawianie czegoś, co nie może

zakończyć się sukcesem.

2. Istnieje duża szansa, że nie wychwycimy innych problemów (łącznie

z błędami w testowanym systemie!) i utracimy cenną wiedzę na temat

ich natury. Jak wspomnieliśmy wcześniej, będzie nam dużo trudniej


zbadać ten problem i może on powodować kolejne problemy, które będą

nas tylko zdezorientować.

Okazjonalnie słyszy się o projektach automatyzacji testów z użyciem

mechanizmu, który automatycznie powtarza testy kończące się

niepowodzeniem. Jednak zastosowanie takiego mechanizmu wskazuje na

złą architekturę, brak izolacji lub po prostu słaby projekt testów.

Implementowanie automatycznego ponawiania testów wskazuje, że

testom ufamy w mniejszym stopniu niż testowanemu systemowi

i zakładamy, że większość niepowodzeń będzie powodowanych przez test

(lub jego środowisko), a nie przez testowany system. Automatyczny test

powinien być przewidywalny i powtarzalny. Jeśli kończy się

niepowodzeniem, powinniśmy być w stanie zbadać i naprawić ten problem

tak szybko, jak to tylko możliwe.

Jak do tej pory wyjaśniliśmy tylko, kiedy należy przechwytywać

wyjątki, ale nie wspomnieliśmy o tym, kiedy powinniśmy zgłosić nasz

własny wyjątek. Jest to jednak bardzo proste – zawsze zgłaszamy wyjątek

z metody, jeśli wykryjemy warunek, który uniemożliwia tej metodzie

zrobienie czegoś, do czego została stworzona. Gdy to robimy, musimy mieć

pewność, że dostarczamy wszystkie niezbędne informacje, które mogą

przydatne dla każdego, kto będzie musiał potem zrozumieć, co się stało.

Zwróćmy uwagę, że w językach obiektowych (które obsługują wyjątki)

nigdy nie należy zwracać kodu błędu lub wartości logicznej w celu

wskazania sukcesu lub niepowodzenia. Od tego są właśnie wyjątki!

Ponadto trzeba unikać zgłaszania wyjątku w celu zapewnienia

normalnego przepływu programu. Wyjątki powinny być używane jedynie

po to, aby wskazać wystąpienie jakiegoś problemu! Jeśli w celu

zrozumienia normalnego przepływu programu konieczne jest podążanie za

konstrukcjami throw i try/catch, to prawdopodobnie robimy to źle.


Najbardziej oczywistym przypadkiem, którego powinniśmy unikać jest

zgłaszanie wyjątku, który jest przechwytywany wewnątrz tej samej metody.

Wybieranie najbardziej odpowiedniego


lokalizatora

Poniższe wskazówki dotyczą narzędzia Selenium, jednak główne idee

i wskazówki dotyczą wszystkich rozwiązań automatyzacji interfejsu

użytkownika, a także mogą mieć zastosowanie do innych przypadków,

w których test musi zidentyfikować fragment danych z aplikacji, jak

w przypadku pozyskiwania wartości z obiektu JSON, kodu XML lub bazy

danych.

Selenium wyszukuje elementy poprzez dopasowywanie wartości

znakowej do określonego rodzaju lokalizatora. Selenium obsługuje

następujące lokalizatory:

1. Id – identyfikuje elementy, których atrybut id pasuje do podanej

wartości. Zgodnie ze standardem HTML, jeśli atrybut ten istnieje, jego

wartość musi być niepowtarzalna w obrębie wszystkich elementów na

stronie. Tak więc teoretycznie, jeśli element ma zdefiniowany atrybut

id, powinniśmy być w stanie jednoznacznie zidentyfikować go za

pomocą tego lokalizatora. Wymaganie to nie jest jednak wymuszane

przez przeglądarki i czasem zdarza się, że strona zawiera więcej niż

jeden element z takim samym identyfikatorem (id).

2. Name – identyfikuje elementy za pomocą ich atrybutu name. Atrybut

name powinien jednoznacznie identyfikować element wewnątrz

formularza HTML.
3. LinkText – identyfikuje elementy poprzez dopasowanie ich

wewnętrznego tekstu do podanej wartości

4. PartialLinkText – działa jak LinkText, ale dopasowuje również

elementy, w których tekst zawiera podaną wartość, nawet jeśli wartość

ta jest tylko fragmentem ciągu tekstu łącza

5. TagName – identyfikuje elementy, których nazwa znacznika pasuje do

podanej wartości. Nazwy znaczników rzadko są unikalne, ale gdy są,

możemy użyć tego lokalizatora

6. ClassName – identyfikuje element za pomocą którejkolwiek z jego nazw

klas. Atrybut class elementu HTML może zawierać zero lub więcej

nazw klas CSS oddzielanych białym znakiem. Ten lokalizator może

przyjmować wyłącznie jedną nazwę klasy i wyszukuje elementy, które

należą do tej klasy

7. XPath – identyfikuje elementy zgodnie ze specyfikacją XPath61.

Specyfikacja XPath zapewnia specjalną składnię zapytań do

lokalizowania elementów wewnątrz dokumentów XML lub HTML.

8. CSS Selector – identyfikuje elementy zgodnie ze wzorcem selektora

CSS62. Selektory CSS były pierwotnie używane do identyfikowania

elementów w dokumencie CSS w celu zastosowania do nich

konkretnego stylu. Obecnie składnia ta jest również wykorzystywana

przez bibliotekę jQuery języka JavaScript do identyfikowania

elementów.

Jeśli chcemy wyszukać pojedynczy element, musimy użyć lokalizatora

i wartości, która pasuje wyłącznie do tego jednego konkretnego elementu.

Jeśli lokalizator pasuje do więcej niż jednego elementu, Selenium zwróci

nam pierwszy z nich, który niekoniecznie musi być tym, jaki nas interesuje.

Tak czy inaczej, choć używany przez nas lokalizator musi jednoznacznie

identyfikować dany element, to nie jest to wystarczające: aby testy były


wiarygodne i łatwe w utrzymaniu, musimy również upewnić się, że

wybrany przez nas lokalizator zawsze będzie identyfikować odpowiedni

element lub przynajmniej ten, który raczej na pewno nie zostanie

zmodyfikowany.

Za najlepsze lokalizatory uznawane są Id i Name, jeśli istnieją,

ponieważ jednoznacznie identyfikują one elementy (a przynajmniej

powinny). W większości przypadków faktycznie tak jest. Jednak niektóre

biblioteki, a nawet własny kod JavaScript, czasami generują te

identyfikatory w czasie wykonywania i robią to losowo lub sekwencyjnie.

Co prawda zapewniają one ich niepowtarzalność, jednak wartości te będą

się prawdopodobnie zmieniać między kolejnymi uruchomieniami, jeśli były

one generowane losowo. Jeśli są one generowane sekwencyjnie, wówczas

zmienią się one prawdopodobnie tylko w przypadku dodania lub usunięcia

jakichś elementów, czy to w czasie uruchamiania, czy też z powodu zmiany

w kodzie. Z tego powodu zaleca się wykorzystywanie lokalizatora id lub

name tylko wtedy, gdy jego wartość jest wymowną nazwą, powiązaną

z funkcjonalnością biznesową poszukiwanego przez nas elementu. Innymi

słowy, używamy ich tylko wtedy, gdy jego wartość została wybrana przez

człowieka. Na przykład, jeśli identyfikatorem elementu jest

submitButton, wówczas będzie on idealnym lokalizatorem. Jeśli jednak


identyfikatorem elementu jest button342, to będzie to zły lokalizator!
Ta sama reguła ma również zastosowanie do nazw klas: jeśli nazwa

klasy jest wymowna i powiązana tematycznie z poszukiwanym przez nas

elementem, to powinniśmy jej użyć. Jeśli jednak nazwa klasy nie ma nic

w wspólnego ze znaczeniem tego elementu, to powinniśmy jej unikać, jeśli

tylko mamy jakieś inne wyjście.

Choć lokalizatory XPath i CSS Selector mają najbardziej złożoną

składnię, oferują one największą elastyczność. Lokalizatory te pozwalają


nam określić kombinację cech samego elementu, jak również jego

elementów nadrzędnych lub nawet elementów równorzędnych – wszystko

w postaci jednego ciągu znaków. Przeglądarka Chrome może wygenerować

te ciągi za nas i skopiować je do schowka, bezpośrednio z poziomu menu

kontekstowego danego elementu w narzędziach dla programistów. Możemy

następnie użyć tych ciągów znaków w naszym kodzie Selenium w celu

zidentyfikowania pożądanego przez nas elementu. Choć brzmi to dosyć

przekonująco i faktycznie wielu deweloperów automatyzacji Selenium

korzysta z tej możliwości, to mimo wszystko nie jest to dobry pomysł.

Chrome próbuje znaleźć najlepszy lokalizator za pomocą heurystyki, która

wyszukuje najprostszą i unikalną kombinację cech. Jednak heurystyka ta

nie jest w stanie przewidzieć tego, co może się zmienić w przyszłości lub

nawet przy kolejnym załadowaniu strony i może nawet zasugerować

lokalizator, który obecnie jest unikalny, ale przy następnym uruchomieniu

już nie będzie. Lokalizatory, które mają mniejszą szansę na zmianę, są

lokalizatorami zawierającymi ciągi reprezentujące prawdziwe znaczenie

elementu. Na przykład, jeśli przycisk ma przypisaną klasę button-


login, to mimo że nazwy klas nie muszą być niepowtarzalne, jej nazwa

wskazuje, że identyfikuje ona przycisk logowania. Z tego powodu

powinniśmy nauczyć się składni CSS Selector oraz XPath i kierować

własnym osądem podczas tworzenia najbardziej odpowiedniego wzorca do

wykorzystania.

Ostatnią (ale nie mniej istotną) wskazówką jest ograniczanie zakresu

wyszukiwania jedynie do określonego kontenera. Innymi słowy,

powinniśmy najpierw zidentyfikować unikalny element zawierający ten

element, którego szukamy, a następnie zidentyfikować wewnątrz niego

poszukiwany przez nas element. Słynna metoda FindElement


w Selenium działa zarówno na obiekcie IWebDriver, jak również na
dowolnych obiektach IWebElement, tak więc możemy używać jej do

identyfikowania elementu wewnątrz innego elementu. Możemy mieć

dowolną liczbę poziomów takiego zagnieżdżenia, a typowym podejściem

jest użycie wzorca obiektu strony (Page Object Pattern) dla każdego takiego

kontenera, który użytkownik może zidentyfikować. Zaletą identyfikowania

elementów wyłącznie wewnątrz ich kontenerów jest to, że o unikalność

tych elementów musimy jedynie martwić się w obrębie tego kontenera,

a nie wśród innych elementów na stronie. Ponadto usuwa to duplikację

z lokalizatorów wszystkich elementów, które znajdują się w tym samym

kontenerze. Choć duplikacja ta jest zwykle jedynie jakimś fragmentem

ciągu (np. początkiem wyrażenia XPath), to w przypadku jego zmiany

konieczna będzie zmiana wszystkich elementów wewnątrz tego kontenera.

Trwale zakodowane ciągi znaków


w automatyzacji testów: za i przeciw

Wielu programistów trwale zakodowane ciągi znaków uznaje za złą

praktykę. Wynika to z kilku dobrych powodów, ale też pewnych

nieporozumień. W rzeczywistości, w większości przypadków nie możemy

całkowicie unikać używania w kodzie literałów tekstowych (znanych

również jako stałe tekstowe lub trwale zakodowane ciągi znaków). Nawet

jeśli będziemy chcieli przechowywać te ciągi w pliku zewnętrznym, trzeba

będzie prawdopodobnie umieścić gdzieś w kodzie nazwę tego pliku.

Oczywiście taki ograniczony zestaw stałych możemy przechowywać

w jednym pliku źródłowym, aby odseparować je od właściwej logiki

aplikacji. Ważne jest jednak, aby zrozumieć, kiedy stosowanie literałów

znakowych w naszym kodzie jest złą praktyką, a kiedy jest to akceptowane

lub nawet uznawane za właściwe.


Istnieje kilka powodów, dla których użycie trwale zakodowanych

łańcuchów tekstowych uznaje się za złą praktykę:

1. Podział odpowiedzialności – ciągi znaków używane są głównie dla

pewnego rodzaju interakcji z człowiekiem, natomiast sam algorytm już

nie. Sposób sformułowania i zapisu ciągu znaków, który można

potencjalnie wyświetlić użytkownikowi, może się zmieniać niezależnie

od algorytmu.

2. Lokalizacja (internacjonalizacja) – aplikacje podlegające lokalizacji (tj.

aplikacje, które projektowane są pod obsługę wielu języków i kultur)

lub które mogą potencjalnie zostać poddane lokalizacji w przyszłości,

powinny wyodrębniać każdy ciąg znaków widoczny dla użytkownika

do pewnego zewnętrznego źródła, które może zostać w prosty sposób

podmienione (w czasie uruchamiania lub podczas kompilacji).

3. Konfiguracja – w wielu przypadkach globalne wartości tekstowe, które

nie są przeznaczone do interakcji ż użytkownikiem (przynajmniej nie

bezpośrednio) mają zwykle różne wartości dla różnych środowisk lub

konfiguracji. Dobrym tego przykładem może być ciąg znaków

reprezentujący parametry połączenia z bazą danych. Trwałe

zakodowanie tych ciągów sprawi, że kod nie będzie elastyczny i nie

będzie go można uruchamiać w różnych środowiskach.

4. Duplikacja – jeśli musimy użyć tego samego ciągu w wielu miejscach

w kodzie i nie umieścimy go w powszechnie dostępnym miejscu,

wówczas doprowadzimy do jego duplikacji. Jeśli w dowolnym

momencie będziemy musieli zmienić ten ciąg znaków, będziemy

musieli to zrobić we wszystkich miejscach, w którym ten ciąg

występuje.

5. Długość – czasem ciągi znaków mogą stać się bardzo długie, a jeśli

zostaną dołączone do algorytmu, będąc obniżać czytelność kodu.


Na podstawie tych powodów niektórzy programiści wnioskują, że

wszystkie literały znakowe powinny zostać wyodrębnione z kodu do

osobnego pliku. Najczęściej twierdzą oni, że pozwala im to zmieniać te

wartości bez ich ponownego kompilowania. Jednak ponowne

kompilowanie jest problemem tylko dla końcowego użytkownika, który nie

ma dostępu do kodu źródłowego i środowiska programowania lub nie jest

wystarczająco obeznany w języku programowania i boi się zmieniać

czegoś, czego nie rozumie.

Rozważmy trzy typowe przypadki użycia literałów znakowych

w automatyzacji testów i przeanalizujmy, w jakich miejscach powinniśmy

je stosować:

1. Informacje środowiskowe – w zależności od wybranej przez nas strategii

izolacji (patrz rozdział 7), niektóre z tych informacji mogą być różne

dla każdego środowiska, w którym chcemy uruchamiać automatyzację,

przy czym niektóre wartości mogą być stałe dla wszystkich środowisk

(lub też istnieje tylko jedno środowisko, które obsługujemy). Mimo że

ponowne kompilowanie nie stanowi dużego problemu w automatyzacji

testów, to prawdopodobnie informacje różniące się od siebie

w poszczególnych środowiskach będziemy chcieli przechowywać

w jakimś pliku konfiguracyjnym lub przynajmniej w oddzielnym pliku

źródłowym. Pomoże nam to zarządzać ich zmianami niezależnie od

plików źródłowych w naszym systemie kontroli wersji. Zgodnie

z podstawową zasadą, te pliki konfiguracyjne powinny być stosunkowo

małe i zawierać niewielką liczbę łatwych do zrozumienia wartości, gdyż

w przeciwnym razie zarządzanie nimi stanie się koszmarem.

„Stałe” wartości są jednak tym, co jest wspólne dla wszystkich środowisk

i nie powinno być zmieniane z żadnego prostego powodu w najbliższej

przyszłości. Nie oznacza to, że w ogóle nie mogą się one zmieniać
w przyszłości, ale na razie nie ma żadnego powodu, by przypuszczać,

że wkrótce tak się stanie – przykładem może być adres URL

zewnętrznego dostawcy usługi. Fakt, że dane takie przechowywane są

bezpośrednio w kodzie, nie stanowi żadnego problemu, jeśli tylko

występują one wyłącznie jednym miejscu, w pobliżu miejsca ich

wykorzystania, przykładowo w formie stałej nazwanej. Jeśli w pewnym

momencie w przyszłości ten adres URL ulegnie zmianie, odnalezienie

definicji tej stałej i jej zmiana nie powinno nam sprawić problemu. Jeśli

jednak umieścimy tę wartość w pliku konfiguracyjnym i będziemy

musieli ją zmienić po kilku latach, prawdopodobnie i tak nie będziemy

pamiętać, że się tam znajduje i będziemy musieli zdebugować

i przeanalizować kod, aby się dowiedzieć, skąd pochodzi ta wartość.

Z tego powodu, jeśli wartość będzie wczytywana z pliku konfiguracyjnego,

znalezienie jej w tym pliku będzie mniej oczywiste, niż gdyby

znajdowała się ona bezpośrednio w kodzie. Jeśli dysponujemy wieloma

instancjami tego pliku konfiguracyjnego (np. dla różnych środowisk)

gdzieś w lokalizacji, która nie jest objęta kontrolą wersji, wówczas,

mimo że wiemy, co chcemy zmienić, to nadal musimy to zrobić dla

każdej instancji tego pliku konfiguracyjnego. I wreszcie, ponieważ

nigdy nie wiemy, co i kiedy może się zmienić, prawdopodobnie

będziemy mieć spore ilości takich wartości konfiguracyjnych, które

będą znacznie trudniejsze w zarządzaniu i utrzymaniu. Wniosek: nie

komplikujmy rzeczy!

2. Wartości lokalizatora (Id, XPath, CSS Selector itd.) – bez względu na to,

czy automatyzujemy interfejs użytkownika (np. za pomocą Selenium),

czy testy API REST (lub może testy jakiegoś innego rodzaju), musimy

odwoływać się do elementów na ekranie lub w komunikacie

odpowiedzi. Aby to zrobić, musimy być w stanie podać odpowiedni


identyfikator, nazwę, ścieżkę XPath itd., takiego elementu. Niektóre

osoby sądzą, że zamieszczenie tych ciągów znaków w zewnętrznym

pliku konfiguracyjnym przyczyni się do zwiększenia łatwości

utrzymania kodu. Cóż, być może faktycznie dzięki temu „kod” będzie

łatwiejszy w utrzymaniu, ale utrzymanie tych plików często będzie

problemem samym w sobie. Jeśli więcej niż jeden deweloper

automatyzacji pracuje nad danym projektem, to taki duży plik

zawierający wszystkie te elementy szybko stanie się piekłem scalania.

W rzeczywistości takie pliki niczego nie rozwiązują, a zamiast tego

przenoszą problem związany z łatwością utrzymania z jednego miejsca

na inne, które jest do tego mniej odpowiednie. Przechowywanie tych

lokalizatorów wraz z ich literałami znakowymi blisko miejsc ich użycia

jest znacznie łatwiejsze w utrzymaniu niż posiadanie wszystkich

lokalizatorów zdefiniowanych w jednym miejscu, gdy miejsca ich

użycia są rozproszone po całej bazie kodu. Enkapsulowanie tych

szczegółów wewnątrz klasy lub metody, która je wykorzystuje, jest

rozwiązaniem pozwalającym uczynić nasz kod łatwiejszym

w utrzymaniu, a wzorzec obiektu strony jest najbardziej powszechnym

sposobem enkapsulowania lokalizatorów wewnątrz klasy.

3. Dane – testy zwykle muszą wprowadzać lub przekazywać ciągi znaków

w formie danych dla testowanego systemu (jak wprowadzanie

nagłówka dyskusji w rozdziale 11). Niektóre osoby lubią

przechowywać te wartości w oddzielnym pliku, aby uzyskać w ten

sposób większą elastyczność. Jeśli tworzymy testy sterowane danymi,

to nie będzie to stanowić problemu. Jeśli jednak nie zamierzamy

uruchamiać tego samego testu z innymi wartościami, które specjalnie

definiujemy w celu weryfikowania różnych przypadków, wówczas nie

ma żadnego dobrego powodu, dla którego powinniśmy wyodrębniać je


z kodu. Potrzeba wyodrębnienia tych wartości do zewnętrznego pliku

jest często symptomem projektu, który zależy od konkretnych danych

istniejących w bazie danych. Jeśli więc w pewnym momencie dane te

ulegną zmianie, nie będziemy musieli zmieniać kodu, a jedynie

zewnętrzny plik. Ale znów jest to tylko przenoszenie problemu łatwości

utrzymania z jednego miejsca na inne. Plik ten staje się punktem

spornym i będzie trudniejszy w utrzymaniu, niż gdyby test korzystał ze

swoich własnych danych.

4. Oczekiwane rezultaty – podobnie jak w przypadku danych, oczekiwane

rezultaty są wartościami, których oczekujemy w postaci wyników

z testowanego systemu. Te same czynniki, które mają zastosowanie do

danych, mają również zastosowanie do spodziewanych rezultatów.

5. Instrukcje SQL lub wstawki JavaScript – czasem musimy wywołać

w kodzie testu instrukcję SQL, polecenie JavaScript lub coś podobnego.

Jeśli skrypty te są długie, umieszczenie ich w osobnych pikach

z pewnością poprawi łatwość utrzymania kodu. Jednak w przypadku

rzadkich, jednowierszowych instrukcji, ich bezpośrednie zagnieżdżanie

w kodzie w pobliżu miejsca ich użycia, bez żadnych duplikacji,

usprawni enkapsulację i sprawi, że kod będzie łatwiejszy do

zrozumienia i śledzenia.

Ogólnie rzecz biorąc, w większości przypadków ciągi znaków używane

w kodzie automatyzacji testów powinny znajdować się w bezpośrednio

kodzie zamiast w pliku zewnętrznym, jeśli tylko nie są one zduplikowane

i występują w pobliżu miejsca ich użycia w kodzie. Plików zewnętrznych

powinniśmy używać tylko dla wartości, które muszą być różne w różnych

środowiskach.
Przypisy

1 Tłumaczenie treści manifestu pochodzi ze strony: http://agilemanifesto.org/iso/pl/manifesto.html


(przyp. tłum.)
2 Scrum jest najbardziej powszechną metodyką, która bazuje na wartościach zwinnego
programowania.
3 Martin Fowler, „Refactoring: Improving the Design of Existing Code” (Addison-Wesley
Professional, 1999).
4 Zintegrowane środowisko programowania (Integrated Development Environment, IDE) odnosi się
do oprogramowania, które składa się głównie z edytora, kompilatora oraz zintegrowanego debugera.
Przykładem najpopularniejszych środowisk dla języków C# i Java są Microsoft Visual Studio,
Eclipse oraz IntelliJ.
5 Testy regresji są testami sprawdzającymi, czy funkcjonalność, która działała wcześniej zgodnie
z założeniami, nadal działa poprawnie.
6 W tym kontekście „kod” odnosi się do dowolnego artefaktu będącego częścią systemu i mogącego
wpływać na jego zachowanie. Jeśli na przykład lista państw w bazie danych jest czymś, czego
użytkownik nie może i nie powinien zmieniać, to listę taką można uznać za część kodu aplikacji.
7 Operacja ewidencjonowania (check-in), znana również jako operacja commit, push lub submit,
polega na zastosowaniu zmian wprowadzonych przez programistę na jego lokalnym komputerze
w scentralizowanym repozytorium kodu źródłowego, który jest współdzielony przez cały zespół
tworzący oprogramowanie. Repozytoria te zarządzane są przez systemy kontroli kodu źródłowego,
takie jak Git, Microsoft Team Foundation Server, SVN, Mercurial i wiele innych.
8 Pewne wskazówki na temat dzielenia dużych historyjek użytkownika można znaleźć pod adresem
http://agileforall.com/new-story-splitting-resource/.
9 Termin przypadek testowy (test case) może mieć wiele znaczeń. Dla mnie przypadek testowy jest
jednym scenariuszem testowym, złożonym z konkretnych kroków (czynności) i weryfikacji.
Przypadki testowe są zwykle grupowane w pakiety testów (test suites). Z kolei plan testowania
zawiera zwykle wiele pakietów testów, w tym również inne szczegóły dotyczące planowania,
zasobów itd.
10 Termin „skrypt” został tutaj użyty w celu opisania pojedynczego automatycznego przypadku
testowego, bez względu na to, czy został on napisany w kodzie, w języku skryptowym, za pomocą
narzędzia do nagrywania i odtwarzania, czy w dowolnej innej formie.
11 Zobacz opis testów „towarzyskich” (sociable tests) pod adresem
https://martinfowler.com/bliki/UnitTest.html. Inne istotne odnośniki to:
https://martinfowler.com/articles/is-tdd-dead/ oraz http://www.se-radio.net/2010/09/episode-167-the-
history-of-junit-and-the-future-of-testing-with-kent-beck/ (od około 22 do 26 minuty).
12 IDE jest skrótem od Integrated Development Environment (Zintegrowane środowisko
programowania). Są to aplikacje, która umożliwiają programistom pisanie, edytowanie,
kompilowanie i debugowanie kodu, pozwalając przy tym na wykonywanie wielu innych czynności
związanych z tworzeniem oprogramowania.
13 Atrapy nazywane są również obiektami imitacji (przyp. tłum.).
14 Niektóre języki kompilowane są do kodu pośredniego (byte-code), który wykonywany jest przez
dedykowany silnik wykonawczy. Maszyna wirtualna Javy (JVM) oraz środowisko uruchomieniowe
.NET (CLR) to najbardziej znane silniki wykonawcze kodu pośredniego. Biblioteki, które
kompilowane są dla tych silników mogą być wykorzystywane przez aplikacje pisane w dowolnym
języku, jaki może być skompilowany dla tego samego silnika. Przykładowo biblioteka WebDriver
dla języka Java może być wykorzystywana przez testy pisane w językach Scala i Groovy, zaś
powiązanie języka C# (.NET) – przez testy napisane w językach VB.NET oraz F#.
15 DOM to skrót od Document Object Model. DOM opisuje drzewo elementów HTML wraz z ich
właściwościami, które w danej chwili są dostępne. Za pomocą języka JavaScript strona może
manipulować swoim modelem DOM w czasie wykonywania, dzięki czemu strona jest dynamiczna.
Zwróćmy uwagę, że choć DOM może się zmieniać, to sam kod HTML jest statycznym opisem
strony, jaką serwer wysłał do przeglądarki.
16 https://seleniumhq.wordpress.com/2017/08/09/firefox-55-and-selenium-ide/
17 https://www.w3schools.com/xml/xpath_intro.asp
18 Dijkstra (1970), „Notes On Structured Programming” (EWD249), część 3 („On the Reliability of
Mechanisms”).
19 SaaS to skrót od Software as a Service (Oprogramowanie jako usługa). Są to aplikacje (zwykle
aplikacje sieci Web lub aplikacje mobilne), za korzystanie z których ich operatorzy pobierają opłaty.
20 Wyczerpujący opis architektury mikrousług można znaleźć w artykule Martina Fowlera
dostępnym pod adresem: https://martinfowler.com/articles/microservices.html.
21 WYSIWYG to skrót od What you see is what you get (Dostajesz to, co widzisz). Oznacza to, że
gdy edytujemy jakiś element, od razu widzimy wynik tej edycji. Świetnym przykładem takiego
edytora jest Microsoft Word, ponieważ podczas pisania widzimy, jak nasz dokument będzie wyglądał
po wydrukowaniu.
22 Termin MV* odnosi się do dowolnego z następujących wzorców projektowych: MVC (Model-
View-Controller), MVP (Model-View-Presenter) lub MVVM (Model-View-View-Model).
23 Niektórzy puryści powiedzą (podążając za książką Gerarda Meszarosa „xUnit Test Patterns”
i wpisem blogowym Martina Fowlera dostępnym pod adresem
https://martinfowler.com/bliki/TestDouble.html), że nie jest to poprawna definicja pozorowania, ale
raczej definicja dublera testowego (test double) lub bardziej precyzyjnie - sztucznego obiektu (fake).
Dubler testowy jest pojęciem ogólnym, który zawiera w sobie terminy: obiekt fikcyjny (dummy),
obiekt sztuczny, szkielet (stub), spy lub mock (obiekty pozorne/atrapy). Jednak mimo że według tej
terminologii „atrapa” jest bardzo specyficznym użyciem dublera testowego, jest to najbardziej
powszechnie stosowany termin, nawet w jego bardziej ogólnym znaczeniu.
24 Mike Cohn, Succeeding with Agile: Software Development Using Scrum (Addison-Wesley
Professional, Boston, Massachusetts, 2009).
25 Sytuacja wyścigu jest przypadkiem, w którym stan systemu jest uzależniony od sekwencji lub
chronometrażu asynchronicznych zdarzeń. Staje się on błędem, gdy umieści system w stanie, którego
programista nie przewidział lub nie obsłużył w poprawny sposób.
26 http://www.melconway.com/research/committees.html
27 Evans, Eric, „Domain-Driven Design: Tackling Complexity in the Heart of Software”, Addison-
Wesley, 2004.
28 https://www.youtube.com/watch?v=EzWmqlBENMM
29 Wersja wykorzystywana w tym samouczku znajduje się pod adresem
http://github.com/arnonax/mvcforum. Aktualna jej wersja dostępna jest pod adresem
https://github.com/YodasMyDad/mvcforum, ale należy postępować ostrożnie, ponieważ nie ma
gwarancji, że będzie ona nadal zgodna z tą książką.
30 Wstawiony w nawiasie tekst w języku polskim jest jedynie tłumaczeniem wcześniejszego zdania
i nie jest częścią tego twierdzenia (przyp. tłum.).
31 https://blogs.msdn.microsoft.com/brada/2004/02/03/history-around-pascal-casing-and-camel-
casing/
32 Steve Freeman i Nat Pryce, „Growing Object-Oriented Software Guided by Tests”, Addison-
Wesley Professional, Menlo Park, CA, 2009, str. 258.
33 Erich Gamma, Richard Helm, Ralph Johnson i John Vlissides, Wzorce Projektowe. Elementy
oprogramowania obiektowego wielokrotnego użytku (Helion, 2017), str. 110.
34 Zhimin Zhan, Selenium WebDriver Recipes in C#, wyd. 2, Apress, New York, 2015.
35 DOM to skrót od Document Object Model (Obiektowy model dokumentu). DOM jest drzewiastą
strukturą danych reprezentującą elementy HTML na stronie sieci Web. W przeciwieństwie do źródła
HTML takiej strony, modelem DOM możemy manipulować i modyfikować go w czasie działania
strony, korzystając z języka JavaScript.
36 Żądanie ściągnięcia (pull-request) jest operacją w witrynie GitHub, która pozwala na przesyłanie
własnego wkładu w rozwój kodu źródłowego projektu open source należącego do innego
użytkownika. Operacja ta nazywana jest żądaniem ściągnięcia, ponieważ wkład taki nie jest
automatycznie wypychany do repozytorium właściciela, ale zamiast tego jest do niego wysyłany
komunikat z prośbą o ściągnięcie zmian z repozytorium osoby wysyłającej żądanie. W ten sposób
właściciel ma kontrolę nad poszczególnymi wkładami i może je akceptować lub odrzucać.
37 Metody rozszerzające są funkcją języka C#, która umożliwia nam powiązanie metod
z istniejącymi klasami lub interfejsami, jak gdyby były one elementami członkowskimi instancji tych
klas lub wszystkich klas implementujących te interfejsy. Metody te są tak naprawdę prostymi
metodami statycznymi, ale dzięki nim kod jest bardziej elegancki. Idea polega na tym, że obiekt, do
którego metody te mają zastosowanie (w naszym wypadku WebDriver), przekazywany jest jako
pierwszy parametr, oznaczony w deklaracji metody za pomocą słowa kluczowego this (jak na
listingu 13.3). Więcej informacji na temat metod rozszerzających w języku C# można znaleźć pod
adresem https://docs.microsoft.com/pl-pl/dotnet/standard/design-guidelines/extension-methods, lub
po prostu wyszukując w sieci frazę „metody rozszerzające C#”.
38 Antywzorzec jest typowym rozwiązaniem dla jakiegoś problemu, jednak mimo że jest on
powszechnie stosowany, to jego efektywność jest kwestionowana i często ma on charakter
destrukcyjny.
39 Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship, Prentice Hall, New
Jersey, USA, 2008, str. 40.
40 Klasa Lazy<T> w bibliotece .NET jest klasą generyczną, która może opakowywać dowolny
obiekt, jaki chcemy zainicjalizować wyłącznie podczas jego pierwszego użycia. W swoim
konstruktorze pozyskuje ona delegat dla metody wytwórczej, która tworzy obiekt w momencie jego
pierwszego użycia.
41 https://martinfowler.com/bliki/FrequencyReducesDifficulty.html
42 Scott J. Ambler, Pramod J. Sadalage, Refactoring Databases: Evolutionary Database Design,
Addison-Wesley, Menlo Park, CA, 2006.
43 http://pplab.snu.ac.kr/courses/adv_pl05/papers/p261-knuth.pdf
44 http://c2.com/doc/oopsla92.html
45 Gojko Adzic i David Evans, Fifty Quick Ideas to Improve Your User Stories, Neuri Consulting
LLP, 2014.
46 Adzic i Evans, Fifty Quick Ideas to Improve Your User Stories.
47 https://medium.com/comparethemarket/i-did-mob-programming-every-day-for-5-monthsheres-
what-i-learnt-b586fb8b67c.
48 Pełna dokumentacja składni języka Gherkin znajduje się pod adresem
https://docs.cucumber.io/gherkin/.
49 Skrót CUT może oznaczać „code under test” (testowany kod) lub „class under test” (testowana
klasa). Terminów tych używamy zamiennie, gdy różnica ta jest nieistotna lub gdy znaczenie tego
terminu można wywnioskować z kontekstu. W innym przypadku zawsze używamy jawnego terminu.
50 https://martinfowler.com/articles/is-tdd-dead/.
51 Jak powiedział Martin Fowler na temat biblioteki JUnit: „Jeszcze nigdy w obszarze tworzenia
oprogramowania tak wielu nie zawdzięczało tak wiele tak niewielu wierszom kodu”.
52 https://github.com/moq/moq4
53 Kent Beck, Test-Driven Development: By Example, Addison-Wesley, Menlo Park, CA, 2002.
54 Michael Feathers, Working Effectively with Legacy Code, Prentice Hall, Englewood Cliffs, NJ,
2004.
55 Erich Gamma, Richard Helm, Ralph Johnson i John Vlissides, Design Patterns: Elements of
Reusable Object-Oriented Software, Addison-Wesley Professional, Menlo Park, CA, 1994, str. 124.
56 W 215. epizodzie podcastu „Software-Engineering Radio” (od około 56 minuty) sami autorzy
książki GOF omawiają problemy związane ze stosowaniem wzorca singletonu (http://www.se-
radio.net/2014/11/episode-215-gang-of-four-20-years-later/).
57 http://blogs.microsoft.co.il/arnona/2011/08/26/tdd-and-the-solid-principlespart-1-introduction/
58 Martin Fowler, Kent Beck, John Brant, William Opdyke i Don Roberts, Refactoring: Improving
the Design of Existing Code, Addison-Wesley Professional, Menlo Park, CA, 1999.
59 www.stackoverflow.com
60 W przypadku aplikacji z interfejsem w języku polskim odpowiednikiem tego skrótu jest
kombinacja klawiszy Alt + P (przyp. tłum.).
61 https://www.w3schools.com/xml/xpath_syntax.asp
62 https://www.w3schools.com/cssref/css_selectors.asp

You might also like