Přejít k navigační liště

Zdroják » JavaScript » Jak na TDD v JavaScriptu

Jak na TDD v JavaScriptu

Články JavaScript

O problematice TDD toho bylo napsáno již hodně, proto si v tomto článku jen osvěžíme nějaké základní pojmy a ukážeme si, jak aplikovat tuto metodu na konkrétním příkladě napsaném v JavaScriptu za pomocí testovacího frameworku Mocha a knihoven Chai, Sinon.

Co je to TDD

Test driven development (TDD) je česky “softwarový vývoj řízený testy”. Podle této praktiky máme psát testy před produkčním kódem. To by nám mělo pomoci k lepšímu porozumění problému, méně chybám, celkovému zlepšení kvality kódu a architektury aplikace. K tomu, abychom mohli TDD aplikovat, si vysvětlíme pojem red/green/refactor cyklus.

Red, green, refactor cyklus

Jedná se o jednoduchý cyklus, který nám říká, že nejprve napíšeme padající testy (fáze red), poté kus produkčního kódu, který padající testy opraví (fáze green) a pak za sebou uklidíme nepořádek, který jsme zanechali (fáze refactor).

Dejte si pozor na poslední fázi refactor, protože pokud zanecháte kód v tom stavu, v jakém je teď, už se k němu s největší pravděpodobností nevrátíte. Všichni přece známe tu kouzelnou větu „Ono se to potom zrefaktoruje.“ A to ideálně samo od sebe.

Vrhněme se na to za pomocí Mocha, Chai a Sinon

Mocha je podle dokumentace jednoduchý, flexibilní a zábavný testovací framework (test runner), který běží v NodeJs. Chai je BDD/TDD asertovací knihovna, a přesto, že to v dokumentaci nepíší, je taky zábavná a snadná. Jednoduše řečeno nám Chai poskytuje syntaxi, ve které píšeme testy, a Mocha nám je spouští. Dále budeme potřebovat NodeJS aspoň ve verzi 7.6, která podporuje async/await. Na stubování a špionáž metod použíjeme sinon.

Praktický příklad

Napíšeme si třídu, která vytváří uživatele v systému.

Podle pravidel red, green, refactor nejprve musíme napsat padající test a až poté produkční kód. Naši třídu pojmenujme UserService. Jako první vytvoříme soubor user-service.spec.js a do něj vložíme náš první test, kde očekáváme existenci třídy UserService.

describe("UserService", () => {
   let userService;

   beforeEach(() => {
      userService = new UserService();
   });

   it('should instantiate', () => {
       expect(userService).not.to.be.undefined;
   });

});

Testy spustíme a ty podle očekávání spadnou, protože třída UserService neexistuje. Červená fáze je tedy za námi. Následuje fáze zelená, ve které se snažíme, aby nám testy prošly. Vytvoříme tedy soubor user-service.js s třidou UserService.

class UserService {
    
    constructor() {
        
    }
    
}
module.exports = UserService;

V testech třídu zahrneme const UserService = require('./user-service'); a testy spustíme znovu. Testy nyní projdou a jelikož není (zatím) co refaktorovat, začneme s další iterací a vytvoříme další padající test. Otestujeme, že UserService má metodu register.

it('should has register method', () => {
   expect(userService.register).not.to.be.undefined;
})

Spustíme testy, a ty (opět) spadnou. Červená fáze je za námi, jdeme na zelenou. Definujeme metodu register v třídě UserService.

class UserService {

   async register(username, password) {

   }

}

Spustíme testy, ty procházejí, a jelikož zase není co refaktorovat, začínáme s další iterací.

Validace uživatelského vstupu

Chceme validovat uživatelské vstupy. Pokud parametr username není string, očekáváme, že metoda register vyhodí výjimku. Napíšeme tedy znovu padající test.

describe('when user registered', () => {
   it('should throw validation error if username is not in valid format', async () => {
       await expect(userService.register({})).to.be.rejectedWith('Username is not a string!');
   });
})

