No artigo anterior eu mostrei como podemos começar a usar o Node.js Test Runner. Agora, como a gente faz mais do que só "começar" com o Node.js Test Runner?

Depois de alguns feedbacks de alunos e leitores, muita gente me pediu pra continuar porque não existem muitos conteúdos sobre o test runner em português, então bora continuar! Vou tentar fazer vários artigos, não necessariamente conectados sobre o que eu passo com o NTR e como a gente pode resolver alguns casos comuns usando ele.

Hoje, vamos falar de mocks!

Sobre mocks, spies, stubs... blá blá blá

Primeiro, se você é novo no mundo de testes e/ou não sabe sobre a existência de test doubles, eu não vou explicar o que eles são aqui. Mas, em 2017 eu escrevi dois artigos sobre testes que ainda estão válidos:

Antes de começar esse artigo, leia um ou os dois conteúdos anteriores porque vamos falar bastante sobre mocks aqui hoje!

Mocks no Node.js Test Runner

Quando o NTR saiu na versão 18 do Node, ele não possuía suporte nativo a mocks, ou seja, você tinha que baixar uma biblioteca de test doubles como o Sinon para poder utilizar qualquer tipo de mock ou spy.

Atualmente (no momento desse artigo, estamos na versão 22), o NTR já tem algum suporte a mocks, mas ainda não está completo, por exemplo, somente temos o suporte a spies e stubs, mas não conseguimos (ainda) mockar módulos completos ou objetos completos.

Nesses casos, onde estamos acostumados com projetos mais antigos como o Jest, que já tem um sistema de mocks próprio, o mais simples é voltar às raizes e utilizar o Sinon para suprir a necessidade, visto que ambos (NTR e Jest) são fortemente inspirados no Sinon.

Porém, mesmo com todas as limitações, ainda conseguimos fazer mocks para quase todos os casos de teste que precisamos. Vou passar um pouco pela API de mocks que temos com o Node Test Runner e como podemos utilizar cada uma das funções.

Vem aprender comigo!

Quer aprender mais sobre criptografia e boas práticas com #TypeScript?

Se inscreva na Formação TS!

A API de Mocks

O módulo node:test possui alguns mocks através de um objeto chamado mock que você pode importar direto do pacote:

import { mock, test } from 'node:test'

Esse mock tem alguns métodos que você pode usar para criar spies de uma função ou objeto usando mock.fn, vamos imaginar uma função de soma contínua como essa aqui:

import { mock, test } from 'node:test'

test('foo', () => {
  const myFun = (...n) => n.reduce((acc, cur) => acc + cur, 0)
})

Como fazemos para saber se ela foi realmente chamada? E com quais parâmetros ela foi chamada? Podemos fazer um wrap dela em um objeto spy:

import { mock, test } from 'node:test'
import assert from 'node:assert'

test('foo', () => {
  const myFun = (...n) => n.reduce((acc, cur) => acc + cur, 0)
  const myFunSpy = mock.fn(myFun)

  assert.strictEqual(myFunSpy.mock.calls.length, 0) // não chamada
  assert.strictEqual(myFun(1,5,7), 13)
  assert.strictEqual(myFunSpy.callCount(), 1) // chamado
})

Você pode ver que tudo relacionado à "metachamada" da função está no objeto mock dentro do myFunSpy. Quando acessamos esse objeto temos uma série de propriedades:

  • callCount: Número de vezes que uma função foi chamada, similar a .mock.calls.length, mas é mais eficiente porque é uma função que não cria uma cópia do array interno do Tester
  • resetCalls: Retorna o número de chamadas para 0
  • mockImplementation: Substitui a implementação da função por outra durante todo o tempo de vida do mock
  • mockImplementationOnce: O mesmo do anterior, porém apenas uma vez (equivalente a usar mockImplementation, chamar a função e, em seguida, restore())
  • restore: Restaura o comportamento original da função, o mock pode continuar sendo usado depois disso

Além disso temos o objeto calls, que é literalmente o array de tracking interno do tester. Esse array tem a lista de todas as chamadas feitas para a função. Então, por exemplo, se quisermos pegar a primeira chamada, podemos fazer mock.calls[0].

Cada chamada tem uma série de outras propriedades:

  • arguments: O array de argumentos posicionais da função, então mock.calls[0].arguments[0] no caso da chamada myFun(1,10) seria 1.
  • error: Se a função lançou um erro, esse valor vai conter o erro lançado, caso contrário será undefined
  • result: Da mesma forma, se a função chegou no final e retornou um valor, esse é o valor retornado, caso contrário é undefined
  • stack: É o StackTrace que foi usado para determinar o erro caso a propriedade error existir
  • target: Se o mock for o construtor de uma classe, essa propriedade vai ser a classe sendo construída
  • this: A propriedade this do objeto mockado

Com essas propriedades a gente consegue basicamente mockar toda função possível, por exemplo, podemos garantir que a nossa função myFun retornou com sucesso e foi chamada com os argumentos certos:

import { mock, test } from 'node:test'
import assert from 'node:assert'

