podstawy testów automatycznych oprogramowania - okładka

Podstawy testów automatycznych oprogramowania

Przez początkujących programistów temat testów automatycznych oprogramowania zwykle jest pomijany lub co najmniej zaniedbywany. Poniekąd rozumiem motywacje, ponieważ komuś początkującemu może się wydawać, że testowanie czegoś co zostało sprawdzone manualnie nie ma sensu. W końcu działa a poza tym to testy są trudne. Chciałbym tu od razu zaznaczyć – pisanie testów nie jest trudne pod warunkiem, że nasz kod pisany jest zgodnie z dobrymi praktykami. Jeśli nasz kod jest chaotyczny, bez zachowania pewnych wzorców, to jego testowanie faktycznie może stanowić problem. Chciałbym w tym miejscu zaznaczyć, że nie można popaść w drugą skrajność i pisać kodu specjalnie pod testy – to również jest złe. Trzeba znaleźć złoty środek.

Po co testować?

Na samym wstępie, aby docenić wartość testów przedstawię kilka zalet:

  • Przede wszystkim upewniamy się, że kod działa jak należy. Bardzo często jesteśmy w stanie sprawdzić to manualnie. Problem powstaje w momencie, gdy manualne testowanie zaczyna zajmować bardzo dużo czasu. Dajmy na przykład funkcję, która może przyjmować dane w kilku formatach. Po dokonaniu zmian w kodzie wypadałoby sprawdzić jak zachowa się napisany fragment kodu dla każdego możliwego formatu danych. Manualne testy pochłoną zdecydowanie więcej czasu niż uruchomienie zestawu testów automatycznych.
  • Testy stanowią dodatkową dokumentację kodu. Oczywiście do w pełni poprawnej dokumentacji służą inne narzędzia (np. JSDoc ). Testy jednak mogą stanowić uzupełnienie. W teście możemy zobaczyć jak wygląda faktyczne użycie kawałka kodu, jakie dane faktycznie mogą zostać przekazane oraz co kod zwraca.
    Do tego każdy przypadek testowy zawiera opis, który również jest przydatny. Szczególnie użyteczne jest to przy wdrażaniu nowych osób w projekt, gdzie rzut okiem na zestwa testów może pomóc przy oswojeniu się z kodem.
  • Pozwalają na uniknięcie regresji, czyli popsucia wcześniej działającego kodu. Jest to sytuacja nader częsta, gdzie zmiana w kodzie w jednym miejscu powoduje problemy w zupełnie innej części oprogramowania. Dzięki temu o problemach poinformuje nas test, a nie klient. Oczywiście w ten sposób nie wyeliminujemy ryzyka regresji całkowicie, ale znacznie je zredukujemy.
  • Testy stanowią materiał marketingowy i zachętę oraz świadczą o jakości. Dokładnie przetestowane oprogramowanie może być dużą zaletą na rynku, gdzie należy rywalizować o klienta, a następnie utrzymać go przy sobie i zaspokajać jego oczekiwania. Testy w oczach klientów są dużą zaletą i pokazują podejście programistów do wytwarzanego oprogramowania. Dzięki testom zwiększa się zaufanie klientów do firmy i produktu. Warto tu również wspomnieć, że testy w wielu przypadkach będą warunkiem koniecznym do pozyskania klienta.
  • Oszczędzamy czas testerów manualnych. Czas to pieniądz – czas testera kosztuje. Zestaw testów redukuje czas jaki tester musi poświęcić na sprawdzenie zmian po każdej zmianie w aplikacji. Bez testów automatycznych liczba drobiazgów, na jakich musiałby się skupić tester byłaby zdecydowanie większa.

Piramida testów

Mając już omówione zalety testów automatycznych przejdę do rodzajów testów. Podstawowe rodzaje testów automatycznych przedstawia bardzo popularny schemat piramidy testów:

Podstawy testów automatycznych oprogramowania - piramida testów

Testy jednostkowe

Na samym dole piramidy znajdują się testy jednostkowe ( unit tests ). Są to testy które testują bardzo mały fragment kodu, na przykład funkcje czy metody. Tego typu testy powinny skupiać się na testowaniu konkretnego fragmentu kodu. Dlatego też wszelkie elementy zewnętrzne takie jak chociażby interfejsy do wykonywania operacji na bazach danych, czy zewnętrzne funkcje czy biblioteki powinny być mockowane. Testy jednostkowe nie bez przyczyny znajdują się na samym dole piramidy. Występuje tutaj ta sama zależność co na przykład przy piramidzie żywienia, czyli to co jest na dole występuje w największej ilości. Testów jednostkowych z zestawieniu z innymi testami będzie najwięcej.

