Testowanie nietestowalnego kodu

Stack Overflow to skarbnica ciekawych pytań i zagadnień, które można rozwinąć w pełnoprawne wpisy na blogu. Niedawno znalazłem właśnie takie pytanie, a jego sens można streścić w następujący sposób: jak napisać test jednostkowy funkcji, która sama instancjonuje zależność, ale jej nie zwraca?

Dla uproszczenia ujmijmy pytanie nieco inaczej: jak przepisać poniższy kod tak, żeby był testowalny?

makeGreeter.js:

const Dependency = require("dependency");

const makeGreeter = function(name) {
    const greeting = `Hello, ${name}!`;
    const dependency = new Dependency(greeting);

    return {
        greet() {
            console.log(dependency.sayHello());
        }
    };
};

module.exports = makeGreeter;

Oryginalne pytanie wraz z moją odpowiedzią znaleźć można tutaj. Oczywiście mój przykład powyżej jest nieco uproszczony. Poniższy wpis bazuje na udzielonej przeze mnie odpowiedzi.

Pytanie, jakie musimy sobie zadać, jest następujące: co właściwie chcemy przetestować? Jakie zachowanie jest dla nas kluczowe? Czy zależy nam na poprawnym zbudowaniu wartości greeting? Czy na fakcie przekazania jej do konstruktora Dependency? A może na samym efekcie metody greet, czyli wylogowaniu odpowiedniego napisu?

1. Poprawne budowanie wartości greeting

Ten przypadek jest prosty, ale zanim przejdę do konkretów, dwa zdania na temat teorii. Zadajmy sobie pytanie: co widzi test jednostkowy? Co jest dla niego dostępne? Odpowiedź jest prosta: dla testu jednostkowego dostępne jest publiczne API. A więc sygnatura metody: jej nazwa, przyjmowane argumenty i zwracana wartość (w przypadku języków silnie typowanych znane są również typy tych wartości, ewentualnie też informacja o rzucanych wyjątkach). Na prostym przykładzie, oto, co widzi test:

const input = "foo";
const expectedOutput = "bar"
const output = testedUnit(input);
expect(output).to.equal(expectedOutput);

A więc raz jeszcze: znamy dane wejściowe, znamy nazwę metody, znamy dane wyjściowe. I tylko takie dane możemy prównać z danymi oczekiwanymi. Co się działo w tym czasie wewnątrz metody, nie jest już dla nas dostępne i nie powinno nas interesować (z grubsza rzecz biorąc).

No dobrze, ale co zrobić, jeśli logika budowania wartości greeting jest dla nas ważna i po prostu musimy mieć pewność, że działa poprawnie? W jaki sposób uwidocznić tę logikę dla testu jednostkowego?

Odpowiedź jest prosta: wyciągając interesującą nas część kodu do oddzielnego modułu (w przypadku korzystania z klas, mogłaby to być oddzielna metoda).

prepareGreeting.spec.js:

const prepareGreeting = require("./prepareGreeting");

describe("prepareGreeting", () => {
    it("should correctly build the greeting", () => {
        // given
        const input = "joe";
        const expectedOutput = "Hello, joe!";

        // when
        const output = prepareGreeting(input);

        // then
        expect(output).to.equal(expectedOutput);
    });
});

prepareGreeting.js:

const prepareGreeting = function(name) {
    return `Hello, ${name}!`;
};

module.exports = prepareGreeting ;

makeGreeter.js:

const Dependency = require("dependency");
const prepareGreeting = require("./prepareGreeting");

const makeGreeter = function(name) {
    const greeting = prepareGreeting(name);
    const dependency = new Dependency(greeting);

    return {
        greet() {
            console.log(dependency.sayHello());
        }
    };
};

module.exports = makeGreeter;

W ten sposób możemy dokładnie przetestować potencjalnie nietrywialną logikę. Dodatkowym atutem takiego podejścia jest to, że makeGreeter robi o jedną rzecz mniej: buduje obiekt Dependency, ale oddelegowuje nietrywialną logikę budowania wartości greeting do innej, wyspecjalizowanej funkcji.

