Moduł 5 - Zajęcia 9 - Testowanie aplikacji

1.1 Wprowadzenie do testowania

Rozwój aplikacji sprawia, że rośnie też prawdopodobieństwo pojawienia się błędów. Bez testowania aplikację często należałoby sprawdzać ręcznie, ale przy dużej ilości funkcjonalności będzie to pochłaniało ogromna ilość czasu.

Z pomocą przychodzi nam zautomatyzowane testowanie funkcjonalności aplikacji. Testowanie oznacza, że piszemy kod, który sprawdza, czy nie pojawiły się błędy przy wprowadzaniu zmian do naszej aplikacji.

Często to właśnie programiści piszą testy modułowe. Modułowe testowanie wykonuje sprawdzenie logiki aplikacji na poziomie funkcji lub metod.

Takie testowanie stosuje się do wszystkich rodzajów aplikacji.

Istnieją dwa podejścia do tworzenia nowych funkcjonalności:

  • Code first. Najpierw programujemy, a potem testujemy. Oznacza to, że na początku pisze się kod, realizuje funkcjonalność naszej aplikacji, a później pisany jest test danej funkcjonalności, następnie albo dopracowujemy funkcjonalność, albo przechodzimy do kolejnego zadania jeśli wszystko jest w porządku.
  • Test first. To drugie podejście. Jeszcze przed napisaniem funkcjonalności naszej aplikacji pisze się test, który sprawdza przyszłą funkcjonalność. Dopiero po stworzeniu testów, przechodzimy do napisania funkcjonalności naszej aplikacji.

Istnieją dwie popularne metodyki które wykorzystują jedno z powyższych podejść:

  • TDD - programowanie przez testowanie (Test-Driven Development);
  • BDD - programowanie przez realizację zachowania (Behavior-Driven Development).

Zarówno w podejściu TDD, jak i w BDD testy pisze się wcześniej, przed napisaniem faktycznego kodu implementującego daną funkcjonalność. Napisanie testów w pierwszej kolejności pomaga zastanowić się nad działaniem oprogramowania, co ostatecznie zapobiega pominięciu w funkcjonalności aplikacji wszystkich potrzebnych elementów. Jak widać na schemacie, BDD pracuje "ponad" TDD.

Algorytm pisania testów w podejściu TDD jest następujący: na początku pisze się kilka testów, następnie wykonuje się uruchomienie testów które, ponieważ funkcjonalność jeszcze nie jest realizowana, nie powodzą się. Przystępujemy więc do realizacji niezbędnej funkcjonalności aplikacji i stopniowo testy zaczynają przechodzić, aż do pełnej realizacji funkcjonalności.

Algorytm pisania testów w BDD jest prawie taki sam, pojawia się jednak różnica stylistyczna. BDD opisuje projektowanie oprogramowania komputerowego, faktycznie tworzymy więc plan przed napisaniem kodu. Testy pisane są z uwzględnieniem tego, czego oczekujemy od pracy całości jeszcze niezrealizowanej funkcjonalności w kontekście oprogramowania. Podejście takie stworzone zostało, aby naprawić problemy, które mogą pojawić się przy wykorzystaniu TDD, a konkretnie, ułatwić tworzenie kodu przez reprezentację wizualną jego funkcjonalności. Testy i ich wyniki wyglądają bardziej zrozumiale nie tylko dla programistów, lecz także dla klienta.

Ważna w zrozumieniu testowania jest piramida testowa. Wykorzystuje się ją do rozłożenia testów na różne poziomy aplikacji.

Każdą aplikację można podzielić na kilka warstw. Rozpatrzymy typowe rozwarstwienie z poziomem komponentów, serwisów i interfejsem użytkownika. Dolna część piramidy jest pokryta modułowymi (unit) testami. Napisane są przeważnie przez programistów i pokrywają komponenty atomowe, takie jak klasy, metody i funkcje. Włączane są bardzo często, pracują szybko, a ich ilość w danej aplikacji jest stosunkowo duża.

Testy integracyjne. To sprawdzenie, czy nowo realizowana funkcjonalność nie zepsuła kodu aplikacji. To scenariusz, który pokrywa bardziej skomplikowane funkcje, takie jak testy API. Uruchamiamy je dosyć rzadko, z reguły przy releasie i branch merges.

W górnej części znajdują się testy interfejsu użytkownika i ręcznego testowania, czyli End-to-End. Nie będziemy ich w poniższych przykładach analizować. Uruchamiane są rzadko i w kontekście całego systemu

2.1 Biblioteki w celach testowych

