Como criar um teste unitário envolvendo timers | Angular

Aprenda a criar testes unitários para componentes Angular que utilizam setTimeout, setInterval, interval ou timer de forma eficiente e controlada com Jasmine e Jest.

Banner de divulgação: Testes unitários envolvendo timers
Teste unitário envolvendo controle de tempo | setInterval | setTimeout | timer | interval

Criar testes unitários eficazes é crucial para garantir a robustez e confiabilidade de um código. Por aqui já abordamos alguns exemplos de como construir testes unitários envolvendo alguns componentes, mas ainda não mostrei um exemplo envolvendo temporizadores, como o setTimeout, setInterval ou timer e interval do RxJS.

Para construir este exemplo, utilizei um projeto que usa a versão 16 do Angular, mas a lógica empregada é a mesma para outras versões. Deixei o link do repositório no final do texto.

O componente com o Timer

Para criar o cenário de estudo, construí um componente bem simples, similar a um cronômetro:

O navegador exibe o componente em execução. Ao clicar no botão "Iniciar", o progresso dos segundos é exibido na tela, semelhante a um relógio
Componente em execução

O código fonte do componente ficou da seguinte forma:

<div class="timer">
  Timer: {{ tempoDecorridoEmSegundos }}s
</div>
<div>
  <button (click)="iniciarTimer()">Iniciar</button>
  <button (click)="pararTimer()">Parar</button>
</div>

timer.component.html

import { Component, OnDestroy } from '@angular/core';

import { EMPTY, Subscription, interval } from 'rxjs';

@Component({
  selector: 'app-timer',
  templateUrl: './timer.component.html',
})
export class TimerComponent implements OnDestroy {
  tempoDecorridoEmSegundos: number = 0;
  dataInicioTimer: Date = new Date();
  subscriptionTimer: Subscription = EMPTY.subscribe();

  ngOnDestroy(): void {
    this.pararTimer();
  }

  iniciarTimer() {
    const umSegundoEmMS = 1e3;

    this.dataInicioTimer = new Date();
    this.tempoDecorridoEmSegundos = 0;

    this.subscriptionTimer = interval(
      umSegundoEmMS
    ).subscribe(() => this.atualizarTimer());
  }

  atualizarTimer() {
    const dataAtual = new Date();

    this.tempoDecorridoEmSegundos = Math.floor(
      (dataAtual.getTime() -
        this.dataInicioTimer.getTime()) /
        1000
    );
  }

  pararTimer() {
    this.subscriptionTimer.unsubscribe();
  }
}

timer.component.ts

Construção dos testes unitários

Para construir os testes unitários vamos utilizar Jasmine, mas vou comentar sobre o Jest que também possui recursos similares para este cenário envolvendo um interval.

A estrutura básica do teste ficará da seguinte forma:

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

