Angular: testes unitários de classes de componentes e serviços com Jasmine

Angular: testes unitários de classes de componentes e serviços com Jasmine

Tem interesse em começar a estudar sobre testes no Angular?

Neste artigo, faremos uma introdução a essa temática, começando com testes unitários e abordando sobre as principais ferramentas que você irá precisar para começar a escrever os seus primeiros testes.

Passaremos pelos seguintes tópicos:

  • Escrever testes na sintaxe do Jasmine;
  • Testar um serviço;
  • Testar uma classe de um componente;
  • Usar o TestBed para lidar com dependências;
  • O futuro dos testes unitários no Angular: o Karma foi depreciado?

Neste artigo, ainda não veremos sobre testes de interação com o DOM, testes de integração ou testes E2E (End-to-End), mas em breve teremos conteúdos sobre esses temas :)

Conhecendo o projeto

Para escrever nossos primeiros testes, vamos usar um simples projeto como exemplo: uma Todo List, ou Lista de Tarefas. Temos um único componente, o AppComponent, com um input, o qual podemos submeter para adicionar um novo item na nossa lista. Confira o seu template:

<!-- app.component.html -->

<main>
  <h1>{{ titulo }}</h1>

  <form (ngSubmit)="adicionarItem()">
    <input type="text" [(ngModel)]="novoItem" name="novoItem" />
  </form>

  <ul>
    @for (item of itens; track item) {
      <li>
        {{ item }}
      </li>
    }
  </ul>
</main>

Note que já estamos utilizando a nova sintaxe @for para repetirmos blocos HTML, disponível a partir da versão 17 do Angular!

Segue também o arquivo TypeScript do componente, que guarda a lógica:

// app.component.ts

import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TodoService } from './todo.service';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule],
  templateUrl: './app.component.html',
  styleUrl: './app.component.css'
})
export class AppComponent implements OnInit {
  titulo = 'Todo List';
  novoItem = '';
  itens: string[] = [];

  constructor(private todoService: TodoService) {}

  ngOnInit(): void {
    this.itens = this.todoService.obterItens();
  }

  adicionarItem() {
    this.todoService.adicionarItem(this.novoItem);
    this.itens = this.todoService.obterItens();
  }
}

Repare a opção standalone: true, que permite a criação de um componente sem criar um módulo. Visite a página Standalone Components da documentação para mais detalhes.

E para demonstrar exemplos mais interessantes com os testes, fiz com que o nosso componente dependesse de um serviço chamado TodoService. Em teoria, o serviço pode fornecer os itens da lista a partir do Local Storage ou de uma API, mas para mantermos a simplicidade deste artigo, faremos ele interagir apenas com uma lista local em memória. Segue o código do serviço:

// todo.service.ts

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

@Injectable({
  providedIn: 'root'
})
export class TodoService {
  private itens: string[] = [];

  obterItens(): string[] {
    return this.itens;
  }

  adicionarItem(item: string): void {
    this.itens.push(item);
  }
}

E agora o nosso próximo passo é começar a escrever os testes! Mas entre todos esses arquivos, o que devemos testar?

Banner promocional da Alura, com um design futurista em tons de azul, apresentando o texto

Escrevendo o primeiro teste

Começaremos com os testes mais simples de serem feitos em Angular: os testes unitários de serviços! Tudo que precisamos fazer é testar a classe TodoService, que, no nosso caso, não depende de nenhum outro serviço.

Se o nosso arquivo de serviço se chama todo.service.ts, o arquivo de teste deve se chamar todo.service.spec.ts e também deve ser posicionado na mesma pasta que o serviço. Além disso facilitar a localização do arquivo de teste, o arquivo será identificado automaticamente pela ferramenta de testes do Angular e indicará que estamos testando o arquivo todo.service.ts.

Mas qual a ferramenta que o Angular utiliza? É o Jasmine, um framework de testes que possui uma sintaxe semelhante ao Jest. A maior diferença entre esses dois frameworks é que, enquanto o Jest utiliza apenas o terminal para imprimir os resultados dos testes, o Jasmine depende do navegador para ser executado. Apesar disso, podemos ver os resultados dos testes do Jasmine tanto no navegador quanto no terminal.

Sabendo disso, vamos criar o arquivo de testes todo.service.spec.ts, seguindo a sintaxe do Jasmine:

// todo.service.spec.ts

import { TodoService } from './todo.service';

describe('TodoService', () => {
  let servico: TodoService;

  beforeEach(() => {
    servico = new TodoService();
  });

  it('deveria inicialmente retornar uma lista vazia', () => {
    expect(servico.obterItens()).toEqual([]);
  });

  it('deveria adicionar um item na lista', () => {
    const item = 'Estudar testes';

    servico.adicionarItem(item);
    expect(servico.obterItens()).toContain(item);
  });
});