Biblioteki do testowania to specjalne narzędzia, pomagające w procesie testowania. Można napisać własną bibliotekę uruchamiania swoich testów, ale społeczność programistów już wykonała ogromną część pracy i napisała wiele takich narzędzi, dlatego łatwiej będzie je wykorzystać niż odkrywać koło na nowo.

2.2 Stack technologiczny do testowania modułowego

2.3 Runner, Reporter

Niezbędne, aby wykonać testy. Nasz test modułowy to zwykły plik JavaScript, ale aby korzystać z dodanych funkcji twórców testów, na przykład włączać kilka testów na raz, informować o błędach lub sukcesie wykonania, potrzebny jest nam wykonawca testów i narzędzie które zgłosi ewentualne błędy, czyli runner i reporter. Będziemy używać Jest, ale istnieją alternatywy takie jak Mocha i Jasmine lub Ava .

2.4 SPY, Mock

Przy pisaniu testów unikamy łączenia ich z innymi częściami infrastruktury naszej aplikacji. W ten sposób inne nietestowalne funkcje powinny być zamienione "fałszywą" funkcją, która zachowuje się tak, jak jest od niej oczekiwane i tu z pomocą przyjdą nam stuby i mocki. Różnica między terminami polega na tym, że stub niczego nie sprawdza, a tylko imituje wprowadzony stan, a mock to obiekt, który ma żądania. Na przykład, że testowana funkcja powinna być wywołana określoną ilość razy. Do testowania stubs i mock wykorzystujemy Jest, ale istnieją również inne biblioteki Sinon i Testdouble.

2.5 Matchers

Biblioteki służące do sprawdzania warunków takich jak: czy a jest większe niż b?. Istnieje wiele rozwiązań które do tego służą, Jest lub alternatywnie innymi Chai i Shouldjs .

2.6 Coverage

Stopień pokrycia testami. To wskaźnik (często procentowy), który służy jako punkt orientacyjny przy określaniu tego, jak dobrze przetestowany jest już nasz kod. Jest używa popularnego narzędzia o nazwie Istanbul aby określić poziom coverage.

Dziś dostępne jest wiele frameworków do testowania modułowego, niektóre mniej inne bardziej skupione na jednym zagadnieniu. Jak widać biblioteka Jest przyda się nam do dowolnego zadania, które może pojawić się przy testowaniu naszej aplikacji .

3.1 Jest

Jest — narzędzie wiersza poleceń, zbudowane jest na podstawie innej popularnej biblioteki testowania Jasmine. Za Jest odpowiedzialny jest Facebook i często wykorzystuje się go razem z React. Dlatego też ważne, żeby fullstack deweloper umiał się nim posługiwać.

Zalety i cechy charakterystyczne Jest:

  • Wbudowana w bibliotekę imitacja modułów JavaScript/Node upraszcza izolowanie kodu przy testowaniu modułowym;
  • Aby zacząć pracować z biblioteką nie trzeba dołączać wielu różnych narzędzi, na przykład dla Mocha wymagane jest importowanie Chai, podłączenie Istanbul i tak dalej;
  • Izolowanie (sandboxed) i równoczesne wykonywanie testów prowadzi do przyspieszenia ich wykonania;
  • Zapewniona modułowość, różne ustawienia i łatwość dostosowania do danej aplikacji.

Biblioteki dla modułowego testowania wykorzystują te same konstrukcje dla określenia testów i ich zbiorów. Jest nie stanowi wyjątku w tym przypadku.

Konstrukcje w plikach testów:

  • describe - pojedynczy zbiór testów;
  • test (lub it) - pojedynczy test;
  • beforeAll - przygotowanie przed testowaniem;
  • beforeEach - przygotowanie dla każdego zbioru testów lub testu;
  • afterAll - działania po zakończeniu testowania;
  • afterEach - działania po zakończeniu każdego zbioru lub testu.

Polecenie describe określa zbiór testów. Wykorzystuje się je jako swego rodzaju kontener dla zbioru testów.

Polecenie test wykorzystywane jest dla pojedynczego testu lub inaczej - przypadku testowego.

Inne konstrukcje, takie jak beforeAll, afterAll, beforeEach i afterEach, jak wynika to z ich nazw, wykonywane są przed lub po zbiorze testów lub przypadku testowym. Przy beforeEach i afterEach wewnętrzny kod tych funkcji wykonuje się wielokrotnie.

Testy powinny zawierać minimum po jednym poleceniu describe i nieograniczoną ilość poleceń test. Pozostałe polecenia nie są obowiązkowe jeśli nie są nam potrzebne.

