Blog

18 grudnia 2015 Wojciech Zawistowski

Izolacja testów: Dependency Injection kontra stubbing konstruktorów

Izolacja testów: Dependency Injection kontra stubbing konstruktorów

By w pełni odizolować testowaną jednostkę kodu, musimy być w stanie zestubbować jej zależności. W wielu językach programowania, stubbing zakodowanych na sztywno wywołań konstruktorów jest niemożliwy. Standardowym rozwiązaniem tego problemu jest wzorzec Dependency Injection – pozwala nam on wyciągnąć zależności do parametrów, przekazywanych do testowanej jednostki kodu z zewnątrz, co z kolei umożliwia nam podmianę tych parametrów na stuby.

Jednakże, w pewnych językach, z powodu ich dynamicznej natury, stubbing zakodowanych na sztywno wywołań konstruktorów JEST możliwy (np. w językach JavaScript albo Ruby). Czy w takim razie ma sens stosowanie w tych językach Dependency Injection? Czy jest to tylko niepotrzebna komplikacja?

Przykład Dependency Injection

// Zachowanie klasy RandomNumber jest niedeterministyczne - trzeba zastąpić ją stubem by umożliwić testowanie.
function RandomNumber() {
var min = 1;
var max = 10;

this.generate = function() {
return min + Math.floor(Math.random() * max);
}
};

// Przekazujemy RandomNumber jawnie, jako parametr w konstruktorze klasy FortuneCookie.
function FortuneCookie(randomNumber) {
this.prophecy = function() {
// Klasa FortuneCookie nie ma świadomości tego, jak utworzyć instancję RandomNumber - używa po prostu przekazanej jej w konstruktorze instancji.
return "Your lucky number is: " + randomNumber.generate();
}
};

describe("FortuneCookie", function() {
it("gives a prophecy with a random lucky number", function() {
// Tworzymy fałszywy substytut klasy RandomNumber...
var randomNumberStub = {
generate: function() { return 5; }
};

// ... i przekazujemy fałszywy substytut do konstruktora FortuneCookie, aby klasa FortuneCookie mogła być testowana w deterministyczny sposób.
var fortuneCookie = new FortuneCookie(randomNumberStub);

expect(fortuneCookie.prophecy()).toEqual("Your lucky number is: 5");
});
});

Przykład stubbingu konstruktorów

// Zachowanie RandomNumber jest niedeterministyczne - trzeba użyć stuba by umożliwić testowanie.
function RandomNumber() {
var min = 1;
var max = 10;

this.generate = function() {
return min + Math.floor(Math.random() * max);
}
};

// Konstruktor FortuneCookie nie przyjmuje żadnych parametrów.
function FortuneCookie() {
this.prophecy = function() {
// Klasa FortuneCookie odpowiada za utworzenie instancji klasy RandomNumber.
var randomNumber = new RandomNumber();
return "Your lucky number is: " + randomNumber.generate();
}
};

describe("FortuneCookie", function() {
var originalRandomNumberConstructor = RandomNumber;

it("gives a prophecy with a random lucky number", function() {
// Zamiast tworzyć fałszywy substytut klasy RandomNumber, podmieniamy na chwilę implementację prawdziwej klasy RandomNumber ...
RandomNumber = function() {
this.generate = function() { return 5; };
};

// ... w efekcie tego, klasa FortuneCookie tworzy i używa instancji klasy RandomNumber o sfabrykowanej przez nas implementacji, nawet o tym nie wiedząc - i dzięki temu może być testowana w deterministyczny sposób.
var fortuneCookie = new FortuneCookie();

expect(fortuneCookie.prophecy()).toEqual("Your lucky number is: 5");
});

afterEach(function() {
RandomNumber = originalRandomNumberConstructor;
});
});

Zalety i wady

złożoność

