Testes de unidade para aplicações AngularJS

Introdução

Quem trabalha com o AngularJS, já leu sua documentação ou simplesmente assistiu uma apresentação tendo o framework como tema, deparou-se com uma lista de vantagens em utilizá-lo: produtividade, expressividade, separação ente visão (HTML) e comportamento (código JavaScript), facilidade de extensão, testabilidade. Como não poderia deixar de ser, chama muito a minha atenção esse último ponto: a testabilidade. Será que é mesmo simples escrever testes de unidade para uma aplicação AngularJS?

Neste artigo vou mostrar como escrever testes unitários para os componentes mais utilizados em uma aplicação AngularJS:

  • Controllers;
  • Serviços; e
  • Diretivas.

Os testes serão escritos utilizando a biblioteca Jasmine (se ainda não conhece o Jasmine, sugiro a leitura do post Jasmine: escreva testes de unidade para seu código JavaScript!).

Vamos aprender que realmente não é uma tarefa complicada a escrita de testes de unidade para os componentes de uma aplicação AngularJS.

A aplicação exemplo: Gerência de Palestras

Para os exemplos deste post vou utilizar uma aplicação de Gerenciamento de Palestras. É uma aplicação CRUD simples, que roda totalmente no lado cliente com todos os dados em memória, sem acesso a nenhum servidor remoto. Ela permite a listagem de palestras, sua inclusão e remoção. Abaixo a tela principal da aplicação onde são listadas as palestras existentes.

Tela principal da aplicação de Gerência de Palestras
Tela principal da aplicação de Gerência de Palestras

Essa aplicação utiliza o AngularJS, versão 1, como biblioteca principal. Seguindo o padrão comumente utilizado em aplicações AngularJS, temos controllersservices. Além disso, utilizei o framework Bootstrap para aplicação de estilos CSS e o Jasmine para escrita dos testes de unidade. Veja abaixo a estrutura com as principais pastas e arquivos do projeto.

- /app
|- app.module.js
|- GerenciaPalestrasController.js
|- PalestraService.js
|- SalaPalestraService.js
- /resources
|- /css
|--- bootstrap.css
|- /js
|--- angular.js
|--- angular-mock.js
|--- /jasmine-2.5.0
|----- jasmine.js
- /specs
|- GerenciaPalestrasControllerSpec.js
|- SalaPalestraServiceSpec.js
|- ...
- gerencia-palestras.html 
- SpecRunner.html

Portanto, temos

  • a pasta /app que contém os arquivos da aplicação AngularJS;
  • a pasta /resources que contém as bibliotecas externas, incluindo o Bootstrap, o AngularJS e o Jasmine;
  • a pasta /specs que contém os testes de unidade
  • o arquivo gerencia-palestras.html que é a página principal da aplicação;
  • e o SpecRunner.html, arquivo que executa o testes de unidade da aplicação escritos em Jasmine.

O código completo dessa aplicação, incluindo os testes que vamos implementar aqui, encontra-se disponível aqui no GitHub.

O Módulo ngMock

Toda aplicação AngularJS 1 contém pelo menos um módulo principal que funciona como um container por meio do qual são criados os serviços, controllers, diretivas e todos outros componentes que constituem a aplicação. Todos esse componentes são criados por meio de uma chamada à angular.module(). Em suma:

  • chamamos angular.module para criar a aplicação, fazendo bootstrap dos serviços core do AngularJS; e
  • carregamos o módulo, também por meio de angular.module, para criar nossos componentes (serviços, controllers etc)

Assim, em nossos testes, precisamos desse módulo para poder ter acesso aos componentes que vamos testar. Mas como fazer isso se, nos testes de unidade, não rodamos nossa aplicação em um navegador?

O AngularJS, tendo a testabilidade como uma de seus princípios orientadores, fornece o módulo ngMock. Este módulo fornece suporte para a carga de módulos e para injetar e criar mocks de serviços AngularJS em testes de unidade. Além disso, ele fornece diversos componentes que estendem alguns serviços core do AngularJS que podem ser utilizados nos códigos de teste.

Principais funções e serviços fornecidos por ngMock

