Angular – Teste de Unidade com Jasmine

Coleção AngularJs Meetup South London | Este artigo

texto alternativo

Angular foi projetado com capacidade de teste em mente e oferece várias opções para apoiar Unit Testing . Neste artigo, vou mostrar como você pode configurar o Jasmine e escrever testes de unidade para seus componentes angulares. Vamos cobrir:

  • Apresentando a sintaxe Jasmine e os conceitos principais
  • Controladores angulares de teste de unidade, serviços, diretivas, filtros, rotas, promessas e eventos

Um exemplo completo de trabalho incluindo todas as especificações pode ser encontrado aqui (plunker).

Jasmim

Escrevendo seus testes

Jasmine usa notação baseada em comportamento que resulta em uma experiência de teste fluente e aprimorada. Estes são os conceitos principais:

  • Suites— descreva (string, função) funções, pegue um título e uma função contendo uma ou mais especificações.
  • Specs— it (string, function) functions, pegue um título e uma função contendo uma ou mais expectativas.
  • Expectativas – são afirmações avaliadas como verdadeiras ou falsas. A sintaxe básica lê expect (real) .toBe (esperado)
  • Matchers – são ajudantes predefinidos para afirmações comuns. Ex: toBe (esperado), toEqual (esperado). Encontre uma lista completa aqui.

Novos recursos do Jasmine 2.1

Jasmine 2.1, lançado em 14 de novembro de 2014, introduziu dois novos recursos. Consulte as notas de lançamento .

  • especificações focadas – usando fit e fdescribe você pode decidir quais especificações ou suítes executar.
  • configuração única e desmontagem – isso pode ser usado chamando beforeAll e afterAll.

Em versões anteriores, semelhante a fit / fdescribe, você poderia desabilitar seletivamente as especificações ou suítes com xit (mostrado como especificações pendentes) e xdescribe.

Configuração e desmontagem

Uma boa prática para evitar a duplicação de código em nossas especificações é incluir um código de configuração definindo algumas variáveis ​​locais a serem reutilizadas.

use beforeEach e afterEach para fazer alterações antes e depois de cada especificação

Jasmine oferece quatro manipuladores para adicionar nosso código de configuração e desmontagem: beforeEach, afterEach executado para cada especificação e beforeAll, afterAll executado uma vez por suíte.

// single line
beforeEach
(module('plunker'));

// multiple lines
beforeEach
(function(){
module('plunker');
//...
});

A função Jasmine inject usa injeção de dependência para resolver serviços ou provedores comuns, como $ rootScope, $ controller, $ q (promete mock), $ httpBackend ($ http mock), e combiná-los com os parâmetros correspondentes. Notações comuns para injeção são:

// Using _serviceProvider_
var $q;
beforeEach
(inject(function (_$q_) {
$q
= _$q_;
}));

// Using $injector
var $q;
beforeEach
(inject(function ($injector) {
$q
= $injector.get('$q');
}));

// Using an alias Eg: $$q, q, _q
var $$q;
beforeEach
(inject(function ($q) {
$$q
= $q;
}));

Verifique a documentação oficial do Angular sobre testes de unidade para obter mais detalhes.

Matchers padrão

Esses são o conjunto de correspondências padrão do Jasmine.

expect(fn).toThrow(e);
expect
(instance).toBe(instance);
expect
(mixed).toBeDefined();
expect
(mixed).toBeFalsy();
expect
(number).toBeGreaterThan(number);
expect
(number).toBeLessThan(number);
expect
(mixed).toBeNull();
expect
(mixed).toBeTruthy();
expect
(mixed).toBeUndefined();
expect
(array).toContain(member);
expect
(string).toContain(substring);
expect
(mixed).toEqual(mixed);
expect
(mixed).toMatch(pattern);

Verifique Jasmine-Matchers para alguns matchers adicionais para Arrays, Booleanos, Navegador, Números, Exceções, Strings, Objetos e Datas.

Criação de correspondências específicas do projeto

Às vezes, você pode melhorar suas especificações ou mensagens de falha usando uma biblioteca de correspondência personalizada.

Vamos ver como criar uma biblioteca myCustomMatchers contendo apenas um matcher simplificado: toBeAllowedToDrive. Um matcher deve estar dentro de um objeto de fábrica contendo uma função de comparação. Sua assinatura é compare (real, esperado), retornando um objeto como {pass: boolean, message: string}. Esta implementação funcionará tanto para expect (age) .toBeAllowedToDrive () e (age) .not.toBeAllowedToDrive ().

var myCustomMatchers = {
// toBeAllowedToDrive matcher
// Usage: expect(age).toBeAllowedToDrive();
// expect(age).not.toBeAllowedToDrive();
toBeAllowedToDrive
: function() {
return {
compare
: function(age) {
var result = {};
result
.pass = age>16;

if (result.pass) {
result
.message = "Expected " + age + " to be allowed to drive";
} else {
result
.message = "Expected " + age + " to be allowed to drive, but it was not";
}
return result;
}
};
}
};


