JEST – sposoby mockowania

jest

W świecie JavaScriptu panuje klęska urodzaju – na rozwiązanie każdego problemu znajdziesz kilka albo kilkanaście bibliotek. W większości przypadków można to rozpatrywać jako zaleta, ale przy okazji znalezienie tej najlepszej (nie na podstawie gwiazdek na githubie 😆) może przysporzyć problemów. I tak ostatnio mam „przyjemność” balansować między trzema różnymi bibliotekami do testów – mocha, hapi i JEST. Niby wszystkie są podobne, ale jednak znajdziemy tam też różnice, które czasem mają znaczenie.

Testowanie w Node.js: JEST alternatywa!

Ostatnio zostałem zapytany czy mógłbym wyjaśnić o co w sumie chodzi z tymi mockami w JEST i kiedy używać poprawnego rodzaju w zależności od sytuacji. Ciekawy temat żeby spisać to w celu podsumowania wiedzy i jednocześnie przekazania swojego podejścia. Wszystkie przykłady będą w TypeScript, jeśli chciałbyś poznać jak skonfugorwać bibliotekę JEST z tym językiem to zapraszam do poprzedniego wpisu.

Konfiguracja JEST w aplikacji Typescript

Dostępne typy mocków w bibliotece JEST

Na początek warto pocieszyć się faktem, że w bibliotece JEST otrzymujemy spore uproszczenie w stosunku do innych rozwiązań dostępnych na rynku. Uproszczenie to polega na zamknięciu pod słowem „mock” mechanizmów, które nierzadko wprowadzają zamieszanie szczególnie wśród początkujących programistów. Zamiast podziału na stuby, mocki, spies otrzymujemy po prostu mock i w jego obszarze można zrobić wszystko to co w innych bibliotekach jest rozdzielone. W moim odczuciu jest to spore udogodnienie, dzięki któremu nie trzeba zastanawiać się podczas tworzenia testów czy w danym miejsciu wykorzystano odpowiedni mechanizm.

Dzięki temu, że wszystko opakowane jest za pomocą mocka warto zaznaczyć, że wszystkie utwrzone mocki otrzymują również komplet funkcji pomagajcych nasłuchiwać to co się dzieję z tymi mockami. W skrócie – możemy śledzić ile razy dana funkcja została wywołana czy z jakimi parametrami.

Mockowanie funkcji

Zacznijmy od najprostszego i z pewnością najczęściej używanego rozwiązania, czyli mockowania funkcji. Przejdźmy od razu do pierwszego przykładu, czyli obserwacji zachowań związanych z mockiem.

Obserwowanie

export function power (input: number, callback: (result: number) => void): void {
    callback(input * input);
}

Została zadeklarowana funkcja, która jako drugi argument oczekuje callbacka. Funkcja nic nie zwraca, a wynik operacji przekazywany jest do callbacka. Warto sprawdzić czy przekazana wartość jest prawidłowa.

import { power } from './foo';

describe('test function mock', () => {
    const mockCallback = jest.fn();

    test('should pass correct value into callback', () => {
        power(4, mockCallback);

        expect(mockCallback).toHaveBeenCalled();
        // first argument during first call of function
        expect(mockFn).toHaveBeenCalled();
        expect(mockCallback.mock.calls[0][0]).toBe(16);
    });
});

W powyższym teście w 4 linii kodu tworzona jest mock function, który automatycznie udostępnia cały zestaw zachowań – zarówno do nasłuchiwania akcji, które zostaną na nim wykonane jak i możliwości sterowania jego zachowaniem.

W liniach 11 i 12 można zaobserwować weryfikację czy mock został wywołany, oraz z jakim argumentem. Składnie mockCallback.mock.calls[0][0] oznacza zwrócenie pierwszego argumentu w pierwszym wywołaniu funkcji.

Zwracanie wartości

export function variousMultiplication(input: number, multiplier: () => number): number {
    return input * multiplier();
}

