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

Mockowanie zależności w JavaScripcie jest łatwo osiągalne dzięki odpowiednim paczkom npm, jednak ich działanie jest w pewien sposób ograniczone. Wymusza ono konkretne działania: zamockowanie modułu, wywołanie kodu wymagającego mocka, wyczyszczenie pamięci podręcznej stosowanej przez funkcję require(). Jest to mało eleganckie i podatne na pomyłki. A jak to wygląda w TypeScripcie?

Uwaga: jeśli nie miałeś okazji zapoznać się z poprzednim wpisem dotyczącym mockowania w JavaScripcie, stanowiącym niejako przedmowę do niniejszego artykułu, polecam zacząć lekturę od niego.

TypeScript ma tę fajną cechę, że dostosowuje się do opracowywanych standardów języka. EcmaScript 2015 wprowadził ładowanie modułów zbliżone do tego znanego z Pythona (instrukcja import), a TypeScript dostosował się do tego. Ma to ogromną zaletę: transpilacja TypeScriptu do ES6 w minimalnym stopniu modyfikuje napisany przez nas kod. Pomimo, że zgodne z konwencją CommonJS ładowanie zależności za pomocą funkcji require() jest cały czas wspierane, to jednak bardziej deklaratywne importy staną się kiedyś standardem, a już dziś są np. domyślnie faworyzowane przez TSLint.

To jednak rodzi problem. Jeśli zaimportujemy testowane moduły w pierwszych linijkach pliku zawierającego testy jednostkowe, możemy zapomnieć o możliwości mockowania w sposób opisany w poprzednim artykule. Przypuszczalnie dokładnie ten sam problem dotyczył będzie kodu ES6, gdy ten zacznie być natywnie wspierany i wykonywany bez transpilacji do ES5.

Na tę trudność natknąłem się przepisując na TypeScript pewien większy kawałek JavaScriptu. O ile sam kod produkcyjny dawało się przepisać bez większych zmian, o tyle testy jednostkowe stanowiły poważną barierę. Brak możliwości łatwego mockowania oznaczał niekompletne testy, a to z kolei podważało zasadność przejścia z JS na TS. Po co mi silne typowanie, skoro pokrycie kodu testami spadnie?

Pierwszym impulsem było pisanie testów w JavaScripcie. Można takie podejście zastosować i na pewno umożliwi ono stosowanie znanych z JavaScriptu sztuczek. Innym, w sumie zbliżonym sposobem mogłoby być stosowanie require() w testach jednostkowych w celu zapewnienia mockowalności i zwyczajne wyłączenie lintera, żeby nie drażnił swoimi ostrzeżeniami. Nie jestem jednak fanem takich rozwiązań.

Na niedawnym spotkaniu meet.js w Gdańsku była prelekcja o TypeScripcie. Zadałem pytanie o problem mockowania i prelegent zaskoczył mnie rozwiązaniem, które określił jako półśrodek, ale które moim zdaniem jest (przynajmniej w większości sytuacji) bardzo dobrym podejściem. Wymaga ono jednak znacznie więcej wysiłku niż zastąpienie zapisanego w pamięci podręcznej modułu jego mockiem i przywróceniu stanu poprzedniego po zakończonym teście. Podejście to wymaga przestawienia się na inny paradygmat programowania.

Chodzi mianowicie o przeorientowanie swojego kodu na bardziej obiektowy i tworzenie klas z konstruktorami przyjmującymi zależności z zewnątrz. Oczywistą zaletą takiego rozwiązania jest jego testowalność — w teście możemy zainstancjonować każdy testowany obiekt, przekazując mu zupełnie dowolne zależności, prawdziwe, czy też zamockowane. Weźmy zatem przykład z wpisu na temat mockowania w JavaScripcie. Po przepisaniu na TypeScript, jego kod mógłby wyglądać następująco:

import * as db from "./Db";

export let getUsers = (): Array<{}> => {
    let results = db.getCollection("users").find();
    return results.map(item => {
        return {
            id: item._id,
            username: item.username,
            email: item.email
        };
    });
};

Powyższy kod jest prawidłowym TypeScriptem, ale jego testowalność jest mocno dyskusyjna. Pierwsza linijka, a więc import, musi nam pobrać mock, a na razie nie mamy innej opcji niż uprzednie poinformowanie funkcji require() o tym, co ma zwrócić. Zastanówmy się zatem, co można zrobić z powyższym kodem, aby napisać go w sposób przyjaźniejszy testom jednostkowym.

Przede wszystkim ubierzmy eksportowaną funkcję w klasę. W opisywanym przypadku prawdopodobnie będzie to jakieś repozytorium:

import * as db from "./Db";

