Testování v JS


Tahák

Tato stránka je tahákem pro testování v JavaScriptu s použitím nástroje jménem Vitest. Jedná se o testovací framework, který je hodně podobný populárnímu frameworku jménem Jest.

Proč psát testy

Vyhneme se nekonečnému manuálnímu testování.

Po každé změně se naše testy mohou automaticky spustit, a zkontrolovat, jestli se něco nerozbilo

Psaní testů nás nutí psát čistší kód, protože se pro něj potom lépe vytvářejí testy.

Typy testů

Existují různé typy testů. Je jich více, ale ty nejznámější jsou pravděpodobně Unit testy, Integration testy a End-to-end testy.

Unit testy

Testování individuálních stavebních bloků aplikace. Každý stavební blok (unit) je testován zvlášť. Stavebním blokem je většinou funkce nebo třída, která nepoužívá jiné stavební bloky. Pokud dělám unit test, tak chceme testovat specifický stavební blok, jiné ne.

Integration testy

Testování kombinace stavebních bloků (unit). Testuje se jestli určité stavební bloky fungují dohromady. I když všechny stavební bloky fungují samostatně, tak se může stát, že pokud budou pracovat dohromady, tak dojde k selhání.

E2E testy

End-to-end nejsou nijak spjaty s Unit a Integration testy. Jejich úkolem je testovat kompletní funkcionalitu aplikace. Protože uživatelé používají celou aplikaci, ne jen její část.

Co (ne)testovat

Vždy bychom měli testovat jen vlastní kód. Pokud používáme nějaké knihovny třetích stran, tak ty netestujeme.

Měli bychom testovat vždy jen jednu věc. Neměli bychom v jednom testu testovat několik věcí najednou.

Pokud testujeme frontend aplikaci, tak bychom zároveň neměli testovat i backend. Backend můžeme testovat zvlášť.

Co je k testování potřeba

Pro testování našeho kódu potřebujeme tři základní věci. Potřebujeme Test Runner, Assertion Library a samozřejmě kód aplikace, kterou chceme testovat.

Aplikační kód

K vytváření testů a jejich spouštění potřebujeme samozřejmě kód naší aplikace. Testování může být (a většinou i je) v naší aplikaci integrováno. Pokud jsme například naši aplikaci vytvořili pomocí nějakého nástroje postaveného třeba na Webpacku (např. Create React App), tak pro nás může být testování připraveno.

Test Runner

Test Runner představuje nástroj pro spouštění našich testů. Automaticky detekuje testovací kód, spouští jej a zobrazuje pro něj výsledky. Populárními test runnery jsou například Jest, Vitest nebo Karma.

Assertion Library

Kromě test runneru potřebujeme k testování také Assertion Library. Tento nástroj slouží k definování výsledků, které po proběhnutím testu očekáváme. Assertion Library zjišťuje jestli se naše očekávání splní nebo ne. Populární Assertion knihovny jsou například Jest, Vitest nebo Chai.

AAA vzor

AAA vzor se stal v testování skoro standardem. Rozděluje tvorbu testů na tři fáze: Arrange (připrav), Act (proveď) a Assert (vyhodnoť).

Arrange

V této fázi si připravíme testovací prostředí a nadefinujeme hodnoty.

Act

V této fázi provedeme testování. Spustíme nějaký kód, který chceme otestovat.

Assert

V této fázi vyhodnotíme výsledek testu. Porovnáme výslednou hodnotu s očekávanou hodnotou.

it("should summarize all numbers in an array", () => {
    // arrange
    const numbers = [1, 2, 3];
    const expectedResult = 6;

    // act
    const result = add(number);

    // assert
    expect(result).toBe(expectedResult);
});

Spouštění testů

Ke spuštění testů můžeme použít příkaz 'vitest', který si můžeme nastavit jako test script v našem package.json souboru. Po jeho spuštění Vitest automaticky prohledá v našem projektu soubory s koncovkou .test.js nebo .spec.js a spustí je.

vitest | vitest watch - spouští testy ve watch módu