Mamy funkcję, która przyjmuje dwa parametry, pierwszy z nich może mieć wartość numeryczną, z kolei drugi parametr to funkcja, która również zwraca liczbę, jednak zależną od pewnych czynników zewnętrznych. Załóżmy, że w ramach funkcji będziemy wywoływać inną funkcję, która ma nam dostarczyć pewną wartość i zwrócić wynik od niej zależną.

import { variousMultiplication } from './bar';

describe('test function mock', () => {
    const mockCallback = jest.fn();

    test('should return multiplied value', () => {
        // set mocked value returned by function
        const mockFn = jest.fn().mockReturnValue(5);
        const result = variousMultiplication(4, mockFn);

        expect(result).toBe(20);
    });
});

W powyższych kodzie można zauważyć, że w 4 linii utworzony został mock dokładnie tak samo jak w poprzednim przykładzie. Jednak tym razem w 8 lini ustawiana jest wartość, jaka ma zostać zwrócona z mocka, który udaje funkcję.

Więcej przykładów

Więcej przykładów i możliwości można znaleźć na oficjalnej stronie.

Mockowanie modułu w JEST

W node.js powszechnie wykorzystywany jest mechanizm modułów, czy to zewnętrznych czy wewnętrznych. Dobra praktyka tworzenia testów jednostkowych mówi, że testowany fragment nie może być zależny od innych miejsc.

Zważywszy na tę zasadę musimy mieć wpływ na importowane moduły, aby móc wyzwalać różne zachowania.

Weźmy na tapet przykład związany z importowaniem natywnego modułu fs, który odpowiada za odczyt plików dyskowych.

import fs from 'fs';

export default function (filePath: string): number {
    if (fs.existsSync(filePath)) {
        const stat = fs.statSync(filePath);
        return stat.size;
    }

    return 0;
}

Powyższa funkcja ma za zadanie zwrócić rozmiar pliku w podanej lokalizacji. Pod warunkiem, że istnieje. Gdybyśmy nie stworzyli odpowiednich mocków na moduł fs to przetestowanie wszystkich wariantów funkcji wymagałoby stworzenie przykładowych plików na dysku i operowaniu na nich. Tego nie chcemy robić.

import fs from 'fs';
import getFileSize from './index';

jest.mock('fs');

describe('test module mock', () => {
    test('should return 0 when file is not exist', () => {
        // given
        fs.existsSync.mockReturnValue(false);

        // when
        const result = getFileSize('file/path');

        // then
        expect(result).toBe(0);
    });

    test('should return size when file exist', () => {
        // given
        fs.existsSync.mockReturnValue(true);

        const stats = new fs.Stats();
        stats.size = 10;
        fs.statSync.mockReturnValue(stats);

        // when
        const result = getFileSize('file/path');

        // then
        expect(result).toBe(10);
    });
});

W pierwszej linii zaimportowany moduł został oznaczony jako mock. Dzięki temu wszystkie metody modułu zostają opakowane w mechanizm do mockowania.

W pierwszym teście chcemy zweryfikować poprawne zachowanie funkcji kiedy wskazany plik nie istnieje. W 9 linii ustawiona została wartość false, która ma zostać zwrócona z metody existSync modułu fs. W rezultacie nasza funkcja powinna zwrócić wartość 0.

Drugi test testuje happy path, czyli zwrócenie rozmiaru wskazanego pliku. W 20 linii ponownie zostaje ustawiona wartość zwracana z metody existSync, tym razem jako true.  Następnie w liniach 22-24 tworzony jest wynik metody statSync. Oczekiwanym wynikiem w linii 30 jest ustawiona wartość w 23 linii.

Mockowanie klasy

W moich przykładach używam Typescript, jednak sposób mockowania klas jest taki sam w natywnym Javascript.

export default class Logger {
    log(message: string): void {
        console.log('original log', message);
    }

    warn(message: string): void {
        console.log('original warn', message);
    }
}
export default class Quux {
    private readonly logger: Logger;
    private size: number = 0;

    constructor(logger: Logger) {
        this.logger = logger;
    }

    fum(size: number): void {
        if (size >= 0) {
            this.size = size;
            this.logger.log(`Size is now ${size}`);
        } else {
            this.logger.warn('Size must be higher than 0!');
        }
    }
}