2. Przekazywanie poprawnej wartości do konstruktora Dependency

Jeśli najważniejsze dla nas jest wywołanie konstruktora Dependency z konkretnym argumentem, a nie sam sposób budowania tego argumentu, mamy troszkę większy problem. Użycie new wewnątrz kodu produkcyjnego utrudnia mockowanie. Do głowy przychodzą mi dwa wyjścia: globalne zamockowanie Dependency albo stworzenie własnej, łatwiej testowalnej zależności.

Na marginesie dodam też, że moim zdaniem testowanie takiego scenariusza jest najmniej prawdopodobne. Bo po co testować wejście konstruktora, skoro jest możliwość przetestowania budowania dostarczanego mu argumentu? Takie podejście ma też niewiele sensu ze względu na niepotrzebne skupianie się na szczegółach implementacyjnych kodu. Poniższe podpunkty zawieram gwoli kompletności wpisu, jednak odradzam ich stosowanie.

2.1. Globalne mockowanie zależności

Pierwsza zasada globalnego mockowania zależności w Node.js powinna brzmieć „nie mockujemy zależności globalnie”. To niesie ze sobą konsekwencje, które nie zawsze są dla nas korzystne, a najgorszą z nich jest moim zdaniem zachęta do pisania kiepskiego kodu.

Do rzeczy jednak. Mockowanie globalne polega na poinstruowaniu funkcji require, że ma zwrócić podmieniony obiekt. Od razu trzeba zaznaczyć, że po wykonaniu testu warto przywrócić oryginalny stan pamięci podręcznej require, gdyż oryginalnie zwracany obiekt może być potrzebny w pozostałych testach. Trzeba też bezwzględnie pamiętać, że najpierw mockujemy, a dopiero później wywołujemy require. Samo mockowanie wykorzystuje jeden z dostępnych modułów, np. mock-require.

makeGreeter.spec.js:

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