import { TimerComponent } from './timer.component';

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

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [TimerComponent],
    });

    fixture = TestBed.createComponent(TimerComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

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

timer.component.spec.ts

O primeiro método a ser testado é o iniciarTimer. Este método é bastante simples, atribuindo valores a duas variáveis e iniciando um intervalo. Após a execução, esperamos que, por exemplo, o método atualizarTimer seja chamado três vezes após 3 segundos, pois configuramos o intervalo para executar a cada 1 segundo.

Para realizar esse teste, não é necessário aguardar os 3 segundos reais. Podemos implementar um "relógio" simulado ("mock"), que substitui o controle padrão de data e hora. Ao utilizar jasmine.clock().install(), temos a capacidade de estabelecer a data atual, atribuindo-a ao valor de new Date(), e ajustá-la, por exemplo, para 2020-01-01T12:00:00.000Z. Posteriormente, podemos avançar no tempo nesse "relógio" simulado conforme necessário, utilizando o comando jasmine.clock().tick().

Para ficar mais claro, veja o exemplo abaixo onde estamos simulando um valor para o new Date():

const dataMock = new Date('2020-01-01T12:00:00.000Z');
jasmine.clock().install();
jasmine.clock().mockDate(dataMock);

console.log('[1]', (new Date()).toISOString());
// [1], 2020-01-01T12:00:00.000Z

jasmine.clock().tick(3000);

console.log('[2]', (new Date()).toISOString());
// [2], 2020-01-01T12:00:03.000Z

Agora que sabemos como controlar a linha do tempo durante os testes unitários, podemos escrever o seguinte teste unitário para o método iniciarTimer:

// ...(imports)...

describe('TimerComponent', () => {
  // ...(trecho ocultado)...

  afterEach(() => {
    jasmine.clock().uninstall();
  });

  it(`deve iniciar dataInicioTimer e tempoDecorridoEmSegundos
      e chamar o atualizarTimerSpy a cada segundo`, () => {
    // Criamos um "spy" para saber quantas vezes o método
    // `atualizarTimer` foi chamado e simulamos seu
    // retorno:
    const atualizarTimerSpy = spyOn(
      component,
      'atualizarTimer'
    );

    // Valor da data corrente que usaremos como valor
    // simulado, ou seja, dentro deste teste qualquer
    // `new Date()` retornará o valor abaixo:
    const dataMock = new Date('2020-01-01T12:00:00.000Z');

    // No comando a seguir criamos um relógio mockado
    // para o JavaScript, ou seja, ao invés dele pegar
    // a data e hora do computador, ele vai pegar
    // a data e hora que nós vamos informar, de forma
    // simulada:
    jasmine.clock().install();

    // Definimos qual o valor da corrente, ou seja,
    // sempre que algum trecho de código executar
    // `new Date()` o valor abaixo será retornado:
    jasmine.clock().mockDate(dataMock);

    // Chama o método que está sendo testado:
    component.iniciarTimer();

    // Esperamos que as variáveis sejam inicializadas
    // com os valores `0` e `new Date()` respectivamente
    expect(component.tempoDecorridoEmSegundos).toBe(0);
    expect(component.dataInicioTimer.getTime()).toBe(
      dataMock.getTime()
    );

    // Avançamos 3 segundos na linha do tempo p/verificar
    // se o método `atualizarTimer` foi chamado 3 vezes.
    // Lembrando que o método deve ser chamado a cada
    // 1 segundo:
    jasmine.clock().tick(3000);
    expect(atualizarTimerSpy).toHaveBeenCalledTimes(3);
  });
});

timer.component.spec.ts

Na sequência vamos criar um teste para o método atualizarTimer. Este método calcula a diferença de tempo em segundos entre a data corrente e a data que consta na variável dataInicioTimer. Então podemos adicionar um valor conhecido em dataInicioTimer e novamente simular a linha do tempo para verificar se o cálculo que o método está fazendo é correto.

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

import { By } from '@angular/platform-browser';

import { TimerComponent } from './timer.component';

describe('TimerComponent', () => {
  // ...(trecho ocultado)...

  it(`deve atualizar o tempoDecorridoEmSegundos quando
  atualizarTimer() for chamado`, () => {
    // Configuramos um valor conhecido para a variável
    // `dataInicioTimer`:
    component.dataInicioTimer = new Date(
      '2020-01-01T12:00:00.000Z'
    );

    const mockNewDate = new Date(
      component.dataInicioTimer.getTime()
    );

    // Configuramos a linha do tempo de modo que a
    // data corrente seja exatamente igual a variável
    // `component.dataInicioTimer`
    jasmine.clock().install();
    jasmine.clock().mockDate(mockNewDate);

    // Avançamos alguns segundos na linha do tempo:
    const avancaRelogioEmXSegundos = 10;
    jasmine.clock().tick(avancaRelogioEmXSegundos * 1000);

    // Ao chamar o `atualizarTimer`, esperamos que seja
    // calculada a diferença de tempo entre
    // `component.dataInicioTimer` e `new Date()`.
    // Lembrando que `new Date()` é um valor conhecido,
    // mockado logo acima.
    component.atualizarTimer();
    expect(component.tempoDecorridoEmSegundos).toBe(
      avancaRelogioEmXSegundos
    );

    // Também testamos se o valor de
    // `component.tempoDecorridoEmSegundos` está sendo
    // atualizado na tela. Para isto chamamos o
    // `detectChanges` para que o Angular atualize o HTML
    fixture.detectChanges();

    const divTimer = fixture.debugElement.query(
      By.css('div.timer')
    );

    const divTimerElement =
      divTimer.nativeElement as HTMLDivElement;

    expect(divTimerElement.textContent).toContain(
      `Timer: ${avancaRelogioEmXSegundos}s`
    );
  });
});

timer.component.spec.ts

JEST

Utilizando o Jest a lógica é a mesma, o que muda são os métodos que o Jest disponibiliza:

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

import { By } from '@angular/platform-browser';

import { TimerComponent } from './timer.component';


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

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [TimerComponent],
    });

    fixture = TestBed.createComponent(TimerComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

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

  afterEach(() => {
    jest.clearAllTimers();
    jest.useRealTimers();
  });

  it(`deve iniciar dataInicioTimer e tempoDecorridoEmSegundos
      e chamar o atualizarTimerSpy a cada segundo`, () => {

    // Criamos um "spy" para saber quantas vezes o método
    // `atualizarTimer` foi chamado e simulamos seu
    // retorno:
    const atualizarTimerSpy = jest.spyOn(
      component,
      'atualizarTimer'
    ).mockImplementation(() => {});

    // Valor da data corrente que usaremos como valor
    // simulado, ou seja, dentro deste teste qualquer
    // `new Date()` retornará o valor abaixo:
    const dataMock = new Date('2020-01-01T12:00:00.000Z');

    jest.useFakeTimers({
      now: dataMock
    });

    // Chama o método que está sendo testado:
    component.iniciarTimer();

    // Esperamos que as variáveis sejam inicializadas
    // com os valores `0` e `new Date()` respectivamente
    expect(component.tempoDecorridoEmSegundos).toBe(0);
    expect(component.dataInicioTimer.getTime()).toBe(
      dataMock.getTime()
    );

    // Avançamos 3 segundos na linha do tempo p/verificar
    // se o método `atualizarTimer` foi chamado 3 vezes.
    // Lembrando que o método deve ser chamado a cada
    // 1 segundo:
    jest.advanceTimersByTime(3000);
    expect(atualizarTimerSpy).toHaveBeenCalledTimes(3);
  });

  it(`deve atualizar o tempoDecorridoEmSegundos quando
      atualizarTimer() for chamado`, () => {

    // Configuramos um valor conhecido para a variável
    // `dataInicioTimer`:
    component.dataInicioTimer = new Date(
      '2020-01-01T12:00:00.000Z'
    );

    const mockNewDate = new Date(
      component.dataInicioTimer.getTime()
    );

    // Configuramos a linha do tempo de modo que a
    // data corrente seja exatamente igual a variável
    // `component.dataInicioTimer`
    jest.useFakeTimers({ now: mockNewDate });

    // Avançamos alguns segundos na linha do tempo:
    const avancaRelogioEmXSegundos = 10;
    jest.advanceTimersByTime(
      avancaRelogioEmXSegundos * 1000);

    // Ao chamar o `atualizarTimer`, esperamos que seja
    // calculada a diferença de tempo entre
    // `component.dataInicioTimer` e `new Date()`.
    // Lembrando que `new Date()` é um valor conhecido,
    // mockado logo acima.
    component.atualizarTimer();
    expect(component.tempoDecorridoEmSegundos).toBe(
      avancaRelogioEmXSegundos
    );

    // Também testamos se o valor de
    // `component.tempoDecorridoEmSegundos` está sendo
    // atualizado na tela. Para isto chamamos o
    // `detectChanges` para que o Angular atualize o HTML
    fixture.detectChanges();

    const divTimer = fixture.debugElement.query(
      By.css('div.timer')
    );

    const divTimerElement =
      divTimer.nativeElement as HTMLDivElement;

    expect(divTimerElement.textContent).toContain(
      `Timer: ${avancaRelogioEmXSegundos}s`
    );
  });
});

