Testowanie w Node.js: JEST alternatywa!

Jeśli zapytasz developera node.js o dobrą bibliotekę do testowania albo poszukasz informacji na ten temat w internecie to dowiesz się, że najpopularniejszym wyborem jest zestaw mocha (test runner) i chai (asercje). Dodatkowo do tworzenia mocków przyda się sinon, a jeśli zależy Ci na ciekawszych wynikach np. pokrycie kodu warto doinstalować np. istanbul. Jak widać trochę trzeba zainstalować paczek żeby napisać pierwszy test. Skonfigurować, przejrzeć 2 albo 3 różne dokumentacje i można działać.

A gdyby tak wszystko uprościć?

A gdyby użyć jednej biblioteki do testowania, która posiada te wszystkie elementy?

Okazuje się, że jest to możliwe dzięki Facebookowi, który stworzył Jest. Biblioteka co prawda głównie kojarzona jest z frontendem, a szczególnie z aplikacjami tworzonymi we frameworku React, ale sprawdza się dobrze również w aplikacjach node.js i właśnie na tym chciałbym się dzisiaj skupić.

Start!

Aby uruchomić pierwszy test nie ma wiele do zrobienia, oczywiście trzeba zainstalować bibliotekę do projektu.

npm install --save-dev jest

W pliku `package.json` należy zaaktualizować sekcję `scripts` w taki sposób aby testy były uruchamiane poprzez `jest`.

{
  "scripts": {
    "test": "jest"
  }
}

Teraz po uruchomieniu polecenia npm test każdy plik z rozszerzeniem .spec.js lub .test.js, który zostanie znaleziony w projekcie zostanie wzięty pod uwagę przez `jest`.

Pierwszy test

Nie będę tutaj rozpisywać szczegółowo jak pisać testy z wykorzystaniem tego narzędzia bo nie widzę sensu w duplikowaniu dokumentacji. Zresztą składnia jest bardzo podobna do innych bibliotek do testowania aplikacji JavaScript (takich jak Jasmine) więc jeśli miałeś z nimi styczność to nie będzie żadnego problemu aby sobie z tym poradzić.

Dzięki temu, że składnia jest bardzo podobna do innych narzędzi samo przejście z istniejącego rozwiązania też nie będzie problematyczne. Istnieje nawet biblioteka do automatycznego migrowania. Dobra… No to odpalamy jakiś przykładowy test i lecimy dalej.

function sum(a, b) {
  return a + b;
}
module.exports = sum;
const sum = require('./sum');

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});

Uruchomiony test przy pomocy polecenia `npm test` wyświetli poniższy wynik:

PASS  ./sum.test.js
✓ adds 1 + 2 to equal 3 (5ms)

Powyższy przykład jest również do znalezienia w dokumentacji. Ja w tym wypadku nie siliłem się nad wymyślaniem równie prostego testu bo chciałbym się skupić na ciekawszych kwestiach.

Przykład

Aby pokazać prostotę użycia i łatwość pisania testów z wykorzystaniem Jest posłużę się szybkim przykładem stworzonym na potrzeby tego posta. Załóżmy, że w aplikacji istnieje komponent do dodawania książek, do którego trzeba napisać testy.

Podstawy

Nie chcemy aby w naszej aplikacji pojawiały się niewspierane dane, dlatego zaprojektowano encję, która zawiera kilka zasad dopuszczalnego formatu danych wejściowych. Na przykład wymagany tytuł, minimalna ilość stron czy właściwy format ISBN.

'use strict';

const Joi = require('joi');
const ISBN = require( 'isbn-validate' );
const bookSchema = require('./book.schema');
const ValidationError = require('./../shared/error.validation');

class Book {
    constructor(data) {
        const validation = Joi.validate(data, bookSchema);

        if (validation.error) {
            const details = validation.error.details[0];
            throw new ValidationError(details.context.label, details.message);
        }

        if (data.isbn && !ISBN.Validate(data.isbn)) {
            throw new ValidationError('isbn', 'ISBN is not valid')
        }

        Object.assign(this, validation.value);
    }
}

module.exports = Book;

W powyższym przykładzie najpierw dokonywana jest walidacja z użyciem biblioteki Joi, która pozwala definiować schemat danych (definicja schematu dla encji książki znajuje się poniżej), a następnie sprawdzamy czy ustawione jest pole isbn – jeśli tak to walidujemy czy jest to poprawny format.

