Angular e Jest: teste unitário envolvendo objetos globais externos

Criar testes unitários em projetos Angular com scripts externos pode ser desafiador. Para lidar com isso, podemos definir mocks globais, garantindo um ambiente de teste isolado e previsível. Neste texto, compartilho minha experiência prática nesse cenário de testes com Jest e Angular.

Angular e Jest: teste unitário envolvendo objetos globais externos
Angular e Jest: teste unitário envolvendo objetos globais externos

Independentemente da escolha do framework ou biblioteca para desenvolver um projeto frontend, é comum que alguns projetos façam uso de scripts externos, como Google Analytics, bibliotecas para geração de gráficos, entre outros. Esses scripts geralmente têm como objetivo solucionar problemas específicos e, muitas vezes, precisam interagir diretamente com o seu projeto Angular.

Neste pequeno texto, vou compartilhar minha experiência prática na criação de testes unitários para projetos Angular que utilizam scripts externos. Também vou mencionar sobre o uso do arquivo jest-setup.(js|ts) para criar mocks globais e uma técnica para evitar o lançamento de erros esperados no console durante a execução dos testes.

Preparei um exemplo de um projeto Angular que utiliza uma lib externa. Essa lib é injetada através da tag <script> direto de um CDN. Sua função é efetuar o parse de conteúdo markdown, ou seja, recebe um texto no formato markdown e converte para HTML.

O código abaixo será o alvo da construção dos testes unitários.

import { Injectable } from '@angular/core';

/**
 * Declaração do tipo para a biblioteca 'marked'.
 * Esta declaração é necessária pois a biblioteca marked
 * é carregada via <script> e não através de um módulo
 * npm. Então esse trecho permite que o TypeScript
 * reconheça o objeto global 'marked' sem precisar 
 * instalar o pacote @types/marked.
 */
declare const marked: {
  parse(conteudoEmMD: string): string;
};

/**
 * Serviço responsável por converter texto em formato
 * Markdown para HTML
 */
@Injectable({ providedIn: 'root' })
export class MarkdownParserService {
  /**
   * Converte uma string em formato Markdown para HTML
   * @param conteudoEmMD - Texto em formato Markdown que
   * será convertido
   * @returns String contendo o HTML gerado a partir do
   * Markdown
   */
  toHtml(conteudoEmMD: string): string {
    if (typeof conteudoEmMD !== 'string') {
      return '';
    }

    try {
      // Observe que o objeto `marked` não foi importado.
      // Veja a explicação ao longo do texto.
      return marked.parse(conteudoEmMD);
    } catch (erro) {
      console.error(
        'Erro ao converter Markdown para HTML:',
        erro,
      );
      return '';
    }
  }
}

markdown-parser.service.ts

Sobre o projeto

O projeto em questão usa as seguintes versões:

  • Angular 19
  • Jest 29
  • Node.js 22.14.0

No arquivo index.html, que é primeiro arquivo carregado pelo navegador, foi adicionado um <script> conforme a seguir:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>TesteJest</title>
    <base href="/" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1"
    />
    <link
      rel="icon"
      type="image/x-icon"
      href="favicon.ico"
    />
    <script
      src="https://cdn.jsdelivr.net/npm/marked/marked.min.js">
    </script>
  </head>
  <body>
    <app-root></app-root>
  </body>
</html>

index.html

O <script> acima, quando carregado pelo navegador, cria o objeto marked que é usado dentro da classe MarkdownParserService (código no início deste texto). Esse objeto, por ser global, pode ser acessado de dentro e fora do contexto da aplicação Angular.

💡
Quando o pacote marked é instalado pelo npm, ele já disponibiliza os arquivos de definição (.d.ts), portanto, o passo acima não é necessário. Ao invés disso, usaríamos um simples import dentro da MarkdownParserService. Contudo, observe esse método porque ele é intercambiável para este cenário onde importamos scripts externos ao mundo Angular.

A título de teste, dentro da classe AppComponent adicionei uma pequena lógica para consumir a classe MarkdownParserService. O resultado foi o seguinte:

Print do navegador mostrando o texto original no formado markdown e o texto convertido em HTML
Página mostrando um texto no formado markdown e o mesmo texto convertido em HTML

Criando o teste unitário

Com o cenário de estudo criado, vamos avaliar como criar um teste unitário para a classe MarkdownParserService.

A estrutura inicial do teste é bem simples, e verifica se o injetor de dependências do Angular consegue fornecer a instância da MarkdownParserService:

import { TestBed } from '@angular/core/testing';
import {
  MarkdownParserService
} from './markdown-parser.service';

describe(MarkdownParserService.name, () => {
  let service: MarkdownParserService;

  beforeEach(() => {
    service = TestBed.inject(MarkdownParserService);
  });

  it(
    'deve ser possível obter a instância da classe ' +
      'MarkdownParserService',
    () => {
      expect(service).toBeDefined();
    },
  );
});

markdown-parser.service.spec.ts

Vamos avançar e testar o método toHtml da classe em questão.

Alternativa 1 - criar o objeto marked no escopo do teste

