DEV Community

Cover image for Testes unitários com Jest
Victor Alves
Victor Alves

Posted on • Originally published at gist.github.com

Testes unitários com Jest

Jest é um framework de teste unitário de código aberto em JavaScript criado pelo Facebook, baseado framework Jasmine. Seu diferencial para o concorrente Jasmine seria a popularidade, flexibilidade e velocidade de execução.

Contexto: O presente artigo é focalizado em diferentes exemplos de raciocínios, expectations e matchers para testes unitários com Jest em um ambiente que utiliza o framework SPA Angular.

Motivação Existem poucos materiais que explicam linha por linha a montagem da suíte e escrita de testes complexos.

Escopo: O presente artigo é recomendado a usuários que já tenham base conceitual sobre o tema de testes unitários em componentes. Os exemplos aqui citados são complexos, não estão disponíveis em um repositório e também não focaliza na instalação da ferramenta, então este material é considerado complementar ao entendimento introdutório do framework Jest. Dito isso, ainda sim foi construída uma estrutura lógica que parte dos conceitos iniciais, detalha a montagem da suíte de testes no componente e finaliza na escrita/execução da spec com foco na métrica do aumento de cobertura de testes no SonarQube.

Objetivo: aqui nós iremos de 0 a 100km muito rápido. Mostrando como planejar e escrever as specs para que ao final, você seja capaz de agir por conta própria.

Instalação

Recomendo instalar além do Jest, o Jest-CLI também para montar um script de execução de testes mais detalhado e que atenda a suas necessidades, abaixo segue o link para instalação:

https://jestjs.io/pt-BR/docs/getting-started

Nos próximos tópicos serão explicados alguns conceitos importantes para configuração e escrita dos testes unitários.

Suíte de testes

Servem para definir o escopo do que está sendo testado.

  • Dentro de uma aplicação existem várias suítes de testes;
  • Alguns exemplos de suítes seriam: Cálculos matemáticos, Cadastro de Clientes, consulta de cadastrados,...
  • No Jest, a suíte é uma função global Javascript chamada describe, que possui dois parâmetros, que seriam sua descrição e os testes (specs).

Exemplo:

describe("Operação de Adição", () => { });
Enter fullscreen mode Exit fullscreen mode

Testes (specs)

  • Specs são os testes que validam uma suíte de testes;
  • Assim como as suítes, ela é uma função global Javascript chamada ‘it’, que contém dois parâmetros, uma descrição e uma função, respectivamente;
  • Dentro do segundo parâmetro, é onde adicionamos as verificações (expectations).

Exemplo:

it("deve garantir que 1 + 9 = 10", () => { });
Enter fullscreen mode Exit fullscreen mode

Verificações (Expectations)

  • Verificações servem para validar um resultado de um teste;
  • O Jest possui uma função global Javascript chamada ‘expect’, que recebe um parâmetro como argumento, que é o resultado a ser verificado;
  • O ‘expect’ deve ser utilizado em conjunto com uma comparação (Matcher), que conterá o valor a ser comparado;
  • Uma Spec poderá conter uma ou mais verificações;
  • Uma boa prática é sempre manter as verificações no final da função.

Exemplo:

expect(Calculadora.adicionar(1, 9)).toBe(10);
Enter fullscreen mode Exit fullscreen mode

Configuração da suíte de testes

Ao escrever testes você tem algum trabalho de configuração que precisa acontecer antes de executa-los. Caso exista algo que precisa ser executado repetidamente antes ou depois para muitos testes, você pode usar os hooks. Para o exemplo dado utilizaremos a função fornecida pelo Jest: beforeEach, que basicamente repetirá tudo que é envolto por ele antes de cada teste realizado.

Assim que criamos um novo componente com auxílio do CLI do angular, automaticamente é gerado um arquivo spec com uma configuração base. Conforme código abaixo:

import { ComponentFixture, TestBed } from  '@angular/core/testing';
import { NovoComponent } from  './novo.component';
import { NovoModule } from  './novo.module';

describe('NovoComponent', () => {
    let  component: NovoComponent;
    let  fixture: ComponentFixture<NovoComponent>;

    beforeEach(() => {
        TestBed.configureTestingModule({
            imports: [ NovoModule ],
            declarations: [],
            providers: []
        })
        .compileComponents();
        fixture = TestBed.createComponent(NovoComponent);
        component = fixture.componentInstance;
        fixture.detectChanges();
    });

    it('should create', () => {
        expect(component).toBeTruthy();
    });
});
Enter fullscreen mode Exit fullscreen mode

