Introdução
O Jest é uma ferramenta incrível para nossos testes com node, aplicações javascript e typescript. Um dos grandes desafios é saber lidar com a configuração de mocks dos módulos que temos na nossa aplicação. Ao trabalhar em cenários como este, é possível que em algum momento tenha se deparado com o seguinte erro:
The module factory of “jest.mock()” is not allowed to reference any out-of-scope variables
Este erro ocorre devido à sequência que o Jest faz o setup do ambiente de teste e como ele lida com as configurações depois de prontas. Estudando com mais calma as configurações, podemos notar que o processo acontece na seguinte ordem:
- O Jest encontra as configurações de mock que estão em um contexto global no arquivo e executa essas configurações;
- Faz as configurações do restante das declarações que estão em um contexto global dentro do arquivo de teste;
- Executa os testes utilizando os mocks e configurações feitas no início da execução do arquivo.
Para ilustrar vamos dar uma olhada em uma implementação de teste onde este problema acontece:
import axios from "axios"; import { useEffect, useState } from "react"; function App() { const [tasks, setTasks] = useState<Task[]>(); async function getTasks() { const { data } = await axios.get<Task[]>("<http://localhost:5000/tasks>"); setTasks(data); } useEffect(() => { getTasks(); }, []); return ( <div className="App"> <header className="App-header"> <h1>To Do List</h1> </header> <main className="App-main"> <ul> {tasks?.map((task) => ( <li key={task.id}>{task.name}</li> ))} </ul> <button onClick={getTasks}>Get tasks</button> </main> </div> ); } interface Task { id: number; name: string; done: boolean; } export default App;
App.tsx para o qual vamos escrever os testes
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import App from "./App"; const getMocked = jest.fn(); jest.mock("axios", () => ({ ...jest.requireActual("axios"), get: () => getMocked(), })); test("renders learn react link", async () => { render(<App />); getMocked.mockReturnValue([ { id: 1, name: "Task 1", done: false, }, { id: 2, name: "Task 2", done: false, }, ]); fireEvent.click(screen.getByText("Get tasks")); await waitFor(() => { const tasks = screen.getAllByRole("listitem"); expect(tasks[0]).toHaveTextContent("Task 1"); }); });
Exemplo de teste utilizando jest.mock
A principal motivação para fazer esse tipo de configuração é possibilitar o controle do que será retornado pela função mockada em cada teste, ou seja, queremos que esta função retorne dados diferentes dependendo do cenário que estamos testando. Por isso declaramos uma constante, no caso a getMocked, para que seja possível acessá-la nos nossos testes e definir seu comportamento de maneira customizada em cada teste. O problema é que, fazendo isso, ocorre o nosso famoso erro The module factory of jest.mock()
is not allowed to reference any out-of-scope variables.
Vamos dar uma olhada na sequência que o Jest cria, para entender melhor porque ele não consegue utilizar a constante getMocked. É como se o código ficasse da seguinte forma:
Sequência que a configuração acontece
É por este motivo que acontece o problema com o escopo. No momento em que o Jest está configurando o mock do Axios, neste nosso caso, a variável getMocked ainda não foi declarada e, consequentemente, ainda não existe no contexto em que o setup está acontecendo.
Considerando que logo no início do teste o Jest já executa as configurações de mock, mesmo que a declaração da variável que utilizaremos dentro do setup esteja linhas antes, na prática, ela ainda não terá sido declarada e é por este motivo que o Jest dispara a mensagem avisando que a variável está fora do escopo no momento em que ele está tentando utilizar.
Como resolver esse problema?
Agora que sabemos o motivo pelo qual esse problema acontece, existem algumas maneiras de resolvê-lo. Basicamente o que precisamos é fazer a configuração da função mockada no momento certo.
Utilizando o prefixo “mock” no início do nome da variável
Ao colocar este prefixo no início do nome da variável, o Jest entenderá que essa variável deve ser levada para o começo do contexto do teste juntamente com a configuração do setup dos testes. Sendo assim, a variável não estará mais em um contexto diferente.
import React from "react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import App from "./App"; const mockGet = jest.fn(); jest.mock("axios", () => ({ ...jest.requireActual("axios"), get: () => mockGet(), })); test("renders learn react link", async () => { render(<App />); mockGet.mockReturnValue([ { id: 1, name: "Task 1", done: false, }, { id: 2, name: "Task 2", done: false, }, ]); fireEvent.click(screen.getByText("Get tasks")); await waitFor(() => { const tasks = screen.getAllByRole("listitem"); expect(tasks[0]).toHaveTextContent("Task 1"); }); });
Por que colocar o prefixo “mock” no nome da variável resolve o problema?
Basicamente o que acontece é que o Jest pega essas variáveis junto com as configurações de mock para serem executadas antes de iniciar os testes, ou seja, acabam acontecendo no mesmo contexto. É como se o Jest separasse tudo que faz parte da configuração de mocks em outro arquivo, e executasse esse arquivo antes do arquivo de testes, como se tudo que está no mesmo arquivo fizesse parte do mesmo contexto. É por isso que esse prefixo mágico faz com que a variável funcione corretamente na configuração do mock. Algo mais ou menos assim:
Utilizando cast para acessar as funcionalidades do mock
Uma alternativa para resolver o nosso problema é configurar a função com um jest.fn() já na configuração do jest.mock. Vamos dar uma olhada em como fica o nosso código utilizando essa abordagem:
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import App from "./App"; import axios from "axios"; jest.mock("axios", () => ({ ...jest.requireActual("axios"), get: jest.fn(), })); const getMocked = axios.get as jest.Mock; test("renders learn react link", async () => { getMocked.mockReturnValue({ data: [ { id: 1, name: "Task 1", done: false, }, { id: 2, name: "Task 2", done: false, }, ], }); render(<App />); fireEvent.click(screen.getByText("Get tasks")); await waitFor(() => { const tasks = screen.getAllByRole("listitem"); expect(tasks[0]).toHaveTextContent("Task 1"); }); });
Ou podemos simplesmente fazer o mock do módulo inteiro e fazer o cast depois:
import axios from "axios"; jest.mock("axios"); const mockedAxios = axios as jest.Mocked<typeof axios>;
Como podemos ver, nessa implementação o módulo é mockado, e quando a importação é feita o nosso teste não sabe que a função get é um mock, por isso que é necessário fazer o cast para permitir que as funcionalidades de mock fiquem acessíveis. Gosto desta abordagem porque permite termos o controle do comportamento do mock de maneira independente em cada um dos nossos testes, sem que haja a necessidade de colocar um prefixo forçado em nossas constantes.
Conclusão
A configuração do jest.mock permite controlar o comportamento dos módulos para garantir a estabilidade dos nossos testes, mas para tirar o máximo de proveito dessas ferramentas, precisamos fazer entender como as coisas funcionam por baixo dos panos. Ainda bem que existe bastante informação sobre o Jest no próprio site oficial.
O código-fonte pode ser encontrado neste repositório.
Referências
Nodejs + TypeScript: Criando uma api rest
Better Software Design with Application Layer Use Cases | Enterprise Node.js + TypeScript