vitest run - spouští testy jen jednou

zobrazit možnosti

Vytvoření testu

Vitest obsahuje funkce it a test, které nám umožňují vytvářet testy. Obě dělají stejnou věc. Jako první parametr berou popis testu a jako druhý funkci s testovacím kódem. V této funkci otestujeme nějaký kód a výsledek porovnáme s očekávaným výsledkem pomocí funkce expect.

import {expect, it} from 'vitest';

it("should summarize all number values in an array", () => {
    const numbers = [1, 2, 3];
    const expectedResult = 6;

    // otestování kódu a získání výsledku
    const result = add(numbers);

    // porovnání výsledku s očekávaným výsledkem
    expect(result).toBe(expectedResult);
});

Za funkci expect, do které předáváme hodnotu kterou chceme kontrolovat, můžeme řetězit spoustu metod, pomocí kterých můžeme předanou hodnotu zkontrolovat. Všechny můžete najít v dokumentaci.

toBe vs. toEqual

Za funkci expect můžeme řetězit dvě podobné metody jménem toBe a toEqual. Rozdíl mezi nimi je ten, že toBe porovnává hodnoty přesně. Pokud chceme porovnávat dva objekty u kterých chceme zjistit jestli mají stejnou strukturu, tak je metoda toBe vyhodnotí jako rozdílné, protože jsou uloženy na jiném místě v paměti. Proto existuje metoda toEqual, která zjišťuje jestli se dvě hodnoty rovnají nebo mají stejnou strukturu.

import {expect, it} from 'vitest';

it("should be equal", () => {
    const obj1 = {
        someValue: true,
        someOtherValue: 2
    };

    const obj2 = {
        someValue: true,
        someOtherValue: 2
    };

    // tento test selže (proměnná obj1 odkazuje na jiný objekt v paměti než obj2)
    expect(obj1).toBe(obj2);
});
import {expect, it} from 'vitest';

it("should be equal", () => {
    const obj1 = {
        someValue: true,
        someOtherValue: 2
    };

    const obj2 = {
        someValue: true,
        someOtherValue: 2
    };

    // tento test projde (obj1 má stejnou strukturu jako obj2)
    expect(obj1).toEqual(obj2);
});

Seskupování testů

Vitest nám nabízí funkci describe, pomocí které můžeme v souboru seskupovat testy dohromady. To se může hodit, když například testujeme soubor obsahující různé funkce.

import {describe, expect, it} from 'vitest';

// seskupení testů pro funkci add
describe("add()", () => {
    it("should do something...", () => {
        // ... nějaký test ...
    });
    it("should do something too...", () => {
        // ... nějaký test ...
    });
});

// seskupení testů pro funkci substract
describe("substract()", () => {
    it("should do something...", () => {
        // ... nějaký test ...
    });

    // describe funkce můžeme i vnořovat
    describe("something", () => {
        // ... nějaké další testy ...
    });
});

Testování chyb

U některých funkcí v našem kódu můžeme v určitých situacích očekávat, že vyhodí chybu. Vitest nám to umožňuje otestovat.


it("should throw an error", () => {
    const numbers = ["A", "B", "C"];

    // nejjednodušší cesta jak otestovat jestli nějaká funkce vrací error
    // je obalit její volání do jiné funkce a tu předat do expect funkce
    // - jinak bychom mohli využít i try a catch
    const resultFn = () => {
        add(numbers);
    }

    // očekáváme že funkce vyhodí chybu
    expect(resultFn).toThrow();
});

Testování asynchronního kódu

Při spouštění testů Vitest nečeká na to až se provedou callbacky a asynchronní kód. Proto může testovací funkce přijímat jako parametr funkci, kterou v testovací funkci zavoláme až budeme s testováním hotovi. Pokud ale testujeme kód, který používá promisy, tak je to o něco jednodušší.

Callbacky

import {expect, it} from 'vitest';

