DEV Community

Cover image for Criando testes unitários com JS puro!
André Altoé Santiago
André Altoé Santiago

Posted on

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

preview

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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", () => {
        ...
    })
})
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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`);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);        
    });
})
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

A nossa div root será utilizada logo mais 😉

E ao abrir o terminal, será possível ver isso no console:
Testes 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/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous"> 
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)