O arquivo do módulo ngMock é o angular-mock.js. Todas as funções disponibilizadas pelo módulo está disponíveis no namespace angular.mock.

Segue abaixo uma lista das principais funções disponibilizadas pelo módulo.

  • angular.mock.module(): esta função nos permite fazer a carga do módulo da aplicação sendo testada. A forma mais comum de uso é chamá-la passando uma string como argumento, indicando o nome do módulo a ser carregado.
  • angular.mock.inject(): como o nome sugere, esta função é utilizada para a injeção de dependências em nossos testes. Ela é um wrapper do angular.injector, que funciona como um service locator para a nossa aplicação. Portanto, é por meio dessa função que temos acesso aos serviços que precisamos e suas dependências. Isso vai ficar mais claro a frente, em nossos exemplos de código.

Além disso, o módulo ngMock disponibiliza alguns serviços e componentes que merecem destaque.

  • $controller: é um decorator do ngMock para o $controller do módulo ng AngularJS, responsável por criar controllers. Este serviço é extremamente útil para o teste de controllers. Vamos aprender na próxima seção como utilizá-lo.
  • $httpBackend: é um serviço que fornece implementações fake para serem utilizadas em testes que usam o serviço $http do AngularJS.

Leia aqui a documentação do ngMock para conhecer outras funções e serviços disponibilizados pelo módulo.

Vamos agora mostrar alguns exemplos de testes para entender melhor esses conceitos.

Testando um controller

Um controller em AngularJS é um componente responsável por ser “cola” entre a página (view) e o modelo (objeto $scope do AngularJS). É por meio dele que a página tem acesso aos dados para exibição e/ou são adicionados comportamentos dinâmicos a ela como, por exemplo, resposta a eventos. Um controller, portanto, aumenta o objeto $scope, definindo valores iniciais para o mesmo e adicionando comportamentos a ele.

Assim, o controller é um elemento-chave em qualquer aplicação AngularJS e, portanto, deve ser testado.

O GerenciaPalestrasController

A nossa aplicação de exemplo possui apenas um controller, o GerenciaPalestrasController.  Segue abaixo o código dele.

angular.module("GerenciaPalestras").
     controller("GerenciaPalestrasController", function($scope, PalestraService) {
	
	$scope.titulo = "Gerenciamento de Palestras";
	$scope.palestras = PalestraService.getPalestras();
		
	$scope.adicionarPalestra = function(palestra) {
		palestra.id = $scope.palestras.length + 1;
		$scope.palestras.push(palestra);
		$scope.mensagemSucesso = "Palestra incluída com sucesso!";
		delete $scope.palestra;
	}
		
	$scope.temPalestraSelecionada = function() {
		return $scope.palestras.some(function (palestra){
			return palestra.selecionada;
		});
	}

	$scope.apagarPalestras = function() {
		var palestras = $scope.palestras.filter(function(palestra) {
			return !palestra.selecionada;
		});
		$scope.palestras = palestras;
		$scope.mensagemSucesso = "Palestras removidas com sucesso!";
	} 
		
});

Veja que esse controller adiciona dados (titulo e palestras) e comportamento (funções adicionarPalestra, temPalestaSelecionada e apagarPalestras) ao objeto $scope, de forma que possam ser acessados pela view. O teste desse controller envolve, basicamente, ter acesso ao objeto $scope e acessar seus dados e funções.

beforeEach e o código de inicialização dos testes

No post anterior apresentei como escrever testes unitários com o Jasmine. Nesse post também vamos utilizar o Jasmine para a escrita de testes unitários para nossos componentes do AngularJS. Uma funcionalidade interessante do Jasmine é a fixture beforeEach(). Essa função, como diz seu nome, é executada antes de cada teste de unidade definido. Portanto, ela é o lugar ideal para se colocar código de inicialização compartilhado entre vários casos de teste.

Abaixo está o código do beforeEach que criei para o GerenciaPalestrasControllerSpec.js, nosso arquivo de teste do controller mostrado anteriormente.

beforeEach(function(){
	angular.mock.module("GerenciaPalestras");
		
	angular.mock.inject(function($controller, $rootScope) {
		// cria o $scope
		$scope = $rootScope.$new();
			
		// injeta o $scope no nosso controller utilizando o decorator $controller
		$controller("GerenciaPalestrasController", {
			$scope : $scope
		});
	});
});

