Testing de componentes Flight
diciembre 03 2014 JavaScript Flight.js , Testing , Jasmine
Esta es la segunda parte del post de Aplicaciones desacopladas con Flight JS. En esta segunda parte desarrollaremos las pruebas unitarias para los componentes de nuestra aplicación de ejemplo.
La aplicación de ejemplo que desarrollamos en el post anterior puede crearse con Yeoman. Hay un generador para Flight que podemos instalar de manera global.
$ npm install -g generator-flight
Este generador nos permite crear aplicaciones, componentes, mixins y páginas
$ flight <app-name>
$ flight:component <component-name>
$ flight:mixin <mixin-name>
$ flight:page <page-name>
El generador instala algunas dependencias usando bower y otras usando npm
Con Bower instala:
- Flight, RequireJS, Jasmine jQuery y Jasmine Flight
Con npm instala:
- Gulp, el runner de pruebas unitarias de Karma, y el servidor de archivos estáticos de Node.
Las dependencias que instala con npm son para poder ejecutar las pruebas unitarias de los componentes Flight con Jasmine. Las pruebas se ejecutan usando Karma que nos permite ejecutar las pruebas en un navegador en modo headless con PhantomJS.
En este post explicaré como ejecutar los specs para componentes de Flight sin Karma, ya que considero que no es necesario ejecutar pruebas en modo 'headless' para los componentes. En su lugar podemos escribir pruebas de aceptación con Codeception que incluyan el funcionamiento de los componentes (que también pueden hacerse 'headles' con PhantomJS). Lo cuál explicaré en el siguiente post.
La configuración que usaré resulta excesiva si es que ya probaste el generador y te dejó todo listo para usar Karma. Para hacer que las pruebas unitarias funcionen debemos hacer una combinación de paquetes de bower instalados con npm, ya que el objetivo es evitar el navegador y que nuestros tests sean lentos. Si no te interesa evitar el navegador puedes saltar a la parte de proveedores de datos y generación de datos para pruebas.
Evitando el navegador
El primer problema es configurar un ambiente similar al de un navegador. Esto significa que variables como window
y
document
existan en el espacio de nombres global
de Node. Esto se puede lograr con los paquetes jsdom
y jQuery
# specs-runner.js
var jQuery;
var jsdom = require('jsdom');
// Setup window and document, jQuery will need them to work properly
global.window = jsdom.jsdom().parentWindow
global.document = global.window.document;
// Add jQuery and $ to the global space
jQuery = require('jquery');
global.jQuery = global.$ = jQuery;
El siguiente paso es configurar RequireJS, ya que en el navegador nuestros componentes lo usan. Debemos tener la
misma configuración en Node para que funcionen, y agregar la funcion define
al espacio global.
# specs-runner.js
var requirejs = require('requirejs');
// Use the same value you use in the browser for 'paths' key.
requirejs.config({
baseUrl: './web/js',
nodeRequire: require,
paths: {
'flight': 'vendor/flight',
'store': 'src/store',
'component': 'src/component'
}
});
global.define = requirejs;
Debemos también instalar y configurar Jasmine para Node en su versión beta 4, ya que es la que usa Jasmine en su versión 2 y que necesitamos para poder usar Jasmine para jQuery. Debemos agregar algunas variables y funciones al espacio global para que funcionen igual que en el navegador.
# specs-runner.js
var jasmine;
// Setup Jasmine
jasmine = require('jasmine-node/lib/jasmine-node/jasmine-loader.js');
global.jasmine = jasmine;
// map jasmine.Env to global namespace
jasmineEnv = global.jasmine.getEnv();
for (key in jasmineEnv) {
if (jasmineEnv[key] instanceof Function) {
global[key] = jasmineEnv[key];
}
};
global.jasmine.addMatchers = jasmineEnv.addMatchers;
El siguiente paso es configurar Jasmine para Flight. Al igual que en los otros casos es necesario registrar algunas funciones en el espacio global.
# specs-runner.js
var jasmineFlight;
jasmineFlight = require('jasmine-flight');
// map jasmine-flight methods to global namespace
for (key in jasmineFlight) {
if (jasmineFlight[key] instanceof Function) {
global[key] = jasmineFlight[key];
}
};
Por último para poder crear 'spies' para eventos debemos usar Jasmine para jQuery y agregar spyOnEvent
al espacio
global.
# specs-runner.js
jasminejQuery = require('jasmine-jquery/lib/jasmine-jquery');
global.spyOnEvent = window.spyOnEvent;
En el caso de Jasmine para Flight, no pude configurarlo para que use la función require
de RequireJS en lugar del
require
de Node. Si sabes de alguna forma te agredeceré que lo expongas en los comentarios. Así que el demo usa un
fork mio donde reemplazo las apariciones de require
por requirejs
. Para esto debemos agregar la función al espacio
global de nombres global.requirejs = requirejs
. Así, el contenido del archivo package.json
sería el siguiente:
{
"name": "flight_demo",
"version": "1.0.0",
"devDependencies": {
"jasmine-flight": "https://github.com/MontealegreLuis/jasmine-flight/archive/no_browser.tar.gz",
"jasmine-jquery": "https://github.com/velesin/jasmine-jquery/archive/2.0.5.tar.gz",
"jasmine-node": "^2.0.0-beta4",
"jquery": "^2.1.1",
"jsdom": "^1.3.1",
"requirejs": "~2.1.11"
},
"scripts": {
"test": "node ./specs-runner.js"
}
}
Puedes revisar el contenido completo del archivo specs-runner.js
aquí.
Testing de componentes
Cuando hacemos tests a los componentes de Flight es muy importante hacer pruebas a la interfaz del componente y no a su comportamiento interno (es una recomendación que se puede aplicar al testing en general). Esto asegura que no tengamos que modificar las pruebas cada que modificamos el código del componente. Desde el punto de vista de la interfaz, los componentes de flight se suscriben a eventos y en ocasiones, como respuesta, publican eventos, esa es su interfaz.
Pruebas a nuestro componente de datos
Jasmine Flight nos proporciona métodos para crear specs para componentes de Flight. La primera diferencia con un spec
de Jasmine tradicional es que reemplazamos describe
por la función describeComponent
. Dentro de describeComponent
en el beforeEach
podemos llamar al método setupComponent
que nos permite pasar a nuestro componente los valores de
sus atributos, de forma similar al método attachTo
. En nuestro ejemplo creamos dos fakes uno para catalog
y
otro para cart
. El spec más simple que podemos generar verifica que el componente esté definido (toBeDefined
).
# web/js/spec/component/DataShoppingCart.spec.js
describeComponent('component/DataShoppingCart', function () {
beforeEach(function () {
this.setupComponent({
catalog: {allProducts: function(){}, productOfId: function(){}},
cart: {addItem: function() { return {} }}
});
});
it('should be defined', function () {
expect(this.component).toBeDefined();
});
});
En el siguiente test verificamos que el componente publique el evento data.whenItemIsAddedToCart
cuando se publique
el evento ui.whenProductIsAdded
. Para lograrlo creamos un spy para el evento ui.whenProductIsAdded
. Disparamos
el evento ui.whenProductIsAdded
en el nodo HTML asociado con el componente, y verificamos que el componente publique
el evento esperado.
# web/js/spec/component/DataShoppingCart.spec.js
it("should listen for 'ui.whenProductIsAdded' events and trigger 'data.whenItemIsAddedToCart' event", function () {
spyOnEvent(this.$node, 'data.whenItemIsAddedToCart');
this.$node.trigger('ui.whenProductIsAdded', {});
expect('data.whenItemIsAddedToCart').toHaveBeenTriggeredOn(this.$node);
});
El componente también debe publicar el evento data.whenProductsAreLoaded
al ejecutar el método loadProducts
. El
código es similar solo que en lugar de disparar un evento en el nodo HTML del componente, ejecutamos el método y
verificamos que el evento haya sido publicado.
# web/js/spec/component/DataShoppingCart.spec.js
it("should trigger 'data.whenProductsAreLoaded' event when method 'loadProducts' is executed", function () {
spyOnEvent(this.$node, 'data.whenProductsAreLoaded');
this.component.loadProducts({});
expect('data.whenProductsAreLoaded').toHaveBeenTriggeredOn(this.$node);
});
Pruebas a nuestro componente de interfaz
Para probar nuestro componente de interfaz, necesitaremos un fixture de HTML que pasaremos como primer argumento
al método setupComponent
. Este fixture reemplaza al nodo HTML asociado al componente que Jasmine Flight crea por
default (el cual es un div
). Lo necesitamos porque nuestro componente de interfaz busca elementos HTML con IDs
específicos que necesitamos pasar a nuestro spec para que funcione.
El primer test verifica que el componente actualice el HTML de la tabla que contiene los elementos del carro de compras
cada vez que se publique el evento data.whenItemIsAddedToCart
.
# web/js/spec/component/UiShoppingCart.spec.js
describeComponent('component/UiShoppingCart', function () {
var itemRow = '<tr><td>Lightsaber</td><td>$20.00</td><td>2</td><td>$ 40.00</td></tr>';
var cartTotal = '<p>$40.00</p>';
beforeEach(function () {
this.setupComponent(
'<table><tbody></tbody><tr><td id="cart-total"></td></tr></table>', {
totalSelector: '#cart-total',
cartItemsSelector: 'tbody',
itemTemplate: {render: function() {return itemRow;}},
totalTemplate: {render: function() {return cartTotal;}}
});
});
it("should listen for 'data.whenItemIsAddedToCart' events and update the cart items HTML", function () {
this.component.trigger(document, 'data.whenItemIsAddedToCart', {});
expect(this.component.select('cartItemsSelector').html()).toEqual(itemRow);
});
});
Proveedores de datos
El objetivo de un proveedor de datos es alimentar un test con varios valores de prueba para evitar repetir el código de
un spec varias veces. Investigando encontré este post que implementa una función using
que provee de datos a un spec.
Encontré también este segundo post donde se mueve la función using
fuera del spec y permite el uso de funciones
para alimentar el test con datos. Me gustó más el estilo del primer post, aunque es un poco antiguo (Jasmine 1.2), así
que terminé con una combinación de ambos ejemplos:
# web/js/spec/helpers/UsingHelper.js
global.using = function(name, values, func) {
for (var i = 0, count = values.length; i < count; i++) {
if (Object.prototype.toString.call(values[i]) !== '[object Array]') {
values[i] = [values[i]];
}
// Pass the name of the spec and its values to add them to their description
it.specName = name;
it.data = values[i];
func.apply(this, values[i]);
// Clear the extra data once it has been used
it.data = null;
it.specName = null;
}
}
var it_multi = function _it_multi(desc, func) {
var _data = [], _desc = desc;
// Check if the current spec was called inside a 'using' call
if (it.data) {
_data = it.data;
// Update the spec description
_desc = desc + ' (with ' + it.specName + ' using values [' + _data.toString() + '])';
}
jasmine.getEnv().it(_desc, function() {
return function() {
func.apply(func, _data);
}
});
};
if ( it && typeof it == 'function') {
it = it_multi;
}
Debemos incluir este archivo en specs-runner.js
para usarlo en nuestros specs. Tomemos como
ejemplo el método total
del módulo OrderItem
. El segundo argumento que pasamos a using
es un arreglo donde el
primer elemento representan valores para el precio unitario y la cantidad de productos que se agregan al carro y el
segundo argumento es el total que esperamos que calcule nuestro módulo.
describe('OrderItem', function () {
using(
'valid products',
[
[[2000, 4], 8000],
[[3000, 3], 9000],
[[1500, 5], 7500]
],
function(item, total) {
it('should calculate an item total price', function () {
var cartItem = new OrderItem(item[0], item[1]);
expect(cartItem.total()).toBe(total);
});
}
);
});
Sin embargo la salida que producen nuestros specs no es tan descriptiva como quisieramos.
should calculate an item total price (with valid products using values [2000,4,8000]) - 156 ms
should calculate an item total price (with valid products using values [3000,3,9000]) - 1 ms
should calculate an item total price (with valid products using values [1500,5,7500]) - 1 ms
Podemos mejorar la legibilidad de nuestros specs si convertimos nuestros valores en objetos y les agregamos un método
toString
.
var toString = function() {
return 'price: ' + this.product.unitPrice + ', quantity: ' + this.quantity;
};
var totalToString = function() {
return ' expecting total to be: ' + this.total;
}
describe('OrderItem', function () {
using(
'valid products',
[
[
{product: {unitPrice: 2000}, quantity: 4, toString: toString},
{total: 8000, toString: totalToString}
],
[ {product: {unitPrice: 3000}, quantity: 3, toString: toString},
{total: 9000, toString: totalToString}
],
[
{product: {unitPrice: 1500}, quantity: 5, toString: toString},
{total: 7500, toString: totalToString}
]
],
function(item, expected) {
it('should calculate an item total price', function () {
var cartItem = new OrderItem(item.product, item.quantity);
expect(cartItem.total()).toBe(expected.total);
});
}
);
});
Lo cual mejora notablemente la legibilidad de nuestros specs.
should calculate an item total price (with valid products using values [price: 2000, quantity: 4, expecting total to be: 8000]) - 133 ms
should calculate an item total price (with valid products using values [price: 3000, quantity: 3, expecting total to be: 9000]) - 1 ms
should calculate an item total price (with valid products using values [price: 1500, quantity: 5, expecting total to be: 7500]) - 1 ms
Generando datos de prueba
Crear los valores para los proveedores de datos es una tarea tediosa que podemos evitar usando un generador de datos como
Fake. Podemos poner de ejemplo un spec para el módulo ProductsCatalog
donde queremos generar productos para
verificar que podemos encontrarlos por su ID. En el ejemplo creamos una función buildProducts
que crea productos
con ID y nombres aleatorios (100 productos en nuestro spec). Esos datos se usan para verificar que un producto se puede
encontrar por ID.
define(['store/ProductsCatalog'], function(ProductsCatalog) {
var faker, catalog;
var buildProducts = function(amount) {
var i, products = [];
for (i = 1; i <= amount; i++) {
products[i] = {productId: faker.Helpers.randomNumber(10), name: faker.Lorem.words(2)};
}
return products;
};
beforeEach(function () {
catalog = new ProductsCatalog();
faker = require('Faker');
});
describe('ProductsCatalog', function () {
it('should find a product by its identifier', function () {
var products = buildProducts(100);
var expectedProduct = products[5]; // Fifth product
var product;
catalog.setProducts(products);
product = catalog.productOfId(expectedProduct.productId);
expect(product.productId).toEqual(expectedProduct.productId);
expect(product.name).toEqual(expectedProduct.name);
});
});
});
Espero que este post te sea de utilidad para realizar testing a componentes Flight y módulos en general. Si tienes algun comentario lo agradeceré mucho. Puedes revisar el código completo en este repo en Github. Si al probar el código algo no funciona y necesitas ayuda por favor deja tu pregunta aquí así más gente puede ayudarte y más se beneficiarán con la respuesta.