IMA.js – Testování aplikace

V minulých dílech jsme úspěšně vytvořili plně funkční IMA.js aplikaci na předpověď počasí. Abychom zajistili, že tato aplikace zůstane funkční i nadále a s dalšími úpravami nic nerozbijeme, doplníme ji o sadu jednoduchých, ale mocných testů.

Seriál: IMA.js: framework od Seznam.cz (5 dílů)

  1. Co je IMA.js? Podívejme se na framework od Seznam.cz 25.2.2020
  2. IMA.js – setup aplikace a vytvoření modelů 13.3.2020
  3. IMA.js – Načítání a vykreslování dat 29.4.2020
  4. IMA.js – Detailní pohled na komponenty, eventy a extensions 22.9.2020
  5. IMA.js – Testování aplikace 8.12.2020

Stručně si představíme nástroj Enzyme, který nám pomůže s otestováním jednotlivých React komponent. Poté otestujeme aplikaci jako celek integračními testy, s čímž nám pomůže @ima/plugin-testing-integration a nakonec spojíme tyto dvě utility k otestování komplexnějších uživatelských scénářů.

Unit testy

IMA.js aplikace přicházejí s předkonfigurovaným prostředím pro psaní unit testů s využitím Jest test runneru a již zmíněného Enzymu. Enzyme umožňuje vykreslit React komponenty v testovém prostředí, kde jim můžeme předat různá nastavení a simulovat uživatelské interakce. Už jednoduchým smoke testem, kde vykreslíme komponentu se základním nastavením, si ověříme, že je komponenta funkční a nerozbije nám při pokusu o vykreslení hned celý web.

Pro spouštění testů použijeme příkaz npm test. Během psaní testů je užitečné spustit testy ve watch módu pomocí npm test -- --watch. Nyní se při jakékoliv úpravě aplikace, nebo testů automaticky spustí ovlivněné testy.

Testy typicky píšeme do souboru končícího Spec.js, který by se měl nacházet ve složce __tests__ ležící hned vedle námi testované komponenty. Základní kostra pro psaní testů může vypadat třeba takto.

// app/component/mojeKomponenta/__tests__/mojeKomponentaSpec.js
    import React from 'react';
    import { shallow } from 'enzyme';
    import MojeKomponenta from '../MojeKomponenta';
    
    describe('MojeKomponenta', () => {
        it('can be rendered', () => {
            const props = {
                // Sem patří nastavení pro komponentu
            };
            const context = {
                $Utils: {
                    // Tady můžeme definovat utility,
                    // které komponenta využívá
                }
            };
            // Použijeme shallow renderování
            const wrapper = shallow(<MojeKomponenta {...props} />, { context });
    
            // Zkontrolujeme, jak se vyrenderovala komponenta
            expect(wrapper).toMatchSnapshot();
        });
    });

Do props a context potřebujeme nadefinovat všechna nastavení, která jsou nutná pro vyrenderování komponenty. Navíc můžeme využít klíče $Utils, kde můžeme namockovat všechny kontextové metody, ke kterým se v komponentě přistupuje přes this.utils.

Použijeme Enzyme metody shallow, která nám vyrenderuje pouze aktuální komponentu, ale už ne zanořené React komponenty. U nich pouze zobrazí, s jakým nastavením by se vykreslily. Tím můžeme izolovat testování pouze na jednu komponentu a testy se nerozbijí s úpravou jiných komponent.

Ke kontrole nakonec použijeme snapshot testování, které nám při prvním běhu testů uloží vyrenderovanou komponentu do souboru a při dalším běhu testů se už jen kontroluje, že se komponenta nezměnila.

Bohužel, velice rychle narazíme na problém s novým React Context API, které zatím není plně podporováno Enzymem. Konkrétně Enzyme neumí kontext předávat do komponent při shallow renderování. Existuje ovšem balíček shallow-with-context, který tenhle problém dočasně řeší. Nainstalujeme ho následovně.

npm install --save-dev shallow-with-context

Kostru pro psaní testů tím pádem musíme dočasne poupravit.