Testy integracyjne

Drugie w kolejności są testy integracyjne. Testy tego typu testują integrację, czyli współdziałanie fragmentów kodu. Za pomocą takiego testu możemy sprawdzić zachowanie wcześniej wspomnianej funkcji z wspomnianym interfejsem do wykonywania operacji na bazie danych. O tym dlaczego testy integracyjne są bardzo ważne możesz się przekonać wpisując w przeglądarkę frazę: “2 jednostkowe 0 integracyjnych”. Znajdziesz wiele zabawnych przypadków, gdzie zabrakło testu integracyjnego 🙂

Aby zobrazować działanie testów integracyjnych wyobraź sobie, że musisz zamontować błotnik w rowerze. Przetestowano wcześniej czy błotnik chroni przed błotem, oraz czy koło się obraca. Nie było jednak testu, czy po montażu obu elementów na rowerze da się jeździć. Przy pierwszej próbie okazało się, że jazda rowerem jest niemożliwa, gdyż źle zamontowany błotnik trze o oponę.

Testy end to end

Trzecim rodzajem testów są testy end to end, lub też inaczej e2e. Sprwdzają one całe funkcje i zadania aplikacji. W tym momencie nie interesuje mnie co zwróci konkretna metoda, lub czy konkretna funkcja została wywołana daną ilość razy.
Takie testy sprawdzają czy na przykład: post został dodany, komentarz został dodany, wpis został usunięty, zamówienie zmieniło status itd. Na takie procesy w aplikacji mogą się składać nawet dziesiątki metod, ale testy e2e sprawdzają jedynie efekt końcowy.

Przedstawiony przeze mnie schemat testów jest oczywiście bardzo uproszczony i nie zawiera wszystkich rodzajów testów jak chociażby testy wydajnościowe, obciążeniowe czy mutacyjne. Niemniej jednak te trzy podstawowe rodzaje testów są dobrym punktem startu jeśli się nie miało wcześniej do czynienia z testami automatycznymi. O innych rodzajach dowiesz się więcej ze źródeł na końcu wpisu.

AAA pattern

Przed napisaniem swojego pierwszego testu warto poznać sposób, który pomoże ustrukturyzować testy, nadać im pewien schemat. Do tego celu służy wzorzec AAA – Arrange, Act, Assert.

Wzorzec ten zakłada podział testu na trzy części:

  1. Arrange – aranżacja. W tej części testu tworzymy środowisko testowe. Tworzymy mocki oraz instancje testowanych obiektów, i definiujemy inne niezbędne element składowe.
  2. Act – odegranie. W tym momencie następuje faktyczne użycie testowanego kodu.
  3. Assert – sprawdzenie. Ostatnim etapem jest sprawdzenie rezultatu. Może to być sprawdzenie wyniku zwracanego przez metodę, czy upewnienie się o wywołaniu metody.

Warto pamiętać, aby unikać łańcuchów pokroju Arrange -> Act -> Assert -> Act -> Assert. Nie jest to karygodny błąd, niemniej jednak jeden test powinien pokrywać jeden przypadek. Taki łańcuch można rozłożyć na dwa osobne testy.

Autmoatyczne testy oprogramowania - narzędzia

Narzędzia do testowania

Do tworzenia testów powstało wiele przydatnych narzędzi. Poniżej przedstawiam narzędzia, które możesz wykorzystać do testowania kodu w środowisku JavaScript:

    • Test runnery: Mocha, Jest, Jasmine,
    • Asercje: Chai,
    • Mocki: Sinon,
    • Code coverage: Istanbul

Przykład

Aby w pełni zobrazować to o czym mówi artykuł przedstawię przykład jak może wyglądać bardzo prosty zestaw testów jednostkowych. Na początek, aby mieć co testować stworzę prostą funkcję:


const repository = require( './repository' );

function exampleFunction( repository, condition = false ) {
if ( condition ) {
	repository.doSomething();

	return true;
}

return false;
}