Zadeklarowano klasęQuux, która w konstruktorze przyjmuje instancję klasy Logger, a następnie podczas wywołania metody fum używane są operacje dostarczone w ramach obiektu Logger.

import Quux from './quux';
import Logger from './logger';

jest.mock('./logger');

describe('basic class mocks', () => {
    test('should log that new size is set', () => {
        // given
        const logger = new Logger();
        const quux = new Quux(logger);

        // when
        quux.fum(2);

        // then
        expect(logger.log).toHaveBeenCalled();
    });
    
    test('should warn when size is less than 0', () => {
        // given
        const logger = new Logger();
        const quux = new Quux(logger);

        // when
        quux.fum(-2);

        // then
        expect(logger.warn).toHaveBeenCalled();
     }); 
});

Obie klasy normalnie są importowane do pliku testowego, jednak druga z nich – Logger w 4 lini oznaczana jest jako mock podobnie jak w poprzednich przykładach o modułach.

Kolejne wykorzystanie jest już jest do przewidzenia – w utworzonych instancjach klasy Logger w liniach 9 czy 21 wszystkie metody posiadają zachowania mocka. W liniach 16 i 28 można zaobserwować nasłuchiwanie czy odpowiednie metody zostały wywołane.

Manualne mocki

Kiedy pierwszy raz zauważyłem zaproponowane rozwiązanie jakim jest „manual mocks” pierwsze co mi przyszło do głowy było „paaanie, a po co to komu?”. Po pewnym czasie jednak zauważyłem kilka miejsc gdzie tego typu rozwiązanie może pasować idealnie. O tym w sumie wspomina sama dokumentacja.

Rozwiązanie mocków manualnych idealnie sprawdzi się jeśli chcemy w jakiś sposób symulować bazę danych czy zewnętrznych providerów danych. 

Cały mechanizm polega na stworzeniu własnej implementacji funkcji/klasy na potrzeby mocków. Będzie to w pewnym sensie odzwierciedlenie oryginalnej implementacji, jednak sami definiujemy jak symulować zachowanie na zewnętrznych zasobach.

Cała zabawa polega na tym, żeby stworzyć katalog o nazwie __mocks__ na tym samym poziomie gdzie znajduje się normalna implementacja. Wewnątrz tego katalogu dodajemy manualne mocki o nazwach plików takich samych jak oryginalne. Nazwa __mocks__ jest case-sensitive, więc katalog musi być nazwany z małych liter!

Weźmy nasz przykład, który zaraz omówimy. Jego struktura mogłaby się prezentować następująco:

.
├── lib
│   ├── __mocks__
│   │   └── connector.ts
│   └── connector.ts
│   └── zot.ts

Przykład

Aplikacja komunikuje się z innym serwisem zewnętrznym, adapterem tej komunikacji jest Connector. Wydaje się to idealne miejsce żeby zasymulować jego działanie podczas testów.

import { IConnector, Response } from "../types";

export default class Connector implements IConnector {
    async getData(): Promise {
        return { title: 'Hello', content: 'from source of truth', downloads: 10 };
    }
}

Klasa Zot na podstawie zwróconych danych z serwisu zewnętrznego dokonuje pewnych akcji.

import {IConnector, Response} from "../types";

export default class Zot {
    private connector: IConnector;

    constructor(connector: IConnector) {
        this.connector = connector;
    }

    async fetch(): Promise {
        const data = await this.connector.getData();

        if (data.downloads <= 0) {
            throw new Error(`Value ${data.downloads} for downloads field is invalid`);
        }

        return data;
    }
}

Dwie poprzednie klasy stanowiły implementację podstawową, czas przejść do stworzenia manualnego mocka.

import {MockedClass, IConnector, Response} from "../../types";

export default class Connector implements MockedClass, IConnector {
    private fakeResponse: Response;

    constructor() {
        this.fakeResponse = { title: 'Fake response', content: 'Fake content', downloads: 0 };
    }

    public __returns(data: Response): void {
        this.fakeResponse = data;
    }

    async getData(): Promise<Response> {
        return this.fakeResponse;
    }
}