export class UserRepository {
    public getUsers(): Array<{}> {
        let results = db.getCollection("users").find();
        return results.map(item => {
            return {
                id: item._id,
                username: item.username,
                email: item.email
            };
        });
    }
}

Wygląda to nieźle, jednak cały czas mamy w pierwszej linii import, który spowoduje połączenie się z bazą danych. Co zatem dalej? Spróbujmy przekazać zależność jako argument konstruktora:

import {Db} from "./Db";

export class UserRepository {
    private db: Db;

    constructor(db: Db) {
        this.db = db;
    }

    public getUsers(): Array<{}> {
        let results = this.db.getCollection("users").find();
        return results.map(item => {
            return {
                id: item._id,
                username: item.username,
                email: item.email
            };
        });
    }
}

Mamy tutaj pewną wyraźną zmianę: zamiast importowania modułu ./Db w całości i przypisania go do zmiennej db, importujemy tu klasę (nie jej instancję!) bądź interfejs. Ciągnie to za sobą oczywiście przepisanie kodu naszej zależności tak, by eksportowana była właśnie klasa lub interfejs. Do czego jest nam to potrzebne? Na pierwszy rzut oka tylko do oznaczenia typu prywatnej zmiennej db oraz argumentu konstruktora. Ale w rzeczywistości to pociąga za sobą bardzo ważną zmianę.

Przepisanie zależności tak, by nie otwierała połączenia z bazą danych sama z siebie, a raczej np. w konstruktorze eksportowanej klasy, sprawia, że możemy bez przeszkód zostawić w pierwszej linii naszego modułu import i w żaden sposób nie zaszkodzi on nam w teście jednostkowym. Oczywiście w kodzie produkcyjnym gdzieś będzie trzeba stworzyć instancję obiektu typu Db celem wstrzyknięcia jej do repozytorium, ale w teście jednostkowym możemy do konstruktora przekazać co nam się żywnie spodoba:

import {UserRepository} from "./UserRepository";

describe("UserRepository", () => {
    it("gets users", () => {
        // given
        let response = [{
            _id: "foo",
            username: "bar",
            email: "qux"
        }];
        let dbMock: any = {
            getCollection: (name) => {
                return this;
            },
            find: () => {
                return response;
            }
        };
        let userRepository = new UserRepository(dbMock);

        // when
        let users = userRepository.getUsers();

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

Zwróćmy uwagę, że pomimo, że TypeScript sprawdza typy danych w trakcie transpilacji, nie będzie narzekał na to, że do konstruktora repozytorium przekazujemy mizerną podróbkę, nawet nieprzypominającą klasy (interfejsu) Db. Możliwe jest to dzięki użyciu typu any.

Możemy przesunąć tworzenie mocków i instancjonowanie testowanego obiektu do bloku beforeEach, żeby nie powtarzać tego samego fragmentu wielokrotnie, jednak nic nie stoi na przeszkodzie, by instancjonować repozytorium oddzielnie w każdym teście. Nie jest już wymagane czyszczenie pamięci podręcznej require() (bo i nie ma z czego, skoro nie zastąpiliśmy żadnego modułu mockiem), więc nie natkniemy się na żadne przeszkody w stylu zwracania przez zamockowaną zależność wartości zdefiniowanej w poprzednim teście.

Zmiana paradygmatu programowania pociąga za sobą oczywiście dość głębokie zmiany i nowe dylematy. Ile instancji repozytorium dopuścimy? Jedną? Wiele? Ile instancji klasy Db pozwolimy stworzyć (weźmy pod uwagę fakt, że połączenie z bazą danych nadal chcemy mieć tylko jedno)? Wreszcie, do której części kodu będzie należała odpowiedzialność za tworzenie tych obiektów? Czy będą wywoływane na bieżąco, czy użyjemy jakiegoś kontenera wstrzykiwania zależności, fabryki, czy może znajdziemy jeszcze inne rozwiązanie? Te pytania wykraczają jednak poza zakres niniejszego wpisu.

Na koniec chciałbym jednak wysnuć pewien morał dla JavaScriptu. TypeScript jest kompilowany do JavaScriptu, więc wszystko, co zrobimy w jednym, da się też zrobić w drugim. Oryginalny kod testowanej funkcji również można przepisać w JS na konstruktor obiektu przyjmującego zależność z zewnątrz. Stosując takie podejście możemy wyeliminować konieczność mało eleganckiego mockowania zależności za pomocą modułów takich jak mock-require:

var UserRepository = function(db) {
    this.db = db;
};

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

module.exports.UserRepository = UserRepository;

W powyższym kodzie w ogóle pozbyliśmy się jakichkolwiek zależności dzięki wstrzykiwaniu ich z zewnątrz. Prawda, że od razu łatwiej to przetestować?


Napisz komentarz


Szukaj wpisów


Chmura tagów