'use strict';

const Joi = require('joi');

module.exports = Joi.object().required().keys({
    title: Joi.string().required(),
    isbn: Joi.string().allow(null),
    pages: Joi.number().greater(10).integer().required(),
    released: Joi.date().timestamp()
});

No to mamy pewien zbiór założeń, który potrzebuje potwierdzenia w testach czy wszystko działa jak należy.

'use strict';

const BookEntity = require('./book.entity');
const ValidationError = require('./../shared/error.validation');

describe('BookEntity', () => {
    test('should throw validation error when title is missing', () => {
        expect(() =>
            new BookEntity({ pages: 100 })
        ).toThrow(ValidationError);
    });

    test('should throw validation error when pages is missing', () => {
        expect(() =>
            new BookEntity({ title: 'Awesome Book' })
        ).toThrow(ValidationError);
    });

    test('should throw validation error when number of pages is less than 10', () => {
        expect(() =>
            new BookEntity({ pages: 5 })
        ).toThrow(ValidationError);
    });

    test('should throw validation error when isbn is invalid', () => {
        expect(() =>
            new BookEntity({ pages: 5, title: 'Awesome Book', isbn: '244' })
        ).toThrow(ValidationError);
    });

    test('should create book entity without isbn', () => {
        const book = new BookEntity({ title: 'Awesome Book', pages: 100 });
        expect(book).toEqual({
            title: 'Awesome Book',
            pages: 100
        })
    });

    test('should create book entity with isbn', () => {
        const book = new BookEntity({ title: 'Awesome Book', pages: 100, isbn: '9788381194747' });
        expect(book).toEqual({
            title: 'Awesome Book',
            pages: 10,
            isbn: '9788381194747'
        })
    });
});

W powyższym przykładzie starałem się pokryć wszystkie warunki brzegowe, które wymaga nasza aplikacja. Zacząłem od zweryfikowania czy walidacja zadziała tak jak potrzeba. Kolejno testuję czy rzucony zostanie wyjątek kiedy dodawana jest książka bez tytułu, bez liczby stron lub z liczbą mniejszą niż 10 oraz z błędnym ISBNem.

W testach oczekiwać wyjątku można przy pomocy specjalnego wywołania funkcji expect – zamiast przekazywać bezpośrednią wartość należy utworzyć funkcję anonimową wewnątrz której wykonana zostanie akcja powodująca zwrócenie wyjątku.

Mockowanie

Jeśli mówimy o testach jednostkowych to jednym z ważniejszych elementów są tzw. mocki, które pozwalają zasymulować działanie funkcji „zewnętrznych”. O co chodzi? Testując jednostkowo musimy sprawdzić działanie konkretnego miejsca w kodzie – załóżmy, że wybranej funkcji. Jeśli ta funkcja odwołuje się do innych funkcji lub metod nie powinny one być testowane w tym miejscu. Każdy element testowany jest z osobna, a więc konkretne testy dla tych funkcji znajdują się w innym miejscu.

Aby jednak symulować różne zachowanie aplikacji trzeba jakoś sterować wynikami zwracanymi przez „funkcje zewnętrzne” lub sprawdzać z jakimi parametrami zostały wywołane, ile razy itd.

Weźmy prosty przykład akcji z kontrolera, który obsługuje dodawanie nowej książki.

'use strict';

const BookEntity = require('./book.entity');
const event = require('./../shared/eventEmitter');

class BookController {
    constructor(dao) {
        this._dao = dao;
    }

    async save(data) {
        const entity = new BookEntity(data);
        const id = await this._dao.save(entity);

        if (!id) {
            throw new Error('Book has not to been added.');
        }

        event.emit('book.created', { id });
        return id;
    }
}

module.exports = BookController;

W powyższym przykładzie można zauważyć wykorzystanie dwóch zewnętrznych modułów – dao, który jest wstrzykiwany przez konstruktor i służy do obsługi komunikacji z bazą danych. Drugi z nich to event, który jest tutaj dołączany z wykorzystaniem słowa kluczowego require. Przez to, że każdy z modułów jest wstrzykiwany w inny sposób, będzie wymagało to trochę innego podejścia w mockowaniu.