module.exports = exampleFunction;

Powyższa funkcja przyjmuje dwa parametry i w zależności od drugiego parametru jej zachowanie jest różne. Korzystając z informacji, które zawarłem w artykule przedstawię jak może wyglądać zestaw testów dla tej funkcji. Do wykonania zestawu przypadków testowych posłużę się bibliotekami Mocha, Sinon i Chai:


const sinon = require( 'sinon' );
const { expect } = require( 'chai' );
const exampleFunction = require( '../src/exampleFunction' );

describe( 'exampleFunction - Unit Tests', () => {
	let repository;
	
	beforeEach( () => {
		repository = {
			doSomething: sinon.spy()
		}
	} );
	
	it( 'should execute repository function', () => {
		const result = exampleFunction( repository, true );
		
		expect( result ).to.equal( true );
		sinon.assert.calledOnce( repository.doSomething );
	} );
	
	it( 'should not execute repository function', () => {
		const result = exampleFunction( repository, false );
		
		expect( result ).to.equal( false );
		sinon.assert.notCalled( repository.doSomething );
	} );
	
	it( 'should not execute repository function if condition is not passed', () => {
		const result = exampleFunction( repository );
		
		expect( result ).to.equal( false );
		sinon.assert.notCalled( repository.doSomething );
	} );
} );

Omówię teraz po kolei co dzieje się w powyższym kodzie. Na samym początku importujemy potrzebne zależności oraz testowaną funkcję. Następnie tworzymy describe(), czyli wrapper do przypadków testowych. Describe() możemy zagnieżdżać wewnątrz siebie przez co możemy tworzyć małe zestawy przypadków testowych, które tyczą się tego samego kawałka kodu.

Przed samym testem – beforeEach

Kolejnym istotnym fragmentem jest funkcja beforeEach(). Wewnątrz tej funkcji znajduje się inna funkcja, która zostanie wywołana przed każdym testem wewnątrz describe, w którym została wywołana. Dzięki beforeEach() pierwszy krok, czyli arrange tworzymy w kodzie tylko raz, lecz jest on wywoływany przed każdym testem. Oprócz tego do dyspozycji mamy także afterEach(), który wykonuje się po każdym teście ( przydatny na przykład do zamykania połączenia z bazą danych czy przerywania wiszących procesów ), oraz before()after() uruchamiające się tylko raz dla danego describe().

beforeEach() stworzony został mock – czyli “fałszywa” funkcja mająca imitować jakąś zewnętrzną zależność. W tym wypadku mock posłuży do sprawdzenia czy metoda przekazanego repozytorium faktycznie została wywołana.

Użycie beforeEach w tym miejscu ma jeszcze jedną zaletę. Mock jest tworzony osobno dla każdego testu. Gdyby mock był tworzony tylko raz to wynik jednego testu mógłby zostać odczytany w innym. Jest to zła praktyka. Jeszcze gorszą praktyką jest tworzenie testów, które wzajemnie mają na siebie wpływ. 

Przypadek testowy – it

Przechodząc dalej możemy zauważyć funkcje it(). Są to funkcje odpowiedzialne za konkretne przypadki testowe. Jako pierwszy argument przyjmują opis przypadku testowego, a następnie funkcję zawierającą test. Aby w pełni przetestować kod stworzyłem trzy przypadki testowe. Myślę, że opisywanie co sprawdza dany przypadek jest zbędne – po to dodajemy opisy do przypadków testowych. Zwróć jednak uwagę na to, że każdy opis zaczyna się słowem should, co jest uznawane za dobrą praktykę.

Przyglądając się strukturze konkretnego testu można dostrzec pozostałe dwa elementy – act i assert. Najpierw funkcja jest wywoływana ( act ). Następnie sprawdzany jest rezultat oraz to, czy metoda przekazanego repozytorium została wywołana ( assert ).

Podsumowanie

Mam nadzieję, że po przeczytaniu tego wpisu jeśl nie testowałeś/aś swojego kodu testami automatycznymi to zaczniesz to robić. Myślę, że czas poświęcony na napisanie zestawu testów automatycznych zwraca się bardzo szybko a i sam komfort pracy z projektem pokrytym testami jest o wiele wyższy. Zachęcam też do zapoznania się ze źródłami i materiałami dodatkowymi.

Źródła i materiały dodatkowe