Programio

Jak v JavaScriptu testovat funkce s přísliby

Z minulého článku o příslibech v JavaScriptu už víme, co jsou to přísliby či promisy a jak s nimi pracovat. Protože ale každý programátor píše testy – a kdo ne, debuguje dodnes – podíváme se na to, jak nějak rozumně otestovat kód, který přísliby používá.

Pro příklad mějme funkci, která odněkud přečte konfiguraci a vrátí ji jako příslib. Pokud bychom tu funkci hodně zjednodušili, mohli bychom ji napsat takto:

1
var Promise = require('bluebird');

var getConfig = function () {
    // tady by se realne config precetl z disku/databaze/whatever
    return Promise.resolve({
        port: 1234,
        host: "localhost"
    });
}

getConfig().then(function (config) {
    console.log(config)
});

// { port: 1234, host: 'localhost' }

Naivní nefunkční cesta…

OK, máme funkci, která vrací příslib, který se resolvne na objekt { port: 1234, host: 'localhost' }. Jak ji otestovat? Na testy použijeme Mochu. Očekáváme, že dostaneme config, který je objekt a má dvě property: port a host. Testy bychom mohli mohli napsat takto:

1
describe('getConfig', function () {

    it('should return an object', function () {
        getConfig().should.be.Object;
    });
 
    it('should return config with valid port', function () {
        getConfig().should.have.property('port').which.is.Number;
    });

    it('should return config with valid host', function () {
        getConfig().should.have.property('host').which.is.String;
    });

});

Radostně pustíme testy a …

1
1 passing (11ms)
2 failing

1) getConfig should return config with valid port:
   AssertionError: expected { _bitField: 268435456, ...

Co se stalo? Samozřejmě se stalo to, že funkce getConfig vrací příslib, nevrací přímo náš objekt. První test prošel, protože příslib je objekt, ale příslib už nemá vlastnosti port a host, proto zbylé testy spadly. Jak testy opravit?

Naivní funkční cesta

Pokud chceme otestovat hodnotu, na kterou se resolvne náš příslib, musíme použít metodu then, abychom se k té resolvnuté hodnotě dostali. Test na validní port bychom mohli napsat například takto:

1
it('should return config with valid port', function () {
    getConfig().then(function (config) {
        config.should.have.property('port').which.is.Number;
    });
});

Nejprve vyhodnotíme příslib, získáme hodnotu, na kterou se resolvuje a až tu kontrolujeme. Všechny tři testy bychom mohli přepsat takto:

1
describe('getConfig', function () {

    it('should return an object', function () {
        getConfig().then(function (config) {
            config.should.be.Object;
        });
    });

    it('should return config with valid port', function () {
        getConfig().then(function (config) {
            config.should.have.property('port').which.is.Number;
        });
    });

    it('should return config with valid host', function () {
        getConfig().then(function (config) {
            config.should.have.property('host').which.is.String;
        });
    });

});

Přísliby = asynchronní kód

Všechny tři testy nyní projdou. Jdeme ale dále: co by se stalo, kdybychom napsali test, který neprojde? Dejme tomu, že se rozhodneme přejmenovat vlastnost host na hostname. A protože se třeba řídíme TDD, jako první přepíšeme testy a až pak kód:

1
it('should return config with valid host', function () {
    getConfig().then(function (config) {
        config.should.have.property('hostname').which.is.String;
    });
});

Testy spustíme a …

1
Possibly unhandled AssertionError: expected { port: 1234, host: 'localhost' } to have property 'hostname'
...

3 passing (19ms)

No… to není úplně výsledek, který bychom čekali, že? Problém je v tom, že při používání příslibů se pohybujeme v asynchronním kódu. Aserce config.should.have.property('hostname').which.is.String; nám tak sice vyhodí výjimku, jenže ji vyhodí v asynchronně prováděném kódu a tato výjimka se tak nebude propagovat zpět do testu Mochy a zůstane ztracena někde uprostřed ničeho. Ona hláška Possibly unhandled AssertionError už je jen takový zoufalý pokus Bluebirdu upozornit nás na to, že se někde stala chyba, na kterou nikdo nikde nereagoval.

Novější verze Mocha umí s přísliby pracovat, takže úplně nejvíc nejjednodušší cesta jak test opravit je vrátit příslib z funkce pomocí return:

1
it('should return config with valid host', function () {
    return getConfig().then(function (config) {
        config.should.have.property('hostname').which.is.String;
    });
});

Testovací funkce nyní vrací příslib, se kterým dále v testu pracujeme. Aserce vyhodí výjimku, tu odchytne příslibová knihovna a nastaví příslib jako rejected. Mocha to pozná, vytáhne si chybu, zobrazí ji a nastaví test jako failed. Jednoduché jako facka.

Výborně, všechny testy takto přepíšeme:

1
describe('getConfig', function () {

    it('should return an object', function () {
        return getConfig().then(function (config) {
            config.should.be.Object;
        });
    });

    it('should return config with valid port', function () {
        return getConfig().then(function (config) {
            config.should.have.property('port').which.is.Number;
        });
    });

    it('should return config with valid host', function () {
        return getConfig().then(function (config) {
            config.should.have.property('hostname').which.is.String;
        });
    });

});

Pokud nyní spustíme testy, získáme správný výstup:

1
2 passing (14ms)
1 failing

1) getConfig should return config with valid host:
   AssertionError: expected { port: 1234, host: 'localhost' } to have property 'hostname'

