Jak na TDD v JavaScriptu
Publikováno: 2.2.2018
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 konké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 i přes to, ž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. Texty 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í vyjímku. 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ázi . Na začátku jsme si řekli, že napíšeme jen tolik produkčního kódu kolik nám stačí k tomu aby tesly prošly. Stačí nám tedy vyhodit patřičnou vyjímku 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 vyjímku 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 vyhazujeme vyjímku 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 vyjímku.
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í vyjímku, 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 vyjímku 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 vyjímku 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, tak metoda register vyhodí vyjímku. 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í vyjímku, 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 vyjímku 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(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.