[JS] Jak przetestować kod napisany w czystym JS?
W poprzednim artykule dotyczącym uruchamiania testów jednostkowych za pomocą Mocha.js, Karma.js oraz Phantom.js zamieściłem przykładowy kod pluginu, który można przetestować za pomocą wcześniej wymienionych narzędzi. Część z Was potem w komentarzach czy też w mailach do mnie pytała się jak w takim razie napisać testy jednostkowe dla zaprezentowanego skryptu JS.
Nie ukrywam, że to był zamierzony ruch z mojej strony, aby zamieścić skrypt JS i nic o nie napisać o jego testowaniu. Byłem ciekaw jak duży będzie odzew w tej kwestii. Chciałbym również przeprosić, jeśli kogoś takie zagranie z mojej strony zabolało.
Wracając do tematu, w tym artykule będę chciał przedstawić Ci sposób na pisanie testów jednostkowych do kodu JS, który nie jest zbudowany w oparciu o popularne frameworki JS, takie jak np. Angular.js czy Backbone.js. Dowiesz się, jak pisać testy jednostkowe do kodu, który jest napisany w czystym JavaScripcie, ewentualnie z wykorzystaniem jQuery.
Jak wygląda szablon testu?
Pierwszą rzeczą, którą należy zrobić jest utworzenie katalogu o nazwie test w którym będziemy zamieszczać wszystkie nasze pliki związane z testami jednostkowymi w JavaScript.
Kolejną rzeczą jest utworzenie szablonu testu. Szablon testu jest to plik HTML, w którym będą załadowane wszystkie pliki JS potrzebne do przeprowadzenia testów oraz zostanie przygotowana wymagana struktura HTML, która pozwala nam wygenerować raport w przeglądarce. Przykładowy szablon testu, może wyglądać następująco:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | <!doctype html> <html> <head> <title>Testy jednostkowe dla skryptu Clicker.js</title> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="stylesheet" href="../node_modules/mocha/mocha.css" /> </head> <body> <div id="mocha"></div> <div id="messages"></div> <div id="fixtures"></div> <script src="../node_modules/mocha/mocha.js"></script> <script src="../node_modules/chai/chai.js"></script> <script src="../node_modules/chai-dom/chai-dom.js"></script> <script src="../clicker.js"></script> <script> mocha.setup('bdd'); mocha.reporter('html'); </script> <script src="clicker-test.js"></script> <script>mocha.run();</script> </body> </html> |
Jak można zauważyć, załadowano wszystkie wymagane biblioteki testowe, tj.:
- Mocha.js,
- Chai.js,
- oraz plugin do Chai - Chai-DOM.
Ponadto, dołączono plik CSS ze stylami dla raportów testów Mocha.js oraz plik ze scenariuszami testów - clicker-test.js. Ten ostatni plik będzie zawierał informacje o tym jak testować funkcjonalności skryptu JS przez nas przygotowanego.
Jak wygląda scenariusz testu jednostkowego?
Plik clicker-test.js zawiera scenariusze testowe, które pomogą nam przetestować kod JS. Dobrze przygotowane scenariusze testowe pozwalają nam uchronić się przed nieoczekiwanymi błędami w kodzie, a także zabezpieczają nas przed wprowadzeniem zmian, które mogłyby coś zepsuć w działającym kodzie JavaScript.
Kod który będzie się znajdował w pliku z testami na początku będzie wyglądał następująco:
1 2 3 4 5 6 7 | var expect = chai.expect; describe('Clicker', function () { describe('Run with default settings', function () { }); }); |
Co się tam dzieje? Generalnie, nic się nie dzieje. Przygotowaliśmy główny scenariusz pod tytułem Clicker który składa się z części pod tytułem: Run with default settings. Gdy otworzymy plik HTML z sablonem testowym w przeglądarce, to zobaczymy białą, pustą stronę.
Natomiast, jeśli masz przygotowane środowisko tak jak to opisałem w poprzednim artykule, to po uruchomieniu w terminalu (wierszu poleceń) komendy gulp test
zobaczymy komunikaty błędów. Na tą chwilę nic nie jest testowane.
Natomiast, gdy rozbudujemy nasz kod do następującej postaci:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | var expect = chai.expect, CLASS_CLICKER = 'clicker', SELECTOR_CLICKER = '.' + CLASS_CLICKER, /** * Usuwa kontener z elementem posiadającym klasę `.clicker` z pliku HTML * * @method destroyClicker */ destroyClicker = function () { var clicker = document.querySelector(SELECTOR_CLICKER); clicker.parentNode.replaceChild(clicker.cloneNode(false), clicker); }, /** * Tworzy nowy element z klasą `.clicker` i wstawia go na początku sekcji <body> * w pliku HTML. * * @method createClicker * @return HTMLElement */ createClickerContainer = function () { var clicker = document.createElement('div'); clicker.classList.add(CLASS_CLICKER); if (document.body.childNodes[0]) { document.body.insertBefore(clicker, document.body.childNodes[0]); } else { document.body.appendChild(clicker); } return clicker; }; describe('Clicker', function () { describe('Run with default settings', function () { /** * Przed uruchomieniem każdego testu * utwórz nowy element z klasą `.clicker` */ beforeEach(function () { createClickerContainer(); new Clicker(); }); /** * Po wykonaniu każdego testu * usuń element z klasą `.clicker` z dokumentu HTML */ afterEach(destroyClicker); /** * Pierwszy test. Uruchamianie funkcjonalności pluginu Clicker */ it('Should implement Clicker features on element with `.clicker` CSS class', function () { }); }); }); |
To już zaczyna się coś dziać. Po pierwsze, przed uruchomieniem każdego testu tworzymy nowy element w drzewie DOM, który będzie wykorzystywany w testowaniu funkcjonalności naszego kodu - funkcja beforeEach. Dodatkowo, po każdym wykonanym teście będziemy usuwać utworzony wcześniej element w drzewie DOM, do którego podpięliśmy testowaną funkcjonalność.
Jaki to ma sens? W tym przypadku, być może niewielki, ale traktuję to jako dobrą praktykę w testowaniu. Dzięki temu, każdy test będziemy przeprowadzać na elemencie, który nie został zmodyfikowany przed poprzedni test.
Właściwe testy zawsze przyjmują postać: it('tytuł testu - jak powinna działać wybrana funkcjonalność?]', [funkcja testująca]);
.
Jak możesz zauważyć w kodzie powyżej, pierwszy test jeszcze nic nie robi, gdy otworzysz plik HTML z szablonem testu w oknie przeglądarki, to zobaczysz następujący widok:

Ponadto, gdy w terminalu/wierszu poleceń uruchomimy polecenie: gulp test
, to będziemy w stanie zobaczyć już pierwsze rezultaty sprawdzenia pokrycia kodu testami:

Jednak musisz uważać w tym przypadku, wynik sprawdzenia pokrycia kodu testami mówi, że przetestowaliśmy ponad 46% napisanego kodu (13 linijek z 28 ogółem), co nie jest prawdą. Pisałem już o tym problemie, że bazowanie na pokryciu kodu jako wskaźniku jakości kodu może być zgubne. Generalnie, chodzi o to że, nic nie robiąc uzyskaliśmy dość wysoki wynik pokrycia kodu testami, a część programistów traktuje wskaźnik pokrycia kodu testami jednostkowymi jako wyznacznik jakości kodu.
Co jest ważne w testowaniu kodu?
Testów jednostkowych nie pisze się po to, aby mieć 100% pokrycia kodu testami. Pisze się je po to, aby w przyszłości wprowadzając zmiany móc łatwo wykryć miejsca w których zostały zmienione kluczowe funkcjonalności naszego kodu. Jeśli coś wydaje się niewarte testowania, to zazwyczaj jest to sugestia, że dany kawałek może być zupełnie zbędny z punktu widzenia działania naszego kodu. Tak więc, co jest ważne w testowaniu kodu?
- 1. Sprawdzenie poprawnego działania kodu
- Dzięki testom powinniśmy dostać zapewnienie, że kod przez nas napisany będzie działał zgodnie z oczekiwaniami w każdym momencie implementacji.
- 2. Testowanie sytuacji skrajnych wywołujących oczekiwane błędy
- Testowanie poprzez kreowanie sytuacji mogących wywołać określone błędy ma na celu sprawdzenie, czy kod naszego pluginu lub funkcjonalności JavaScript będzie zachowywał się zgodnie z oczekiwaniami, np.: wyświetlał komunikaty błędów, pozwalał na korzystanie z możliwości naszego kodu mimo błędów (np. pozwalał na ponowienie akcji, a nie zawieszał działanie całego kodu), itd., itp.
- 3. Analiza kodu i poprawianie go
- Testując kod powinniśmy jednocześnie go analizować. Jeśli wystąpią miejsca, gdzie można go poprawić to zdecydowanie zalecam poprawienie go. Dzięki temu, możemy usprawnić działanie wszystkich funkcji a także zmiejszyć ryzyko, że wprowadzenie w przyszłości innych zmian sprawi, że kod stanie się trudniejszy do testowania.
- 4. Specjalizacja testów
- Pod pojęciem - specjalizacja testów, kryje się podejście, które wymaga od programistów rozdzielanie testów na mniejsze kawałki, co sprawi że testy będą łatwiejsze do analizy. Testy powinniśmy dzielić na grupy wg:
- Wybranej większej funkcjonalności (np. testujemy tylko funkcjonalność związaną z dodawaniem nowej wiadomości w oknie logowania zdarzeń),
- Wybranego pluginu JavaScript (np. testujemy kod związany tylko z pluginem Clicker.js).
Pierwszy test - inicjalizacja pluginu
Nasz pierwszy test będzie sprawdzał czy plugin został zainicjalizowany na wybranym elemencie. Mając wiedzę, że plugin do każdego elementu, w którym ma być zaimplementowana funkcjonalność Clickera, dodaje dodatkową klasę .clicker--ready
to możemy w łatwy sposób przetestować czy Clicker.js został poprawnie zainicjalizowany.
Dodatkowo, sprawdzimy też czy kontener logów został utworzony.
Przyjrzyj się testowi poniżej:
1 2 3 4 5 6 7 | /** * Pierwszy test. Uruchamianie funkcjonalności pluginu Clicker */ it('Should implement Clicker features on element with `.clicker` CSS class', function () { expect(document.querySelector(SELECTOR_CLICKER)).to.have.class(CLASS_CLICKER_READY); expect(document.querySelector(SELECTOR_LOGS)).to.exist; }); |
Dzięki pluginowi chai-dom jesteśmy w stanie dokonywać sprawdzeń i porównań bezpośrednio na elemencie drzewa DOM. W teście powyżej, sprawdzamy czy element ma dodaną wymaganą klasę CSS.
Plugin JS - Clicker.js - na potrzeby tego artykułu jest bardzo prosty w swym działaniu i podczas inicjalizacji nie robi nic więcej poza dodaniem klasy CSS, podpięciem event listenera do elementu w drzewie DOM oraz utworzeniem kontenera na logi o kliknięciach.
Drugi test - reakcja na zdarzenia
Celem drugiego testu będzie sprawdzenie czy element reaguje odpowiednio na kliknięcie kursorem myszy. Będziemy musieli emulować kliknięcie za pomocą kodu JS. Ze względu na to, że PhantomJS nie obsługuje metody .click()
tak jak to robią przeglądarki, to będziemy musieli w naszym teście emulować kliknięcie.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | it('Should log a message when a user clicks on a clicker element', function () { var clickerElement = document.querySelector(SELECTOR_CLICKER), logsContainer = document.querySelector(SELECTOR_LOGS), // tworzymy nowy event symulujący akcje wykonywane myszką mouseEvent = document.createEvent('MouseEvents'); // konfigurujemy event tak, aby symulował kliknięcie mouseEvent.initEvent('click', false, true); // inicjujemy zdarzenie na elemencie z funkcjonalnością clicker.js clickerElement.dispatchEvent(mouseEvent); expect(logsContainer).not.to.be.empty; expect(logsContainer).to.contain('p'); expect(logsContainer.querySelector('p')).to.have.text('Click!'); }); |
To co jest najważniejsze z punktu przeprowadzenia testu to zostało opisane za pomocą komentarzy w kodzie. W tym teście sprawdzamy czy kliknięcie w element spowodowało utworzenie nowego wpisu w kontenerze logów oraz czy wpis ma odpowiedni tekst.
Po napisaniu każdego z testów możemy uruchamiać komendę gulp test
i sprawdzać czy testy nie zwracają błędów (to samo możemy uzyskać otwierając plik HTML w przeglądarce) oraz możemy sprawdzić stopień pokrycia kodu testami (poglądowo, bo tylko od naszej sumienności będzie zależało to, czy nasze testy będą sprawdzały wszystkie istotne rzeczy w danym przebiegu).
Podsumowanie
Mam nadzieję, że udało mi się wytłumaczyć to, jak się przeprowadza testy jednostkowe dla kodu napisanego w czystym JS. Podobne testy można przeprowadzać jeśli jako zależność mamy jQuery. W takiej sytuacji, wystarczy dołączyć plik z jQuery do szablonu testowego a potem sprawdzać zachowanie naszego kodu, np. czy zakładka się otworzyła po kliknięciu w etykietę.
Pisanie testów jednostkowych jest bardzo przydatne. Pozwala to uniknąć błędów w trakcie dokładania nowych rzeczy do istniejącego kodu czy też po dokonaniu zmian w nim. Nie zliczę sytuacji, gdzie dobrze napisane testy pozwoliły mi uniknąć wprowadzania zmian, które psuły, nie bezpośrednio, istniejący kod.
Może się wydawać, że dla prostych skryptów bazujących na jQuery testy jednostkowe są zbędne, ale to nie jest prawda. Biblioteki ulegają aktualizacjom, a my chcielibyśmy być pewni, że aktualizacje nie powodują błędnego zachowania naszego kodu. Dzięki testom jednostkowym, a także testom integracyjnym (nie poruszałem tego tematu tutaj) możemy być pewni, że nie przeoczymy zmian modyfikujących działanie naszego kodu.
Cały kod testu (2 przykłady zaprezentowane powyżej) znajdziesz tutaj:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | /* jshint expr: true, strict: false */ /* globals chai, describe, afterEach, beforeEach, it, Clicker */ var expect = chai.expect, CLASS_CLICKER = 'clicker', CLASS_CLICKER_READY = CLASS_CLICKER + '--ready', SELECTOR_CLICKER = '.' + CLASS_CLICKER, SELECTOR_LOGS = '.logs', /** * Usuwa kontener z elementem posiadającym klasę `.clicker` z pliku HTML * * @method destroyClicker */ destroyClicker = function () { var clicker = document.querySelector(SELECTOR_CLICKER); clicker.parentNode.replaceChild(clicker.cloneNode(false), clicker); document.body.removeChild(document.querySelector(SELECTOR_LOGS)); }, /** * Tworzy nowy element z klasą `.clicker` i wstawia go na początku sekcji <body> * w pliku HTML. * * @method createClicker * @return HTMLElement */ createClicker = function () { var clicker = document.createElement('div'); clicker.classList.add(CLASS_CLICKER); if (document.body.childNodes[0]) { document.body.insertBefore(clicker, document.body.childNodes[0]); } else { document.body.appendChild(clicker); } return clicker; }; describe('Clicker', function () { describe('Run with default settings', function () { /** * Przed uruchomieniem każdego testu * utwórz nowy element z klasą `.clicker` */ beforeEach(function () { createClicker(); new Clicker(); }); /** * Po wykonaniu każdego testu * usuń element z klasą `.clicker` z dokumentu HTML */ afterEach(destroyClicker); /** * Pierwszy test. Uruchamianie funkcjonalności pluginu Clicker */ it('Should implement Clicker features on element with `.clicker` CSS class', function () { expect(document.querySelector(SELECTOR_CLICKER)).to.have.class(CLASS_CLICKER_READY); expect(document.querySelector(SELECTOR_LOGS)).to.exist; }); it('Should log a message when a user clicks on a clicker element', function () { var clickerElement = document.querySelector(SELECTOR_CLICKER), logsContainer = document.querySelector(SELECTOR_LOGS), mouseEvent = document.createEvent('MouseEvents'); mouseEvent.initEvent('click', false, true); clickerElement.dispatchEvent(mouseEvent); expect(logsContainer).not.to.be.empty; expect(logsContainer).to.contain('p'); expect(logsContainer.querySelector('p')).to.have.text('Click!'); }); }); }); |
Powyższy test nie jest ukończony. Jeszcze wiele przypadków nie zostało rozpatrzonych. Lecz daje on wystarczający pogląd na to, jak powinno się testować kod.