Stwórzmy prosty przykład. Aby móc skorzystać z Jest w nowym folderze projektu należy wykonać polecenie:

            npm init -y
          

To utworzy plik package.json. Następnie zainstalujemy Jest:

            npm i jest -D
          

Teraz należy otworzyć plik package.json i ręcznie zmienić wartość polecenia test na jest, jak to przedstawiono niżej. Dodaliśmy więc komendę która uruchomi testy.

            {
              "name": "test-jest",
              "version": "1.0.0",
              "description": "",
              "main": "index.js",
              "scripts": {
                "test": "jest"
              },
              "keywords": [],
              "author": "",
              "license": "ISC",
              "devDependencies": {
                "jest": "^26.6.1"
              }
            }
          

Utworzymy moduł pow.js, który będzie zawierał funkcję potęgowania.

            module.exports = (a, b) => {
              return a ** b;
            };
          

Teraz utworzymy plik z testami pow.test.js. W nazwie będzie zawierał słowo- klucz test (wymóg Jest dzięki któremu biblioteka może rozpoznać plik z testami):

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

            describe('hooks', function () {
              beforeAll(() => {
                console.log('Wykonam się przed testami');
              });

              afterAll(() => {
                console.log('Wykonam się po testach');
              });

              beforeEach(() => {
                console.log('Wykonam się przed każdym testem');
              });

              afterEach(() => {
                console.log('Wykonam się po każdym teście');
              });

              test('1 to power 2 to equal 1', () => {
                console.log('1 to power 2 to equal 1');
                expect(pow(1, 2)).toBe(1);
              });

              test('3 to power 2 to equal 9', () => {
                console.log('3 to power 2 to equal 9');
                expect(pow(3, 2)).toBe(9);
              });
            });
          

Jako przykład wykorzystaliśmy wszystkie konstrukcje o których wcześniej mówiliśmy. W każdej z nich wyprowadzamy przy pomocy console.log odpowiednią wiadomość.

Faktycznie testowanie funkcji obsługujemy dwoma testami, gdzie oczekujemy, że funkcja potęgowania zwróci prawidłowy rezultat.

Włączenie testów nastąpi przez polecenie:

            npm run test
          

Po włączeniu powinniśmy otrzymać taki log w konsoli:

             console.log
                Wykonam się przed testami

                  at Object.log (pow.test.js:5:13)

              console.log
                Wykonam się przed każdym testem

                  at Object.log (pow.test.js:13:13)

              console.log
                1 to power 2 to equal 1

                  at Object.log (pow.test.js:21:13)

              console.log
                Wykonam się po każdym teście

                  at Object.log (pow.test.js:17:13)

              console.log
                Wykonam się przed każdym testem

                  at Object.log (pow.test.js:13:13)

              console.log
                3 to power 2 to equal 9

                  at Object.log (pow.test.js:26:13)

              console.log
                Wykonam się po każdym teście

                  at Object.log (pow.test.js:17:13)

              console.log
                Wykonam się po testach

                  at Object.log (pow.test.js:9:13)

            PASS  ./pow.test.js
              hooks
                ✓ 1 to power 2 to equal 1 (6 ms)
                ✓ 3 to power 2 to equal 9 (3 ms)

            Test Suites: 1 passed, 1 total
            Tests:       2 passed, 2 total
            Snapshots:   0 total
            Time:        0.521 s
            Ran all test suites.
          

Zwróć uwagę na porządek wykonania konstrukcji.

W Jest wykorzystuje się składnię oczekiwania, u podstaw której leży język twierdzenia Expect. Wymieńmy podstawowe metody Expect, które obsługuje Jest. Pozostałe można zobaczyć w dokumentacji.

  • not — odwraca wartość następnej operacji w łańcuchu sprawdzenia;
  • expect(func(arg)).toBe(value) — sprawdzenie ścisłej równości pomiędzy zwróconą wartością func(arg) a podanym value;
  • expect(func(arg)).toEqual(value) — głębokie porównanie podanych wartości
  • expect(func(arg)).toBeTruthy() — sprawdza, czy podana wartość jest truthy
  • expect(func(arg)).toBeNull() — sprawdza czy otrzymana wartość to null;
  • expect(func(arg)).toBeUndefined() — sprawdza czy wartość to undefined;
  • expect(func).toBeDefined() — sprawdza, czy funkcja/zmienna jest zdefiniowana/li>

W expect() przekazywane jest wywołanie funkcji, która zwraca faktyczną wartość i wykorzystując metody Expect porównujemy ją z oczekiwaną wartością, twardo zakodowaną w testach.