Basicamente o que fazemos é:

  1. Carga do módulo de nossa aplicação. Por meio da chamada angular.mock.module("GerenciaPalestras") fazemos a carga da nossa aplicação. Lembre: sempre que vamos testar uma aplicação AngularJS, precisamos fazer a carga do módulo.
  2. Criação do controller que vamos testar. Aqui fazemos uma chamada à angular.mock.inject(). Recebemos como parâmetros dois argumentos: o objeto $rootScope e o serviço $controller.
    1. O $rootScope é utilizado para que criemos um escopo filho dele, o qual vamos repassar para o nosso controller.
    2. O $controller nos permite, como já explicado, criarmos nosso controller. Ele recebe como primeiro argumento o nome do nosso controller. O segundo argumento é uma lista de dependências que queremos passar para ele – nesse caso o $scope criado anteriormente.

Você pode estar se perguntando:

– “Mas e a dependência do PalestraService? Ele também é uma dependência de GerenciaPalestrasController; não preciso adicioná-lo em à lista de dependências adicionadas em $controller?”

A resposta é não, não precisa. Quando criamos o controller com $controller, as dependências são resolvidas automaticamente. É criado um PalestraService e oferecido ao nosso controller. Ponto para o ngMock.

Casos de Teste

Depois de inicializado o módulo, criado o objeto e adicionadas suas dependências, podemos escrever nosso código de teste. Vou mostrar aqui 4 dos casos de testes que implementei para o GerenciaPalestrasController. Vamos ao código.

it("O título da aplicação deve ser Gerenciamento de Palestras", function() {
	expect($scope.titulo).toBe("Gerenciamento de Palestras");
});

it("Não deve ter palestras selecionadas se nenhuma palestra tem o atributo 'selecionada' definido.", function() {
	expect($scope.temPalestraSelecionada()).toBe(false);
});

it("Deve ter palestras selecionadas como true se pelo menos 1 palestra tiver atributo selecionada = true.", function() {
	// pego uma palestra qualquer e marco como true
	var indice = Math.floor(Math.random() * $scope.palestras.length);
	$scope.palestras[indice].selecionada = true;
		
	expect($scope.temPalestraSelecionada()).toBe(true);
});

it("A adição de palestras acresenta um novo elemento à lista de palestras.", function() {
	// pego uma palestra qualquer e marco como true
	var quantidadePalestras = $scope.palestras.length;
	var novaPalestra = {'nome' : 'Nova Palestra', 'autor': 'Nome do Autor'};
	$scope.adicionarPalestra(novaPalestra);
				
	expect($scope.palestras.length).toBe(quantidadePalestras + 1);
	expect(novaPalestra).toEqual($scope.palestras[$scope.palestras.length - 1]);
});

Se observarmos bem, estes testes pouco tem de especificidade do AngularJS. Eles são testes Jasmine (compare com os códigos apresentados no post anterior). O que eles tem do AngularJS é o uso do objeto $scope, criado anteriormente em beforeEach().

Entendendo cada caso de teste

Os testes são autodescritivos. Mas, para esclarecer:

  • O primeiro teste verifica o valor da propriedade titulo de $scope, validando o nome da aplicação;
  • O segundo verifica a função temPalestraSelecionada; o código do controller verifica se há palestras selecionadas por meio do atributo selecionada. Na aplicação este valor só é populado quando o checkbox da listagem de palestras (veja a figura da aplicação apresentada no início do post) é marcado. Portanto, inicialmente, nenhuma palestra tem esse atributo. Logo, nenhuma pode estar selecionada.
  • O terceiro teste é o contrário do anterior. É selecionada uma palestra ao acaso e a ela é adicionada o atributo selecionada com o valor true. Logo, a chamada à temPalestraSelecionada deve retornar true.
  • O último caso de teste é o de adição de nova palestra. É criada uma nova palestra e, depois que a função adicionarPalestra é chamada, verifica-se que a quantidade de palestras inicial aumentou de 1 e que a nova palestra está presente na lista de palestras.

Por fim, uma pergunta que pode aparecer é:

