Tesztvezérelt fejlesztés JavaScriptben
Egy konkrét példán keresztül szeretném bemutatni, hogy miként lehet JavaScriptben tesztvezérelt fejlesztés szerint programozni.
Mit jelent a tesztvezérelt fejlesztés?
A tesztvezérelt fejlesztés (angolul: Test Driven Development, röviden: TDD) egy szoftverfejlesztési technika. Szerencsére ma már magyarul is számtalan cikk foglalkozik a témával, emiatt az elméleti hátterét csak nagyon röviden szeretném ismertetni. A TDD nem tesztelési, hanem fejlesztési módszertan. A lényege, hogy előbb készítjük el a tesztet, majd utána a neki megfelelő kódot. A tesztvezérelt fejlesztés menetét legegyszerűbben úgy tudjuk megfogalmazni, hogy a fejlesztés irányát a tesztek határozzák meg, első lépésként a specifikációt tesztekké alakítjuk, majd megírjuk azt a kódot, amely kielégíti a teszteket.Mi ellen nem véd?
Tehát a TDD-ben a specifikációt alakítjuk át tesztekké, majd a tesztekben elvárt működést valósítjuk meg. Ám ez nem feltétlen azt jelenti, hogy ezután kizárólag hibátlan kódot készítünk, hanem azt, hogy a specifikációnak megfelelő programot írunk. Rosszul megfogalmazott specifikáció, rosszul megírt kódot eredményez.Miért jó ez mégis nekünk?
A tesztek segítenek abban, hogy bármikor, bátran módosítsuk a programunkat. Nem kell attól félni, hogy egy-egy utólagos módosítás elrontja a már meglévő programunkat. Ha módosul a specifikáció, akkor a teszteseteket kiegészítjük, esetleg átalakítjuk, majd utána elvégezzük a kódon a szükséges módosításokat. Végül, ha minden teszt hibátlanul lefut, akkor biztosak lehetünk abban, hogy semmit sem rontottunk el. Szerintem nem lehet azt kellően kihangsúlyozni, hogy fejlesztés közben mekkora segítség az, hogy a programunk helyes működését bármikor, pillanatok alatt ellenőrizni tudjuk.A konkrét feladat
Hosszan kerestem, hogy mi lenne az ideális feladat, amivel a TDD-t be lehet mutatni. Ha túl egyszerű kódon mutatom be a tesztelést, akkor mindenki csak legyint. Mi értelme az egyértelműen helyes függvényről bebizonyítani, hogy tényleg helyes? Ám, ha túl komplex a példa, akkor nehezen látszódik a lényeg. Remélem, sikerült megtalálni a középutat... Egy nagyon egyszerű sablonrendszert fogunk készíteni JavaScriptben. Megadunk egy sablont, megadjunk a behelyettesítendő értékeket, végül kinyerjük az eredményt.var tpl = new JSTemplate(
'<div class="{$cls}">',
'<foreach name="$users">',
'<a href="?id={$id}">{$name} ({$age})</a><br />',
'</foreach>',
'</div>');
tpl.append({
'cls': 'users',
'users': [{
'id': 1,
'name': 'John',
'age': 21
}, {
'id': 2,
'name': 'Bob',
'age': 30
}]
});
document.getElementById('ct').innerHTML = tpl.fetch();
// <div class="users"><a href="?id=1">John (21)</a><br /><a href="?id=2">Bob (30)</a><br /></div>
Az alapötletet az ExtJS 1.0.1 Ext.Template osztályából vettem. Hogy ne vesszünk el a részletekben, az ottani kódot jelentősen egyszerűsítettem.
Specifikáció
Alapértelmezetten a sablont egy string-ként várjuk, de lehetőséget kell biztosítani tetszőlegesen több string megadására is, illetve string-eket tartalmazó tömböt is adhassunk át. Ezekben az esetekben a string-ek összefűzése legyen az alap sablon. A változókat{$varName}
alakban kell megadni, lehessen használni módosítókat {$varName:upper}
, illetve lehessen paramétereket is átadni {$varName:substr(1, 2)}
.
A módosítókat külön kell kezelni, lehetőséget kell biztosítani, hogy azokat az eredeti kód módosítása nélkül is bővíthessük.
Listák feldolgozására is szükségünk van, ezeket a <foreach name="$varName">...</foreach>
alakban lehessen megadni. Első körben, a listában listákkal nem foglalkozunk.
Az osztály neve legyen JStemplate
, a konstruktor paraméterébe adjuk meg a sablont. Legyen egy append
nevű függvény, ahol egy objektumban átadjuk az összes változót, a fetch
adja vissza a behelyettesítés eredményét.
Ismerkedés a Jasmine framework-kel
Mielőtt nekikezdünk a munkának, gyorsan nézzük meg, hogy JavaScript alatt milyen segítséget kapunk az egységtesztek készítésére. Jelen példában a Jasmine nevű rendszert fogjuk használni, számtalan függvényt kapunk segítségül, nekünk a jelenlegi feladathoz elegendő lesz csak néhánnyal megismerkedni:describe
- létrehozunk egy csomagot (suite)it
- létrehozunk egy specifikációt (spec)afterEach
- megadható, hogy specifikációk után fusson-e le még valamiexpect(...).toEqual(...)
- ellenőrzés, egyenlőségvizsgálat
Az első tesztek
Ha TDD szerint programozunk, akkor első lépés a legrövidebb teszt elkészítése, ami hibára fut. Ez legtöbb esetben nem más, mint a nem létező osztály példányosítása.describe('init', function () {
it('Start', function () {
new JSTemplate();
});
});
Ha megnézzük a tesztoldalt, akkor láthatjuk, hogy tényleg hibás.
A teszt javítása sem lesz hosszabb:
var JSTemplate = function () {};
Gondolom, most többekben felmerül az a kérdés, hogy ennek van-e bármi értelme? Két dolgot vegyünk figyelembe. Először is, eddig alig tíz másodpercet kódoltunk, így ne várjunk még túl nagy eredményeket. Másodszor, ez tényleg csak az első lépés volt.
Nézzük tovább a specifikációt, ha egy változó nélküli sablont adunk át, akkor egy az egyben ugyanazt szeretnénk visszakapni. Az előző tesztet írjuk át.
describe('init', function () {
it('Kezdeti parameteratadas', function () {
var str = 'Szia vilag!', t;
t = new JSTemplate(str);
expect(t.fetch()).toEqual(str);
});
});
Gyors váltás a böngészőre, ahol láthatjuk, hogy az új tesztünk hibás lett. Nézzük a hozzá tartozó kódot.
var JSTemplate = function (html) {
this.html = html;
};
JSTemplate.prototype = {
fetch: function () {
return this.html;
}
};
Jelenleg még nagyon egyszerű a kódunk, emiatt a refaktorálásra még nincs szükség, de ne feledjük, hogy az iteráció utolsó lépése a refaktorálás!
Következő lépésben vegyük azt az esetet, amikor egy tömböt adunk át:
...
t = new JSTemplate([str, str]);
expect(t.fetch()).toEqual(str + str);
A hozzá tartozó megoldás:
var JSTemplate = function (html) {
if (html instanceof Array) {
html = html.join('');
}
...
Végül, amikor több paramétert adunk át:
...
t = new JSTemplate(str, str);
expect(t.fetch()).toEqual(str + str);
...
És a gyors megoldása:
...
} else if (arguments.length > 1) {
html = Array.prototype.join.call(arguments, '');
...
A cikkben igyekszem csak azokat a forráskód részleteket leírni, amik módosulnak, de hogy biztosan követhető legyen a folyamat, feltöltöttem a GitHub-ra a teljes kódot, a fontosabb állomásoknál egy-egy commit-tal.
Reguláris kifejezések
Ha ezzel megvagyunk, akkor térjünk át a reguláris kifejezésekre, a string-ekből szedjük ki a változókat. Én a reguláris kifejezésekhez mindig készítek teszteket, hiába privát elem, mégiscsak ez a legkönnyebben elrontható része a kódnak, ám a legkönnyebben tesztelhető is. Szükségünk van a teljes mintára, illetve a változónévre:describe('regexp1', function () {
var template = new JSTemplate(),
re = template.variableRegexp;
it('Szuro nelkuli vatlozok', function () {
var str = '<div id="{$id}" class="{$class}">{$content}</div>',
matches = [],
names = [],
match;
while ((match = re.exec(str)) !== null) {
matches.push(match[0]);
names.push(match[1]);
}
expect(matches).toEqual(['{$id}', '{$class}', '{$content}']);
expect(names).toEqual(['id', 'class', 'content']);
});
});
A neki megfelelő reguláris kifejezés:
JSTemplate.prototype = {
variableRegexp: /\{\$(\w+)\}/g,
...
Várakozásunknak megfelelően a teszt hibátlanul lefut.
Változó behelyettesítés
Most választhatunk. Vagy folytatjuk a reguláris kifejezés továbbfejlesztését és elkészítjük a módosítókat, majd a paramétereket vagy a sablonrendszert elkészítjük módosítók nélkül. Az utóbbit javaslom. Nézzük a következő tesztet:describe('simple', function () {
it('Egyszeru valtozok 1.', function () {
var str = 'Szia {$name}!',
obj = {
'name': 'vilag'
},
result = 'Szia vilag!',
t = new JSTemplate(str);
t.append(obj);
expect(t.fetch()).toEqual(result);
});
});
Kezd hasonlítani a cikk elején lévő példához. Készítsük el gyorsan a hozzá tartozó kódot is:
JSTemplate.prototype = {
...
append: function (values) {
this.values = values;
return this;
},
fetch: function () {
var me = this, re = this.variableRegexp;
return this.html.replace(re, function (match, name) {
return me.values[name];
});
}
};
Közben ne felejtsük el, hogy...
- elkészítjük a tesztet
- megnézzük, hogy valóban hibát jelez
- megírjuk a kódot
- ismét megnézzük, hogy kijavult-e a teszt
- ha szükséges, akkor refaktorálás, kódszépítés
- refakorálás után újra megnézzük, hogy nem rontottunk-e el valamit
További tesztek
Sokan mindig csak annyi tesztet készítenek, amennyi feltétlenül szükséges. Ebben a kérdésben én engedékenyebb vagyok, alkalmanként készítek olyan teszteket, amelyek már az elkészítésekor is helyesen lefutnak. Ha később törik a kód, akkor előfordulhat, hogy pontosabb képet kapunk a hiba okáról. A refaktorálás részeként a teszteket bővítem....
it('Egyszeru valtozok 2.', function () {
var str = '<div id="{$id}" class="{$cls}">{$content}</div>',
obj = {
'id': 'v1',
'cls': 'title',
'content': 'Hello!'
},
result = '<div id="v1" class="title">Hello!</div>',
t = new JSTemplate(str);
t.append(obj);
expect(t.fetch()).toEqual(result);
});
it('Egyszeru valtozok 3.', function () {
var str = '{$foo}{$foo}{$foo}{$boo}',
obj = {
'foo': 'f',
'boo': 'b'
},
result = 'fffb',
t = new JSTemplate(str);
t.append(obj);
expect(t.fetch()).toEqual(result);
});
...
Nem létező paraméter
Az előbbi tesztgyártás közben jutott eszembe, hogy készítek egy olyan tesztet, ahol a nem létező paramétert vizsgálom. Az elvárás az üres string lenne, ezzel szemben az eredmény az'undefined'
string lett. A specifikáció hiányosságait alkalmanként néhány "felesleges teszt" pótolhatja. A teszt:
...
it('Undefined', function () {
var str = 'Szia {$nev}!',
obj = {
'name': 'vilag'
},
result = 'Szia !',
t = new JSTemplate(str);
t.append(obj);
expect(t.fetch()).toEqual(result);
});
...
A hozzá tartozó kód:
...
return this.html.replace(re, function (match, name) {
return me.values[name] || '';
});
...
Privát metódusok
Gyakori kérdés az egységtesztekkel kapcsolatban, hogy a privát metódusokat hogyan teszteljük? (Most attól az apróságtól tekintsünk el, hogy valójában JavaScript-ben nincs is privát metódus. Dokumentációs kommenttel jelezhetjük a metódusunk láthatóságát, megfelelő IDE ezt a függvénylistában jelzi is nekünk.) A válasz roppant egyszerű, és rendkívül kényelmes. Nem kell tesztelni a privát elemeket. Egy jó specifikáció arról szól, hogy mit valósítsunk meg, nem pedig arról, hogy hogyan valósítsuk meg. Amíg a programunk megfelel a specifikációnak, addig a privát metódusokat nyugodtan alakíthatjuk, nem rontunk el semmit. Ennek szellemében, akkor kicsit alakítsuk át a fetch függvényünket....
fetch: function () {
var me = this, re = this.variableRegexp;
return this.html.replace(re, function (match, name) {
return me.getValueByName(name);
});
},
/** @private */
getValueByName: function (name) {
return this.values[name] || '';
}
...
Ismét reguláris kifejezések
A következő lépésben zárjuk le a változókhoz tartozó reguláris kifejezés vizsgálatát, először a paraméter nélküli módosítók:...
it('Szuros vatlozo', function () {
var str = 'Szia {$name:upper}!',
match = re.exec(str);
expect(match[0]).toEqual('{$name:upper}');
expect(match[1]).toEqual('name');
expect(match[2]).toEqual('upper');
});
...
A hozzá tartozó megoldás:
JSTemplate.prototype = {
variableRegexp: /\{\$(\w+)(?:\:(\w+))?\}/g,
...
Majd a paraméteres verzió:
...
it('Parameteres vatlozo', function () {
var str = '{$content:truncate(10)}',
match = re.exec(str);
expect(match[0]).toEqual('{$content:truncate(10)}');
expect(match[1]).toEqual('content');
expect(match[2]).toEqual('truncate');
expect(match[3]).toEqual('10');
});
...
A végleges reguláris kifejezés:
JSTemplate.prototype = {
variableRegexp: /\{\$(\w+)(?:\:(\w+)(?:\((.*?)\))?)?\}/g,
...
Álljunk meg egy pillanatra! Azt várnánk, hogy minden szépen kizöldül, ám hibára futottunk. Azok, akik jártasak a reguláris kifejezésekben biztos tudják, hogy mi a hiba. A többieknek előbb, még egy gyors ellenőrzést ajánlok. Futassuk csak ezt a tesztet le önállóan. Azaz kattintsuk a hibás tesztre. Meglepődtünk? Ha önállóan futtatjuk a tesztet, akkor nincs hiba!
A tesztektől egy fontos elvárás, hogy függetlenek legyenek egymástól, illetve ne módosítsák a környezetüket. Jelen példában ez nem áll fenn, a második teszt elrontja a harmadik tesztet. Egy gyors kiegészítés, amit minden teszt után el kell indítani:
describe('regexp1', function () {
var template = new JSTemplate(),
re = template.variableRegexp;
afterEach(function () {
re.lastIndex = 0;
});
...
Ezzel a kiegészítéssel ismét az összes teszt zöldre váltott!
Módosítók
Az előző fejezet után egy könnyebb téma következik, készítsünk el néhány módosítót, amivel be tudjuk mutatni a sablon rendszerünket. Ezek a függvények tesztelés szempontjából is hálásak. Néhány egyszerű string műveletről lesz csak szó. Először jöjjön a csupa nagybetűre alakítás:describe('filters', function () {
var fn = JSTemplate.filters, str = 'Szia vilag!';
it('upper', function () {
expect(fn.upper(str)).toEqual('SZIA VILAG!');
});
});
A megvalósítás.
JSTemplate.filters = {
upper: function (str) {
return String(str).toUpperCase();
}
};
Kisbetű és a megvalósítás.
...
it('lower', function () {
expect(fn.lower(str)).toEqual('szia vilag!');
});
...
lower: function (str) {
return String(str).toLowerCase();
}
Végül a substr és a truncate:
...
it('substr', function () {
expect(fn.substr(str, 5)).toEqual('vilag!');
expect(fn.substr(str, 0, 5)).toEqual('Szia ');
});
...
substr: function (str, start, length) {
return String(str).substr(start, length);
}
...
it('truncate', function () {
expect(fn.truncate(str, 100)).toEqual('Szia vilag!');
expect(fn.truncate(str, 10)).toEqual('Szia vi...');
});
...
truncate: function (str, length) {
str = String(str);
return str.length > length - 3 ? str.substring(0, length - 3) + '...' : str;
}
A módosítók használata
Nézzük, hol tartunk! A reguláris kifejezéssel már tudunk módosítókat és paramétereket is lekezelni, és vannak elkészített módosítók. Adja magát, hogy a következő lépés az legyen, hogy a sablonrendszer is használja fel az előbbieket:describe('filtered', function () {
it('Szuros valtozo', function () {
var str = 'Szia {$name:upper}!',
obj = {
'name': 'vilag'
},
result = 'Szia VILAG!',
t = new JSTemplate(str);
t.append(obj);
expect(t.fetch()).toEqual(result);
});
});
Majd a megvalósító kód:
...
fetch: function () {
var me = this, re = this.variableRegexp;
return this.html.replace(re, function (match, name, filter) {
var value = me.getValueByName(name);
if (filter && JSTemplate.filters[filter]) {
value = JSTemplate.filters[filter].call(me, value);
}
return value;
});
},
...
Adjunk meg egy paramétert is:
describe('params', function () {
it('Parameteres valtozo', function () {
var str = '<div class="{$cls:lower}">{$content:truncate(20)}</div>',
obj = {
'cls': 'Content',
'content': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
},
result = '<div class="content">Lorem ipsum dolor...</div>',
t = new JSTemplate(str);
t.append(obj);
expect(t.fetch()).toEqual(result);
});
});
A hozzá tartozó kód:
...
return this.html.replace(re, function (match, name, filter, param) {
var value = me.getValueByName(name);
if (filter && JSTemplate.filters[filter]) {
value = JSTemplate.filters[filter].call(me, value, param);
}
return value;
});
...
Több paraméter esetén már a paraméterek különválasztásával is foglalkozni kell. A teszt:
...
it('Tobbparameteres valtozo', function () {
var str = '<p>{$content:substr(6, 5)}</p>',
obj = {
'content': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
},
result = '<p>ipsum</p>',
t = new JSTemplate(str);
t.append(obj);
expect(t.fetch()).toEqual(result);
});
...
Végül a kód:
...
fetch: function () {
var me = this, re = this.variableRegexp;
return this.html.replace(re, function (match, name, filter, param) {
var value = me.getValueByName(name), params;
if (filter && JSTemplate.filters[filter]) {
params = me.createFilterParams(value, param);
value = JSTemplate.filters[filter].apply(me, params);
}
return value;
});
},
/** @private */
createFilterParams: function (value, param) {
var result = String(param || '').split(','), i;
for (i = 0; i < result.length; ++i) {
result[i] = result[i].trim();
}
result.unshift(value);
return result;
}
...
Továbbra is minden zöld. Itt meg lehet jegyezni, hogy talán a számmá alakítással is foglalkozhatnánk. Ha tudunk olyan tesztet készíteni, ami a jelenlegi megoldással hibára fut, akkor készítsük el! Ám ha csak egy esetleges későbbi probléma miatt módosítanánk a kódon, akkor ezt a módosítást halasszuk el.
Készítsünk még egy tesztet, ahol több változóval is dolgozunk, nehogy ismét abba a hibába essünk, hogy elsőre helyes eredményt kapunk a reguláris kifejezéssel, másodszorra meg nem.
...
it('Tobb tobbparameteres valtozo', function () {
var str = '<p>{$content:substr(6, 5)}</p><p>{$content:substr(6, 11)}</p>',
obj = {
'content': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
},
result = '<p>ipsum</p><p>ipsum dolor</p>',
t = new JSTemplate(str);
t.append(obj);
expect(t.fetch()).toEqual(result);
});
Foreach
Remélem, hogy már látszódik, hogy nagyon közel vagyunk a végéhez. Szükségünk van egy újabb reguláris kifejezésre, amely kiszedi a sablonunkból a foreach tag-et.describe('regexp2', function () {
var template = new JSTemplate(),
re = template.foreachRegexp;
afterEach(function(){
re.lastIndex = 0;
});
it('Ciklus ellenorzes', function () {
var str = '<ul><foreach name="$list"><li>{$content}</li></foreach></ul>',
match = re.exec(str);
expect(match[0]).toEqual('<foreach name="$list"><li>{$content}</li></foreach>');
expect(match[1]).toEqual('list');
expect(match[2]).toEqual('<li>{$content}</li>');
});
});
Az előző reguláris kifejezés után ez már gyerekjáték.
JSTemplate.prototype = {
...
foreachRegexp: /<foreach name="\$(\w+)">(.*)<\/foreach>/g,
...
Készítsük el a tesztet, ahol már ezt használni fogjuk:
describe('foreach', function () {
it('Valtozo listaval 1.', function () {
var str = '<h1>{$header}</h1><ul><foreach name="$list"><li>{$content}</li></foreach></ul>',
obj = {
'header': 'Lista',
'list': [{
'content': '1.'
}, {
'content': '2.'
}, {
'content': '3.'
}]
},
result = '<h1>Lista</h1><ul><li>1.</li><li>2.</li><li>3.</li></ul>',
t = new JSTemplate(str);
t.append(obj);
expect(t.fetch()).toEqual(result);
});
});
A megvalósításhoz egy apró trükkre lesz szükségünk, a foreach belsejét egy másik JSTemplate objektummal valósítjuk meg, azaz:
...
fetch: function () {
...
return this.doForeach().replace(re, function (match, name, filter, param) {
...
});
},
/** @private */
doForeach: function () {
var me = this, re = this.foreachRegexp;
return this.html.replace(re, function (match, name, content) {
var list = me.getListValueByName(name), i, subTemplate, result;
if (!list.length || !content) {
return '';
}
result = [];
subTemplate = new JSTemplate(content);
for (i = 0; i < list.length; ++i) {
result.push(subTemplate.append(list[i]).fetch());
}
return result.join('');
});
},
/** @private */
getListValueByName: function (name) {
if (!this.values[name]) {
return [];
}
return [].concat(this.values[name]);
},
...
Ezzel akkor készen is lennénk, még utoljára adjuk hozzá azt a tesztet, amivel a cikk indult:
...
it('Valtozo listaval 2.', function () {
var template = [
'<div class="{$cls}">',
'<foreach name="$users">',
'<a href="?id={$id}">{$name} ({$age})</a><br />',
'</foreach>',
'</div>'],
obj = {
'cls': 'users',
'users': [{
'id': 1,
'name': 'John',
'age': 21
}, {
'id': 2,
'name': 'Bob',
'age': 30
}]
},
result = '<div class="users"><a href="?id=1">John (21)</a><br /><a href="?id=2">Bob (30)</a><br /></div>',
t = new JSTemplate(template);
t.append(obj);
expect(t.fetch()).toEqual(result);
});
...
Az utoljára hozzáadott teszt is hibátlanul lefutott, elkészültünk!
Még egy kis kitérő
Ha ezt a kódot most félreteszed, majd később majdnem ismeretlenül ismét hozzányúlsz, akkor is bátran módosíthatod, mert biztos lehetsz benne, hogy nem törhetsz el működő kódot. A cikk elején szándékosan emeltem ki, hogy ne azt várjuk, hogy a módszerrel hibátlan kódot tudunk írni, hanem azt, hogy egy könnyen módosítható kódhoz jutunk. Hogy ez a gyakorlatban mit jelent? Miután az elkészült kódot elkezdjük használni, hamar megtapasztalhatjuk, hogy annak ellenére, hogy a foreach XML szerű alakban várjuk, ha két szóközt teszünk a foreach és a name közé, akkor már nem illeszkedik a mintára. Semmi gond, a specifikációnk valóban erre nem tért ki. Készítsünk egy tesztet a feladathoz:...
it('Felesleges white-space-ek', function () {
var str = '<foreach name="$list" ></foreach>',
match = re.exec(str);
expect(match[0]).toEqual(str);
expect(match[1]).toEqual('list');
expect(match[2]).toEqual('');
});
...
Majd a hozzá tartozó kód:
...
foreachRegexp: /<foreach\s+name="\$(\w+)"\s*>(.*)<\/foreach>/g,
...
Futassuk gyorsan le az összes tesztet. Mindegyik teszt hibátlanul lefutott, tehát ezzel a módosítással nem rontottunk el semmit.
Természetesen kisebb-nagyobb módosításokra biztos szükség lenne ahhoz, hogy ezt a kódot valós környezetben is használjuk, az Undefined résznél elég elnagyolt voltam, ennek a pontosítása megnézhető itt. További apróságok is lennének, de azokat már az olvasóra bízom. :)