describe
("Custom matcher: 'toBeAllowedToDrive'", function() {
var John = 17,
Mary = 16;

// Custom Matchers must be added using beforeEach
beforeEach
(function() {
jasmine
.addMatchers(myCustomMatchers);
});

it
("should allow John to drive", function() {
expect
(John).toBeAllowedToDrive();
// replaces
expect
(John).toBeGreaterThan(16);
});

it
("should not allow Mary to drive", function() {
expect
(Mary).not.toBeAllowedToDrive();
// replaces
expect
(Mary).not.toBeGreaterThan(16);
});
});

Podemos ver como melhoramos a legibilidade das especificações no bloco de código anterior. As mensagens também melhorarão a experiência futura de manutenção e depuração.

Teste de Unidade Angular

Configurando uma página do executor de teste

Encontre as etapas para configurar o Jasmine aqui . Você pode usar SpecRunner.html do Jasmine para iniciar ou criar o seu próprio, de qualquer forma, ele deve ser semelhante a este:

<!-- Jasmine dependencies -->
<link rel="stylesheet" href="//cdn.jsdelivr.net/jasmine/2.0.0/jasmine.css" />
<script src="//cdn.jsdelivr.net/jasmine/2.0.0/jasmine.js"></script>
<script src="//cdn.jsdelivr.net/jasmine/2.0.0/jasmine-html.js"></script>
<script src="//cdn.jsdelivr.net/jasmine/2.0.0/boot.js"></script>

<!-- Angular dependencies -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.3.7/angular.js"></script>
<script src="https://code.angularjs.org/1.3.5/angular-mocks.js"></script>

<!-- Application -->
<link rel="stylesheet" href="style.css" />
<script src="app.js"></script>

<!-- Tests + Jasmine Bootstrap -->
<script src="appSpec.js"></script>
<script src="jasmineBootstrap.js"></script>

Arquivos importantes a serem observados:

  • app.js – contém nosso aplicativo Angular (geralmente versão minimizada)
  • angular-mocks.js – contém simulações para os principais serviços Angular e nos permite injetá-los em testes
  • appSpec.js – contém nossas especificações

Testando um controlador

Vamos pegar um controlador muito simples que define uma propriedade de título no escopo.

app.controller('MainCtrl', function($scope) {
$scope
.title = 'Hello World';
});

Para ajudar a testar este controlador, usaremos uma configuração comum usando beforeEach. Isso carregará o aplicativo e entrará em contato com o provedor do controlador.

use a função módulo (‘nome’) para carregar o componente correspondente para que esteja disponível em seus testes

Em nosso teste, estamos instanciando o controlador usando um escopo vazio e verificando o título para o valor esperado.

// Suite
describe
('Testing a Hello World controller', function() {
var $controller;

// Setup for all tests
beforeEach
(function(){
// loads the app module
module('plunker');
inject
(function(_$controller_){
// inject removes the underscores and finds the $controller Provider
$controller
= _$controller_;
});
});

// Test (spec)
it
('should say \'Hello World\'', function() {
var $scope = {};
// $controller takes an object containing a reference to the $scope
var controller = $controller('MainCtrl', { $scope: $scope });
// the assertion checks the expected result
expect
($scope.title).toEqual('Hello World');
});

// ... Other tests here ...
});

Testando um serviço

Vamos criar LanguagesService, com apenas um método que retorna uma matriz de idiomas disponíveis para o aplicativo.

// Languages Service
app
.factory('LanguagesService', function(){
var lng = {},
_languages
= ['en', 'es', 'fr'];

lng
.get = function() {
return _languages;
}

return lng;
});

Semelhante ao nosso exemplo anterior, instanciamos o serviço usando beforeEach. Como dissemos, essa é uma boa prática, mesmo se tivermos apenas uma especificação. Nesta ocasião, estamos verificando cada idioma individualmente e a contagem total.

describe('Testing Languages Service', function(){
var LanguagesService;

beforeEach
(function(){
module('plunker');
inject
(function($injector){
LanguagesService = $injector.get('LanguagesService');
});
});

it
('should return available languages', function() {
var languages = LanguagesService.get();
expect
(languages).toContain('en');
expect
(languages).toContain('es');
expect
(languages).toContain('fr');
expect
(languages.length).toEqual(3);
});
});

Testando uma diretiva

As diretivas geralmente encapsulam funcionalidades e interações complexas, de modo que escrever testes de unidade abrangentes se torna uma tarefa obrigatória. Usaremos uma diretiva muito básica, myProfile, que renderiza um perfil de usuário para que você possa entender a ideia básica. Confira estes artigos para obter uma introdução sobre as diretivas: manuseio do escopo , usando modelos dinâmicos .

app.directive('myProfile', function(){
return {
restrict: 'E',
template: '<div>{{user.name}}</div>',
//templateUrl: 'path/template.tpl.html'
scope
: {
user
: '=data'
},
replace
: true
};
});