Durante a execução do service.toHtml(), o código irá chamar marked.parse(). Por essa razão, antes de chamar o service.toHtml() é necessário criar um objeto de escopo global chamado marked. Esse objeto deve ter o método parse. Então criamos um objeto simulado chamado markedMock e adicionamos o método parsed utilizando o jest.fn(). Na sequência registramos esse objeto a nível global com o Object.defineProperty, conforme o código abaixo:

import { TestBed } from '@angular/core/testing';
import {
  MarkdownParserService
} from './markdown-parser.service';

describe(MarkdownParserService.name, () => {
  let service: MarkdownParserService;

  beforeEach(() => {
    service = TestBed.inject(MarkdownParserService);
  });

  it(
    'deve ser possível obter a instância da classe ' +
      'MarkdownParserService',
    () => {
      expect(service).toBeDefined();
    },
  );

  it(
    'deve chamar o método `marked.parse` e retornar ' +
      'o valor',
    () => {
      const markedMock = {
        parse: jest
          .fn()
          .mockImplementation(
            (conteudoMarkdown: string) => {
              return `parse teste: ${conteudoMarkdown}`;
            },
          ),
      };

      Object.defineProperty(globalThis, 'marked', {
        value: markedMock,
      });

      const conteudoMD = 'Teste 123';
      const resultado = service.toHtml(conteudoMD);

      expect(resultado).toBe(
        `parse teste: ${conteudoMD}`,
      );
    },
  );
});

markdown-parser.service.spec.ts

💡
Sobre o globalThis - em diferentes ambientes JavaScript (navegador, Node.js, etc.), o objeto global pode ter nomes diferentes (window, global). Isso pode causar problemas ao escrever código que precisa acessar o objeto global de forma consistente em diferentes ambientes.

O globalThis foi introduzido para resolver esse problema. Ele fornece uma maneira padrão de acessar o objeto global, independentemente do ambiente.

Alternativa 2 - criar o objeto marked no escopo global do teste

Caso outros testes utilizem o objeto marked, faz sentido mover o trecho Object.defineProperty do código acima, para o arquivo jest-setup.ts.

O conteúdo desse arquivo é interpretado antes da execução de cada arquivo de teste unitário.

const markedMock = {
  parse: jest
    .fn()
    .mockImplementation((conteudoMarkdown: string) => {
      return `parse teste: ${conteudoMarkdown}`;
    }),
};

Object.defineProperty(globalThis, 'marked', {
  value: markedMock,
});

jest-setup.ts

Criado o arquivo jest-setup.ts, é necessário fazer uma referência à ele dentro do arquivo jest.config.ts:

import type {Config} from 'jest';

const config: Config = {
  setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'],
  /* ... (outras configurações) ... */
}

jest.config.ts

Então o teste unitário fica da seguinte forma:

import { TestBed } from '@angular/core/testing';
import {
  MarkdownParserService
} from './markdown-parser.service';

describe(MarkdownParserService.name, () => {
  let service: MarkdownParserService;

  beforeEach(() => {
    service = TestBed.inject(MarkdownParserService);
  });

  it(
    'deve ser possível obter a instância da classe ' +
      'MarkdownParserService',
    () => {
      expect(service).toBeDefined();
    },
  );

  it(
    'deve chamar o método `marked.parse` e retornar ' +
      'o valor',
    () => {
      const conteudoMD = 'Teste 123';
      const resultado = service.toHtml(conteudoMD);

      expect(resultado).toBe(
        `parse teste: ${conteudoMD}`,
      );

      expect(marked.parse).toHaveBeenCalledTimes(1);
      expect(marked.parse).toHaveBeenCalledWith(
        conteudoMD,
      );
    },
  );
});

markdown-parser.service.spec.ts

Fazendo isso, você vai observar que as duas últimas linhas vão apresentar o seguinte erro:

⚠️
Cannot find name 'marked'.ts(2304)

O TypeScript não sabe que o objeto global marked foi criado. Então precisamos declará-lo. Para fazer isto, vou criar o arquivo src/globals.d.ts e recortar o seguinte trecho da classe MarkdownParserService:

declare const marked: {
  parse(conteudoEmMD: string): string;
};

globals.d.ts

O arquivo acima é lido pelo TypeScript e então o objeto marked é reconhecido ao longo do seu projeto. É como se disséssemos para o TypeScript "pode confiar que o objeto global marked existe".

Feito isto, o teste executa com sucesso!

Aumentando a cobertura de testes

Se abrimos o relatório de cobertura, que é gerado por padrão na pasta coverage/index.html, vamos encontrar o seguinte:

Relatório de cobertura de testes do arquivo markdown-parser.service.ts
Relatório de cobertura

Observe que há trechos do código que não foram testados. Então vamos aumentar essa cobertura!

Primeiro modifiquei o arquivo setup-jest.ts para que o objeto marked simulado retorne valores condicionados ao parâmetro de entrada:

import {
  setupZoneTestEnv
} from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv();

const markedMock = {
  parse: jest
    .fn()
    .mockImplementation((conteudoMarkdown: string) => {
      if (conteudoMarkdown === 'erro-simulado') {
        throw new Error('Erro simulado da lib marked');
      }

      return `parse teste: ${conteudoMarkdown}`;
    }),
};

