La aplicacion sobre la que vamos a realizar nuestras pruebas se basa en Foodme. Se ha adaptado el código para que funcione con versiones superiores del stack (angular, express).
Ejecutar la aplicación con:
./scripts/web-server.sh
Automáticamente se nos abrirá el navegador con la página principal de la aplicación:
Objetivo: Ejemplo para calcular una suma y realizar un test, usando la sintaxis del framework Jasmine y TDD.
Revisar la sintaxis de Jasmine antes de empezar.
-
Acceder a la carpeta 'test/unit'
-
Ejecutar
jasmine init
Se creará una estructura de directorios 'spec/support' con el fichero jasmine.json
{ "spec_dir": "spec", "spec_files": [ "**/*[sS]pec.js" ], "helpers": [ "helpers/**/*.js" ], "stopSpecOnExpectationFailure": false, "random": false }
Fijarse en donde espera encontrar las especificaciones de prueba y la extensión de las mismas.
-
Definimos la prueba antes que el código: 'test/unit/spec/calculator_spec.js':
// Pruebas unitarias de una calculadora var calc = require('../calculator.js') describe('Calculadora', function() { beforeEach(function() { }); afterEach(function() { }); describe('sumar', function() { it('deberia sumar 3 y 7 y devolver 10', function() { var result = calc.add(3,7); expect(result).toBe(10); }); }); });
NOTA: Recordar nombrar siempre las pruebas con el sufijo 'spec.js', para que jasmine los ejecute
-
Ejecutar en la consola:
jasmine
Ver el resultado que falla.
-
Realizar el mínimo código para que funcione (test/unit/calculator.js)
exports.add = function(a,b) { return 10; }
-
Volver a ejecutar la prueba
-
Añadir una nueva condición para que falle
// Pruebas unitarias de una calculadora var calc = require('./calculator.js') describe('Calculadora', function() { beforeEach(function() { }); afterEach(function() { }); describe('sumar', function() { it('deberia sumar 3 y 7 y devolver 10', function() { var result = calc.add(3,7); expect(result).toBe(10); }); it('deberia sumar -2 y 8 y devolver 6', function() { var result = calc.add(-2,8); expect(result).toBe(6); }); }); });
-
Hacer la refactorización para que no falle:
exports.add = function(a,b) { return parseInt(a)+parseInt(b); }
-
Volver a ejecutar la prueba
Ver enlace documentación Cucumber.js
-
Definimos el feature (features/sumar.feature)
Feature: Sumar números As a usuario de la calculadora Yo quiero poder añadir 2 números Scenario: Añadir 2 números Given la calculadora está inicializada When Yo sumo 3 y 7 Then el resultado debería ser 10
-
Definir el objecto World que permite llamar al código de la aplicación (support/world.js)
(function() { var World; module.exports.World = World = function(callback) { var calc = require('../../models/calculator.js'); this.add = function(arg1, arg2) { return calc.add(arg1, arg2); }; }; }).call(this);
-
Dejamos en la carpeta 'models' el fichero 'calculator.js' (el mismo que habíamos usado en la prueba con Jasmine)
-
Definimos la especificación de pruebas que cumple el feature definido (features/step_definitions/myStepDefinitions.js):
(function() { module.exports = function() { var result; this.World = require('../support/world').World; this.Given(/^la calculadora está inicializada$/, function(callback) { //this.clearCalculator(); callback(); }); this.When(/^Yo sumo (\d+) y (\d+)$/, function(arg1, arg2, callback) { result = this.add(arg1, arg2); //callback(null, 'pending'); callback(); }); this.Then(/^el resultado debería ser (\d+)$/, function(arg1, callback) { console.log('Result:' + result); if (result === Number(arg1)) { callback(); } else { callback(new Error('La suma se esperaba que fuera ' + arg1 + ' y es:' + result)); } }); }; }).call(this);
-
Ejecutar el comando
cucumber.js
desde la carpeta raíz del directorio de pruebas con Cucumber. -
Fijarse en la salida
Imaginemos que nuestro cliente quiere aplicar descuentos en la compra de platos en las siguientes condiciones:
- Si se dispone de un vale descuento (por una compra anterior) se aplica un descuento del 5%
- Si la compra actual supera los 50 euros se descuenta del precio un 5%
- Si la compra actual supera los 100 euros se descuenta del precio un 10%
Fichero 'test/unit/calculator_discount.js':
exports.getDiscountByVale = function(active) {
if (active) return 0.05;
else throw new Error('El vale ha caducado');
}
exports.getDiscountByPrice = function(price) {
if (price>100) return 0.1;
else if (price>50) return 0.05;
}
TAREA: Implementar pruebas unitarias de estas funciones para comprobar si fallan o no en base a algún valor.
Considerar diferentes cambios habituales de errores de programadores (sobre valores límite, poner el if antes, ...)
Fichero: 'calculator_discount_spec.js'
var calc = require('../calculator_discount.js')
describe('Calculo descuento compra', function() {
describe('calcular descuento por vale', function() {
it('deberia devolver un descuento del 5% si el vale es activo', function() {
expect(calc.getDiscountByVale(true)).toEqual(0.05);
});
it('deberia devolver un mensaje de error si el vale es inactivo', function() {
expect(function() {
calc.getDiscountByVale(false);
}).toThrow(new Error('El vale ha caducado'));
});
});
describe('calcular descuento por precio de compra', function() {
it('deberia devolver un descuento del 5% si el precio supera los 50 euros', function() {
expect(calc.getDiscountByPrice(51)).toBe(0.05);
});
it('deberia devolver un descuento del 10% si el precio supera los 100 euros', function() {
expect(calc.getDiscountByPrice(200)).toBe(0.1);
});
});
});
Objetivo: Especificar una prueba de integración para el controlador de Restaurantes y comprobar que filtra correctamente la lista de restaurantes en base al filtro de rating, usando mock objects. Además incorporaremos el uso de un TestRunner como Karma para mejorar el proceso de pruebas.
Referencia a Karma Referencia a angular-mocks
El fichero karma de configuración de nuestro proyecto indicará la ubicación de nuestros ficheros de pruebas y otras variables.
cd test/component
karma init
Aparecerán varias preguntas. Dar las siguientes respuestas:
Which testing framework do you want to use ?
Press tab to list possible options. Enter to move to the next question.
> jasmine
Do you want to use Require.js ?
This will add Require.js plugin.
Press tab to list possible options. Enter to move to the next question.
> no
Do you want to capture a browser automatically ?
Press tab to list possible options. Enter empty string to move to the next question.
> Chrome
>
What is the location of your source and test files ?
You can use glob patterns, eg. "js/*.js" or "test/**/*Spec.js".
Enter empty string to move to the next question.
>
Should any of the files included by the previous patterns be excluded ?
You can use glob patterns, eg. "**/*.swp".
Enter empty string to move to the next question.
>
Do you want Karma to watch all the files and run the tests on change ?
Press tab to list possible options.
> yes
Abrir el fichero creado 'karma.config.js' y configurar las rutas a los ficheros de prueba y de aplicación:
files: [
'app/lib/angular/angular.js',
'app/lib/angular/angular-*.js',
'test/lib/angular/angular-mocks.js',
'app/js/**/*.js',
'test/component/**/*.js'
]
-
Revisar el contenido del código controlador de la aplicación y el código de prueba de integración para comprobar como se realiza el filtrado en la búsqueda
Fichero: app/js/controllers/RestaurantsController.js
var allRestaurants = Restaurant.query(filterAndSortRestaurants); $scope.$watch('filter', filterAndSortRestaurants, true); function filterAndSortRestaurants() { $scope.restaurants = []; // filter angular.forEach(allRestaurants, function(item, key) { if (filter.price && filter.price !== item.price) { return; } if (filter.rating && filter.rating !== item.rating) { return; } if (filter.cuisine.length && filter.cuisine.indexOf(item.cuisine) === -1) { return; } $scope.restaurants.push(item); }); ...
-
Definir la prueba (/component/controllers/RestaurantControllerSpec.js):
'use strict'; var RESPONSE = [ { "price": 3, "id": "esthers", "cuisine": "german", "rating": 3, "name": "Esther's German Saloon" }, { "price": 4, "id": "robatayaki", "cuisine": "japanese", "rating": 5, "name": "Robatayaki Hachi" }, { "price": 2, "id": "tofuparadise", "cuisine": "vegetarian", "rating": 1, "name": "BBQ Tofu Paradise" }, { "price": 5, "id": "bateaurouge", "cuisine": "french", "rating": 4, "name": "Le Bateau Rouge" }, { "price": 3, "id": "khartoum", "cuisine": "african", "rating": 2, "name": "Khartoum Khartoum" } ]; describe('RestaurantsController', function() { var scope; var idsFrom = function(restaurants) { return restaurants.map(function(restaurant) { return restaurant.id; }); }; beforeEach(function() { module('foodMeApp'); // <= inicializar el módulo a probar }); beforeEach(inject(function($controller, $httpBackend, $rootScope) { scope = $rootScope; $httpBackend.whenGET('/api/restaurant').respond(RESPONSE); // <= Aquí introducimos el mock $controller('RestaurantsController', {$scope: scope}); $httpBackend.flush(); })); it('deberia filtrar por rating', function() { expect(scope.restaurants.length).toBe(5); scope.$apply(function() { scope.filter.rating = 1; }); expect(idsFrom(scope.restaurants)).toEqual(['tofuparadise']); scope.$apply(function() { scope.filter.rating = null; }); expect(scope.restaurants.length).toBe(5); }); expect(idsFrom(scope.restaurants)).toEqual([ 'tofuparadise', 'khartoum', 'esthers', 'bateaurouge', 'robatayaki' ]); // second click on "rating" makes it sort desc scope.$apply(function() { scope.sortBy('rating'); }); expect(idsFrom(scope.restaurants)).toEqual([ 'robatayaki', 'bateaurouge', 'esthers', 'khartoum', 'tofuparadise' ]); }); });
El array 'RESPONSE' es el array dummy que devolveremos siempre que se reciba la llamada para obtener un listado de restaurantes. Este array 'predefinido' permite que las pruebas sean repetibles y deterministas.
La función:
$httpBackend.whenGET(...)
es la que permite inyectar un test double, de forma que siempre que desde la aplicación, durante la prueba, se obtengan un listado de restaurantes se devolverá la lista predefinida, y no la contenida en la base de datos. -
Ejecutar la prueba mediante
karma start
-
Cambiar en el código de la prueba la línea:
expect(idsFrom(scope.restaurants)).toEqual(['tofuparadise']);
y modificarla porexpect(idsFrom(scope.restaurants)).toEqual(['tofuparadises']);
Fijarse como en la consola automáticamente se muestra que la prueba falla.
Objetivo: Realizar una prueba basada en servicios (REST API).
En nuestra aplicación de ejemplo los servicios están definidos con REST y JSON. Usaremos para las pruebas el framework frisbyjs. Consultar http://frisbyjs.com.
¿Cómo realizaremos la prueba que verifica que en el restaurante 'Robatayaki Hachi' su primer plato es 'California roll'?
- Abrir en el menú del navegador Chrome la opción 'More tools>Developer Tools' (o en Firefox con Firebug por ejemplo)
- Navegar en la aplicación hasta pulsar el enlace al restaurante 'Robatayaki Hachi'
- En la pestanya Network, pulsar en el menú izquierdo el nombre 'robatayaki' que corresponde a una llamada realizada desde la presentación al servidor
Si pasamos por encima del nombre 'robatayaki' también podemos ver la llamada realizada 'http://localhost:3000/api/restaurant/robatayaki'
-
Crear la prueba (test/acceptance-rest/spec/restaurants_spec.js):
var frisby = require('/usr/local/lib/node_modules/frisby'); frisby.create('Obtener Robatayaki Hachi') .get('http://localhost:3000/api/restaurant/robatayaki') .expectStatus(200) .expectHeaderContains('content-type', 'application/json') .expectJSON('menuItems.0', { name: function(val) { expect(val).toMatch("California roll"); }, // Comprobacion que el atributo 'name' tiene este valor }) .toss();
-
Ejecutar la prueba mediante
jasmine-node spec/restaurants_spec.js
Objetivo: Realizar pruebas de aceptación en base a la presentación o la interacción real del usuario con la aplicación. Usaremos Selenium y Protractor
-
Crear fichero 'test/acceptance/conf.js':
// conf.js exports.config = { seleniumAddress: 'http://localhost:4444/wd/hub', specs: ['spec.js'] }
-
Crear fichero de especificaciones de prueba 'test/acceptance/spec.js':
describe('UPC Foodme test titulo', function() { it('comprobar el titulo', function() { browser.get('http://localhost:3000/#/'); expect(browser.getTitle()).toEqual('FoodMe'); }); });
-
Ejecutar las pruebas:
En una consola ejecutar:
sudo webdriver-manager start
Abrir otra consola y ejecutar:
protractor conf.js
NOTA: Previamente hay que arrancar la aplicación.
-
Cambiar el código de la página 'index.html' para verificar que ahora la prueba falla, cambiando el título de la página.
<head>
<meta charset="utf-8">
<title>Cómeme</title>
...
</head>
<body>
-
Acceder a la aplicación en el enlace http://localhost:3000/#/menu/robatayaki
-
Para consultar locators complejos usaremos el plugin Firepath. Seleccionar el elemento en el navegador y con botón derecho del ratón escoger 'Inspect in Firepath'. Veremos en la ventana inferior un campo 'XPath' con información de la referencia
3. La prueba sería (test/acceptance/spec.js):
describe('Cuando añado platos a mi pedido', function() {
var priceSelected;
beforeEach(function() {
browser.driver.manage().deleteAllCookies();
browser.get('http://localhost:3000/index.html#/customer');
element(by.model('customerName')).sendKeys('Xavier');
element(by.model('customerAddress')).sendKeys('Mi direccion');
element(by.css('.btn-primary')).click();
element(by.css('a[href*="#/menu/robatayaki"]')).click();
});
afterEach(function() {
browser.manage().logs().get('browser').then(function(browserLog) {
expect(browserLog.length).toEqual(0);
console.log('log: ' + require('util').inspect(browserLog));
});
});
describe('Cuando añado un plato a mi pedido vacio', function() {
beforeEach(function() {
var h3Tag = element(by.tagName('h3'));
expect(h3Tag.getText()).toBe('Robatayaki Hachi');
var linkAddCart = element(by.xpath('html/body/div/ng-view/div[2]/div[1]/ul/li[1]/a'));
element(by.xpath('html/body/div/ng-view/div[2]/div[1]/ul/li[2]/a/span[2]')).getText().then(function(price) {
console.log('Precio del plato:' + price);
priceSelected = price;
linkAddCart.click();
});
});
xit('deberia el pedido tener el plato', function() {
});
it('deberia el pedido tener un precio total igual al plato escogido', function() {
var total = element(by.binding('cart.total()'));
var expectedTotal = 'Total: $' + priceSelected;
expect(total.getText()).toEqual(expectedTotal);
});
});-
});
- Ejecutar la prueba:
protractor conf.js
-
Estructurar cada página en un fichero u objeto
Fichero 'test/acceptance-pageobjects/home_page.js':
'use strict'; var HomePage = function() { browser.driver.manage().deleteAllCookies(); browser.get('http://localhost:3000/index.html#/customer'); }; HomePage.prototype = Object.create({ },{ customerName: { get: function () { return element(by.model('customerName')); }}, customerAddress: { get: function () { return element(by.model('customerAddress')); }}, loginBtn: { get: function () { return element(by.css('.btn-primary')); }}, doLogin: {value: function(name, address) { this.customerName.sendKeys(name); this.customerAddress.sendKeys(address); this.loginBtn.click(); }}, linkRestaurant: {value: function(restaurant) {return element(by.css('a[href*="#/menu/' + restaurant + '"]'))}}, goToRestaurant: {value: function(restaurant) { this.linkRestaurant(restaurant).click() }} }); module.exports = HomePage;
Fichero 'test/acceptance-pageobjects/pages/restaurant_page.js':
'use strict'; var RestaurantPage = function() { }; RestaurantPage.prototype = Object.create({ },{ linkAdd2Cart: {get: function() {return element(by.xpath('html/body/div/ng-view/div[2]/div[1]/ul/li[1]/a'));}}, add2Cart: {value: function() {this.linkAdd2Cart.click()}}, dishPrice: {get: function() {return element(by.xpath('html/body/div/ng-view/div[2]/div[1]/ul/li[2]/a/span[2]'));}}, cartTotal: {get: function() {return element(by.binding('cart.total()')).getText();}} }); module.exports = RestaurantPage;
-
Actualizar la prueba de la sección anterior usando las definiciones de página anteriores:
'use strict'; var HomePage = require('./pages/home-page.js'); var RestaurantPage = require('./pages/restaurant-page.js'); var pageHome; var pageRestaurant; var priceSelected; describe('Cuando añado platos a mi pedido', function() { beforeEach(function() { pageHome = new HomePage(); pageHome.doLogin('Xavier Escudero', 'Mi direccion'); }); describe('Cuando añado platos a mi pedido vacio', function() { beforeEach(function() { pageRestaurant = new RestaurantPage(); pageHome.goToRestaurant('robatayaki'); element(by.xpath('html/body/div/ng-view/div[2]/div[1]/ul/li[2]/a/span[2]')).getText().then(function(price) { priceSelected = price; pageRestaurant.add2Cart(); }); }); xit('deberia el pedido tener el plato', function() { }); it('deberia el pedido tener un precio total igual al plato escogido', function() { var expectedTotal = 'Total: $' + priceSelected; expect(pageRestaurant.cartTotal).toEqual(expectedTotal); }); }); });
Ver presentacion de clase. Usaremos Jira Capture.
- Arrancar sonar
cd /etc/sonarqube-5.5/bin/linux-x86-64
./sonar.sh console
-
Ejecutar
sonar-scanner
desde la raíz del proyecto -
Abrir la url 'http://127.0.0.1:9000/sonar'
-
Pulsar en el proyecto
-
Una vez revisados los valores obtenidos, realizar una modificación para introducir un nuevo bug:
Abrir el fichero 'app/services/cart.js' y modificar la línea
if (self.restaurant.id === restaurant.id) {
porif (self.restaurant.id == restaurant.id)
y grabar. -
Ejecutar de nuevo
sonar-scanner
desde la raíz del proyecto -
Revisar el impacto del nuevo cambio
-
Abrir fichero '/test/component/karma.conf.js'
reporters: ['progress', 'coverage'], preprocessors: { 'app/js/**/*.js': ['coverage'] }, ... coverageReporter: { // specify a common output directory dir: 'build/reports/coverage', reporters: [ { type: 'html', subdir: 'report-html' }, { type: 'lcovonly', subdir: '.', file: 'report-lcovonly.txt' }, ] }
-
Ejecutar en 'test/component' el comando ``karma start```
-
Incorporar el informe de cobertura de pruebas en una carpeta y referenciarlo en el fichero 'sonar-project.properties':
# Report LCOV generado con Karma
sonar.javascript.lcov.reportPath=build/reports/coverage/report-lcovonly.txt
- Volver a ejecutar el scanner:
sonar-scanner
- Visualizar la nueva métrica de cobertura