O que estamos fazendo aqui?

  • Primeiro, nós criamos um contexto para os testes com o método describe(), nesse caso escolhi o nome 'TodoService', mas ele pode ter qualquer nome.
  • Utilizamos a função beforeEach() para realizar uma ação antes de executar cada um dos testes: no nosso caso, sempre instaciamos um novo TodoService do zero. Isso evita repetição de código ao longo de vários testes.
  • Cada teste é definido com uma função it(), que também deve receber um nome descritivo para o que o teste está verificando.

Agora vamos rodar os testes! Para isso, basta você executar o seguinte comando na pasta do projeto:

ng test

Aguardamos um pouco e o navegador será aberto:

Captura de tela do navegador. Há um título em inglês que informa que o Karma está sendo utilizado na versão “6.4.2” e que os testes foram completados. Logo abaixo, há o ícone do Jasmine junto com o número de sua versão “4.6.0”. Por fim, há um texto informando que foram rodados dois testes e não houve nenhuma falha. Abaixo desse texto, é possível conferir o contexto “TodoService” junto com os nomes dos seus dois testes que rodaram.

E podemos conferir que os testes passaram!

Escrevemos alguns testes que garantem que o TodoService funcione como esperado: estamos garantindo que ele inicie com uma lista vazia e que nós conseguimos adicionar um item na lista com sucesso.

Para realizar essas verificações, utilizamos as funções obterItens() e adicionarItem() do serviço. Note que, para escrever o teste, não precisamos conhecer a implementação dessas funções, mas estamos conferindo se elas funcionam da forma esperada. Esse é um dos principais objetivos ao se escrever testes.

Os testes garantem o comportamento que esperamos que nossa aplicação tenha. Isso nos dá segurança para realizar alguma refatoração ou implementação de alguma nova funcionalidade no projeto, pois se alterarmos um comportamento que não deveria ser alterado, os testes irão denunciar que houve uma modificação não pretendida.

Mas para que os testes denunciem corretamente os comportamentos que não devem ser alterados, também é essencial que eles sejam escritos corretamente e sigam boas práticas.

Confira os artigos Dicas para desenvolver testes unitários e de integração no Front-end e Dicas essenciais para escrever testes end to end melhores!

Usando o pacote de testes do Angular

Para escrever o teste unitário do serviço, nós utilizamos apenas a classe do serviço e as funções do Jasmine. No entanto, conforme nossa aplicação cresce e nossos serviços ficam mais complexos, utilizar apenas os recursos do Jasmine tornariam os nossos testes cada vez mais difíceis de serem escritos.

Por esse motivo, o Angular fornece alguns utilitários que integram bem os recursos do Jasmine com os do Angular, facilitando os testes de algumas operações, como injeção de dependências.

Uma das ferramentas mais essenciais para testes do Angular é a classe TestBed do pacote @angular/core/testing. Para conferir como podemos utilizá-la no nosso teste unitário, vamos primeiro importá-la:

// todo.service.spec.ts

import { TestBed } from '@angular/core/testing';

Em seguida, vamos reescrever o beforeEach(), agora utilizando o TestBed:

// todo.service.spec.ts

beforeEach(() => {
  TestBed.configureTestingModule({ providers: [TodoService] });
  servico = TestBed.inject(TodoService);
});

E o teste funcionará igual a antes. O método configureTestingModule() recebe opções muito semelhantes aos módulos do Angular, e é a partir dele que podemos gerenciar dependências de serviços de uma forma mais robusta.

Agora, qual o próximo passo?

Testando a classe do componente

Isso mesmo! Vamos testar o componente AppComponent e, inclusive, se ele se comunica corretamente com o TodoService.

Nesse primeiro momento, não iremos testar diretamente as interações do usuário com o DOM, como o preenchimento do input. Mas, ainda assim, testar a classe do componente já nos garante boa parte do funcionamento do componente.

Para começar a escrever o teste, primeiro precisaremos criar um mock (simulação) do TodoService. Lembre-se que o TodoService, em teoria, pode até mesmo realizar requisições para uma API. Assim, não devemos utilizar o serviço real, e sim uma classe mock que simula esse serviço.

Então, criamos o arquivo app.component.spec.ts e começamos escrevendo o seguinte:

// app.component.spec.ts

const mockListaTodo = ['Estudar Testes', 'Estudar Angular'];

class MockTodoService {
  private itens: string[] = mockListaTodo;

  obterItens(): string[] {
    return this.itens;
  }

  adicionarItem(item: string): void {
    this.itens.push(item);
  }
}

O MockTodoService é quase idêntico à nossa classe original, mas estamos simulando que ele já irá nos retornar uma lista com dois itens, como se estivesse buscando esses itens de uma API.

Agora vamos preparar o setup dos nossos testes. Vamos importar as classes necessárias no início do arquivo:

// app.component.spec.ts

import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { TodoService } from './todo.service'; 

E agora vamos implementar o describe() e o beforeEach():

// app.component.spec.ts

describe('AppComponent', () => {
  let componente: AppComponent;
  let servico: TodoService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        AppComponent,
        { provide: TodoService, useClass: MockTodoService },
      ],
    });

    componente = TestBed.inject(AppComponent);
    servico = TestBed.inject(TodoService);
  });

  // testes a serem escritos...
});

