Seções iMasters
JavaScript

Escrevendo um JavaScript testável

A cultura de engenharia do Twitter requer testes. Muitos.
Não tenho experiência formal em testes de JavaScript antes do Twitter, de forma
que tenho aprendido bastante no processo. Em particular, os padrões que
costumava usar, escrever e encorajar mostraram não ser bons na confecção de
códigos que possam ser testados. Assim, penso valer a pena compartilhar alguns
dos princípios mais importantes que desenvolvi para testes do JavaScript. Os
exemplos que dou são baseados em QUnit,
mas devem ser aplicáveis a qualquer framework de teste do JavaScript.

Evite singletons

Um de meus artigos mais
populares foi a respeito do uso do JavaScript
Module Pattern
(inglês) para criar singletons poderosos em seu
aplicativo. Essa abordagem pode ser útil e simples, mas cria problemas para
a realização de testes por uma razão muito simples: os singletons influenciam em outros testes pois mantêm o estado. Em vez de criar os singletons como
módulos, você deve fazer sua composição como objetos construíveis, e atribui
uma instância única como padrão no nível global no init de sua aplicação.

Por exemplo, considere o
módulo singleton a seguir (exemplo forçado, claro):

var dataStore = (function() { 
var data = [];
return {
push: function (item) {
data.push(item);
},
pop: function() {
return data.pop();
},
length: function() {
return data.length;
}
};
}());

Com esse módulo, podemos testar o método foo.bar. Abaixo, um test suite QUnit
simples:

module("dataStore"); 
test("pop", function() {
dataStore.push("foo");
dataStore.push("bar")
equal(dataStore.pop(), "bar", "popping returns the most-recently pushed item");
});

test("length", function() {
dataStore.push("foo");
equal(dataStore.length(), 1, "adding 1 item makes the length 1");
});

Ao rodar esse test suite, a assertion no
teste lenght falhará, mas
inicialmente não fica claro o porquê. O problema é que esse state foi deixado
no  dataStore no teste anterior. O mero reordenamento
desses testes fará o teste length passar, o que é um claro sinal vermelho de
que alguma coisa está errada. Poderíamos consertar isso com o setup, ou o
teardown que reverte o state do dataStore, mas isso significa que precisamos
manter constantemente nosso teste boilerplate à medida que fazemos mudanças no módulo dataStore. Uma abordagem melhor é a seguinte:

function newDataStore() { 
var data = [];
return {
push: function (item) {
data.push(item);
},
pop: function() {
return data.pop();
},
length: function() {
return data.length;
}
};
}

Agora, seu test suite ficará assim:

module("dataStore"); 
test("pop", function() {
var dataStore = newDataStore();
dataStore.push("foo");
dataStore.push("bar")
equal(dataStore.pop(), "bar", "popping returns the most-recently pushed item");
});

test("length", function() {
var dataStore = newDataStore();
dataStore.push("foo");
equal(dataStore.length(), 1, "adding 1 item makes the length 1");
});

Isso permite ao nosso dataStore global
funcionar como antes, ao mesmo tempo em que evita que nossos testes poluam uns aos
outros. Cada teste tem sua própria instância de um objeto dataStore, que será recolhido pelo lixo
quando o teste terminar.

Evite
instâncias privadas baseadas em Closure

Outra característica
que costumo promover é métodos realmente privados em JavaScript. A vantagem é que você pode manter namespaces globalmente acessíveis livres de referências desnecessárias a detalhes de implementações privadas. No entanto, o uso excessivo desse pattern pode gerar um
código instável. Isso ocorre porque a sua suíte de testes não
consegue acessar e, portanto, não consegue testar funções privadas em closures. Considere o seguinte:

function Templater() { 
function supplant(str, params) {
for (var prop in params) {
str.split("{" + prop +"}").join(params[prop]);
}
return str;
}

var templates = {};

this.defineTemplate = function(name, template) {
templates[name] = template;
};

this.render = function(name, params) {
if (typeof templates[name] !== "string") {
throw "Template " + name + " not found!";
}

return supplant(templates[name], params);
};
}

O método crucial para nosso objeto Templater é o supplant, mas não podemos acessá-lo de fora da closure
do constructor. Assim, uma suite de testes como QUnit não pode ter esperança de
verificar se ele funciona como pretendido. Além disso, não podemos verificar se
nosso método defineTemplate
faz alguma coisa sem tentar uma chamada .render()
no template, esperando por um exceção. Poderíamos simplesmente adicionar um
método  getTemplate(),
mas então estaríamos adicionando métodos à interface pública somente com a
finalidade de realização de testes, o que não é uma boa abordagem. Embora essas
questões estejam provavelmente bem encaminhadas
nesse exemplo simples, a construção de objetos complexos com métodos privados
importantes levará à confiança em códigos sem possibilidade de serem testados,
o que é um sinal vermelho. Segue uma versão do acima considerado, que pode ser
testada:

function Templater() { 
this._templates = {};
}

Templater.prototype = {
_supplant: function(str, params) {
for (var prop in params) {
str.split("{" + prop +"}").join(params[prop]);
}
return str;
},
render: function(name, params) {
if (typeof this._templates[name] !== "string") {
throw "Template " + name + " not found!";
}

return this._supplant(this._templates[name], params);
},
defineTemplate: function(name, template) {
this._templates[name] = template;
}
};