describe("makeGreeter", () => {
    it("should pass correct data to Dependency ctor", () => {
        // given
        const input = "joe";
        const expectedCtorInput = "Hello, joe!";
        let actualCtorInput;
        mock("Dependency", function(greeting) {
            actualCtorInput = greeting;
            this.sayHello = function() {
                return greeting;
            };
        };
        const makeGreeter = require("./makeGreeter");

        // when
        makeGreeting(input);

        // then
        expect(actualCtorInput).to.equal(expectedCtorInput);
        mock.stopAll();
    });
});

Dlaczego require("./makeGreeter") zawarłem w samym teście i dlaczego na końcu testu wołam mock.stopAll()? O tym można poczytać w tym wpisie, nieco bliżej analizującym temat globalnego mockowania.

Pamiętajmy jednak, że jest tu pewien fundamentalny problem. Test jednostkowy nie widzi zależności wewnątrz testowanej funkcji. Nadal dostępna jest nazwa funkcji, jej wejście i jej wyjście. Nic poza tym. Podkreślam zatem raz jeszcze: testujemy tu część wewnętrznej logiki, szczegół implementacyjny niewidoczny z zewnątrz. Gdyby któregoś dnia moduł Dependency stał się zdeprecjonowany i zastąpiłby go inny, o nazwie BetterDependency, za którego pomocą (choć niekoniecznie w ten sam sposób) można osiągnąć ten sam wynik końcowy, dlaczego mielibyśmy zmieniać też testy jednostkowe? Szczegóły implementacyjne nas nie powinny interesować!

Czy już wszystkich zniechęciłem? Mam nadzieję, że tak. Przyjrzyjmy się zatem drugiej, odrobinę lepszej, choć nadal słabej metodzie.

2.2. Owinięcie zależności i jej wstrzyknięcie

Do naszej zależności możemy napisać tzw. wrapper, który sprawi, że nie będziemy w kodzie makeGreeter instancjonowali zależności. Kod takiego wrappera będzie niezmiernie prosty:

makeDependency.js:

const Dependency = require("dependency");

const makeDependency = function(greeting) {
    return new Dependency(greeting);
};

module.exports = makeDependency;

W ten sposób w testowanej funkcji nie będziemy już mieli wywołania new. Pozostaje nam tylko wstrzyknąć nasz wrapper tak, by móc go spokojnie mockować w testach jednostkowych.

makeGreeter.spec.js:

const makeGreeter = require("./makeGreeter");

describe("makeGreeter", () => {
    it("should pass greeting to dependency", () => {
        // given
        const input = "joe";
        const expectedGreeting = "Hello, joe!";
        let actualGreeting;
        const makeDependency = function(greeting) {
            actualGreeting = greeting;
            this.sayHello = function() {
                return greeting;
            };
        };

        // when
        makeGreeter(makeDependency, input);

        // then
        expect(actualGreeting).to.equal(expectedGreeting);
    });
});

makeGreeter.js:

const makeGreeter = function(makeDependency, name) {
    const greeting = `Hello, ${name}!`;
    const dependency = makeDependency(greeting);

    return {
        greet() {
            console.log(dependency.sayHello());
        }
    };
};

module.exports = makeGreeter;

Wygląda to znacznie czyściej. Pozbyliśmy się z testowanego kodu zewnętrznej zależności i ukrytego wywołania new, a w zamian możemy swobodnie mockować interesującą nas logikę. Nadal musimy zadbać jednak o metodę sayHello, która jest szczegółem implementacyjnym. W razie zamiany Dependency na BetterDependency, może się zdarzyć, że ten sam efekt uzyskamy wywołaniem metody o innej nazwie. Będziemy wtedy musieli modyfikować test.

3. Testowanie efektu metody greet

W moim odczuciu prezentowane poniżej podejście jest rozwiązaniem problemu z punktu 2. Nie powinniśmy testować szczegółów implementacyjnych, a oprzeć się na zasadzie, którą przywołam po raz kolejny: test jednostkowy zna nazwę funkcji, jej wejście i jej wyjście. Nie interesuje nas, co dzieje się w jej wnętrzu.

Problemem jednak jest tutaj fakt, że makeGreeter nie zwraca żadnej wartości, a jedynie wywołuje efekt uboczny w postaci wylogowania napisu na konsoli.

Oczywiście moglibyśmy stworzyć wrapper dla samej konsoli, przekazać go do testowanej funkcji i sprawdzić, czy karmimy go prawidłowymi danymi. Nadal jednak pozostaje pewien problem: funkcja robi więcej niż jedną rzecz. Buduje pozdrowienie (już nie interesuje nas, że w tym procesie wykorzytywana jest jakaś zależność), a następnie umożliwia wysłanie go do konsoli. To dwie czynności. A co gdyby metoda greet zwracała wartość, a logowanie wyrzucić na zewnątrz?

makeGreeter.spec.js:

const makeGreeter = require("./makeGreeter");

describe("makeGreeter", () => {
    it("should return correct greeting", () => {
        // given
        const input = "joe";
        const expectedGreeting = "<strong>Hello, joe!</strong>";
        const greeter = makeGreeter(input);

        // when
        const actualGreeting = greeter.greet();

        // then
        expect(actualGreeting).to.equal(expectedGreeting);
    });
});

makeGreeter.js:

const Dependency = require("dependency");

const makeGreeter = function(name) {
    const greeting = `Hello, ${name}!`;
    const dependency = new Dependency(greeting);

    return {
        greet() {
            return dependency.sayHello();
        }
    };
};

module.exports = makeGreeter;

Teraz oczywiście musimy gdzieś użyć nieobecnego tu console.log(), jednak makeGreeter tym jednym prostym zabiegiem stał się testowalny: interesuje nas tylko wejście, wyjście i nazwa testowanej funkcji. Szczegóły implementacyjne są zupełnie nieważne.

Jeśli wewnętrzna logika jest zbyt skomplikowana, nie ma problemu z zastosowaniem tego podejścia w połączeniu z tym z punktu 1. Tak czy inaczej efekt jest zadowalający: udało się tak zmodyfikować nietestowalny kod, aby przetestowanie go stało się dziecinnie proste. Nie bez znaczenia jest też fakt, że wymagany nakład pracy był niemal zerowy.


Napisz komentarz


Szukaj wpisów


Chmura tagów