// testovací funkce jako parametr přijímá funkci, kterou zavoláme až budeme s testováním hotovi
it("should generate a token value", (done) => {
    const testUserEmail = 'test@test.com';

    generateToken(testUserEmail, (err, token) => {
        // metoda toBeDefined (a podobné metody) může vrátit error, ale tady se nacházíme v
        // callbacku, ne v it funkci, proto musíme její volání obalit do try catch bloku
        try {
            expect(token).toBeDefined();
            // pokud metoda toBeDefined nevyhodila žádný error, tak můžeme done funkci zavolat bez parametrů
            done();
        } catch(err) {
            // jako parametr předáváme do done funkce error, který metoda toBeDefined vyhodila
            done(err);
        }
    });
});

Promisy

import {expect, it} from 'vitest';

it("should generate a token value", () => {
    const testUserEmail = 'test@test.com';

    // pokud testovaná funkce vrací promise, tak jej můžeme předat do expect funkce
    // - když předáváme do expect funkce promise, tak za ni můžeme řetězit resolves (pokud očekáváme že se promise splní)
    // nebo rejects (pokud očekáváme že se promise nesplní) a nakonec podmínku pro hodnotu s kterou se promise splní/nesplní
    // - funkce expect, která přijímá jako parametr promise, se musí v testovací funkci vrátit pomocí return
    return expect(generateToken(testUserEmail)).resolves.toBeDefined();
});

// pokud chceme používat async await, tak můžeme, testování je díky tomu ještě jednodušší
it("should generate a token value - v2", async () => { // testovací funkce je definována jako async (automaticky vrací promise)
    const testUserEmail = 'test@test.com';

    const token = await generateTokenPromise(testUserEmail);

    expect(token).toBeDefined();
});

Hooks

Pokud používáme nějakou hodnotu ve více testech (v každém testu ji vytváříme), tak ji můžeme deklarovat mimo testy a v testech ji jen použít. Takto to ale můžeme udělat jen pro hodnoty, které se nemění. Jinak bychom museli před každém spuštění testu hodnotu resetovat. K tomuto účelu složí hooks. Můžeme s jejich pomocí spustit nějaký kód před/po každým/všemi testem.

import {beforeEach, afterEach, beforeAll, afterAll, describe} from 'vitest';

beforeEach(() => {
    // kód, který se spustí před každým testem
});

afterEach(() => {
    // kód, který se spustí po každém testu
});

beforeAll(() => {
    // kód, který se spustí před všemi testy
});

afterAll(() => {
    // kód, který se spustí po provedení všech testů
});

// hooks můžeme také vnořovat do describe funkcí - budou se týkat jen testů, které describe funkce obsahuje
describe("add()", {
    beforeEach(() => { /* nějaký kód */ });
});

Špióni

Vitest nám umožňuje vytvořit si funkci, kterou můžeme použít jako obal nebo prázdnou náhradu pro jinou funkci. Máme s její pomocí možnost například zjistit, jestli byla funkce volána když ji předáme jako parametr testované funkci.

import {it, expect, vi} from 'vitest';

it("should call passed function", () => {
    // vytvoření špionážní funkce
    const callback = vi.fn();

    // předání špionážní funkce jako parametr testované funkci
    generateData(callback);

    // očekáváme že se funkce callback ve funkci generateData zavolala
    expect(callback).toBeCalled();

    // dále můžeme po předání špionážní funkce do expect funkce řetězit metody:
    // toBeCalledTimes, toBeCalledWith, atd... (více v dokumentaci)
});
více v dokumentaci

Mocking

Vitest nám umožňuje nahradit části API jiným kódem. Při testování například nechceme, aby funkce která něco ukládá na disk opravdu něco na disk ukládala. Chceme to jen otestovat. Proto můžeme třeba změnit funkcionalitu funkcí, které testovaná funkce používá k zápisu dat na disk.

import {it, expect, vi} from 'vitest';
import {promises as fs} from 'fs';

// všechny funkce v modulu fs se změní v prázdné funkce
vi.mock(fs);

it('should execute the writeFile method', () => {
    const testData = 'Test';
    const testFilename = 'test.txt';

    // otestování funkce writeData
    writeData(testData, testFilename);

    // očekáváme že se funkce writeFile ve funkci writeData zavolá
    expect(fs.writeFile).toBeCalled();
});