// app/component/mojeKomponenta/__tests__/mojeKomponentaSpec.js
    import React from 'react';
    import { shallow } from 'enzyme';
    import { withContext } from 'shallow-with-context';
    import MojeKomponenta from '../MojeKomponenta';
    
    describe('MojeKomponenta', () => {
        it('can be rendered', () => {
            const props = {
                // Sem patří nastavení pro komponentu
            };
            const context = {
                $Utils: {
                    // Tady můžeme definovat utility,
                    // které komponenta využívá
                }
            };
            // Využijeme metody withContext k předání kontextu do komponenty
            const MojeKomponentWithContext = withContext(MojeKomponenta, context);
            // Použijeme shallow renderování k vykreslení komponenty s kontextem
            const wrapper = shallow(<MojeKomponentWithContext {...props} />, { context });
    
            // Zkontrolujeme, jak se vyrenderovala komponenta
            expect(wrapper).toMatchSnapshot();
        });
    });

Příklad

Nejlépe si fungování testů ukážeme na příkladu. Řekněme, že chceme otestovat komponentu ForecastDay. Prozkoumáme tedy kód, abychom zjistili, co vše je potřeba k vykreslení komponenty. Můžeme si všimnout, že komponenta primárně využívá nastavení objektu forecast a z kontextových metod využívá metodu cssClasses. Z toho plyne, že pro tyhle 2 případy musíme poskytnout mocknutá data. Nachystáme si tedy objekty props a context.

import { defaultCssClasses } from '@ima/core';
    
    const props = {
        forecast: {
            localDate: '2019-03-18T08:06:08.341Z',
            tempMin: 5,
            tempMax: 10,
            sunrise: '6:00',
            sunset: '20:00',
            icon: 0
        }
    };
    const context = {
        $Utils: {
            $CssClasses: defaultCssClasses
        }
    };

Jelikož metoda this.cssClasses (která je poděděná z AbstractPureComponent) uvnitř využívá metody this.utils.$CssClasses, můžeme její definování provést přímo v $Utils.

S tímhle nastavením by se nám měla komponenta bez problémů vyrenderovat. Vyzkoušíme si to v praxi.

// app/component/forecastDay/ForecastDaySpec.js
    import React from 'react';
    import { defaultCssClasses } from '@ima/core';
    import { shallow } from 'enzyme';
    import { withContext } from 'shallow-with-context';
    import ForecastDay from '../ForecastDay';
    
    describe('ForecastDay', () => {
        it('can be rendered', () => {
            const props = {
                forecast: {
                    localDate: '2019-03-18T08:06:08.341Z',
                    tempMin: 5,
                    tempMax: 10,
                    sunrise: '6:00',
                    sunset: '20:00',
                    icon: 0
                }
            };
            const context = {
                $Utils: {
                    $CssClasses: defaultCssClasses
                }
            };
            const ForecastDayWithContext = withContext(ForecastDay, context);
            const wrapper = shallow(<ForecastDayWithContext {...props} />, { context });
    
            expect(wrapper).toMatchSnapshot();
        });
    });

Když nyní spustíme testy, zobrazí se nám hláška 1 snapshot written. Tímto se nám vygeneroval soubor se snapshotem, který najdete vedle vašeho testu v podsložce __snapshots__. Když do něj nahlédneme, měli bychom vidět, že se komponenta vykreslila podle našeho očekávání.

Už tímhle jednoduchým testem jsme si ověřili, že komponentu lze vůbec vykreslit a při dalších úpravách komponenty se nemusíme bát, že se rozbije. Tenhle základní test bychom měli mít napsaný úplně pro všechny naše komponenty. Testy by se dali rozšířit o různé interakce, nebo bychom mohli komponentu přerenderovat s jiným nastavením. Enzyme pro tohle implementuje spoustu možností, ty ovšem nezvládneme projít v tomhle tutoriálu. Proto, pokud vás tenhle typ testování zaujal, doporučuji přejít přímo na dokumentaci Enzyme.

Integrační Testy

Nyní přikročíme k testování komplexnější logiky aplikace. Nebudeme testovat její jednotlivé části, ale budeme ji testovat jako celek. S tím nám pomůže již zmíněný @ima/plugin-testing-integration. Ten se postará o inicializaci celé aplikace a její vykreslení do JSDOMu (virtuální dom v Node.js). Podobně jako v prohlížeči budete mít k dispozici document a window, což otevírá obrovské možnosti testování nejrůznějších částí aplikace. Narozdíl od reálného prostředí nebudeme mít k dispozici vůbec server. Na to je potřeba brát ohled při provádění requestu, třeba na naše proxy API.

Narozdíl od unit testů potřebujeme pro integrační testy nachystat prostředí. Naštěstí jde jen o instalaci jednoho balíčku, jehož výchozí konfigurace nám umožní se pustit hned do psaní testů.

npm i --save-dev @ima/plugin-testing-integration