Desta vez, estamos criando um novo escopo $ e passando para $ compile. Envolvemos nossas alterações em $ scope usando $ apply para substituir {{user.name}} pelo valor final e compilá-lo. Fazendo assim, ele renderiza com o contexto certo. Não precisamos chamar $ digest separadamente, pois $ apply chama $ digest internamente assim que terminar de avaliar todas as alterações.

describe('Testing my-directive', function() {
var $rootScope, $compile, element, scope;

beforeEach
(function(){
module('plunker');
inject
(function($injector){
$rootScope
= $injector.get('$rootScope');
$compile
= $injector.get('$compile');
element
= angular.element('<my-profile data="user"></my-profile>');
scope
= $rootScope.$new();
// wrap scope changes using $apply
scope
.$apply(function(){
scope
.user = { name: "John" };
$compile
(element)(scope);
});
});
});

it
('Name should be rendered', function() {
expect
(element[0].innerText).toEqual('John');
});
});

Testando um Filtro

Filtros são funções que transformam os dados de entrada em um formato legível pelo usuário. Vamos escrever um filtro personalizado em maiúsculas, myUpper, usando o padrão String.prototype.toUpperCase (). Isso é apenas para simplificar, já que o angular tem sua própria implementação de uppercaseFilter.

app.filter('myUpper', function() {
return function(input) {
return input.toUpperCase();
};
});

Os filtros podem ser injetados usando seu nome registrado no mecanismo angular DI, como myUpperFilter (entrada, [argumentos]), ou se quisermos testar muitos filtros, podemos injetar o provedor $ filter uma vez e instanciar cada filtro usando seu nome, como $ filter (‘myUpper’) (entrada, [argumentos]).

describe('Testing myUpper Filter', function(){
var myUpperFilter, $filter;

beforeEach
(function(){
module('plunker');
inject
(function($injector){
// append Filter to the filter name
myUpperFilter
= $injector.get('myUpperFilter');

// usign $filter Provider
$filter
= $injector.get('$filter');
});
});

it
('should uppercase input', function(){
expect
(myUpperFilter('Home')).toEqual('HOME');
// using $filter
expect
($filter('myUpper')('Home')).toEqual('HOME');
})
})

Confira este artigo sobre injeção de filtros, que também aborda como encadear e compor vários filtros.

Rotas de teste

As rotas às vezes são deixadas de fora, mas geralmente é visto como uma boa prática para a contabilidade por partidas dobradas. Em nosso exemplo, usaremos uma configuração de rota muito simples com apenas uma rota apontando para casa.

app.config(function($routeProvider){
$routeProvider
.when('/home', {
templateUrl
: 'home.tpl.html',
controller
: 'MainCtrl'
})
.otherwise({ redirectTo:'/home' });
});

Nosso teste usará a configuração de $ route do aplicativo para verificar nossas expectativas. Verificaremos se, ao navegar para ‘/ home’, nossa rota será usada corretamente, de modo que use o modelo e controlador correspondentes. Por fim, verificamos se qualquer outra rota redireciona você para casa também.

describe('Testing Routes', function(){
var $route, $rootScope, $location, $httpBackend;

beforeEach
(function(){
module('plunker');

inject
(function($injector){
$route
= $injector.get('$route');
$rootScope
= $injector.get('$rootScope');
$location
= $injector.get('$location');
$httpBackend
= $injector.get('$httpBackend');

$httpBackend
.when('GET', 'home.tpl.html').respond('home');
});
})

it
('should navigate to home', function(){
// navigate using $apply to safely run the $digest cycle
$rootScope
.$apply(function() {
$location
.path('/home');
});
expect
($location.path()).toBe('/home');
expect
($route.current.templateUrl).toBe('home.tpl.html');
expect
($route.current.controller).toBe('MainCtrl');
})

it
('should redirect not registered urls to home', function(){
// navigate using $apply to safely run the $digest cycle
$rootScope
.$apply(function() {
$location
.path('/other');
});
expect
($location.path()).toBe('/home');
expect
($route.current.templateUrl).toBe('home.tpl.html');
expect
($route.current.controller).toBe('MainCtrl');
})
})

Use $ apply em vez de $ digest, pois ele envolve seu código dentro de um try / catch além de chamar $ digest

Testando uma promessa

As promessas estão se tornando mais populares e serviços Angular como $ http ou ng-resource as utilizam. Veja um artigo anterior para saber mais sobre promessas.

Jasmine 2.0 introduziu uma nova sintaxe para especificações assíncronas usando um parâmetro de retorno de chamada concluído opcional como este (título, função (concluído) {…})
. Alteramos nosso serviço anterior usando uma promessa em cima da promessa usada por $ http. Usamos Array.prototype.map () para transformar o formato JSON bruto em uma matriz plana em vez de usar para.

app.factory('LanguagesServicePromise', ['$http', '$q', function($http, $q){
var lng = {};
lng
.get = function() {
var deferred = $q.defer();
$http
.get('languages.json')
.then(function(response){
var languages = response.data.map(function