Powyżej stworzony manualny mock dla klasy Connector przypomina oryginalną klasę. Musi implementować te same interfejsy aby zapewnić konsekwencję. Została dołożona metoda __returns, którą można porównać do mockReturnValue, pojawiająca się przy tworzeniu standardowego mocka. Dzięki naszej metodzie możemy zaimplementować co ma zostać zwrócone, albo zdefiniować inne zachowania.

Nazwa __returns w tym wypadku nie jest w żaden sposób narzucona, można ją nazywać dowolnie, można też dodać inne metody, które mogą robić to co potrzebujemy w danym momencie do sterowania mockiem. Warto jednak zostać przy notacji podwójnego podkreślenia jako prefix nazwy tej metody.

import Zot from "./zot";

jest.mock('./connector');
import Connector from './connector';

describe('manual mocks', () => {
    let connector: Connector;

    beforeEach(() => {
        connector = new Connector();
    });

    test('should throw error when downloads is less than 0', async () => {
        // given
        connector.__returns({ title: 'Invalid response', content: 'Content', downloads: -5 });
        const zot = new Zot(connector);

        // then
        await expect(zot.fetch()).rejects.toThrowError('Value -5 for downloads field is invalid');
    });

    test('should return whole data when downloads field is above 0', async () => {
        // given
        const data = { title: 'Valid response', content: 'Content', downloads: 5 };

        connector.__returns(data);
        const zot = new Zot(connector);

        // when
        const result = await zot.fetch();

        // then
       expect(result).toBe(data);
    });
});

W kwestii testowania tej funkcjonalności otrzymujemy już całkiem znany z poprzednich przykładów sposób uruchomienia mocków. W 3 linii oznaczamy Connector jako mock i teraz biblioteka JEST pod spodem już znajdzie odpowiedni manual mock

W 15 i 26 linii widać wykorzystanie zaimplementowanej metody __returns jako wskazanie tego co ma zwrócić metoda getData klasy Connector.

Po co to komu?

Jak mówiłem we wstępie tego podrozdziału, początkowo zastanawiałem się  po co komu taki mechanizm. Po co inwestować czas w pisaniu swoich mocków zamiast używać gotowych funkcjonalności, które daje biblioteka? Wszystko to co zrobimy w manualnym mocku można zastąpić tradycyjnym podejściem.

Odpowiedź na to wszystko jest tylko jedna – zachowanie czystości w testach. Kiedy dochodzi do testowania takich funkcjonalności jak wspomniana wyżej integracja z zewnętrznymi serwisami często pliki testów zawierają więcej definicji mocków i określania ich zachowań niż samych testów. W takich sytuacjach czytelność tych testów spada dramatyczne.

Podsumowanie

W tym wpisie przedstawiłem kilka typów mocków udostępnionych w bibliotece JEST oraz jak stosować w różnych sytuacji. Jak zawsze twórcy tego narzędzia stawiają na prostotę i szybkość wykorzystania upraszczając pewne kwestie. Zachęcam jeszcze do spojrzenia do oficjalnej dokumentacji.

Poniżej przypomnienie najważniejszych kwestii

  • mocki automatycznie udostępniają metody typu spy, które pozwalają nasłuchiwać co wydarzyło się z danym mockiem (np. ile razy został  wywołany i z jakimi parametrami)
  • mocki udostępniają możliwości do zmiany zachowań funkcji lub wskazania co powinny zwracać
  • mockowanie funkcji możliwe jest dzięki wywołaniu metody jest.fn()
  • klasy czy moduły mockowane są w podobny sposób, na początku testów należy oznaczyć importowany plik jako jest.mock(filePath) po czym automatycznie zostaje opakowany jako mock
  • manualne mocki mogą pomóc uprosić testy i zwiększyć ich czytelność, bardzo dobry moment na ich użycie to wszelkie adaptery i connectory

A jakie jest twoje doświadczenie z mockami w tej bibliotece? Masz inne podejście do którejś kwestii? Daj znać w komentarzu!

 

NodeStart - Twórz back-end w JavaScript / TypeScript
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Ę