Jelikož nepotřebujeme chystat komplexnější aplikační prostředí, tak nemáme nijak velkou motivaci oddělovat unit testy od těch integračních. Z toho plyne, že spouštění testů je pro nás stále stejné pomocí příkazu npm test.

Pozn. V případě, že byste potřebovali pro nastartování aplikace rozjet složitější prostředí, jehož inicializace zabírá nějaký citelný čas, pak se může vyplatit spouštění integračních testů oddělit. Hodně záleží na tom, jak komplexní testy chcete psát.

Opět si ukážeme základní strukturu, tentokrát integračních testů.

import { initImaApp, clearImaApp } from '@ima/plugin-testing-integration';
    
    describe('Integration tests', () => {
        let app;
    
        beforeEach(async () => {
            // Inicializujeme aplikaci
            app = await initImaApp({
                // Můžete rozšířit libovolnou bootovací metodu
                initSettings: (ns, oc, config) => {...},
                initBindApp: (ns, oc, config) => {...},
                initServicesApp: (ns, oc, config) => {...},
                initRoutes: (ns, oc, config) => {...}
            });
        });
    
        afterEach(() => {
            // Vyčistíme prostředí od inicializované aplikace
            clearImaApp(app);
        });
    
        it('can load homepage', async () => {
            // Pomocí IMA Routeru přejdeme na domovskou stránku
            await app.oc.get('$Router').route('/');
    
            // Můžeme provést libovolné akce
    
            // Zkontrolujeme, že jsme na očekávané stránce
            expect(document.title).toEqual('Můj Oblíbený Web');
        });
    });

Metoda initImaApp provede přípravu prostředí a inicializaci samotné aplikace. Pomocí prvního argumentu máme možnost rozšířit bootovací metody. Umožňuje nám to napříklat mocknout různé klíčové komponenty, upravit nastavení, nebo definovat vlastní routy. Prakticky můžeme dělat to samé co děláme v souborech uvnitř app/config. Tato inicializační metoda navíc vrací objekt app ze kterého můžeme přistoupit k ObjectContaineru (app.oc). Ten nám zase umožňuje získat jakoukoliv instanci použitého objektu v aplikaci. Takhle si můžeme vzít třeba $Router a použít ho k navigaci na domovskou stránku.

Dále máme k dispozici document a window což nám umožňuje jak provádění různých akcí, tak i jejich kontrolu.

Každá inicializace aplikace vyžaduje zase její pročištění, jelikož ovlivňuje globální prostředí. Proto je potřeba po sobě uklízet pomocí metody clearImaApp.

Příklad

V příkladu si zkusíme otestovat domovskou stránku naší WeatherApp. Ověříme si, že se nám stránka úspěšně načetla a vykreslila námi očekávané komponenty.

Nejprve se budeme muset poprat s chybějícím serverem a vyřešit problém se získáním dat o počasí z API. Řešení se nabízí celá řada, ale v našem případě si data z API prostě mockneme a to úpravou funkce HttpAgent.get. Toho docílíme rozšířením bootovací metody initBindApp uvnitř initImaApp.

Pozn. Při psaní integračních testů je důležité si předem definovat k čemu je chcete použít. Zda je chcete použít na komplexní testování celé aplikace, nebo s nimi třeba chcete testovat integraci se 3. stranami. V tom případě třeba mockování API smysl moc nedává, ale na druhou stranu tím zase ohrožujete samotnou stabilitu testů.

const mockData = {
        // Mocknuté response data z API
    };
    
    app = initImaApp({
        initBindApp: (ns, oc, config) => {
            // Pomocí Object Containeru získáme instanci HttpAgenta
            // a mockneme get metodu pomocí jest.fn
            oc.get('$Http').get = jest.fn((_, params) => {
                const body = {};
    
                // Do body nastavíme pouze data o které
                // si říkáme v parametrech
                params.include.forEach(key => (body[key] = mockData[key]));
    
                return Promise.resolve({ body });
            });
        }
    });

Tímhle jsme upravili get metodu HttpAgenta tak, aby nám vracela vždy naše mocknutá data. Navíc jsme přidali jednoduchou logiku, aby vracela jen ta data o které si říkáme v parametru include, čímž se snažíme simulovat chování reálného API.

Při bližším zkoumání jaké odpovědi dostaneme ze skutečného API bychom měli získat následující datovou strukturu.

