Esse artigo é um curso para escrever JavaScript fácil de dar manutenção. Iremos adicionar funcionalidades iterativamente para executar o exemplo seguindo um simples princípio: escreva um teste unitário, faça ele passar. Cada teste servirá como um loop de feedback de qualidade, criando tanto uma rede segura e um formulário de documentação executável para qualquer um que queira mudar um código de produção. Começando cada funcionalidade com um simples teste de falha nós asseguramos que todas funcionalidades estão testadas. Evitamos o custo de reescrever código para testar-lo mais tarde. Isso é particularmente útil dado o fato que desenvolvedores de JavaScript tem muita corda para se enforcar - considerar o quanto existe variáveis de estado global mutável entre a API DOM e a própria linguagem.
Nosso exemplo executável é uma máquina de cassino caça-níquel, com três bobinas. Cada bobina tem cinco estados possíveis, representados por imagens. Cada bobina assume randomicamente um estado quando o botão de play da máquina é apertado. A máquina será incrementada ou decrementada dependendo se todos os estados são iguais ou não.
Nossas feramentas será stub, objetos mock e um pouco de injeção de dependência. Iremos usar JsUnit para rodar testes de unidade e uma biblioteca JavaScript de objetos mock chamada JsMock. Teste de Integração, um elogio de testes de unidade, está além do escopo desse artigo. Isso não significa que teste de integração é menos importante - só que nós vamos lutar pelo retorno rápido, e não o mais lento e mais abrangente feedback obtido com ferramentas como o Selenium e Watir.
JsUnit, um Framework JavaScript de Teste Unitário
JsUnit é um framework open source de teste unitário para JavaScript. Ele é inspirado pelo JUnit e escrito inteiramente em JavaScript. Adicionalmente sendo o mais popular framework JavaScript de teste unitário, ele vem com um punhado de tarefas ant. As tarefas ant permitem desenvolvedores rodar facilmente uma suite de testes no servidor de build de Integração Contínua. Integração Contínua, outra boa prática que está além do escopo desse artigo, é uma 'força multiplicadora' para qualidade quando compinada com TDD.
Vamos começar com o JsUnit test runner. The test runner é um HTML puro e página web de com JavaScript, significando que seu teste de unidade rodará diretamente em um browser ou browsers que você deseje suportar. Descompacte o arquivo JsUnit e você irá encontrar o testRunner.html no diretório root. Você não precisa de um servidor web para o test runner - simplesmente carregue ele navegando pelo sistema de arquivos.
O controle mais importante do test runner é o formulário de entrada de arquivo localizado no topo da página. Esse controle recebe o caminho para um arquivo de página de teste ou um arquivo de uma página com a suite de testes. Agora vamos olhar um simples exemplo de uma página teste de JsUnit.
<html>
<title>A unit test for drw.SystemUnderTest class</title>
<head>
<script type='text/javascript' src='../jsunit/app/jsUnitCore.js'></script>
<script type='text/javascript' src='../app/system_under_test.js'></script>
<script type='text/javascript'>
function setUp(){
// perform fixture set up
}
function tearDown() {
// clean up
}
function testOneThing(){
// instantiating a SystemUnderTest, a class in the drw namespace
var sut = new drw.SystemUnderTest();
var thing = sut.oneThing();
assertEquals(1, thing);
}
function testAnotherThing(){
var sut = new drw.SystemUnderTest();
var thing = sut.anotherThing();
assertNotEquals(1, thing);
}
</script>
</head>
<body />
</html>
JsUnit tem muitas coisas em comum com outros frameworks xUnit. Como você poderia esperar o test runner carrega a página de teste, e chama cada função de teste. Cada chamada de função teste está entre uma chamada setUp e tearDown. A função setUp fornece ao autor do teste a oportunidade de opcionalmente construir um teste fixture. O teste fixture se refere ao estado destinado a ser corrigido por todos testes na página. A função tearDown fornece ao autor do teste a oportunidade de limpar ou repor o teste fixture.
Existe entretanto uma diferença sutil no ciclo de vida do teste no JsUnit comparado a outro framework xUnit. Cada página de teste é carregada dentro de uma janela separada, evitando código da aplicação de subescrever o código de teste do framework via classes abertas. Dentro de cada janela carregada, todas funções de teste unitário são invocados. A página não é recarregada para cada função teste. No JUnit por outro lado, a página de teste equivalente é o test case, e o test runner instanciaria um test case separado para cada método teste. em outras palavras,
JsUnit carrega a página teste com N funções teste de uma vez,
JUnit cria um test case com N métodos teste N vezes
Desenvolvedores JavaScript estão, portanto, em um caminho mais perigoso porque as alterações no estado da página teste podem influenciar os resultados de testes subsequentes. Um desenvolvedor Java entretando não é exposto a esse risco quando muda o estado de um objeto test case. Por que JsUnit faz isso quando a página de teste poderia simplesmente ser recarregada uma vez pode teste? Porque existem custos de performance de recriar o DOM para cada função teste em uma suite de teste. Felizmente JavaScript tem que se cuidar menos sobre efeitos colaterais de estados globais mutáveis. Em uma plataforma de programação semelhante a JVM ou o CLR, um teste unitário que muda uma variável estática pode impactar o resultado de todos testes subsequentes em uma suite de testes inteira, não só naquele teste que pertence ao mesmo test case.
O script jsUnitCore.js deve estar contido em todas páginas de teste. Esse arquivo importante é localizado no diretório app do arquivo descompactado do JsUnit. Ele contém um punhado de funções de assertion que se comportam mais o menos idênticos a outros frameworks xUnit. Uma diferença sutil entretanto decorre do fato de que o JavaScript tem duas noções de igualdade. JavaScript tem um operador equals e um operador threequals. Por exemplo, a primeira expressão abaixo avalia a verdade, a segunda avalia para falso:
0 == false
0 === false
Como isso funciona? O operador equals não é tão estrito como o operador threequals. permitindo a execução performar o tipo de conversão para a primeira expressão booleana. Assim isso não é entendido por um novato que a assertion abaixo vai passar.
assertEquals(false, 0);
Esse assertion irá de fato falhar o teste porque a afunção assertion fornecida pelo framework JsUnit usa o operador threequals como contrariamente ao operador equals para todas comparações. Ao evitar o operador equals o JsUnit pode evitar muitos falsos testes positivos rodar.
Stubs versus Mocks
Vamos olhar para os stubs e objetos mock com nosso exemplo executável, uma máquina de cassino. Como esse teste de unidade é focado em um único objeto, nós iremos criar uma máquina de cassino e referenciar a ele como o system under test. Agora vamos escrever um simples teste para renderizar a máquina de cassino.
function testRender() { var buttonStub = {}; var balanceStub = {}; var reelsStub = [{},{},{}]; var randomNumbers = [2, 1, 3]; var randomStub = function(){return randomNumbers.shift();}; var slotMachine = new drw.SlotMachine(buttonStub, balanceStub, reelsStub, randomStub); slotMachine.render(); assertEquals('Pay to play', buttonStub.value); assertTrue(buttonStub.disabled); assertEquals(0, balanceStub.innerHTML); assertEquals('images/2.jpg', reelsStub[0].src); assertEquals('images/1.jpg', reelsStub[1].src); assertEquals('images/3.jpg', reelsStub[2].src); }
A função testRender faz stub de dois elementos DOM, injetando ambos no construtor do sistema em teste e chama o método render. O teste termina com assertions para que o lado esperado tenha o mesmo efeito do método render. Note que fazendo o stub de elementos DOM podemos testar os efeitos do método render sem na verdade fazer alguma coisa que poderia invalidar outros testes com o teste dessa página. Existe um conflito entre escolher essa abordagem e escolher elementos DOM reais. Usando elementos reais DOM é mais parecido para capturar bugs relacionados com incompatibilidades de browsers, mas você está mais propenso a ter bugs em seu teste se o estado do DOM não for resetado no fim de cada teste ou no tearDown.
O sistema em teste não faz chamadas diretas a funções Math.random a fim de determinar o estado inicial das bobinas. O caça-niquel ao invés disso confia que está prevista essa criação na ordem que obtêm esses números. Isso nos permite testar um pedaço não determinístico de software como se fosse inteiramente previsível. Note como o teste evita os riscos de estados mutáveis e efeitos colaterais por não subescrever a implementação nativa do Math.random do navegador.
Espere, espere um minuto...a função de teste tem mais que um assertion. Isso está correto? Existem um pequeno grupo de pessoas na comunidade ágil que pensa que é um pecado colocar mais de uma asserção por teste. A suite de testes escrita para aplicações reais fazendo dinheiro real entretanto são raramente escritas dessa forma. Muitas dessas pessoas ficam espantadas quando vêem muitas assertions por teste encontradas dentro da verdadeira suite de testes for do próprio framework JUnit.
O construtor do objeto e esse método render se parecer com isso:
/** * Construtor para o caça-níquel. */ drw.SlotMachine = function(buttonElement, balanceElement, reels, random, networkClient) { this.buttonElement = buttonElement; this.balanceElement = balanceElement; this.reels = reels; this.random = random; this.networkClient = networkClient; this.balance = 0; }; drw.SlotMachine.prototype.render = function() { this.buttonElement.disabled = true; this.buttonElement.value = 'Pay to play'; this.balanceElement.innerHTML = 0; for(var i = 0; i < this.reels.length;){ this.reels[i++].src = 'images/' + this.random() + '.jpg'; } };
Vamos colocar algum dinheiro dentro do caça-níquel. Nesse cenário, o caça-níquel fará chamadas de retorno assíncronas para o servidor para retornar o balanço do usuário. Isso é um desafio porque não existe rede em teste unitário e chamadas AJAX irão falhar. Quando escrevemos testes unitários nós devemos nos esforçar para que o código seja livre de efeitos colaterais, e IO certamente se enquadra nessa categoria.
function testGetBalanceGoesToNetwork(){ var url, callback; var networkStub = { send : function() { url = arguments[0]; callback = arguments[1]; } }; var slotMachine = new drw.SlotMachine(null, null, null, null, networkStub); slotMachine.getBalance(); assertEquals('/getBalance.jsp', url); assertEquals('function', typeof callback); }
Esse teste simula (stub) a rede. O que é stub? E qual a diferença de stub e mock? Muitos desenvolvedores geralmente se confundem com esses dois termos como sendo sinônimos. A comunidade de teste reserva a palavra stub para estado baseado em teste. No JavaScript isso geralmente significa um objeto simples com valores de retorno "hard coded", ou seja, retornos fixos A apalvra mock por outro lado é reservada para testes de interação. Mocks podem ter o comportamento treinado. Esses comportamentos interagem com o sistema de teste e a interação pode ser verificada.
Fazendo stub da rede do cliente nós podemos agora testar o método getBalance. O objeto stub aplica gravações ao contrutor com o sistema de teste via variáveis locais de url e callback. Essas variáveis locais nos dá algo para performar assertions no final do teste. Infelizmente temos usado a ferramenta errada para o trabalho. Esse é um exemplo clássico das limitações de stubs e porque objetos mocks servem um propósito. O propósito desse teste não é verificar o comportamento do sistema de este dado um certo estado injetado; esse teste é concedido para verificar a interação de uma instância drw.SlotMachine com uma dessas colaborações, a rede do cliente.
JsMock, uma biblioteca de objeto Mock para JavaScript
Se você olhar de perto poderá ver que testGetBalanceGoesToNetwork cria seu próprio framework de mocks em miniatura. Agora vamos refatorar o teste para usar um propósito geral do framework mock. Fazemos isso adicionando um único script tag para a página de teste e reescrevendo o teste como esse:
function testGetBalanceWithMocks(){ var mockControl = new MockControl(); var networkMock = mockControl.createMock({ send : function() {} }); networkMock.expects().send('/getBalance.jsp', TypeOf.isA(Function)); var slotMachine = new drw.SlotMachine(null, null, null, null, networkMock); slotMachine.getBalance(); mockControl.verify(); }
O teste agora nos dá o mesmo feedback em poucas linhas de código e deixamos o código mais limpo para futuros testes. Como ele trabalha? A primeira linha de código cria um objeto usando o construtor de MockControl fornecido pelo JsMock. O teste depois cria um objeto mock com um método send. Em uma aplicação com uma verdadeira classe NetworkClient nós não temos que aplicar um objeto literal para o método createMock. JsMock é capaz de inferir nisso pelo prototype:
var mock = mockControl.createMock(NetworkClient.prototype);
Depois de um objeto mock para a rede cliente foi criado e está programado para executar o método send e ser chamado uma vez com alguns parâmetros. Nos preocupamos que o nome do servidor está correto e que o segundo argumento é uma função callback. O objeto mock é injetado dentro do construtor do sistema teste e o teste conclui verificando essa interação via o método verify do objeto MockControl. Se por algum motivo a implementação do caça-níquel não chamar o método send na rede cliente, ou se falhar no encontro dos parâmetros encontrados, o método verify lançará uma exceção e o teste vai falhar.
Agora vamos fazer outro teste para verificar quando e com que frequência uma instância de drw.SlotMachine vai para a rede. Se o método getBalance é chamado antes da resposta do servidor ser completada. não queremos que o balance seja chamado duas vezes. Isso poderia resultar em muita banda ao caça-níquel.
function testGetBalanceWithMocksToTheNetworkOnce(){ var mockControl = new MockControl(); var networkMock = mockControl.createMock({ send : function() {} }); networkMock.expects().send('/getBalance.jsp', TypeOf.isA(Function)); var slotMachine = new drw.SlotMachine(null, null, null, null, networkMock); slotMachine.getBalance(); slotMachine.getBalance(); // no response from server yet slotMachine.getBalance(); // still no response mockControl.verify(); }
Lembra-se de quando criamos nosso primeiro mock? Tinha parecido uma solução prática mas imagine quanto código teria para testar uma iteração como essa. Apenas como argumento, vamos olhar uma solução stub pura.
function testGetBalanceFlawed(){ var networkStub = { send : function() { if(this.called) throw new Error('This should not be called > 1 time'); this.called = true; } }; var slotMachine = new drw.SlotMachine(null, null, null, null, networkStub); slotMachine.getBalance(); slotMachine.getBalance(); // no response from server yet slotMachine.getBalance(); // still no response }
Esse teste assegura que a rede cliente está sendo usada uma vez pelo simples lançamento de erros da rede stub após o primeiro uso. Existe um problema sutil aqui porque o teste está tendo o controle das assertions em volta do objeto a ser testado. Por exemplo, se o sistema de teste chamar a função send muitas vezes, mas engolir as exceções lançadas por exe, o teste nunca iria falhar porque o teste runner nunca receberia a notificação do problema. Existem maneiras de ao redor disso criando um mock mais elaborado mas sempre será mais fácil usar um propósito geral como JsMock.
JsMock não dá simplesmente a habilidade de testar chamadas de métodos e valores de parâmetros. Esse é um teste para demonstrar como será o comportamento do caça-níquel se a rede falhar.
function testGetBalanceWithFailure(){ var buttonStub = {}; var mockControl = new MockControl(); var networkMock = mockControl.createMock({ send : function() {} }); networkMock.expects() .send('/getBalance.jsp', TypeOf.isA(Function)) .andThrow('network failure'); var slotMachine = new drw.SlotMachine(buttonStub, null, null, null, networkMock); slotMachine.getBalance(); assertEquals('Sorry, can't talk to the server right now', buttonStub.value); mockControl.verify(); }
Aqui nós estamos verificando que o caça-níquel pode falhar graciosamente na eventualidade da falha da rede. Esse é um bom exemplo de quando um teste unitário pode realmente superar com um sistema de integração. Pode imaginar quanto dinheiro e tempo que custaria para manualmente simular uma falha de rede para cada ponto com o servidor durante um QA / ciclo de release?
A implementaçnao do método getBalance agora parece com isso::
drw.SlotMachine.prototype.getBalance = function() { if(this.balanceRequested) return; try{ // essa linha de ceodigo requere a excelente functional.js // biblioteca, encontrada em http://osteele.com/sources/javascript/functional this.networkClient.send('/getBalance.jsp', this.deposit.bind(this)); this.balanceRequested = true; }catch(e){ this.buttonElement.value = 'Sorry, can't talk to the server right now'; } };
Um inconveniente do mock é que eles são consideravelmente mais atrelado ao sistema de teste que os stubs, pelo menos fora da caixa. Você quer testar a falha quando o sistema de teste já não produz um comportamento esperado - você não quer testar a falha em toda mudança encapsulada em detalhes de implementação. Para remediar essa situação o JsMock fornece a habilidade de relaxar as expectativas. Você já viu um exemplo disso. Quando treinamos nosso mock de rede, nós escrevemos isso:
networkMock.expects().send('/getBalance.jsp', TypeOf.isA(Function));
Nós não especificamos qual função callback seria aplicada como segundo argumento, apenas que uma função callback seria aplicada. Se quiséssemos afrouxar ainda mais a essas expectativas, podemos tentar algo parecido com isso:
networkMock.expects().send(TypeOf.isA(String), TypeOf.isA(Function));
Se quiséssemos que uma referência ao callback real fosse passado para o método "send" da rede cliente, poderíamos fazer uso do método andStub do framework JsMock:
var depositCallback; networkMock.expects() .send('/getBalance.jsp', TypeOf.isA(Function)) .andStub( function(){depositCallback = arguments[1];} ); depositCallback({responseText:"10"});
Duas coisas boas no objeto mock antes de prosseguirmos. Note como cada fim de teste tem uma chamada para o método verify do MockControl. Isso é importante. Um teste unitário que não chama o método verify é um teste unitário que não falha. Isso ocorre com muitos desenvolvedores depois de escreverem alguns testes unitários, o qual seria melhor mover a chamada do verify nas funções de teste para a função tearDown. Enquanto isso irá poupar-lhes de algumas linhas de código, vai alivia-lo de nunca ter que se lembrar desse importante detalhe no fim de cada função de teste. Isso infelizmente irá apresentá-lo à um novo problema. Uma exceção é lançada no tearDown pode ser mascarada pela primeira exceção do teste. Um segundo problema é que alguns desenvolvedores que são novos em objetos mock irão geralmente usar muitos mocks. Mais especificamente, eles tentam usar mocks para substituir todos os stubs. Não faça isso. Use stubs para testes baseados em estados, use mocks para testes baseado em interações.
Um cenário de Teste Vencedor
Nós podemos usar tudo que aprendemos para testar os seguinte cenário. Esse teste simula um usuário perdendo e depois ganhando com o caça-níquel.
function testLoseThenWin(){ var buttonStub = {}; var balanceStub = {}; var reelsStub = [{},{},{}]; // a losing combination, followed by a winning combination var randomNumbers = [2, 1, 3].concat([4, 4, 4]); var randomStub = function(){return randomNumbers.shift();}; var slotMachine = new drw.SlotMachine(buttonStub, balanceStub, reelsStub, randomStub); var balance = 10; slotMachine.deposit({responseText: String(balance)}); slotMachine.play(); assertEquals(balance - 1, balanceStub.innerHTML); assertEquals('Sorry, try again', buttonStub.value); slotMachine.play(); assertEquals('balance - 2 + 40', 48, balanceStub.innerHTML); assertEquals('You Won!', buttonStub.value); assertEquals('images/4.jpg', reelsStub[0].src); assertEquals('images/4.jpg', reelsStub[1].src); assertEquals('images/4.jpg', reelsStub[2].src); }
A implementação do método play da classe drw.SlotMachine parece com isso:
drw.SlotMachine.prototype.play = function(){ var outcomes = []; var msg = 'Sorry, try again'; for(var i = 0; i < this.reels.length; i++){ this.reels[i].src = 'images/' + (outcomes[i] = this.random()) + '.jpg'; } if(outcomes[0] == outcomes[1] && outcomes[0] == outcomes[2]){ msg = 'You Won!'; this.balance += (outcomes[0] * 10); } this.buttonElement.value = msg; this.balanceElement.innerHTML = --this.balance; }; |
E finalmente uma demonstração do caça-níquel:
<html>
<title>A Slot Machine Demonstration</title>
<head>
<script type='text/javascript' src='functional.js'></script>
<script type='text/javascript' src='slot_machine.js'></script>
<script type='text/javascript' src='network_client.js'></script>
<script type='text/javascript'>
window.onload = function(){
var leftReel = document.getElementById('leftReel');
var middleReel = document.getElementById('middleReel');
var rightReel = document.getElementById('rightReel');
var random = function(){
return Math.floor(Math.random()*5) + 1; // generate 1 through 5
};
slotMachine = new drw.SlotMachine(document.getElementById('buttonElement'),
document.getElementById('balanceElement'),
[leftReel, middleReel, rightReel],
random,
new NetworkClient());
slotMachine.render();
slotMachine.getBalance();
};
</script>
</head>
<body id='body'>
<div style="text-align:center; background-color:#BFE4FF; padding: 5px; width: 160px;">
<div>Slot Machine Widget</div>
<div style="padding: 5 0 5 0;">
<img id='leftReel' />
<img id='middleReel' />
<img id='rightReel' />
</div>
<div>Balance: <span id="balanceElement"></span></div>
<input id="buttonElement" style="width:150px" type="button" onclick="slotMachine.play()"></input>
</div>
</body>
</html>
Materiais de Referência
- JSMock is a fully featured Mock Object library for JavaScript authored by Justin DeWind
- JsUnit is a Unit Testing framework for client-side (in-browser) JavaScript
- Mocks Aren’t Stubs, an article by Martin Fowler
- Functional is a library for functional programming in JavaScript authored by Oliver Steele
- Dependency Injection, an article by Martin Fowler
Sobre o Autor
Dennis Byrne mora em Chicado e trabalha para DRW Trading. Ele é um escritor, apresentador e membro ativo da comunidade open source.