– “Para que o controller foi criado se eu não utilizo em nenhum dos testes?

Lembre-se: um controller adiciona dados e comportamento ao $scope. Os métodos que chamamos nos testes (temPalestraSelecionada e adicionarPalestra) foram adicionados ao $scope pelo GerenciaPalestrasController. Logo, a criação de um controller é necessária para esse código de teste funcionar.

Para executar os testes, basta abrir o arquivo SpecRunner.html em um navegador.

Testando um service

Um service no AngularJS é um objeto criado com o reuso de código em mente. Códigos que se repetem entre controllers e outros componentes pode ser colocado dentro de um service para se evitar a repetição e maximizar o reuso.

SalaPalestraService

Para exemplificar a criação de testes para um service criado em AngularJS, foi criado o SalaPalestraService, cujo código é mostrado a seguir.

angular.module("GerenciaPalestras").factory("SalaPalestraService",function(PalestraService) {

    var salas = [ 
         "Sala Marte",
         "Sala Júpiter", 
         "Sala Mercúrio", 
         "Sala Vênus" 
    ];

    return {

	getSalas : function() {
		return salas;
	},

	getSalaDisponivel : function(dataHoraPalestra) {
		var palestras = PalestraService.getPalestras();

		var salasIndisponiveis = palestras.filter(function(palestra) {
			return palestra.dataHora.getTime() === dataHoraPalestra.getTime();
		}).map(function(palestra) {
			return palestra.sala;
		});
			
		if(salasIndisponiveis.length === salas.length) {
			return null;
		} 
		else {
			var salasDisponiveis = salas.filter(function(sala) {
				return salasIndisponiveis.indexOf(sala) < 0;
			});
			return salasDisponiveis[0];
		}
	}

    };

});

Esse service oferece duas funções:

  • a primeira retorna todas as salas existentes (retorna o array definido no início do arquivo).
  • a segunda função, dada uma data e hora, checa se há salas disponíveis para uma palestra naquela data/horário.

Vamos ao teste desse service.

Isolamento de testes unitários

Uma coisa importante ao se escrever testes unitários é saber que eles devem ser isolados. Eles não devem depender de fatores externos: tudo o que for necessário para executar o teste deve estar no código de teste.

Veja o código da função getSalaDisponivel. A primeira coisa que ele faz é recuperar a lista de palestras existentes por meio do objeto PalestraService. Isso é feito para verificar quais palestras ocorrem na data/hora desejada (argumento da função getSalaDisponivel).

Do jeito que está, essa chamada vai retornar o array de palestras definido no início do arquivo PalestraService.js. Isso é ruim para os testes: temos que conhecer os valores desse array; e, além disso, se esse array mudar, os nossos testes podem falhar. Os testes ficam frágeis; não funcionam isolados.

Utilizando o spyOn()

A solução é utilizar uma lista de palestras definida exclusivamente para os casos de teste. Para isso vamos utilizar o spyOn() do Jasmine. Veja abaixo o código.

var palestras = [ 
      {	"id" : 1, "nome" : "Palestra 01", "autor" : "Autor 01", "sala" : "Sala Vênus", "dataHora" : new Date("2016-10-01 14:30")}, 
      { "id" : 2, "nome" : "Palestra 02", "autor" : "Autor 02", "sala" : "Sala Júpiter", "dataHora" : new Date("2016-10-01 14:30")}, 
      {	"id" : 3, "nome" : "Palestra 03", "autor" : "Autor 03", "sala" : "Sala Marte", "dataHora" : new Date("2016-10-01 14:30")},
      { "id" : 4, "nome" : "Palestra 04", "autor" : "Autor 04", "sala" : "Sala Mercúrio", "dataHora" : new Date("2016-10-01 14:30")}
];
	
var salaPalestraService, palestraService;

beforeEach(function() {
	angular.mock.module("GerenciaPalestras");

	// injeção de serviços mocks
	angular.mock.inject(function(SalaPalestraService, PalestraService) {
		salaPalestraService = SalaPalestraService;
		palestraService = PalestraService;
			
		// faz com que à chamada a getPalestras() retorna a lista de palestras definida aqui
		spyOn(palestraService, "getPalestras").and.returnValue(palestras);
	});		
});