Oba przykłady wyglądają niemal identycznie pod względem złożoności, jest jednak jeden element niewidoczny na powyższych przykładach: w przypadku Dependency Injection musisz w jakiś sposób wstrzyknąć zależności w Twoim produkcyjnym kodzie. Wymaga to albo użycia frameworka Dependency Injection, albo wstrzykiwania wszystkich zależności ręcznie, w jakimś wysoko-poziomowym pliku typu “bootstrap”. Tak czy inaczej, jest to bardziej skomplikowane niż w przypadku stubbowania konstruktorów – tak więc stubbowanie konstruktorów zdobywa w tej kategorii punkt.

zdolność do testowania kodu “legacy”

Kolejną przewagą stubbowania konstruktorów nad Dependency Injection jest to, że można je stosować… bez Dependency Injection. Jeśli kod legacy nie został napisany z myślą o Dependency Injection, odpowiednie dopasowanie go oznacza poważny, ryzykowny refactoring – podczas gdy stubbowanie konstruktorów jest w takim przypadku nadal możliwe, nawet dla kodu typu “big ball of mud”. Kolejny punkt dla stubbowania konstruktorów.

jawne API

Gdy przekazujesz zależność jawnie, jako parametr, staje się ona częścią publicznego API testowanej jednostki kodu. W językach dynamicznych to API nie jest tak dobrze zdefiniowane jak w językach typowanych statycznie (widzisz jedynie, że jest wymagany jakiś parametr, ale nie znasz jego typu, nie wiesz jakie powinien mieć metody itp.), jednak wciąż jest to jakaś informacja. Gdy polegasz na stubbowaniu konstruktorów, nie dostajesz żadnej informacji – nawet o tym, że w ogóle istnieje jakaś zależność. To czyni testy trudniejszymi do zrozumienia, nie jest bowiem jasne, bez wgryzania się głęboko w implementację testowanej jednostki kodu, co test stubbuje i dlaczego. Punkt za czytelność trafia więc do Dependency Injection.

kruchość

To wiąże się z jawnym API. W przypadku stubbowania konstruktorów, testy są powiązane ze szczegółami implementacji testowanej jednostki kodu. Implementacja jest znacznie bardziej podatna na zmiany niż sygnatura konstruktora. Dodatkowo, gdy modyfikujesz implementację, trudno ocenić wpływ, jaki ta zmiana będzie miała na testy – gdy zmieniasz sygnaturę konstruktora, ta relacja jest oczywista. W rezultacie, stubbowanie konstruktorów owocuje bardziej kruchymi testami – kolejny punkt trafia do Dependency Injection.

Wnioski

Wyniki mogą się na pierwszy rzut oka wydawać remisowe (2:2). Jednak korzyści obu podejść nie są tego samego “ciężaru”. Kruche i nieczytelne testy są znacznie poważniejszym problemem niż bardziej opasły bootstrap aplikacji. Dependency Injection jest wyraźnym zwycięzcą – i zapewnia wiele korzyści nawet w językach dynamicznych.

Jedyną uzasadnioną korzyścią stubbowania konstruktorów jest to, że umożliwia ono testowanie kodu “legacy”. Jednakże, powinieneś traktować takie testy jako tymczasowe testy charakteryzacyjne, które pozwolą Ci bezpiecznie zrefaktoryzować kod do czystszej, opartej na Dependency Injection struktury – pozostawienie ich zbyt długo sprawi, że Twoja suita testowa będzie trudna w utrzymaniu.

A co Ty o tym sądzisz?

Jakie są Twoje doświadczenia? Dostrzegasz jakieś inne korzyści albo wady stubbowania konstruktorów lub Dependency Injection? Którą metodę wolisz? Chętnie poznałbym Twoje zdanie!

Zobacz na blogu

09.09.2022
Marcin Jahn
It’s Not Just HTTP It’s Not Just HTTP

In today’s world of cloud-based solutions, distributed systems, and microservices-based architectures, network communication is a...

23.08.2022
Adam Mrowiec
Konferencja IPC 2022 Berlin Konferencja IPC 2022 Berlin

Pandemia wreszcie się kończy, dlatego w tym roku postanowiliśmy wrócić do naszych wyjazdów na konferencje....