const mockData = {
        daily: [
            {
                dataHours: 21,
                icon: 19,
                localDate: '2020-03-03',
                precip: 23.700000000000003,
                precipType: 1,
                snowPrecip: 0,
                sunrise: '06:39',
                sunset: '17:48',
                tempMax: 6,
                tempMin: 2.3335345453157856,
                wind: 6,
                windDir: 284
            },
            {
                dataHours: 24,
                icon: 1,
                localDate: '2020-03-04',
                precip: 0,
                precipType: 0,
                snowPrecip: 0,
                sunrise: '06:37',
                sunset: '17:50',
                tempMax: 7.720383000000027,
                tempMin: -0.8994912273421392,
                wind: 5.3,
                windDir: 252
            }
        ],
        entries: [
            {
                dayId: 0,
                icon: 19,
                isDay: true,
                localDate: '2020-03-03',
                localTime: '07:00',
                precip: 0.5,
                snowPrecip: 0,
                temp: 6,
                wind: 2.9,
                windDir: 236
            },
            {
                dayId: 0,
                icon: 27,
                isDay: true,
                localDate: '2020-03-03',
                localTime: '16:00',
                precip: 1.6,
                snowPrecip: 0,
                temp: 4.906368817973657,
                wind: 6,
                windDir: 284
            },
            {
                dayId: 1,
                icon: 34,
                isDay: true,
                localDate: '2020-03-04',
                localTime: '07:00',
                precip: 0,
                snowPrecip: 0,
                temp: -0.8994912273421392,
                wind: 3.9,
                windDir: 240
            },
            {
                dayId: 1,
                icon: 26,
                isDay: true,
                localDate: '2020-03-04',
                localTime: '16:00',
                precip: 0,
                snowPrecip: 0,
                temp: 6.889491726579081,
                wind: 4.4,
                windDir: 245
            }
        ],
        place: {
            TZoffset: 1,
            isDay: false,
            lat: 50.0720383,
            localNow: '2020-03-03T18:17:05+01:00',
            lon: 14.4395213
        }
    };

Data jsou již osekaná pouze na dva dny a u každého dne jsou data jen za dva časové úseky, což nám pro otestování veškeré logiky stačí.

Teď už máme nachystané kompletní prostředí a můžeme se pustit do kontroly vykresleného webu. Na začátek se nabízí kontrola provolání HttpAgent.get metody, abychom si ověřili, že naše mocknutá data skutečně doputovala do aplikace. Při bližším zkoumání zjistíme, že aplikace posílá requesty 2, kde si jednou říká o klíče place a daily a podruhé o entries. Jejich kontrola může vypadat třeba následovně.

expect(app.oc.get('$Http').get).toHaveBeenCalledTimes(2);
    expect(app.oc.get('$Http').get.mock.calls[0][1].include).toEqual(['place', 'daily']);
    expect(app.oc.get('$Http').get.mock.calls[1][1].include).toEqual(['entries']);

Dále bychom mohli otestovat, že máme vykreslené všechny komponenty dle očekávání.

expect(document.querySelectorAll('.forecast-day').length).toEqual(2);
    expect(document.querySelectorAll('.forecast-detail').length).toBeGreaterThan(2);

Nyní bychom mohli otestovat i logiku přepnutí na detail předpovědi druhého dne. Tohle je možné, ovšem nesmíme zapomínat, že jde o React aplikaci. Aby se interakce s domem správně všude propisovaly, je potřeba informovat o všech akcích právě i React. Tohle už z velké části řeší knihovny jako třeba Enzyme, kterou jsme použili pro unit testy, tak proč ji nepoužít i teď?

Integrační Testy s Enzymem

Můžeme si představit, že prakticky celý web je jedna velká React komponenta složená z hromady menších. Proto není důvod, proč si tuhle web komponentu nevyrendrovat přímo Enzymem. Akorát místo metody shallow využijeme metody mount, která vykreslí kompletní dom a otevře nám dveře ke všem funkcionalitám Enzymu.

Nachystání prostředí tentokrát nevyžaduje instalaci dalšího balíčku, ale prostou úpravu konfigurace @ima/plugin-testing-integration, která vypadá následovně.

const { setConfig } = require('@ima/plugin-testing-integration');
    const EnzymePageRenderer = require('@ima/plugin-testing-integration/EnzymePageRenderer');
    
    setConfig({
        TestPageRenderer: EnzymePageRenderer
    });

Tímto máme nachystané prostředí a můžeme rovnou využívat funkcí Enzymu. Dostaneme se k nim pomocí app.wrapper(), což je funkce vracející wrapper celé web aplikace stejně jako bychom provolali Enyzme metodu mount.