E aqui vai um test suite QUnit  para isso:

module("Templater"); 
test("_supplant", function() {
var templater = new Templater();
equal(templater._supplant("{foo}", {foo: "bar"}), "bar"))
equal(templater._supplant("foo {bar}", {bar: "baz"}), "foo baz"));
});

test("defineTemplate", function() {
var templater = new Templater();
templater.defineTemplate("foo", "{foo}");
equal(template._templates.foo, "{foo}");
});

test("render", function() {
var templater = new Templater();
templater.defineTemplate("hello", "hello {world}!");
equal(templater.render("hello", {world: "internet"}), "hello internet!");
});

Note que nosso teste para render é de fato somente um teste para verificar se o defineTemplate e o supplant  se integram corretamente. Já testamos esses
métodos isoladamente, o que nos permitirá facilmente descobrir quais
componentes estão com problemas quando testes com o método render falharem.

Escreva funções coesas

Funções tight são
importantes em qualquer linguagem, mas o JavaScript tem suas próprias razões
quanto à sua importância. Muito do que você faz com o JavaScript é feito contra
singletons globais dados pelo ambiente, dos quais sua suíte de testes depende.

O teste de uma URL re-writer será difícil, se todos seus métodos tentarem manipular window.location, por exemplo..
Em vez disso, você deve dividir seu sistema em seus componentes
lógicos que decidem o que fazer, e então escrever funções curtas que de fato
realizarão o trabalho. Você pode testar as funções lógicas com vários
inputs e outputs, deixando a função final que modifica o window.location sem testar. Desde que você tenha feito
seu sistema corretamente, isso deve ser seguro.

Aqui está um exemplo
de rewriter de URL que não pode ser testado:

function redirectTo(url) { 
if (url.charAt(0) === "#") {
window.location.hash = url;
} else if (url.charAt(0) === "/") {
window.location.pathname = url;
} else {
window.location.href = url;
}
}

A lógica nesse exemplo é relativamente
simples, mas podemos imaginar um redirecionador mais complexo. À medida que a
complexidade aumenta, não seremos capazes de testar este método sem causar o
redirecionamento da janela, abandonando assim completamente nossa suíte de
testes.

Aí vai uma versão que pode ser testada:

function _getRedirectPart(url) { 
if (url.charAt(0) === "#") {
return "hash";
} else if (url.charAt(0) === "/") {
return "pathname";
} else {
return "href";
}
}

function redirectTo(url) {
window.location[_getRedirectPart(url)] = url;
}

Agora podemos escrever uma suíte de testes simples
para _getRedirectPart:

test("_getRedirectPart", function() { 
equal(_getRedirectPart("#foo"), "hash");
equal(_getRedirectPart("/foo"), "pathname");
equal(_getRedirectPart("http://foo.com"), "href");
});

Agora a essência do redirectTo
foi testada e não teremos que nos preocupar com um redirecionamento acidental para fora de nossa suíte de testes.

Nota: Há uma solução
alternativa, criando uma função performRedirect que
faz a mudança de locação, mas tire isso de sua suíte de testes. Essa é uma
prática comum para muitas pessoas, mas tenho tentado evitar o método
stubbing. Descobri que o QUnit funciona
bem em todas situações pelas quais passei até agora, e preferiria não lembrar de mockear um método como esse para
meus testes, mas o seu caso pode ser diferente.

Escreva um
monte de testes

Isso é básico, mas é
importante ressaltar. Muitos programadores escrevem poucos testes porque
escrever testes é difícil e dá muito trabalho. Sofro com esse problema o tempo
todo, de forma que usei um pequeno ajudante para QUnit para fazer a escrita de um monte de testes bem mais fácil. É uma função chamada 
testCases, que você pode chamar em um bloco de testes, passando uma função, chamando context e
array de inputs/outputs para experimentar e comparar. Você pode construir
rapidamente uma suite robusta para suas funções input/output para fazer testes
rigorosos.

function testCases(fn, context, tests) { 
for (var i = 0; i < tests.length; i++) {
same(fn.apply(context, tests[i][0]), tests[i][1],
tests[i][2] || JSON.stringify(tests[i]));
}
}

E aqui está um exemplo simples de utilização:

test("foo", function() { 
testCases(foo, null, [
[["bar", "baz"], "barbaz"],
[["bar", "bar"], "barbar", "a passing test"]
]);
});

Conclusões

Há muito mais a ser escrito
a respeito de JavaScript que possa ser testado, e tenho certeza de que há muitos
livros bons, mas espero que aqui tenhamos tido uma boa visão geral de casos
práticos que encontramos no dia a dia. Não sou de maneira nenhuma um expert em
testes, de forma que me avisem se cometi enganos ou dei maus conselhos.

?

Texto original disponível em http://www.adequatelygood.com/2010/7/Writing-Testable-JavaScript

Comente também

1 Comentário

William Bruno

O artigo é muito bom, mas por se tratar de ‘testes’, acredito que a maioria dos desenvolvedores não fazem ainda. Infelizmente.

Qual a sua opinião?