Spustíme testy, ty padají. Pokračujeme zelenou fází . Na začátku jsme si řekli, že napíšeme jen tolik produkčního kódu, kolik nám stačí k tomu, aby testy prošly. Stačí nám tedy vyhodit patřičnou výjimku v metodě register.

class UserService {

   async register(username, password) {
       throw new Error('Username has to be a string!');
   }

}

Testy prošly a není co refaktorovat. Začínáme další iteraci, a tedy červenou fázi. Teď chceme ověřit, že při správném typu parametru username nám metoda register výjimku nevyhodí.

it('should NOT throw validation error if username is in valid format', async () => {
   await  expect(userService.register('validUsername')).not.to.be.rejectedWith('Username has to be a string!');
});

Spustíme testy, ty padají, protože register metoda vyhazuje výjimku při každém jejím zavolání. Následuje zelená fáze a nám nezbývá nic jiného než zkontrolovat parametr username a v případě, že parametr není string, tak vyhodit výjimku.

async register(username, password) {
   if(typeof username !== 'string') {
       throw new Error('Username has to be a string!');
   }
}

Spustíme testy, ty nyní prochází. Fázi refaktoringu přeskočíme, protože (stále) není co refaktorovat. Pokračujeme další iterací a napíšeme nový padající test. Vrhneme se na validaci parametru password, u kterého platí stejná pravidla jako pro username. Očekáváme, že metoda register vyhodí výjimku, pokud parametr password není správného typu (string).

it('should throw validation error if password is not in valid format', async () => {
   await expect(userService.register('validUsername', {})).to.be.rejectedWith('Password has to be a string!');
});

Spustíme testy, ty padají. Postupujeme do zelené fáze. Napíšeme opět co nejmenší kus produkčního kódu, který opraví padající testy. Vyhodíme tedy správnou výjimku v metodě register.

async register(username, password) {
   if(typeof username !== 'string') {
       throw new Error('Username has to be a string!');
   }

   throw new Error('Password has to be a string!');
}

Testy procházejí, není co refaktorovat a pokračujeme další iterací, kdy ověříme, že při správném typu parametru password metoda register výjimku nevyhodí.

it('should NOT throw validation error if password is in valid format', async () => {
   await expect(userService.register('validUsername', 'validPassword')).not.to.be.rejectedWith('Password has to be a string!');
});

Spustíme testy, ty padají a jdeme tedy do zelené fáze, kdy doimplementujeme stejnou podmínku, jakou jsme už implementovali pro parametr username.

async register(username, password) {
   if(typeof username !== 'string') {
       throw new Error('Username has to be a string!');
   }
   if(typeof password !== 'string') {
       throw new Error('Password has to be a string!');
   }
}

Spustíme testy, ty procházejí a jelikož jsme v refaktorovací fázi, trochu po sobě uklidíme a vytkneme validaci bokem.

async register(username, password) {
   this._throwIfCredentialsAreInIncorrectFormat(username, password);
}

_throwIfCredentialsAreInIncorrectFormat(username, password) {
   if (typeof username !== 'string') {
       throw new Error('Username has to be a string!');
   }
   if (typeof password !== 'string') {
       throw new Error('Password has to be a string!');
   }
}

Spustíme testy, ty stále prochází. Začínáme další iteraci.

Vytvoříme si UserRepository

Teď chceme otestovat, že v případě, kdy uživatel již v systému existuje, metoda register vyhodí výjimku. Aby třída UserService mohla zjistit, jestli daný uživatel existuje nebo ne, zeptá se UserRepository, kterou dostane v konstruktoru (DI princip). Vytvoříme si tedy “spy/stub” user repository pomocí knihovny sinon a předáme ji do konstruktoru.

