Mockowanie zależności w Node.js. JavaScript

Testy jednostkowe, jak wiadomo, powinny być uruchamiane w izolacji. Test nie może operować na nieprzewidywalnych danych ani zależeć od dostępności zewnętrznych serwisów, takich jak baza danych, czy mikroserwis w większej infrastrukturze. Wyjściem z sytuacji jest zwykle operowanie na mockach.

Na wstępie pragnę zaznaczyć, że nie będę się tu wypowiadał na temat mockowania elementów modułu, np. pojedynczych funkcji jakiegoś obiektu. Takie możliwości daje biblioteka Jasmine, czy popularny Sinon. Poniższy tekst natomiast dotyczy mockowania całych modułów, a więc tego, co jest zwracane przez funkcję require().

Zanim przejdę do meritum, chciałbym również wyjaśnić kwestię samych pojęć mock i mockowanie. Otóż obu pojęć używam w znaczeniu potocznym; mam tu na myśli zastąpienie prawdziwej zależności dowolnym obiektem, niezależnie od jego przeznaczenia i zawartej w nim logiki. Zainteresowanych tematem precyzyjnego nazewnictwa podmienionych na potrzeby testów (test doubles) obiektów odsyłam do wpisu Roberta C. Martina The Little Mocker.

W JavaScripcie mockowanie modułów stanowiących zależności testowanego modułu jest względnie proste. Do zainstalowania mamy wiele paczek, które pozwalają na zastąpienie modułów zwracanych przez funckję require() innymi. Jedną z takich paczek jest mock-require. Przywołuję akurat tę paczkę, bo sam jej używałem, a nawet w którymś tam momencie wysłałem do niej pull requesta (zaakceptowanego zresztą). Możliwości jest jednak znacznie więcej.

Przypuśćmy, że chcemy przetestować moduł, który zależy od otwartego połączenia z bazą danych:

var db = require("./Db");

var getUsers = function() {
    var results = db.getCollection("users").find();
    return results.map(function(item) {
        return {
            id: item._id,
            username: item.username,
            email: item.email
        };
    });
};

module.exports.getUsers = getUsers;

W teście jednostkowym, nasz moduł ma zostać przetestowany w izolacji — w przeciwnym razie nie jest to test jednostkowy. Zapewnienie izolacji daje nam możliwość podmienienia zależności naszego modułu. W teście jednostkowym nie możemy polegać na prawdziwej bazie danych, bo zwracane przez nią wartości są w pewnym stopniu niedeterministyczne (nigdy nie wiemy, czy nie został dopisany nowy dokument lub czy isteniejące nie uległy zmianie), podobnie jak niedeterministyczne jest samo połączenie (nie wiadomo, czy baza danych w ogóle w danej chwili będzie działać ani jak długo zajmie jej odpowiedź).

Dlaczego sytuacja jest kłopotliwa?

Otóż problem polega na tym, że z chwilą załadowania modułu getUsers, funkcja require() w jego pierwszej linii ładuje również zależność, która (w domyśle) otwiera połączenie z bazą danych. Nie możemy zatem ot tak sobie załadować modułu do przetestowania.

Spójrzmy na przykładowy kod testu jednostkowego:

describe("getUsers", function() {
    var getUsers = require("./getUsers").getUsers;
    it("gets users", function() {
        // given
        // when
        var users = getUsers();

        // then
        expect(users).toEqual([{
            id: "foo",
            username: "bar",
            email: "qux"
        }]);
    });
});

Powyższy kod nie ma szans zadziałać na prawdziwej bazie danych (wspomniane niedeterministyczne zachowanie). Musimy zadbać o to, by zależność testowanego modułu, nie tylko nie otworzyła niepożądanego połączenia z bazą, ale też zwróciła nam dokładnie taką wartość, jakiej oczekujemy.

Aby to osiągnąć, wykorzystajmy mock-require. W związku z faktem, że po skorzystaniu z mocka powinniśmy po sobie posprzątać i przywrócić możliwość załadowania prawdziwej zależności, będziemy musieli napisać troszeczkę więcej kodu.

var mock = require("mock-require");

describe("getUsers", function() {
    var getUsers;
    it("gets users", function() {
        // given
        var response = [{
            _id: "foo",
            username: "bar",
            email: "qux"
        }];
        mock("./Db", {
            getCollection: function(name) {
                return this;
            },
            find: function() {
                return response;
            }
        });
        getUsers = require("./getUsers").getUsers;

        // when
        var users = getUsers();

        // then
        expect(users).toEqual([{
            id: "foo",
            username: "bar",
            email: "qux"
        }]);
        mock.stopAll();
    });
});

Widzimy tutaj dwie zmiany. Po pierwsze, zanim załadujemy moduł getUsers, tworzymy zamockowany moduł ./Db (podajemy tu ścieżkę relatywną do aktualnego pliku, a nie testowanego modułu!), a dopiero później ładujemy testowany moduł. Druga zmiana znajduje się na końcu testu. Wywołanie mock.stopAll() usuwa wszystkie zdefiniowane mocki.

To jednak nie koniec zabawy. Załóżmy, że chcemy napisać drugi test, w którym nasza udawana baza danych zwróci inną wartość. Przyjęcie tego samego schematu w drugim teście może się skończyć długą sesją debugowania, baza danych bowiem nie zwróci wartości określonej w nowym mocku. Dzieje się tak dlatego, że pobrany moduł getUsers został już raz stworzony i zapisany w pamięci podręcznej.

Zatem napisanie kolejnych testów wymaga jeszcze zadbania o wyczyszczenie pamięci podręcznej funkcji require():

beforeEach(function() {
    Object.keys(require.cache).forEach(function(key) {
        delete require.cache[key];
    });
});

Powyższy fragment kodu najlepiej umieścić nie w samym bloku describe naszego zestawu testów, a poza nim, tak, by obowiązywał we wszystkich blokach describe za jednym zamachem. Jasmine umożliwia zdefiniowanie helperów — plików, które ładowane są przed każdym uruchomieniem testów jednostkowych. W pliku konfiguracyjnym jasmine.json wystarczy podać ścieżkę do naszych helperów:

{
    "spec_dir": "spec",
    "spec_files": [
        "**/*[sS]pec.js"
    ],
    "helpers": [
        "helpers/*.js"
    ]
}

Oczywiście mockowanie zależności również można wyekstrahować sobie gdzieś poza poszczególne testy, np. do bloków beforeEach w obrębie danego describe. Takie usprawnienia jednak zostawiam już autorom poszczególnych testów i indywidualnym wymaganiom względem testowanych modułów.

Polecam zapoznanie się z bardziej skoncentrowaną na TypeScripcie kontynuacją niniejszego artykułu, gdzie biorę pod lupę zupełnie inne podejście do mockowania.


Napisz komentarz


Szukaj wpisów


Chmura tagów