Lepsze testy jednostkowe w TypeScript

Pracując kiedyś nad projektem tworzonym w TypeScript zauważyłem, że tworzone przez nas testy jednostkowe nie wyczerpują pełni potencjału technologii, z której korzystamy. Jak wiadomo, TypeScript kompilowany jest do standardowego JavaScript i w tym samym języku powstawały owe testy – testujące efekty końcowe takiej kompilacji. (czy dokładniej z ang. transpile, czyli kompilacja z jednego kodu źródłowego do innego)

Nie ma w takim podejściu oczywiście nic złego i to rzeczywiście działa. Jednak skoro TypeScript służy do wyciskania z JavaScript „więcej”, to nie widzę powodu by takie możliwości zostały pominięte w testach, czyli integralnej części każdego porządnego kodu źródłowego.

Po krótkich poszukiwaniach okazało się, że problem jest już rozwiązany i pozostaje tylko podążać sprawdzonym sposobem. Otóż okazuje się, że można sprawić by framework testujący rozumiał TypeScript, nie ma więc w takim wypadku potrzeby dodatkowej kompilacji przed uruchomieniem zestawu testów.

Najwygodniej będzie mi jak zwykle zobrazować to na konkretnym przykładzie. Załóżmy, że dostaliśmy zadanie napisać klasę Shop, która symuluje pewien rodzaju sklepu. Mamy mieć możliwość określenia listy produktów wraz z cenami, dodawanie do koszyka i podliczanie sumy przedmiotów w koszyku. Nic specjalnego, ale widać tu konkretne funkcjonalności.

Zaczynamy od utworzenia folderu na projekt, a wewnątrz niego używając NPM (może być yarn lub nawet każdy inny) tworzymy nowy projekt.

npm init –force

npm i -D typescript mocha chai ts-node

npm i -D @types/chai @types/mocha

Polecenie init z ustawioną flagę force utworzy nowy plik package.json z domyślnymi ustawieniami, nazwą projektu taką samą jak folder, w którym działamy – wszystko bez zbędnych pytań jako, że to tylko ćwiczenie. Użyte przeze mnie moduły to:

  • typescript – ponieważ piszę w TypeScript, jednak nie będzie za bardzo przydatny.
  • mocha i chai, czyli moje ulubione narzędzia do testów w JS. Mocha to framework, który umożliwia bardzo zgrabne, opisowe tworzenie testów jednostkowych. Jeżeli nie znasz tego narzędzia, po dalszych przykładach zobaczysz o czym mówię. Chai jest natomiast ciekawym zestawem narzędzi do formułowania założeń i warunków testów.
  • ts-node to moduł, dzięki któremu mocha będzie w stanie rozumieć testy pisane w TypeScript, czyli wspomniane wyżej rozwiązanie projektu.
  • @types/chai oraz @types/mocha to zestaw definicji w TypeScript dla wymienionych framework’ów. W praktyce oznacza to na przykład, że Twój edytor tekstu (jeżeli wspiera TypeScript) będzie w stanie rozumieć składnię używanych modułów i przykładowo formułować podpowiedzi czy listy argumentów dla funkcji. Te dwa moduły są opcjonalne, jedynie dla wygody użytkowania.

Polecenie npm i -D zainstaluje wskazane moduły i zapisze je w sekcji devDependencies w pliku package.json.

Po zainstalowaniu potrzebnych narzędzi czas zabrać się za napisanie właściwego testu. Robię to jeszcze przed utworzeniem samego kodu klasy Shop, czyli tak jak przystało na szanującego się wyznawcę TDD (ang. test driven development).


describe('Given the Shop instance with few products defined', () => {
    var shop: Shop = new Shop([
        {id: 'apple', price: 1.50},
        {id: 'banana', price: 2.30}
    ]);

    describe('When purchasing three items of the same kind', () => {
        shop.addToBasket('apple', 1);
        shop.addToBasket('banana', 1);
        shop.addToBasket('apple', 2);

        it('Then the "3 for 2" discount is applied', () => {
            expect(shop.getTotal()).to.equal(5.30);
        })
    })
})

Podaję tutaj tylko jeden przykładowy test, który sprawdza główne założenie mojej klasy Shop, ponieważ wymyśliłem sobie, że za każde 3 kupione przedmioty tego samego typu dostajemy zniżkę na jeden z nich – chodzi tutaj głównie o przestrzeń na jakąś logikę programu.