Object.defineProperty(globalThis, 'marked', {
  value: markedMock,
});

setup-jest.ts

Por fim, adicionei mais 2 testes ficando da seguinte forma:

import { TestBed } from '@angular/core/testing';
import {
  MarkdownParserService
} from './markdown-parser.service';

describe(MarkdownParserService.name, () => {
  let service: MarkdownParserService;

  beforeEach(() => {
    service = TestBed.inject(MarkdownParserService);
  });

  it(
    'deve ser possível obter a instância da classe ' +
      'MarkdownParserService',
    () => {
      expect(service).toBeDefined();
    },
  );

  it(
    'deve chamar o método `marked.parse` e retornar ' +
      'o valor',
    () => {
      const conteudoMD = 'Teste 123';
      const resultado = service.toHtml(conteudoMD);

      expect(resultado).toBe(
        `parse teste: ${conteudoMD}`,
      );
      expect(marked.parse).toHaveBeenCalledTimes(1);
      expect(marked.parse).toHaveBeenCalledWith(
        conteudoMD,
      );
    },
  );

  it('deve retornar "" quando parse() lançar erro', () => {
    // Perceba que esse valor está no arquivo
    // setup-jest.ts. Quando passamos esse valor, o
    // `marked` simulado lança um erro para fins de teste:
    const conteudoMD = 'erro-simulado';
    const resultado = service.toHtml(conteudoMD);

    expect(resultado).toBe('');
    expect(marked.parse).toHaveBeenCalledTimes(1);
    expect(marked.parse).toHaveBeenCalledWith(conteudoMD);
  });

  it(
    'deve retornar "" quando `conteudoEmMD` não ' +
      'for string',
    () => {
      const conteudoMD = null;

      // Adicionei `@ts-ignore` para que o TypeScript
      // ignore a validação de tipagem, uma vez que
      // `conteudoEmMD` é uma `string`.
      // @ts-ignore
      const resultado = service.toHtml(conteudoMD);

      expect(resultado).toBe('');
      expect(marked.parse).toHaveBeenCalledTimes(0);
    },
  );
});

markdown-parser.service.spec.ts

Resultado da cobertura de código após a execução dos testes:

Relatório de cobertura de testes aberto no navegador mostrando a cobertura do arquivo markdown-parser.service.ts
Relatório de cobertura

Dica para não "sujar" o log durante a execução dos testes

Apesar do sucesso na execução dos testes, quando testamos um cenário de erro, acabamos executando um trecho de console.error da classe MarkdownParserService.

Terminal exibindo a mensagem de erro gerada durante a execução dos testes devido a um cenário de falha
Resultado da execução dos testes

Uma forma de evitarmos isso, é sobrescrevendo o console.error antes da execução dos testes. Podemos fazer isso no arquivo setup-jest.ts:

import {
  setupZoneTestEnv
} from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv();

// *********************************************************
// Cria o objeto global `marked` de forma simulada:
// *********************************************************
const markedMock = {
  parse: jest
    .fn()
    .mockImplementation((conteudoMarkdown: string) => {
      if (conteudoMarkdown === 'erro-simulado') {
        throw new Error('Erro simulado da lib marked');
      }

      return `parse teste: ${conteudoMarkdown}`;
    }),
};

Object.defineProperty(globalThis, 'marked', {
  value: markedMock,
});

// *********************************************************
// Sobrescrevemos o `console.error` antes da execução dos
// testes, e restauramos depois:
// *********************************************************
let consoleErrorSpy: jest.SpyInstance;

beforeAll(() => {
  consoleErrorSpy = jest
    .spyOn(console, 'error')
    .mockImplementation(() => {});
});

afterAll(() => {
  consoleErrorSpy.mockRestore();
});

setup-jest.ts

Este padrão é comumente usado para:

  • Evitar que erros esperados durante os testes apareçam no console;
  • Testar cenários de erro e não poluir o output dos testes;
  • Verificar se determinados erros foram logados sem realmente exibi-los

É uma prática útil para manter a saída dos testes limpa e focada apenas nas informações relevantes.

Veja o resultado do npm run test agora:

Terminal mostrando o resultado da execução dos testes - todos os testes passaram com sucesso
Resultado da execução dos testes

Considerações

Em testes unitários com Angular e Jest, é comum simular o comportamento de dependências externas, como a biblioteca marked. Essa prática garante que o teste se concentre na lógica da unidade em teste, isolando-a de fatores externos.

As duas alternativas apresentadas, simularam a chamada do marked.parse, ou seja, criaram um objeto global chamado marked com um comportamento simulado. É um teste válido, contudo, poderíamos abordar uma terceira via: importar o script marked.js e executar a biblioteca real durante o teste unitário. Essa abordagem pode ser considerada em cenários específicos, como testes de integração ou testes de ponta a ponta (e2e), nos quais a interação com a biblioteca real é essencial. No entanto, é importante estar ciente dos desafios e trade-offs envolvidos, como a maior dependência externa, complexidade e impacto no desempenho dos testes.