Analisando o código acima. Percebe-se o uso do describe para criar a suíte de testes para o NovoComponent, podemos ver que existem duas variáveis declaradas component e fixture, na primeira a "tipagem" é o nome da classe que foi criada, na segunda usa o componentFixture para ter acesso ao DOM, depurar e testar o componente. No próximo comando, encontra-se a função beforeEach, já descrita anteriormente. Por convenção do Angular, adotamos que cada componente obrigatoriamente deve estar contido em um módulo, portanto dentro da estrutura beforeEach sempre importaremos o modulo que está declarado o componente a ser testado. Deve-se adicionar ao providers as dependências que estão sendo injetadas no arquivo typescript.

Se necessário também, será adicionado aos providers, classes que estendem as dependências injetadas.

Após a compilação destes componentes pelocompileComponents(), utilizamos o TestBed, que cria um módulo Angular de teste que podemos usar para instanciar componentes, executar injeção de dependência afim de configurar e inicializar o ambiente para teste. Na próxima linha de código o componentInstance é usado para acessar a instância da classe do componente raiz e o fixtureé um wrapper para um componente e seu template. Ofixture.detectChanges() será acionado para qualquer mudança que aconteça no DOM.
Por fim, serão adicionados os testes de unidade utilizando a estrutura "it". No código acima podemos ver um exemplo padrão de teste unitário que verifica se o componente está sendo criado. É de suma importância que neste ponto aconteça a primeira verificação de execução do teste unitário, pois o mesmo nos dirá se a suíte de testes foi corretamente montada.

Mockando Serviços

A partir de agora recomendo fortemente que utilize o recurso de divisão de tela em seu editor de código, desta forma permitirá a visualização de um lado, o arquivo typescript e do outro, o arquivo spec.

O mock das dependências injetadas vai nos permitir testar nosso componente de maneira isolada, sem nos preocuparmos com as demais dependências da aplicação. Em tese será criada uma instância de objeto com dados "fake", que refletirá toda vez que a dependência for requisitada.

Primeiro ponto a ser observado no código são as variáveis que precisam ser inicializadas e as dependências a serem injetadas:

import { Component, OnInit, ViewChild, ElementRef, OnDestroy } from  '@angular/core';
import { Subscription } from  'rxjs';

import { ChatOptionsQuery } from  'src/chat/store/chat-options/chat.options.query';

@Component({
    selector:  'app-chat-trigger',
    templateUrl:  './chat-trigger.component.html',
    styleUrls: ['./chat-trigger.component.scss'],
})
export class ChatTriggerComponent implements OnInit, OnDestroy  {
    totalPendingMessages = 0;
    maxMessagesCounter = 100
    chatTitle: string;
    chatMessage: string;
    openTooltip: boolean;
    ariaLabel:string;
    chatTitleSub$: Subscription;
    chatMessageSub$: Subscription;

    constructor(
        private chatOptionsQuery: ChatOptionsQuery,
        private appViewStore: AppViewStore,
    ) { }

    onHide(): void {
        this.appViewStore.update((state: AppViewState) => ({
            ...state,
            chatOpen: false,
            chatMinized: true,
            floatChat: true,
        }));
    }
Enter fullscreen mode Exit fullscreen mode

O serviço AppViewStore é utilizado para chamar o método update neste componente. Neste ponto é muito importante ter cuidado, pois como podemos ver no código abaixo, ao acessar este serviço o método update não se encontra lá.

@Injectable({
providedIn: 'root'
})
@StoreConfig({ name: 'AppView' })
export class AppViewStore extends EntityStore<AppViewState> {
    constructor() {
        super(initialStateAppView);
    }
}
Enter fullscreen mode Exit fullscreen mode

Podemos observar que a classe deste serviço estende de EntityStore que contém o método update, exibido no código abaixo.

export declare class EntityStore extends Store<S> {
    