Note que estamos utilizando o TestBed novamente para que ele cuide da injeção de dependências por nós. Utilizamos a sintaxe { provide: TodoService, useClass: MockTodoService } para utilizar a classe MockTodoService no lugar do TodoService no componente!

Agora, vamos aos testes! Ainda dentro do describe(), vamos adicionar:

// app.component.spec.ts

it('deveria ser criado com lista vazia (antes de buscar do serviço)', () => {
  expect(componente.itens).toEqual([]);
});

it('deveria obter lista do TodoService', () => {
  componente.ngOnInit();
  expect(componente.itens).toEqual(mockListaTodo);
});

it('deveria adicionar um novo item', () => {
  componente.ngOnInit();

  const novoItem = 'Assinar Alura';
  componente.novoItem = novoItem;
  componente.adicionarItem();

  expect(componente.itens).toContain(novoItem);
});

Estamos testando 3 cenários diferentes, bem descritos por cada um dos testes. Agora vamos a alguns pontos de atenção:

  • Podemos chamar os métodos da classe do componente normalmente, inclusive o método de ciclo de vida ngOnInit(). Com isso, perceba que há uma grande diferença em verificar o valor de componente.itens antes ou depois de chamar o ngOnInit().
  • Não estamos simulando diretamente as interações do DOM, mas estamos testando funções que deveriam ser executadas em reação aos eventos do DOM. Por exemplo, no último teste, fizemos um fluxo de interações:
    • Atribuímos um valor a componente.novoItem, como se o usuário tivesse digitado no input;
    • Em seguida, executamos componente.adicionarItem(), como se o usuário tivesse submetido o formulário.

Dessa forma, percebemos que esse é um teste unitário da classe do componente, que não é tão completo quanto um teste que também simula as interações do DOM, mas, em contrapartida, é mais simples de ser escrito.

Ao escrever testes, sempre devemos levar em conta a relação entre complexidade e custos dos testes. Quanto mais bem feitos os testes são, ou seja, quanto mais eles cobrem o código, quanto mais situações eles abrangem, mais tempo e dinheiro eles irão demandar da equipe para serem feitos.

Por esse motivo, é comum que muitas empresas optem por certas definições para escrever seus testes, como:

  • Um número mínimo aceitável de cobertura de testes, como 80% em vez de 100%. Cobertura de testes é quanto do código das classes os seus testes executaram;
  • Realizar apenas testes unitários e de integração, já que testes E2E são mais custosos.

A tomada dessas decisões irá depender do contexto de cada projeto e empresa.

Dica: para gerar um relatório de cobertura de testes no Angular, execute ng test --no-watch --code-coverage!

Agora que você escreveu seus primeiros testes, vamos conferir um detalhe importante em relação ao futuro dos testes unitários no Angular!

Karma depreciado?

No momento da escrita deste artigo, o ambiente de testes no Angular conta com duas ferramentas principais: O Jasmine e o Karma.

O Jasmine é o framework que fornece funções como o describe(), beforeEach(), it(), além de avaliar e executar os testes que escrevemos. Esse framework pode ser utilizado tanto no front-end quanto no back-end. Já o Karma é o executor de testes (test runner) no front-end, e ele fornece suporte para que os testes do Jasmine possam ser feitos para o front-end, simulando eventos do DOM, por exemplo.

No entanto, talvez você tenha conferido que o Karma foi depreciado por sua equipe de desenvolvimento. No que isso impacta?

Felizmente, isso não significa que você terá que refazer os testes Jasmine do seu projeto Angular. De acordo com a equipe do angular, eles irão migrar do Karma para o Web Test Runner. Ou seja, o executor de testes para o front-end será mudado, mas os testes que já utilizam o Jasmine não precisarão modificar seu código, ou apenas precisarão mudar poucas configurações do projeto. Isso porque o Angular utiliza pacotes de integração que abstraem o trabalho do test runner.

Com isso, caso você já tenha um projeto que utiliza o Jasmine, não se preocupe: basta acompanhar as atualizações do Angular para quando o test runner for alterado e conferir o guia de migração que a equipe deverá disponibilizar.

Alternativamente, a versão 16 do Angular já fornece suporte experimental para o Jest, caso você queira utilizá-lo. Eventualmente ele deverá ser integrado completamente ao ambiente de testes do Angular, então é uma opção interessante para seus novos projetos :)

Confira mais informações sobre isso nesse post.

Quer se aprofundar mais em Angular e em testes? Confira os seguintes conteúdos da Alura:

Antônio Evaldo
Antônio Evaldo

Instrutor e Desenvolvedor de Software nas escolas de Front-end e de Programação da Alura, com foco em JavaScript. Sou técnico em Informática pelo IFPI e cursei Engenharia Elétrica na UFPI. Sou apaixonado por desenvolvimento web e por compartilhar conhecimento de forma encantadora. No tempo livre, assisto séries, filmes e animes.

Veja outros artigos sobre Front-end