describe("UserService", () => {
   let userRepository;
   let userService;

   beforeEach(() => {
       userRepository = {
           hasUser: sinon.stub(),
           createUser: sinon.spy()
       };
       userService = new UserService(userRepository);
   });

   ...

Teď si ověříme, že metoda register vyhodí výjimku, když uživatel již existuje.

it('should throw error if user already exists', async () => {
   userRepository.hasUser = sinon.stub().returns(true);
   await expect(userService.register('validUsername', 'validPassword')).to.be.rejectedWith('User already exists!');
});

Spustíme testy, ty padají a následuje tedy zelená fáze, kde zase jenom vyhodíme výjimku v register metodě, aby testy procházely.

async register(username, password) {
   this.throwIfCredentialsAreInIncorrectFormat(username, password);
   throw new Error('User already exists!');
}

Není co refaktorovat, začínáme novou iteraci.  Posledním krokem je otestovat, že v případě pokud jsou vstupy od uživatele v pořádku, a uživatel neexistuje v systému, podaří se nám ho úspešně vytvořit.

it('should properly register user', async () => {
   userRepository.hasUser = sinon.stub().returns(false);
   await userService.register('validUsername', 'validPassword');
   expect(userRepository.createUser).to.have.been.calledWith('validUsername', 'validPassword');
});

Testy spustíme, ty padají. Jdeme do zelené fáze a musíme zavolat createUser nad UserRepository a už doopravdy zkontrolovat, jestli uživatel neexistuje, jinak nám testy budou pořád padat.

async register(username, password) {
   this.throwIfCredentialsAreInIncorrectFormat(username, password);

   if(await this.userRepository.hasUser(username)) {
       throw new Error('User already exists!');
   }

   await this.userRepository.createUser(username, password);
}

Spustíme testy, ty prochází a následující fázi refaktoring neřeším, protože nejsou třeba už žádné úpravy. Tímto jsme zprovoznili veškerou požadovanou funkcionalitu. Nakonec smažu pomocné testy, které jsem vytvořil na začátku. Sloužily jen jako berle a už nejsou třeba.

it('should instantiate', () => {    // smažu
   expect(userService).not.to.be.undefined;
});

it('should has register method', () => {   // smažu
   expect(userService.register).not.to.be.undefined;
});

Teď už můžeme výsledný kód předat na code review.

Shrnutí

Všimněte si, že vůbec nepotřebujeme znát databázi, do které uživatele ukládáme. Pokud budete dodržovat SOLID principy, tak rozhodnutí ohledně databáze a dalších implementačních detailů můžete nechat až na vzdálenou budoucnost. Pokud se budete k testům chovat zodpovědně, bude se vám v noci lépe spát a váš kód i design aplikace bude v dobré kondici. Nebojte se psát jednoduché pomocné testy, které potom smažete. Nejsou zbytečné. Postupně vás navedou k vytváření tříd a metod.

Zdrojový kód celého příkladu najdete na githubu.

V příštím článku se podíváme detailněji na použité knihovny Mocha, Chai a Sinon.

Komentáře

Subscribe
Upozornit na
guest
20 Komentářů
Nejstarší
Nejnovější Most Voted
Inline Feedbacks
View all comments
Mates

Parádní, do kostky shrnutý jednoduchý příklad jak testovat.

Petr

To mi tedy přijde jako šílená blbost a neuvěřitelné plýtvání časem, abych testy prokazoval nedostatky o kterých vím. A ještě takto inkrementálně. Možná je to dobrá metoda pro cvičené opice v indii, nevím, ale pro inteligentního vývojáře tohle podle mě není.

Martin Hassman

Tak ten příklad byl záměrně jednoduchý, to jistě chápete.

Nicméně zdá se vám, že tímto postupem opravdu otestujete jen příklady, o kterých víte? (Tip: Uvažujte v čase. A rozsahu.)

V.Novák

Mně to taky přijde trošku přes ucho – když vím, že test neprojde, protože jsem ještě nenapsal to, co testuje, tak nemá vyýznam ho spouštět, ne? Dopíšu i testované – a PAK pustím testy, které mně otestují, co jsem napsal.
Klidně začnu testama, určitě jsou výborné, pomůžou s definicí, když se budu snažit udělat je pořádně a budou se hodit až za půl roku udělám nějaké změny – a ony přes x, y, z a bflm, psvz ovlivní můj dnešní kód – ale testovat to, o čem vím, že ještě není…

Martin Hassman

Hmm, myslím že teď jsme (snad) narazili na to nedorozumění, resp. na rozdílnost přístupu. Netestujete, co není. Vím, vypadá to tak (a můžete k tomu i tak přistupovat, ale pak celý proces popsaný v článku opravdu nemusí dávat smysl), vy totiž testujete to, co bude. Píšete test na to, co má vzniknout a s vědomím, že to (nejspíš brzy) vznikne a vy ještě než to vznikne pomáháte kódem zajistit (specifikovat/zkontrolovat), že to vznikne správně. Pokud tuhle premisu přijmete, na celý proces se budete dívat jinak. Nepíšete test, protože teď spadne, ale protože (za chvíli) projde. Ale je to o přístupu, pokud tuhle myšlenku nepřijmete, tak tu výhodu zřejmě neuvidíte a pak budete mít (svou) pravdu, že to (vaším pohledem) děláte zbytečně.

Vím, je to příliš filosofické, jinak to ale nevysvětlím. Rozlousknout by to mohla nějaká tvrdá data. Jak kvalitní kód vzniká, když tenhle postup “testy first” používáte vs. když ne. Nikdy jsem nehledal, zda existují, počítám že někdo to už mohl zkoušet zkoumat (měřit). Třeba se někdo přidá vhodný odkaz.

BTW to že test neprojde, když nemá projít, netestuje onen nenapsaný kód, ale onen hotový test. Test nyní nemá projít, ale napsali jsme ho dobře? To nejmenší co proto můžeme udělat je ho spustit a sledovat, že opravdu selhal. Časová ztráta minimální.

tacoberu

Já si s tebou dovolím nesouhlasit :-) TDD je o tom, že napíšeš testy pro kód tak, aby až někdy v budoucnosti ten kód změníš/odstraníš, tak aby sis toho všiml. A toho tím, že nenapíšu test abych prokazoval nedostatky o kterých vím nedosáhnu. A když to nebudu psát inkrementálně, tak vždycky něco přehlédnu.

Martin Hassman

TDD je o tom, že napíšeš testy pro kód tak, aby až někdy v budoucnosti ten kód změníš/odstraníš, tak aby sis toho všiml.

Ano, v tom se shodneme. Zbytku komentáře ale nerozumím 8-)

