Uma estratégia para testar comportamentos de controladores angulares

<small> Isenção de responsabilidade : ainda sou um noob Angular. Estas são minhas idéias atuais sobre o assunto e não representam conselhos de especialistas. Sim, embora esta seja uma “dica profissional” :). </small>

tl; dr

Não faça o teste de unidade dos detalhes de implementação do seu controlador. Trate-o como uma máquina de estado e altere seu estado resolvendo e rejeitando promessas retornadas de chamadas HTTP simuladas ou por funções definidas no escopo que são para interações do usuário. Escreva suas afirmações no escopo e nas chamadas HTTP, se necessário.

Versão Prolix

Se seus testes de unidade dependerem dos detalhes da implementação de seu controlador, eles serão frágeis. O encapsulamento deve ser seu amigo ao escrever testes de unidade. Teste os comportamentos do controlador. Quais são seus comportamentos? Geralmente, como ele altera o estado de seu escopo e quaisquer solicitações HTTP que ele faz. Teste nesse nível, não no nível de qualquer uma de suas funções internas. Eu acredito que esta é simplesmente uma boa prática de teste de unidade orientada para o comportamento aplicada a controladores Angular.

Suponha que você tenha um controlador Angular MyPageCtrlque dependa de vários serviços que envolvem chamadas para recursos HTTP e retornam promessas; ligue para eles fooe bar. Algo como isto (isenção de responsabilidade: isso é conceitual; na verdade, não executei este exemplo):

controller('MyPageCtrl', ['$scope', '$q', 'foo', 'bar', function($scope, $q, foo,  bar) {
var ctrl = this;

ctrl
.load = function(data) {
$scope
.loading = true;
$q
.all({
foo
: foo.get(),
bar
: bar.get()
}).then(ctrl.onSuccess, ctrl.onFailure);
};

ctrl
.onSuccess = function(data) {
$scope
.foo = data.foo;
$scope
.baz = data.bar.baz;
$scope
.loading = false;
};

ctrl
.onFailure = function() {
$scope
.errorOccurred = true
};

ctrl
.load();
}]);

Não teste as funções-membro de ctrldiretamente. Em vez disso, escreva um teste que seja feito quando a promessa for rejeitada e um teste que seja feito quando a promessa for rejeitada. Em seguida, escreva um que afirma isso e está definido quando ambas as promessas são resolvidas. Ah, também, você provavelmente vai querer testar se está configurado conforme o esperado, é claro. Então, algo como (conceitual e fragmentário):$scope.errorOccurredfoo.get()$foo.bar()$scope.foo$scope.baz$scope.loading

describe('mymodule MyPageCtrl', function() {
var ctrl;
var $scope;
var apply;
var foo;
var bar;
var deferreds = {};

beforeEach
(function() {
module('mymodule');
inject
(function($controller, $rootScope, $q) {
$scope
= $rootScope.$new;
apply
= function() {
$rootScope
.$apply();
};
deferreds
.foo = {
get: $q.defer()
};
deferreds
.bar = {
get: $q.defer()
};
foo
= {
jasmine
.createSpy('foo', ['get']).andReturn(deferreds.foo.get.promise)
};
bar
= {
jasmine
.createSpy('bar', ['get']).andReturn(deferreds.bar.get.promise)
};
ctrl
= $controller('MyPageCtrl', function() {
$scope
: $scope,
foo
: foo,
bar
: bar
});
});
});

it
('should set the loading flag to true on initialization', function() {
expect
($scope.loading).toBe(true);
});

it
('should set the error flag when it fails to retrieve foo', function() {
deferreds
.foo.get.reject();
apply
();

expect
($scope.errorOccurred).toBe(true);
});
...
it
('should set foo and baz on the scope when both requests succeed', function() {
deferreds
.foo.get.resolve({ some: 'data' });
deferreds
.bar.get.resolve({ baz: 'more data' });
apply
();

expect
($scope.errorOccurred).toBe(false);
...
});
...
});

Neste ponto, você pode refatorar seu controlador em seu lazer. “Não! É horrivelmente estruturado! Esse cara não sabe nada sobre controladores angulares!” Bem, você deve ser capaz de consertar isso com pouco impacto nos testes de unidade. Eles não se importam se você tem uma função chamada ou não .ctrl.load

Você entendeu a ideia. Esses são meus dois centavos. Mas eu sou novo no Angular, então fique à vontade para contribuir com correções, melhorias ou outros comentários.