Pro nastavení této konfigurace globálně pro všechny testy můžete tohle nastavení vložit přímo do jest.setup.js.

Nové nastavení můžeme hned otestovat rozšířením již existujících integračních testů o další kontroly. Ověříme, že první den je na stránce zvolen jako aktivní a zobrazuje se jeho detail. To bychom bez enzymu museli dělat hledáním tříd, ale takhle můžeme přistoupit přímo k props konkrétní komponenty a testování si tím zjednodušit.

expect(app.wrapper().find(ForecastDay).get(0).props.isActive).toEqual(true);
    expect(app.wrapper().find(ForecastDetail).get(0).props.dayId).toEqual(0);

Také zkontrolujeme, že druhý den zase aktivní není.

expect(app.wrapper().find(ForecastDay).get(1).props.isActive).toEqual(false);

Nyní můžeme napsat druhý test, který provede výběr druhého dne a zkontrolujeme, že se na stránce přepne jeho detail.

app.wrapper().find(ForecastDay).at(1).simulate('click');
    
    expect(app.wrapper().find(ForecastDay).get(0).props.isActive).toEqual(false);
    expect(app.wrapper().find(ForecastDay).get(1).props.isActive).toEqual(true);
    expect(app.wrapper().find(ForecastDetail).get(0).props.dayId).toEqual(1);

Výsledný test může nakonec vypadat třeba takhle.

import { initImaApp, clearImaApp } from '@ima/plugin-testing-integration';
    import ForecastDay from 'app/component/forecastDay/ForecastDay';
    import ForecastDetail from 'app/component/forecastDetail/ForecastDetail';
    
    const mockData = {...};
    
    describe('Integration tests', () => {
        let app;
    
        beforeAll(async () => {
            app = await initImaApp({...});
    
            await app.oc.get('$Router').route('/');
        });
    
        afterAll(() => {
            clearImaApp(app);
        });
    
        it('can load homepage', () => {
            expect(document.title).toEqual('Počasí Praha - IMA.js Example');
    
            expect(app.oc.get('$Http').get).toHaveBeenCalledTimes(2);
            expect(app.oc.get('$Http').get.mock.calls[0][1].include).toEqual(['place', 'daily']);
            expect(app.oc.get('$Http').get.mock.calls[1][1].include).toEqual(['entries']);
    
            expect(document.querySelectorAll('.forecast-day').length).toEqual(2);
            expect(document.querySelectorAll('.forecast-detail').length).toEqual(2);
    
            expect(app.wrapper().find(ForecastDay).get(0).props.isActive).toEqual(true);
            expect(app.wrapper().find(ForecastDay).get(1).props.isActive).toEqual(false);
            expect(app.wrapper().find(ForecastDetail).get(0).props.dayId).toEqual(0);
        });
    
        it('can switch forecast day', () => {
            app.wrapper().find(ForecastDay).at(1).simulate('click');
    
            expect(app.wrapper().find(ForecastDay).get(0).props.isActive).toEqual(false);
            expect(app.wrapper().find(ForecastDay).get(1).props.isActive).toEqual(true);
            expect(app.wrapper().find(ForecastDetail).get(0).props.dayId).toEqual(1);
        });
    });

Tímto máme otestovaný základní use case naší aplikace a nemusíme se bát, že ho s dalšími úpravami rozbijeme.

Závěrem

Ve výsledku námi napsané testy nejsou nijak zvlášť dlouhé, nebo složité a přitom testují hromadu funkcionality. Víme, že jednotlivé komponenty samostatně fungují, že se aplikace dokáže celá úspěšně spustit, vykresluje všechny námi očekávané komponenty a funguje nám základní uživatelský scénář. Tohle vše dokážeme nyní ověřit ještě dřív, než se vůbec zahájí proces balení webu a přitom vše trvá jen několik vteřin.

Doufám, že vás testování IMA.js aplikace alespoň trochu zaujalo a již brzy zužitkujete nově nabyté znalosti i v praxi.

Filip působí ve společnosti Seznam.cz od roku 2016, aktuálně na pozici vedoucího QA týmu. Má na starosti budování testové infrastruktury, automatizaci vývojových procesů a monitorování obsahových služeb jako Novinky.cz, SeznamZprávy.cz nebo Proženy.cz.

Zatím nebyl přidán žádný komentář, buďte první!

Přidat komentář
Zdroj: https://www.zdrojak.cz/?p=24155