Jakie wnioski widać na powyższym listingu?

  • Mocha daje możliwość opisywania (dosłownie z ang. describe) konkretnych przypadków
  • Chai pozwala dość opisowo zawierać asercje (expect x to equal y)
  • Choć w moim prostym przykładzie nie ma za dużo miejsca na wykorzystanie TypeScript to widać jednak, że wiemy dokładnie jakich typów są zmienne oraz wykorzystuję funkcje lambda, zamiast dłuższego słowa kluczowego function.
  • Całość służy również jako dokumentacja klasy, więc ile nudnej pracy z głowy. Dzięki opisom  i przykładowym danym wiemy bowiem dokładnie czego od kodu należy się spodziewać, zwłaszcza gdy dokładnie opiszemy wszystkie przypadki.

Pełniejszy zestaw testów, który stworzyłem dla tego problemu znajdziesz na moim githubie.

Następny krok to uruchomienie testu. Konfiguruję najpierw skrypt npm poprzez modyfikację domyślnego skryptu test w pliku package.json:

"scripts": {
   "test": "mocha -r ts-node/register src/**/*.test.ts || exit 0"
},
Mocha uruchamiamy normalnie, ale z tą różnicą, że parametr -r dostaje jako wartość ścieżkę do modułu ts-node. Stąd mocha będzie wiedzieć, że ma analizować TypeScript. Parametr -r oznacza dosłownie jaki moduł ma być użyty do odpalenia testu. Jeżeli nie ustawimy go oczywiście domyślnie framework spodziewa się JavaScript. Postanowiłem dodać jeszcze polecenie exit 0 w celu ukrycia wszelkich zbędnych, ewentualnych błędów w używanych modułach.
Oczywiście jako parametr do polecenia mocha podajemy wzór ścieżki, czyli pod uwagę brane będę wszystkie pliki z rozszerzeniem .test.ts. Pozwala to zignorować wszelkie pliki testami nie będące.
Testy będziemy więc odpalać następującym poleceniem z linii komend:

npm test

Na ten moment zbudowany test zakończy się niepowodzeniem, ponieważ nie powstał jeszcze sam testowany kod. Oznacza to idealny moment na jego utworzenie. Ważnym jest by spróbować napisać jak najwięcej testów przed zaczęciem właściwego programowania. Zazwyczaj pozostałe przypadki przychodzą do głowy dopiero później, więc dodajemy je na bieżąco.
Później możemy spodziewać się już tylko ładnego raportu z wykonanych testów. Jest zielono, znaczy jest dobrze!

Ja pozwoliłem sobie rozwiązać zadany problem w następujący sposób, ale zachęcam do próbowania własnych sił.
interface IBasketItem{
    id: string;
    price: number;
    amount?: number;
}

export default class Shop
{
    basket: Array<IBasketItem> = [];
    inventory: Array<IBasketItem> = [];

    constructor(inventory: Array<IBasketItem> = []){
        this.inventory = inventory;
        this.emptyBasket();
    }

    private findInInventory(itemId: string): IBasketItem{
        for(var i: number = 0; i<this.inventory.length; i++){
            if(this.inventory[i].id === itemId)
                return this.inventory[i];
        }

        return null;
    }

    private positionInBasket(itemId: string): number{
        for(var i: number = 0; i<this.basket.length; i++){
            if(this.basket[i].id === itemId)
                return i;
        }

        return -1;
    }

    addToBasket(itemId: string, amount: number = 1): void{
        var item = this.findInInventory(itemId);

        if(item !== null){
            var isAlreadyInTheBasket = this.positionInBasket(itemId);

            if(isAlreadyInTheBasket !== -1){
                this.basket[isAlreadyInTheBasket].amount += amount;
            }
            else{
                item.amount = amount;
                this.basket.push(item);
            }
        }
    }

    getTotal(): number{
        var total: number = 0;

        for(var i: number = 0; i<this.basket.length; i++){ // 3 for 2 discount logic var amount: number = this.basket[i].amount; if(amount > 2){
                amount -= Math.floor(amount / 3);
            }
            // end of: 3 for 2 discount logic

            total += amount * this.basket[i].price;
        }

        return total;
    }

    emptyBasket(): void{
        this.basket = [];
    }
}
Całość projektu znajdziesz tutaj.

Poniższy przykład obrazuje zalety korzystania z TypeScript w postaci analizy składni i podpowiedzi.

…i kolejny…

Opisany przeze mnie sposób na ulepszenie Twoich testów ma prawdopodobnie jedynie zalety, przy relatywnie niskim koszcie implementacji. Jeżeli zdecydujesz się zastosować to rozwiązanie w swoim projekcie, zachęcam do podzielenia się wnioskami lub napotkanymi problemami.