Protože jsme ještě nepřepsali produkční kód, tak nám jeden test padá a Mocha nám to hezky oznamuje.

Nechybí nám tam nějaké done?

Z klasických asynchronních testů, které používají callbacky, jste asi zvyklí na to, že testovací funkce má ještě parametr done, což je funkce, která se volá, když všechny testy úspěšně doběhnou.

Co kdybychom naši funkci getConfig přepsali takto?

1
var getConfig = function () {
    return new Promise(function(resolve, reject) {
        setTimeout(function() {
            resolve({
                port: 1234,
                hostname: "localhost"
            });
        }, 1000);
    });
}

Tj. funkce by sice vrátila příslib, který by se resolvnul na náš správný objekt (je tam už hostname místo host), ale vrátila by nám až za jednu sekundu. Jasně, v unit testech bychom nikdy neměli volat pravý setTimeout, který bude čekat sekundu nebo více, ale jen tak pro legraci … co by nám teď řekly testy?

1
3 passing (3s)

Mocha je dost chytrá na to, aby věděla, že má počkat, než bude příslib resolvnutý nebo rejectnutý a ve chvíli, kdy jedno z toho nastane, tak testování ukončí. Alespoň nová verze Mochy, jestli máte nějakou starší, tak to nebude fungovat. Žádný parametr done tak do testovací funkce přidávat nemusíme.

Finální řešení: Chai as promised

Existuje jedna velice šikovná knihovna, která umožňuje dále zjednodušit psaní příslibových testů: chai as promised. K této knihovně budeme ještě potřebovat samotnou knihovnu chai.

1
npm install chai chai-as-promised

V testovacím souboru pak obě knihovny requirneme a nastavíme takto:

1
var chai = require("chai");
chai.use(require("chai-as-promised"));
var expect = chai.expect

Na prvních dvou řádcích taháme testovací knihovnu chai a říkáme, aby používala plugin chai-as-promised. Na třetím řádku z Čaje vytáhneme funkci expect, kterou budeme dále používat pro testování. Vytáhnul jsem expect jednak proto, že ho mám nejraději a jednak proto, ať to odliším od předchozího should, který jsem používal z Mochy. Nicméně i Čaj umožňuje používat should.

Tím, že jsme použili plugin chai-as-promised, můžeme psát testy za pomocí vlastnosti eventually. V podstatě jen na vhodné místo přidáme vlastnost eventually a jinak napíšeme test jak jsme zvyklí. Například tento kód

1
expect([1, 2]).to.have.length(2);

testuje, jestli má dané pole délku dva. Pokud bychom nepracovali s polem, ale s příslibem, který se teprve vyhodnotí na pole o délce dva, napsali bychom:

1
expect(Promise.resolve([1, 2])).to.eventually.have.length(2);

Přidali jsme vlastnost eventually, čímž říkáme, že v expectu je příslib, který by se měl jednou vyhodnotit na pole o délce dva. Eventually samo se postará o vyhodnocení příslibu, my se už o nic starat nemusíme. Naše testy bychom tak přepsali úplně stejně:

1
describe('getConfig', function () {
    it('should return an object', function () {
        return expect(getConfig()).to.eventually.be.an("Object");
    });

    it('should return config with valid port', function () {
        return expect(getConfig()).to.eventually.have.property('port').that.is.a('Number');
    });

    it('should return config with valid hostname', function () {
        return expect(getConfig()).to.eventually.have.property('hostname').that.is.a('String');
    });

});

Pokud spustíme tyto testy, tak správně spadnou na tom, že naše funkce getConfig vytváří config s vlastností host, ne hostname. Pokud tuto chybu opravíme, testy správně projdou.

No není to nádhera? Testy jsou krásně krátké a funkční, co víc chtít.

Chai as promised toho samozřejmě umí mnohem více, podívejte se do dokumentace. Můžete například snadno testovat, jestli je příslib rejectnutý, nebo je fulfilled a podobně.

Toť vše, veselé testování. Čáj!

  1. Přísliby v JavaScriptu