test('foo', () => {
  const myFun = (...n) => n.reduce((acc, cur) => acc + cur, 0)
  const myFunSpy = mock.fn(myFun)

  assert.strictEqual(myFunSpy.mock.calls.length, 0) // não chamada
  assert.strictEqual(myFun(1,5,7), 13)
  assert.strictEqual(myFunSpy.callCount(), 1) // chamado

  const lastCall = myFunSpy.mock.calls[0]
  assert.deepStrictEqual(lastCall.arguments, [1,5,7])
  assert.strictEqual(lastCall.result, 13)
  assert.strictEqual(lastCall.error, undefined)
})

Podemos também mudar o comportamento da função para que ela retorne sempre a mesma coisa:

import { mock, test } from 'node:test'
import assert from 'node:assert'

test('foo', () => {
  const myFun = (...n) => n.reduce((acc, cur) => acc + cur, 0)
  const myFunSpy = mock.fn(myFun, (...x) => 10) // a função agora retorna sempre 10

  assert.strictEqual(myFunSpy.mock.calls.length, 0) // não chamada
  assert.strictEqual(myFun(1,5,7), 10)
  assert.strictEqual(myFunSpy.callCount(), 1) // chamado

  const lastCall = myFunSpy.mock.calls[0]
  assert.deepStrictEqual(lastCall.arguments, [1,5,7])
  assert.strictEqual(lastCall.result, 10)
  assert.strictEqual(lastCall.error, undefined)
})
💡
Se passarmos um terceiro parâmetro para a função, podemos dizer quantas vezes o mock estará valendo, então se passarmos mock.fn(original, implementation, { times: 5 }) vamos mockar original para ter o retorno de implementation, mas apenas em 5 chamadas

O objeto mock

Além de termos propriedades específicas dentro da função spy, também temos propriedades globais do mock em si, por exemplo, podemos fazer o mock de um método de um objeto usando mock.method:

import { mock, test } from 'node:test'
import assert from 'node:assert'

test('foo', () => {
  const mathObj = {
    spreadSum: (...n) => n.reduce((acc, cur) => acc + cur, 0),
    sum: (a, b) => a+b,
    max: (a, b) => Math.max(a, b) 
  })

  const maxMock = mock.method(mathObj, 'max')
  assert.strictEqual(maxMock.callCount(), 0)
  assert.strictEqual(mathObj.max(1, 3), 3)
  assert.strictEqual(maxMock.callCount(), 1)
})

Da mesma forma do anterior, podemos passar um terceiro parâmetro que é a implementação, além de um quarto parâmetro que é um objeto de opções com as seguintes propriedades:

  • getter: Se true a propriedade mockada é tratada como um getter
  • setter: Se true a propriedade mockada será tratada como um setter (não pode ser usada junto com getter: true)
  • times: Quantas vezes que a implementação será utilizada
💡
Além do method, também temos dois atalhos que são mock.getter e mock.setter que tem a mesma função de chamar mock.method com a propriedade getter ou setter como true.

Outra função que temos diretamente em mock é a função reset que vai resetar todas as propriedades de todos os mocks criados globalmente, e a função restoreAll que, como você pode imaginar, faz o mesmo que o restore mas em nível global.

Contextos

Outra coisa que é importante comentar é que o objeto mock pode ter dois contextos: local e global.

O contexto global é o que estamos chamando diretamente do módulo node:test, enquanto o local é o contexto dentro do teste individual. Esse contexto pode ser visto pela (recém exportada por este que vos fala) interface chamada TestContext.

O que estamos fazendo até agora é o contexto global. Para chamarmos direto de dentro do contexto podemos utilizar o parâmetro que é passado para a função test ou it:

import { test } from 'node:test'
import assert from 'node:assert'

test('contexto local', (ctx) => {
  const myFun = (...n) => n.reduce((acc, cur) => acc + cur, 0)
  const myFunSpy = ctx.mock.fn(myFun) // veja o ctx aqui

  assert.strictEqual(myFunSpy.mock.calls.length, 0)
  assert.strictEqual(myFun(1,5,7), 10)
  assert.strictEqual(myFunSpy.callCount(), 1)
})

Podemos usar com it:

import { describe, it } from 'node:test'
import assert from 'node:assert'

describe('contexto local com it', () => {
  it('nome do teste', (ctx) => {
    const myFun = (...n) => n.reduce((acc, cur) => acc + cur, 0)
    const myFunSpy = ctx.mock.fn(myFun) // veja o ctx
  
    assert.strictEqual(myFunSpy.mock.calls.length, 0)
    assert.strictEqual(myFun(1,5,7), 10)
    assert.strictEqual(myFunSpy.callCount(), 1)
  })
})

A grande vantagem (e o motivo pelo qual eu recomendo usar o contexto local sempre) é que, quando um teste acabar, ele vai automaticamente limpar e remover o mock, dessa forma um mock não vai interferir em outro mock.

Fun Fact: Tivemos esse problema durante uma das lives na nossa comunidade da Formação TS. Quando estávamos fazendo o módulo de testes com o Node.js Test Runner, acidentalmente criamos um mock global que acabou interferindo em todos os testes do projeto.

Conclusão

O Node.js Test Runner é uma ferramenta incrível. E ele tem muito a oferecer mesmo estando com algumas funcionalidades faltando, porém mesmo com o módulo de mocks incompleto, podemos ver que é possível fazer tudo que precisamos usando somente o test runner.

Se você está interessado em ver um test completo de um projeto grande, veja o nosso repositório do projeto número 3 da Formação TS que eu resolvo passo a passo com os alunos durante o curso!