timer.component.spec.ts

Considerações

A possibilidade de simular e controlar o relógio do JavaScript, como no Jasmine ou Jest, proporciona uma abordagem valiosa para testes unitários mais eficientes e controlados. Essa prática é particularmente benéfica para avaliar funcionalidades que envolvem timeouts, intervals e operações dependentes do tempo, resultando em testes mais rápidos, confiáveis e independentes do ambiente externo.

Link do projeto utilizando Jasmine e Jest respectivamente:

GitHub - marcelovismari/testes-unitarios-angular: Exemplos de como construir testes unitários para um projeto Angular
Exemplos de como construir testes unitários para um projeto Angular - GitHub - marcelovismari/testes-unitarios-angular: Exemplos de como construir testes unitários para um projeto Angular

Link com os exemplos utilizados neste texto com Jasmine

marcelovismari/testes-unitarios-angular-jest
Exemplos de como construir testes unitários para um projeto Angular com Jest - marcelovismari/testes-unitarios-angular-jest

Link com os exemplos utilizados neste texto com Jest

Outros links:

Class: Clock
Simulações de Temporizador · Jest
The native timer functions (i.e., setTimeout(), setInterval(), clearTimeout(), clearInterval()) are less than ideal for a testing environment since they depend on real time to elapse. Jest pode trocar temporizadores por funções que permitem controlar a passagem do tempo. Great Scott!