Pokud chceme pro nějakou funkci v modulu nastavit jinou než prázdnou funkci, tak to můžeme udělat následující způsobem:

vi.mock(path, () => {
    return {
        default: {
            // náhrada pro funkci join v modulu path
            join: (...args) => {
                return args[args.length - 1];
            }
        }
    }
});

Pokud chceme mockovat stejný module ve více souborech, tak kód pro jeho mockování můžeme přesunout do samostatného souboru. Vitest nám umožňuje vytvořit si složku __mocks__, kde můžeme vytvářet soubory pojmenované jako názvy modulů (třeba fs.js). V těchto souborech můžeme exportovat mocknutou funkcionalitu, kterou chceme používat když v jiných souborech zavoláme vi.mock.

// __mocks__/fs.js

import { vi } from 'vitest';

// mocknutí části modulu fs jménem promises
export const promises = {
    // náhrada pro funkci writeFile
    writeFile: vi.fn((path, data) => {
        return new Promise((resolve, reject) => {
            resolve();
        });
    })
}

Pokud chceme mocknout nějakou globální funkci, tak nám Vitest poskytuje kromě metody mock také metodu stubGlobal, kterou k tomu můžeme využít.

const testFetch = vi.fn();
// mocknutí globální funkce jménem fetch
vi.stubGlobal("fetch", testFetch);

DOM testování

Pokud chceme testovat DOM, tak nejprve musíme změnit prostředí, ve kterém se náš testovací kód spouští. Máme na výběr z těchto tří možností:

node

Tato možnost je nastavena jako defaultní. V tomto prostředí jsou k dispozici NodeJS API a moduly.

jsdom

Prostředí pro testování frontend kódu. K dispozici také třeba v testovacím frameworku jménem Jest.

happy-dom

Další prostředí pro testování frontend kódu.

Prostředí ve kterém se naše testy spustí můžeme nastavit pomocí možnosti --environment:

vitest --environment happy-dom

Načtení stránky pro testování

Předtím než začneme s testováním, tak musíme načíst stránku, kterou chceme testovat. To můžeme udělat následujícím způsobem (používáme happy-dom):

import fs from 'fs';
import path from 'path';
import { Window } from 'happy-dom';


// získání cesty k HTML souboru
const htmlDocPath = path.join(process.cwd(), 'index.html');
// přečtení HTML souboru a uložení jeho obsahu do proměnné
const htmlDocumentContent = fs.readFileSync(htmlDocPath).toString();

// vytvoření nového window objektu
const window = new Window();
// získání documentu window objektu
const document = window.document;
// zapsání obsahu HTML souboru do documentu
document.write(htmlDocumentContent);

// změnění documentu na document virtuálního DOMu
vi.stubGlobal('document', document);

Příklad DOM testování

Následující ukázka ukazuje, jakým způsobem můžeme DOM testovat.

 /* ... kód z minulé ukázky a importy ... */

// při DOM testování bychom měli virtual DOM před každým testem resetovat
beforeEach(() => {
    // smazání obsahu documentu
    document.body.innerHTML = '';
    // zapsání obsahu HTML souboru do documentu
    document.write(htmlDocumentContent);
    // stubGlobal volat nemusíme, protože document je objekt a ten se předává adresou
});

it('should add new element to the id="todo-list" element', () => {
    // spuštění testované funkce
    addItem("create website");

    // získání elementu s ID todo-list
    const todoList = document.getElementById("todo-list");
    // získání prvního elementu z elementu s ID todo-list
    const liElement = todoList.firstElementChild;

    // očekáváme že se do elementu s ID todo-list přidal nový element
    expect(liElement).not.toBeNull();
});

Pro testování DOMu nám JSDOM nebo Happy-DOM nemusí stačit. Proto existují různé knihovny pro DOM testování. Jednou z nich je třeba Testing Library, pomocí které můžeme jednoduše testovat i populární frontend frameworky jako je React.