'use strict';

const BookController = require('./book.controller');

jest.mock('../shared/eventEmitter');
const event = require('../shared/eventEmitter');

describe('BookController', () => {
    const dao = {};
    let bookController;

    beforeEach(() => {
        dao.save = jest.fn();
        bookController = new BookController(dao);
    });

    describe('save', () => {
        test('should thrown error when dao does not return id for new book', async () => {
            return expect(
                bookController.save({ title: 'Awesome Book', pages: 100  })
            ).rejects.toThrow('Book has not to been added.');
        });

        test('should return id of created book', async () => {
            dao.save.mockReturnValueOnce(12345);

            const result = await bookController.save({ title: 'Awesome Book', pages: 100  });
            expect(result).toEqual(12345);
        });

        test('should emit event about created book with id of this book', async () => {
            dao.save.mockReturnValueOnce(12345);

            await bookController.save({ title: 'Awesome Book', pages: 100  });
            expect(event.emit.mock.calls).toContainEqual(['book.created', { id: 12345 }]);
        });
    });
});

Idąc krok po kroku po powyższych testach od razu na początku możemy natrafić na kolejny pozytywny aspekt w JEST. Mianowicie w 5 linii kodu, można zauważyć jest.mock('../shared/eventEmitter');, dzięki któremu mockowany jest cały moduł. W popularnych bibliotekach takich jak mocha nie ma takich udogodnień i często trzeba doinstalować takie pakiety jak proxyquire. W 35 linii kodu widoczne jest sprawdzenie czy wywołano metodę emit z modułu event z odpowiednimi argumentami.

Drugi typ tworzenia mocków, już bardziej znany z innych bibliotek do testowania możemy znaleźć w 13 linii. Przykład tego, jak można symulować zachowanie metody save widoczne jest w 25 i 32 linii.

Ustawienia

Bibliotekę jest można dostroić do własnych potrzeb w dość prosty sposób. Nie jest do tego potrzebne tworzenie specjalnego pliku konfiguracyjnego. Zamiast tego, można wykorzystać package.json.

Załóżmy, że do testów chcemy wyświetlać pokrycie kodu. W dowolnym miejscu pliku wystarczy dodać pole jest i ustawić odpowiednie opcje:

"jest": {
  "collectCoverage": true
}

Od razu przy okazji uruchamiania sprawdzania pokrycia kodu, chciałbym zauważyć, że domyślnie zwracany jest wynik tylko dla testowanych plików, a nie dla wszystkich z projektu. Aby to zmienić trzeba dodać kolejny parametr, gdzie przekażemy konkretne ścieżki, które powinny być brane pod uwagę.

"jest": {
    "collectCoverage": true,
    "collectCoverageFrom": [
      "bin/**/*.{js,jsx}",
      "!**/*.test.js"
    ]
  }

Więcej pomocnych ustawień można znaleźć w dokumentacji.

Podsumowanie

Artykułem tym nie miałem w zamiarach zachęcić do porzucenia obecnie przez Ciebie wykorzystywanych bibliotek do testowania aplikacji w node.js. Jeśli Ty i Twój zespół macie wyrobione standardy i przyzwyczajenia z inna biblioteką i nie stwarza dla was większych problemów to zostańcie przy tym co się sprawdza.

Moim głównym założeniem było przedstawienie alternatywy, która może być wykorzystywana w nowych projektach. W projektach, które muszą być szybko dostarczone i nie ma czasu na skomponowanie zestawu narzędzi developerskich. Wykorzystanie biblioteki Jest powinno sprawnie przyspieszyć wejście, ponieważ większość niezbędnych elementów, które są potrzebne do pisania szybkich i wydajnych testów jest już na miejscu. Bez konieczności szukania kolejnych pluginów i bibliotek.

Programista skupiony głównie wokół technologii webowych, ale nie przywiązujący się do konkretnych języków i narzędzi. Skoncentrowany na ciągłym rozwoju, zwolennik ruchu Software Crafmanship. Na codzień pracując w DAZN ma okazję rozwijać interesujący projekt do streamingu wydarzeń sportowych. Prywatnie fan sportu, a szczególnie piłki nożnej. Po godzinach próbuje również swoich sił w piwowarstwie domowym.
PODZIEL SIĘ