Jarda

Martin Hassman už to trochu napsal, ale já to chci zdůraznit. Hlavním přínosem pro mě je, že si můžu být jistý, že nemám false positive testy. Mnohokrát se mi stalo, že jsem měl chybu v kódu a test si krásně procházel a byl zelený. Protože jsem udělal v testu chybu, anebo jsem testoval něco jiného než jsem si myslel, že testuju.

Tím, že nejprve napíšu test první, poté si zkontroluju, že test neprochází a je červený. Teprve poté napíšu kód a přesvědčím se, že jsem opravil ten červený test.

Navíc pokud použiješ nějaký watcher, tak se testy spouští samy a vůbec nemusíš řešit časovou ztrátu. Na jednom monitoru konzole, na druhém editor. Uložím soubor s testem, testy se spustí a jsou červené, žádná časová ztráta, jen se stačí podívat jak to dopadlo.

Jirka

Zde jde o ověření správnosti testu. Kdyby to bylo jak píšete vy, riskujete následující scénář:

  1. Napíšu test. (třeba expect(true).isTruthy())
  2. Napíšu funkčnost, která ale dělá úplně něco jiného
  3. Pustím test a on projde
  4. Kód ale dělá něco úplně jiného, než jsem čekal

Proto se prvně píše test, který selhává. Tím vidím, že je potřeba nová funkčnost (nebo úprava stávající).
Ve chvíli kdy test prochází, vím že mám „hotovo“.

