Criando testes unitários com JS puro!
Aos apressados TL;DR
Neste repositório há um exemplo simplório da implementação de testes unitários com inspiração no Jest, com direito à visualização no browser.
DIY JS Tester
Motivação
Recentemente fiz um desafio em JS, e sempre imaginei que lidar com a segurança dos testes é algo muito importante, tanto ao se fazer código quanto ao realizar manutenções no mesmo, porém, como poderia fazê-los sem o auxílio do fiel companheiro Jest? 😯 Logo resolvi pegar o desafio de implementar uma forma simples mas que pudesse me trazer todos os recursos que eu precisava naquele momento, com isso pude aprender ainda mais sobre a linguagem e seus recursos!
Mão na massa!
Primeiro de tudo, iniciaremos um arquivo para receber o código do testador, assim poderemos deixar as coisas o mais "separadas" o possível.
tester.js
Aqui, iniciaremos o nosso código com a descrição dos testes
const describe = (module, fn) => {
// printModule(module);
console.info(`---- ${module} ----`);
fn();
console.info('\n\n');
}
const it = (desc, fn) => {
try {
fn();
// printTest(desc);
console.log(`PASS - ${desc}`);
} catch (error) {
// printTest(desc, error);
console.log(`FAIL - ${desc}`);
console.error(error);
}
}
Falaremos das funções de prefixo print
posteriormente. Primeiro, criamos uma estrutura de funções que nos permite escrever testes separados em "módulos" que poderão ser escritos da seguinte forma:
describe("funcao imprimeLinha", () => {
it("deve imprimir a linha", () => {
...
})
it("não deve pular linha", () => {
...
})
})
Agora, bastar criar uma função de asserção, e voilá, seus testes já podem dar os primeiros passos!
Mas para isso, faremos de uma forma que nos permite expandir e ir além.
Nossa primeira asserção será de igualdade, para isso, criaremos uma classe _assert
, que sempre será chamada por uma função assert
, fazendo que não seja necessário a utilização do operador de instanciação de classe new
.
class _assert {
constructor(value) {
this.value = value;
}
toBe(expected) {
if (!Object.is(this.value, expected)) {
throw new Error(`"\nEsperado: ${JSON.stringify(expected)} \nRecebido: ${JSON.stringify(this.value)}\n`);
}
}
const assert = (value) => new _assert(value);
A nossa classe _assert
recebe um valor em seu construtor que poderá ser qualquer coisa, assim, ao chamar seu método toBe(expected)
, o mesmo irá comparar com o auxílio da função Object.is()
se o valor recebido (value) é o mesmo que o esperado (expected).
Assim, ao criar nossos testes, poderemos fazer uma asserção da seguinte forma:
assert(criaString('ola mundo')).toBe('ola mundo');
Porém, como estamos lidando com Javascript, você deve saber (ou não, e está aprendendo agora) que 0.1 + 0.2 não é igual a 0.3 (é 0.30000000000000004), tal como um array [] nunca é igual a outro array ([] === [] -> false), para isso, estaremos criando mais duas asserções, e aproveitando que isso é uma classe, criaremos também um modificador not()
, que ira fazer justamente o contrário, e esperar que o valor seja diferente de um esperado, assim nossa classe terminará dessa forma:
class _assert {
isNot = false;
constructor(value) {
this.value = value;
}
not() {
this.isNot = true;
return this;
}
toBe(expected) {
if(this.isNot && Object.is(this.value, expected)){
throw new Error(`"\nNão esperado: ${JSON.stringify(expected)} \nRecebido: ${JSON.stringify(this.value)}\n`);
}
if (!Object.is(this.value, expected) && !this.isNot) {
throw new Error(`"\nEsperado: ${JSON.stringify(expected)} \nRecebido: ${JSON.stringify(this.value)}\n`);
}
}
toBeCloseTo(expected, precision = 2) {
const roundedExpected = Number(expected.toFixed(precision));
const roundedValue = Number(this.value.toFixed(precision));
if(this.isNot && Object.is(roundedValue, roundedExpected)){
throw new Error(`"\nNão esperado (aproximado): ${JSON.stringify(roundedExpected)} \nRecebido: ${JSON.stringify(roundedValue)}\n`);
}
if (!Object.is(roundedValue, roundedExpected) && !this.isNot) {
throw new Error(`"\nEsperado: ${JSON.stringify(expected)} \nRecebido: ${JSON.stringify(this.value)}\n`);
}
}
toEqual(expected) {
if(this.isNot && JSON.stringify(this.value) === JSON.stringify(expected)){
throw new Error(`"\nNão esperado: ${JSON.stringify(expected)} \nRecebido: ${JSON.stringify(this.value)}\n`);
}
if (JSON.stringify(this.value) !== JSON.stringify(expected) && !this.isNot) {
throw new Error(`"\nEsperado: ${JSON.stringify(expected)} \nRecebido: ${JSON.stringify(this.value)}\n`);
}
}
}
Para que possamos testar com floats que poderão nos trazer algum número com inúmeras casas decimais, criamos a função toBeCloseTo(expected, precision = 2)
, ela receberá o número esperado e a precisão (com a padrão sendo 2 casas decimais quando não especificado), assim o número será arredondado antes de ser comparado!
E para que possamos testar arrays e outros objetos que poderiam trazer alguma confusão criamos também a função toEqual(expected)
, a estratégia aqui é transformar nossos valores com o JSON.strigify()
para sua forma em JSON string, e então compará-los.
Para criação do modificador colocamos mais uma propriedade em nossa classe, isNot
, que nos dirá quando queremos que o valor seja diferente, para isso, alteramos em todas as funções o que fazer quando esse modificador for verdadeiro.
Agora basta criar o nosso index.html
e inserir nosso testador e um arquivo de testes como esse:
simple.test.js
describe('simple tests', () => {
it('should be true', () => {
assert(true).toBe(true);
});
it('should be equal arrays', () => {
const array = [1, 2, 3];
const array2 = [1, 2, 3];
assert(array).toEqual(array2);
});
it('0.1 + 0.2 should be 0.3', () => {
assert(0.1 + 0.2).toBeCloseTo(0.3);
});
it('should fail', () => {
assert(0.1 + 0.2).toBe(0.3);
});
})
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Runner</title>
</head>
<body>
<div id="root" class="d-grid p-3 gap-3">
<script src="tester.js"></script>
<script src="simple.test.js"></script>
</body>
</html>
A nossa div root
será utilizada logo mais 😉
E ao abrir o terminal, será possível ver isso no console:
Mas André, estou enjoado do console! E aquela interface bonitinha do início do artigo?!
O que vocês verão abaixo pode ser um crime de código, então, eu estou super disposto a receber dicas de melhorias, para entender como poderia ter deixado as coisas menos macarrônicas por aqui!
Primeiramente, inclua a biblioteca do bootstrap no arquivo index.html
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
retornaremos no arquivo tester.js
e incluiremos o seguinte código:
const root = document.getElementById('root');
const printTest = (desc, error) => {
const card = document.createElement('div');
card.className = `card ${!error ? 'bg-success' : 'bg-danger'}`;
const cardBody = card.appendChild(document.createElement('div'));
cardBody.className = 'card-body';
const header = cardBody.appendChild(document.createElement('div'));
header.className = 'd-inline';
const h5 = header.appendChild(document.createElement('h5'));
h5.className = 'me-2 d-inline';
h5.innerText = !error ? 'PASS -' : 'FAIL -';
const h5_2 = header.appendChild(document.createElement('h5'));
h5_2.innerText = desc;
h5_2.className = 'd-inline';
document.getElementById(moduleName).appendChild(card);
if (error) {
const errorElement = cardBody.appendChild(document.createElement('div'));
errorElement.className = 'card bg-light mt-3';
const pre = errorElement.appendChild(document.createElement('pre'));
const code = pre.appendChild(document.createElement('code'));
code.innerText = error.stack;
}
}
let moduleName = '';
const printModule = (module) => {
const moduleElement = root.appendChild(document.createElement('div'));
moduleElement.id = module;
moduleElement.className = 'd-grid gap-2'
moduleName = module;
const h3 = moduleElement.appendChild(document.createElement('h3'));
h3.innerText = module;
}
E você já pode descomentar a parte do código referente às funções com prefixo print
.
O que fazemos primeiramente é encontrar a nossa div root
para que possamos criar novos elementos na DOM, com isso, criamos uma variável global chamada moduleName
que servirá para imprimir os testes de um módulo ali dentro.
A função printModule(module)
simplesmente recebe o nome do módulo de teste e imprime um Header3 na tela com esse nome e uma Div que será utilizada para mostrar os testes com um distanciamento que mantém o conforto visual!
Já a função printTest(desc, error)
receberá a descrição do teste, e caso exista um erro, ele também será passado. A função criará um card com a descrição e utilizará do argumento error
para definir a cor do card e se ele deverá mostrar o rastreamento de pilha, necessário para entender de onde vem o erro.