Veja que no início é definida uma lista de palestras para ser utilizada nesse código de teste. Depois é carregado o módulo e finalmente está o código que merece nossa atenção.

Dentro da função inject() são injetados os objetos importantes para os nossos testes: o SalaPalestraService – que é o alvo do nosso teste, e o PalestraService que é utilizado para recuperar a lista de palestras.

Em seguida, ainda dentro de inject(), fazemos o uso de spyOn() -linha destacada. Um spy no Jasmine permite simular a chamada a uma função de um objeto. Para o spyOn() informamos o objeto que estamos “espiando” e qual a função que queremos simular. Utilizada em conjunto com returnValue(), fazemos com que ele retorne a nossa lista de palestras – e não aquela que o getPalestras() retornaria.

Casos de Teste

Vamos agora aos casos de teste do nosso service.

it("Deve ter sala disponível para a data/horário", function() {
	var sala = salaPalestraService.getSalaDisponivel(new Date("2016-10-01 15:30"));

	expect(sala).not.toBeNull();
	expect(sala).not.toBeUndefined();
});

it("Não deve ter sala disponível para a data/horário", function() {
	var sala = salaPalestraService.getSalaDisponivel(new Date("2016-10-01 14:30"));

	expect(sala).toBeNull();
});

Se observarmos a lista de palestras definidas no início do arquivo, vemos que todas as palestras ocorrem na mesma data e horário: 01/10/2016 às 14:30. Nossos testes são muito simples:

  • o primeiro testa se há sala disponível para às 15:30 do mesmo dia. Dada a nossa lista de palestras, o método deve retornar uma sala (por isso os matchers not.toBeNull() e not.toBeUndefined()).
  • o segundo testa se há sala disponível para às 14:30. Como todas as palestras ocorrem às 14:30, não há sala disponível; logo, o retorno deve ser nulo (toBeNull()).

Testando uma diretiva

Diretivas permitem que o AngularJS adicione ao HTML (seja por meio de novos elementos, atributos, classes CSS ou comentários) um comportamento adicional. O AngularJS já vem com diversas diretivas como ngModel, ngClick, ngBind. É possível também criarmos nossas próprias diretivas.

Nesta seção vamos mostrar como podemos escrever testes para diretivas que criamos.

A diretiva MostraMensagemDirective

Para ilustrar os testes de diretivas, criei uma diretiva simples que exibe uma mensagem na tela reportando ao usuário o resultado de uma operação. A figura abaixo mostra essa diretiva em ação após a inclusão de uma nova palestra.

Diretiva em ação: mensagem de sucesso exibida ao usuário após a inclusão de uma palestra.

O código que adiciona essa diretiva em uma página HTML é o exibido abaixo.

<div mostra-mensagem tipo="'success'" mensagem="'Aqui vai o texto da mensagem.'"></div>

A diretiva é a mostra-mensagem. Ela possui 2 atributos: o tipo da mensagem (success, alert, warning, fatal) e o texto da mensagem.

O código Javascript da diretiva (arquivo MostraMensagemDirective.js) é apresentado a seguir.

angular.module("GerenciaPalestras").directive("mostraMensagem", function() {
	return {
		restrict: 'A',
		scope: {
			tipo : '=',
			mensagem : '='
		},
		template: '<div class="alert alert-{{tipo}}">{{mensagem}}</div>'
	};
});

Essa diretiva:

  • é do tipo atributo de um elemento (restrict: 'A');
  • possui 2 argumentos (tipo e mensagem);
  • e ao ser compilada renderiza uma div HTML que representa um componente alert do Bootstrap, exibindo a mensagem informada como texto (valor do atributo template).

Se você ainda não sabe como criar diretivas, sugiro a leitura desse post do CodeIgniterBrasil.

Inicialização do teste

Como nos testes apresentados anteriormente, a parte de inicialização é que envolve interação com o módulo ngMock do AngularJS. Vamos ao código.

var scope, diretiva;
var mensagem = 'Operação realizada com sucesso!';
 
angular.mock.module("GerenciaPalestras");
 