Jirka

Z vlastní zkušenosti vím, že to je právě ta „chyba“ tutoriálů. Jsou moc jednoduché, příjemcům to pak připadá zbytečné. Osvědčila se mi metoda školení tak, že účastníkům připravím nějaký již existující „projekt“ a chci po nich přidání / změnu funkčnosti.

Má to několik výhod:

  • Odstíním je od problému „jak vše zprovoznit“. (Zprovoznit v tu chvíli znamená git clone; npm install)
  • Nesvádí je to k „To je jednoduché, proč bych pro to měl psát test?“
  • Většinou pak příjemce přijde na to, že testy lze vnímat jako „dokumentaci“

Stále ovšem bojuji s tím, abych příjemce přesvědčil o tom, že testy nejsou časově náročné. Máte nějaké zkušenosti, případně i rady?

Ondřej Novák

Já stále považuji psaní testu dopředu jako zbytečně časově náročný úkol. Zvlášť pokud jde o nějaké prototypovaní a hledání cesty vývojem. Typická ukázka: napíšu testy. Začnu psát kód. Počas psaní kódu zjistím, že je to totální ptákovina a projekt smažu (opustím). Škoda času na testy. V mém případě je nadpoloviční počet případů jak vývoj začíná. Pokud překonám tuto první fázi, vznikne funkční prototyp. Na něj napíšu testy a začnu ho leštit a vylepšovat

tacoberu

Na prototypování se testy nepíší. Minimálně ne tak důsledné.

suwer

TDD predevsim neni vhodne pro prototypy, kde si programator ujasnuje dalsi postup. Naopak je vice nez vhodne pro psani ostreho kodu. A tady casto vznika nepochopeni mezi zastanci a odpurci TDD :-)

Franta.

To spíš naopak, ne? Podle mě je hlavní nepochopení to, že unit testy se píší proto, abychom věděli že kód funguje. K tomu ve skutečnosti slouží integrační a end-to-end testy. Unit testy jsou od toho, abychom kód dobře navrhli a dal se v budoucnu udržovat. I když programátor unit testy po dopsání kódu smaže, tak mají smysl.

suwer

Jak sam pises, unit testy slouzi pro spravny navrh objektu (unit). Ale o tom prototypovani vubec neni, proto je zde TDD nevhodne. Pokud uz existuje priblizne jasna myslenka a zacne se skladat ostry kod, tak prichazi chvile TDD.

Unit testy urcite nedoporucuji mazat. Pro budouciho vyvojare je to prvni a nejrychlejsi moznost, jak pochopit kod a jak si otestovat svoje zasahy do nej. Pak nasleduji IT testy, ktere z podstaty mivaji radove narocnejsi spousteni. E2E testy uz pokryvaji konkretni business casy a samozrejme zaberou asi nejvic casu.

uetoyo

Pěkný článek. Nejdřív jsem přehlédl odkaz na github, takže jsem chtěl lamentovat, že by bylo dobré někde napsat, jak to workflow vlastně zprovoznit :) Nicméně bude příště popsané jak zapojit webpack, babel atd.?
Dík!

Oldis

no to asi snad neni ani potreba, devstacky existujou, treba este, ale to je uz v utlumu protoze vznikla alternativa

uetoyo

Jaká? Sem s ní!

Enum a statická analýza kódu

Mám jednu univerzální radu pro začínající programátorty. V učení sice neexistují rychlé zkratky, ovšem tuhle radu můžete snadno začít používat a zrychlit tak tempo učení. Tou tajemnou ingrediencí je statická analýza kódu. Ukážeme si to na příkladu enum.

Pocta C64

Za prvopočátek své programátorské kariéry vděčím počítači Commodore 64. Tehdy jsem genialitu návrhu nemohl docenit. Dnes dokážu lehce nahlédnout pod pokličku. Chtěl bych se o to s vámi podělit a vzdát mu hold.