    update(newState: UpdateStateCallback<S>): any;
Enter fullscreen mode Exit fullscreen mode

Entendendo esse cenário, deve-se criar um mock dessas duas classes e adicionar o método update na classe mockada com o valor MockEntityStore.

Seguindo convenção do Angular, o nome dos mocks são formados pelo nome Mock + nome do serviço.

const MockAppViewStore = { };
const MockEntityStore = {
    update() {
        return true
    }
};

beforeEach(() => {
    TestBed.configureTestingModule({
        imports: [ ChatTriggerModule],
        declarations: [],
        providers: [
            { provide: AppViewStore, useValue: MockAppViewStore },
            { provide: EntityStore, useValue: MockEntityStore },
        ]
})
.compileComponents();
fixture = TestBed.createComponent(ChatTriggerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
Enter fullscreen mode Exit fullscreen mode

É imprescindível que o nome do método ou da variável mockada sejam idênticos. Porém como exibido no exemplo acima, o parâmetro passado no método update não foi mockado, além disso, foi inventado um valor booleano de retorno. E isso é o suficiente para executar os testes.

Criando testes unitários na prática

Jest utiliza de "matchers" (combinadores) para realizar os testes efetivamente. Existem diversos matchers para cada situação em particular dentro do contexto de testes. Os matchers são implementados a partir da chamada de expect(). Para inserir um exemplo com uma complexidade maior, antes de tudo é necessário entendermos o conceito e como implementar as funções de mock.

Funções mock

  • Permitem criar funções e módulos falsos que simulam uma dependência.
  • Com o mock é possível interceptar chamadas dessa função (e seus parâmetros) pelo código sendo testado.
  • Permite interceptar instâncias de funções construtoras quando implementadas usando new.
  • Permitem a configuração dos valores retornados para o código sob teste.

É comum encontrar em outros artigos a utilização do comando jest.fn() para criar funções mock, porém o presente arquivo utiliza uma sintaxe semelhante a do Jasmine, por tanto serão criadas as funções mock usando o comando Jest.spyOn(objeto, nomeDoMétodo) encadeado por exemplo com a função mockImplementation que possibilita a substituição da função original.

Abaixo teremos alguns exemplos de matchers juntamente com as funções mock.

Exemplo

Usaremos este código em typescript como base para este primeiro exemplo, com o intuito de testar o ciclo de vida (lifecycle hook) ngOnInit() do Angular.

@Input('controls') controls: controls;
@Input("session") session: Session;

public floatChat$: Observable<boolean>;
public chatOpen$: Observable<boolean>;

public  joined: boolean;
public  joined$: Subscription;

constructor(
    public  appViewQuery: AppViewQuery,
) {
}

ngOnInit(): void {
    this.session = typeof  this.session == "string" ? JSON.parse(this.session) : this.session;
    this.controls = typeof  this.controls == "string" ? JSON.parse(this.controls) : this.controls;

    this.floatChat$ = this.appViewQuery.floatChat$;
    this.chatOpen$ = this.appViewQuery.chatOpen$;

    this.joined$ = this.appViewQuery.joined$.subscribe((data:boolean)=>{
        this.joined = data;
    });

    if (this.controls?.alwaysOpenChat) {
        this.onClickChatTrigger();
    }
}
Enter fullscreen mode Exit fullscreen mode

Hora de colocar o que foi explicado desde o inicio do artigo, essa analise inicial é de extrema importância para definirmos o plano de ação para criar os testes sobre o ngOnInit(). Nas duas primeiras linhas desse hook temos dois if's ternários, que utilizam as variáveis session e controls que tem suas próprias interfaces. Primeiro passo é acessar tais interfaces e criar um mock nos moldes dela.

export interface Session {
  "contactId"?: string,
  "sessionId": string,
  "rede": string,
  "channel": channel,
  "nickname": string
}

export enum channel{
  "INTERNET_ON" = "INTERNET_ON",
  "INTERNET_OFF" = "INTERNET_OFF",
  "MOBILE_OFF" = "MOBILE_OFF", 
  "MOBILE_ON" = "MOBILE_ON"
}

export  interface  controls {
    alwaysOpenChat: boolean,
    buttonClose: boolean,
    nicknameChat?: string,
    nicknameAgent?: string,
    iconChat?: string,
}
Enter fullscreen mode Exit fullscreen mode

Adicionaremos tais mocks de forma global (acesso em qualquer estrutura dentro deste arquivo spec). Caso em próximos testes seja preciso modificar algum valor, basta fazer isso dentro da estrutura it.
Serão adicionados dois mocks para a variável session, a primeira em formato de string e a segunda como Object. Desta forma poderá ser testado o JSON.parse dentro do "if" ternário.

describe('ChatComponent', () => {
    let component: ChatComponent;
    let fixture: ComponentFixture<ChatComponent>;

    const  mockSessionString: any = '{"contactId": "", "sessionId": "", "rede": "", "channel": "INTERNET_ON", "nickname": ""}';

    const mockSessionObject: Session = {
        contactId: '',
        sessionId: '',
        rede: '',
        channel: 'INTERNET_ON' as channel,
        nickname: ''
    };

    const mockControls: controls = {
        alwaysOpenChat: true,
        buttonClose: true,
        nicknameChat: '',
        nicknameAgent: '',
        iconChat: '',
    }
...
}
Enter fullscreen mode Exit fullscreen mode

Agora vamos iniciar a edição da spec para este hook. Lembrando que como configurado anteriormente criamos uma variável component que refere-se a uma instância da classe a ser testada, então iremos atribuir os mocks criados a instância da classe para este teste em específico:

fit('Should test ngOnInit', () => {
    component.session = mockSessionString;
    component.controls = mockControls;
    ...
}
Enter fullscreen mode Exit fullscreen mode

Adicionando a letra "f" a estrutura "it", na hora que executar os testes, essa será a única spec a ser testada dentro desta suíte.

Continuando a analise do hook, nas próximas três linhas atribuímos a duas variáveis observables do tipo boolean e a uma do tipo "subscription()" valores da dependência AppViewQuery. Neste ponto precisamos adicionar tal dependência no *providers da suíte de testes e além disso adicionar as variáveis mockadas.

@Injectable({ providedIn:  'root' })
export  class  AppViewQuery  extends  QueryEntity<AppViewState> {
    floatChat$ =this.select("floatChat");
    chatOpen$ =this.select("chatOpen");
    joined$ =this.select("joined");
Enter fullscreen mode Exit fullscreen mode

Quando passamos o mouse por cima do método, o mesmo nos mostra a "tipagem" do que é retornado, e para método select() é um Observable<boolean>, com essa informação criaremos o mock, iremos utilizar o função of() do RxJS:

const MockAppViewQuery = {
    floatChat$: of(false),
    chatOpen$: of(true),
    joined$: of(false)
};

beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [],
      declarations: [ChatComponent],
      providers: [
        { provide: AppViewQuery, useValue: MockAppViewQuery }
      ]
    })
Enter fullscreen mode Exit fullscreen mode

Analisando o restante do hook, temos uma condição e que pra o cenário que montamos irá retornar verdadeiro pois this.controls?.alwaysOpenChat existe. Desta forma teremos que mockar o método que encontra-se dentro da condicional if(), para este exemplo userei o mockImplementation(), reescrevendo (de forma aleatória) o retorno do método para um boolean true:

fit('Should test ngOnInit', () => {
    component.session = mockSessionString;
    component.controls = mockControls;

    const spyOnClickChatTrigger = jest.spyOn(component, 'onClickChatTrigger').mockImplementation(()=> {
      return true;
    });
    ...
}
Enter fullscreen mode Exit fullscreen mode

Neste ponto já preparamos todas as linhas da spec do ngOnInit(), resta adicionar as verificações e o comando para executar o hook:

fit('Should test ngOnInit', () => {
    //PREPARAÇÃO
    component.session = mockSessionString;
    component.controls = mockControls;

    const spyOnClickChatTrigger = jest.spyOn(component, 'onClickChatTrigger').mockImplementation(()=> {
      return true;
    });

    //EXECUÇÃO
    component.ngOnInit(); //LINHA PARA EXECUTAR O HOOK

    //VERIFICAÇÃO
    expect(component.session).toEqual(mockSessionObject);
    expect(component.controls).toBe(mockControls);

    component.floatChat$.subscribe((res: boolean)=>{
      expect(res).toBeFalsy();
    });

    component.floatChat$.subscribe((res: boolean)=>{
      expect(res).toBeTruthy();
    });

    component.chatOpen$.subscribe(()=>{
      expect(component.joined).toBeFalsy();
      done();
    })

    expect(spyOnClickChatTrigger).toHaveBeenCalled();
 });
Enter fullscreen mode Exit fullscreen mode

Podemos dizer que a montagem dos testes unitários sempre seguem uma estrutura simples dividida em 3 partes, definida como comentário no código acima. Na preparação iremos organizar tudo necessário para realização deste teste; Na execução vamos de fato rodar os testes; Por fim na verificação definiremos qual o resultado que esperamos.

Os matchers toBe e toEqual são usados para testar equidade numérica, sendo que o primeiro compara em adicional a tipagem e o segundo somente os valores.

1ª verificação: o cenário foi preparado para que a variável session passe pelo JSON.parse() do "if" ternário. Desta forma quando comparado com o mock em formato de objeto deverá retornar os mesmo valores.

2ª verificação: o cenário foi preparado para que a variável controls entrasse na condição falsa do "if" ternário e retornasse o mesmo objeto com a mesma tipagem.

A função toBeTruthy e toBeFalsy testam se o resultado passado tem valor que pode ser passado como true e false, respectivamente, em um if.

3ª, 4ª e 5ª verificações: para estes casos precisamos nos inscrever nos observables para testar se o retorno mockado da depedência AppViewQuery é condizente com o recebido pelas variáveis floatChat$, chatOpen$ e joined. Para tipo de verificações com assíncrono, usamos um artificio de passar 1 argumento na função "it" chamado de done. Assim que o houver a última verificação de assíncrono nós chamamos a função done();, que permitirá que de fato a comparação dos expects sejam realizadas.

6ª verificação: o mock da variável controls foi preenchido para que entrasse na estrutura if(). Dito isso, neste caso criamos um spy que retornará true toda vez que o método for chamado. Para este caso podemos realizar diferentes testes:

  1. testar se o retorno da variável spy é true, usando o toBeTruthy();
  2. testar se o método onClickChatTrigger() foi chamado, usando a função toHaveBeenCalled();
  3. testar se o método onClickChatTrigger() foi chamado 1 vez, usando a função toHaveBeenCalledTimes(1). Escolhemos usar a opção 2.

Agora devemos executar a suíte de testes e verificar se os testes obtiveram sucesso.

Execução

O comando base para executar a suíte de testes é:

npm run test
Enter fullscreen mode Exit fullscreen mode

Porém quando o CLI do Jest está instalado no projeto, o mesmo nos oferece suporte a argumentos camelCase e tracejados, então podemos combinar 1 ou mais scripts ao código acima. Exemplo:

  • --detectOpenHandles
    Tenta coletar e imprimir os manipuladores que estejam abertos impedindo o Jest de sair de forma limpa.

  • --silent
    Evita que testes imprimam mensagens no console.

  • --coverage
    Indica que as informações de coleta do teste devem ser coletadas e reportadas no console.

  • --ci
    Jest assume a execução em um ambiente de CI (integração contínua). Alterando o comportamento quando é encontrado um novo "snapshot". Em vez do comportamento normal de armazenar um novo "snapshot" automaticamente, o teste irá falhar e exigir Jest ser executado com --updateSnapshot.

Para executar os testes unicamente do arquivo citado acima, utilizamos a seguinte sintaxe:

npm test -- Chat.component.spec.ts

o resultado será:

 PASS  src/chat/Chat.component.spec.ts (119.938 s)
  ChatComponent
    √ Should test ngoninit (155 ms)
    ○ skipped Should test create component
    ○ skipped Should test ngOnChanges
    ○ skipped Should test ngAfterViewInit
    ○ skipped Should test load
    ○ skipped Should test hasAttribute
Enter fullscreen mode Exit fullscreen mode

Percebemos que nossos testes passaram com êxito!! Ele ignora os testes nos demais métodos pois especificamos com "fit" a spec do ngOnInit().

Referências

https://jestjs.io/pt-BR/

https://cursos.alura.com.br/forum/topico-jasmine-x-jest-179443

https://www.devmedia.com.br/teste-unitario-com-jest/41234#:~:text=Jest%20%C3%A9%20um%20framework%20de,dentro%20da%20comunidade%20de%20JavaScript.

Revisão e agradecimento

Agradeço a João Paulo Castro Lima pela ideia e apoio na confecção deste artigo e também aos meus amigos revisores:

Elves Gomes Neves Santos;
Francis Gomes Santos;
Matheus Vinicius Geronimo Fald;
Flávio Takeuchi.

Oldest comments (0)