angular.mock.inject(function($compile, $rootScope){
	scope = $rootScope.$new();
 
	diretiva = $compile("<div mostra-mensagem tipo=\"'success'\" mensagem=\"'"+ mensagem +"'\"></div>")(scope);
	scope.$digest();
});

Já conhecemos parte desse código: angular.mock.module() e angular.mock.inject(). Vamos discutir um pouco outros elementos:

  • Um dos argumentos que recebemos na função inject() é o $compile. O $compile é um serviço do core do AngularJS que permite compilar strings HTML em uma função template, possibilitando que esse template gerado seja, posteriormente, associado a um objeto scope.
  • Utilizamos, também, a função $digest() do scope; isto é feito para simular o ciclo de vida do objeto scope para que o AngularJS saiba que o modelo (dados) foram modificados e atualize o scope. Geralmente não precisamos fazer isso em códigos da aplicação, mas em códigos de teste, sobretudo em testes de diretivas, essa chamada faz-se necessária.

Com a inicialização feita, podemos escrever o caso de teste.

Caso de Teste

Segue agora o código completo do teste da nossa diretiva.

describe("MostaMensagemDirective", function() {
	
	it("Deve compilar corretamente a diretiva", function() {
		
		var scope, diretiva;
		var mensagem = 'Operação realizada com sucesso!';
		
		angular.mock.module("GerenciaPalestras");
		
		angular.mock.inject(function($compile, $rootScope){
			scope = $rootScope.$new();
			
			diretiva = $compile("<div mostra-mensagem tipo=\"'success'\" mensagem=\"'"+ mensagem +"'\"></div>")(scope);
			scope.$digest();
		});
		
		var resultadoEsperado = "<div class=\"alert alert-success\">"+ mensagem +"</div>"
		
		expect(diretiva.html()).toBe(resultadoEsperado);
		expect(diretiva.text()).toEqual(mensagem);
		
	});
	
});

Veja a variável resultadoEsperado. Ela representa o valor que a diretiva deve assumir após compilada. Ou seja, quando escrevemos em nossa página HTML o trecho de código

<div mostra-mensagem tipo="'success'" mensagem="'Palestra incluída com sucesso!'"></div>

esperamos que o resultado, após a compilação, seja

<div class="alert alert-success">Palestra incluída com sucesso!</div>

E é isso que nosso teste verifica. O primeiro expect() verifica se o código HTML da diretiva coincide com o resultado esperado. O segundo expect() checa se o texto da div é igual ao valor da mensagem informada como atributo da diretiva.

Conclusão

O modelo de aplicação preconizado pelo AngularJS 1 favorece em muito a testabilidade. O isolamento do código JavaScript das páginas HTML facilita a tarefa da escrita de testes de unidade. O objetivo deste post foi demonstrar que realmente essa facilidade existe e que não é complexo a escrita de testes de unidade em aplicações AngularJS.

Mostrei aqui testes de unidade para os elementos mais utilizados por desenvolvedores de aplicações que utilizam AngularJS: os controllers, serviços e diretivas. Muitos outros elementos ainda podem ser testados, como filtersinterceptors dentre outros, utilizando o modelo aqui apresentado.

Os testes mostrados utilizam o Jasmine e o módulo ngMock do AngularJS. Conforme pode-se observar, os códigos de testes não são complexos, mas faz-se necessário um conhecimento do módulo ngMock – suas funções e serviços – para que se escreva testes eficientemente.

Para finalizar, segue alguns links que recomendo o acesso para leitura.

  • Código completo da aplicação exemplo desse post e do código de teste: meu repositório no GitHub.
  • Se você quiser ter uma visão introdutória objetiva e direto ao ponto sobre o AngularJS, recomendo a leitura desse excelente post do blog do Google Developer Groups de Pato Branco: Introdução ao AngularJS.
  • Por fim, se você dispuser de tempo, recomendo a excelente série de vídeos do Rodrigo Branas sobre o AngularJS; bastante completa e com uma didática sensacional: Tudo sobre AngularJS.

Abraços e até a próxima.

  • Leandro Parazito

    Achei super completo o seu artigo. Show de bola, André. Parabéns!

    • Valeu, @disqus_pZnY0fvZbr:disqus . Muito gratificante receber seu feedback!