Como mockar uma service ou dependência de um componente - Angular + JEST

Isolar a unidade de código de suas dependências é extremamente importante para a construção de testes unitários. Neste post é abordado como isolar estas dependências de um componente através do dependency injector ou spyOn com foco nos testes unitários.

Como mockar uma service ou dependência de um componente - Angular + JEST

Cenário

Em uma aplicação Angular, efetuar uma consulta HTTP para se obter informações é basicamente uma das tarefas mais comuns no cotidiano do desenvolvedor. Por esta razão em nosso cenário de testes criamos uma service que é injetada no app.component.ts  com o objetivo de mostrar uma lista de estados na tela. Em caso de erro, é exibida uma mensagem (Ocorreu um erro inesperado).

estados.service.ts:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';

const url =
    'https://servicodados.ibge.gov.br/api/v1/localidades/estados';

@Injectable({providedIn: 'root'})
export class EstadosService {
    constructor(private httpClient: HttpClient) { }

    listar(): Observable<any[]> {
        return this.httpClient
            .get<any[]>(url);
    }
}

Apenas para ficar mais claro, ao acessar o link utilizado dentro https://servicodados.ibge.gov.br/api/v1/localidades/estados é retornado um JSON no seguinte formado:

[
  {
    "id": 11,
    "sigla": "RO",
    "nome": "Rondônia",
    "regiao": {
      "id": 1,
      "sigla": "N",
      "nome": "Norte"
    }
  },
  /* ... Código ocultado ... */
]

app.component.ts

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

import { EstadosService } from './services/estados.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
  estados: any = [];
  ocorreuErro: boolean = false;

  constructor(private estadosService: EstadosService) { }

  ngOnInit() {
    this.estadosService
      .listar()
      .subscribe(
        listaEstados => {
          this.ocorreuErro = false;
          this.estados = listaEstados;
        },
        error => {
          console.error('Erro ao obter os registros', error);
          this.ocorreuErro = true;
        }
      );
  }
}

app.component.html

<html>
  <head>
    <title>Testes</title>
  </head>
  <body>
    <table *ngIf="!ocorreuErro; else templateErro">
      <tr *ngFor="let estado of estados">
        <td>{{estado.sigla}}</td>
        <td>{{estado.nome}}</td>
      </tr>
    </table>
    <router-outlet></router-outlet>
  </body>
</html>
<ng-template #templateErro>
  <div class="erro">
    Ocorreu um erro inesperado
  </div>
</ng-template>

Ao executar a aplicação executando o comando ng serve e acessar http://localhost:4200 no seu navegador, é possível visualizar o seguinte resultado:

Antes de começar a construção do teste unitário, certifique-se de que no seu arquivo /tsconfig.spec.json tenha a propriedade "emitDecoratorMetadata": true:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/spec",
    "emitDecoratorMetadata": true,
    "types": [
      "jest",
      "node"
    ]
  },
  "files": [
    "src/polyfills.ts"
  ],
  "include": [
    "src/**/*.spec.ts",
    "src/**/*.d.ts"
  ]
}

Construção do teste unitário

Veja que nosso app.component.ts depende da classe EstadosService. Como a ideia é testar apenas o comportamento de cada unidade de código do app.component.ts, temos que mockar de alguma forma a classe EstadosService para simular os vários comportamentos que queremos testar.

Existe algumas formas de efetuar este trabalho. A primeira delas é criar uma classe de mock com a mesma assinatura da classe EstadosService e indicar ao Angular que toda vez que EstadosService for requisitada será entregue uma outra classe.

Definindo um provider

Veja no código abaixo os comentários:

import { async, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing';
import { Observable, of, throwError } from 'rxjs';

import { AppComponent } from './app.component';
import { EstadosService } from './services/estados.service';

// Dados que serão retornados pela nossa classe
// de mock (MockEstadosService)
let mockEstadosServiceData = null;

class MockEstadosService {
  listar(): Observable<any[]> {
    return mockEstadosServiceData;
  }
}

describe('AppComponent', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [
        RouterTestingModule,
      ],
      declarations: [
        AppComponent,
      ],
      providers: [
        // IMPORTANTE
        // Aqui dizemos ao Angular que toda vez que EstadosService
        // for solicitada, será entregue a classe MockEstadosService
        { provide: EstadosService, useClass: MockEstadosService }
      ]
    }).compileComponents();
  }));

  it('should create the app', () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.componentInstance;
    expect(app).toBeTruthy();
  });

  it('deve renderizar a lista de estados', () => {
    const fixture = TestBed.createComponent(AppComponent);

    // Mock do retorno da nossa service (MockEstadosService)
    const dadosMockados = [
      { sigla: 'Te', nome: 'Teste' }
    ];

    // Preenchemos o mockEstadosServiceData para
    // que a classe MockEstadosService possa
    // retornar o valor ao app.component.ts
    //
    // O of() retorna um Observable exatamente
    // como o httpClient.get
    mockEstadosServiceData = of(dadosMockados);

    fixture.detectChanges();
    const celulasDaTabela = fixture.debugElement
      .queryAll(By.css('td'));

    // Esperamos encontrar a seguinte estrutura HTML quando
    // a página estiver renderizada:
    // <table>
    //   <tr>
    //    <td>Te</td>
    //    <td>Teste</td>
    //  </tr>
    // </table>

    expect(celulasDaTabela[0].nativeElement.innerHTML)
      .toBe(dadosMockados[0].sigla);

    expect(celulasDaTabela[1].nativeElement.innerHTML)
      .toBe(dadosMockados[0].nome);
  });
});

O teste funciona perfeitamente mas ainda precisamos testar um outro comportamento. O que se espera quando a EstadosService retorna um erro? Será exibida uma mensagem de erro na tela conforme no template app.component.html, então é justamente isto que iremos testar.

Para não ficar repetindo código, vamos criar mais um it() dentro código acima:

  it('deve renderizar a mensagem de erro', () => {
   const fixture = TestBed.createComponent(AppComponent);

   // 'Mockamos' um erro, simulando que houve uma falha na
   // requisição
   mockEstadosServiceData = throwError('teste unitario erro');

   fixture.detectChanges();
   const divTemplateErro = fixture.debugElement
     .query(By.css('div.erro'));

   // Esperamos encontrar a seguinte estrutura HTML quando
   // a página estiver renderizada:
   // <div class="erro">
   //   Ocorreu um erro inesperado
   // </div>

   expect(divTemplateErro.nativeElement.innerHTML)
     .toMatch(/Ocorreu um erro inesperado/);
 });

Resultado do teste unitário:

Resultado da execução do teste unitário efetuando o mock da service utilizada pelo componente

Observação: estados.service.ts não é objeto de estudo neste post, portanto ficará sem cobertura de testes

Utilizando o spyOn

Uma outra forma de efetuar o mock da service e isolar o teste do componente é utilizar o spyOn para mockar as funções da service. Veja no código abaixo o mesmo teste unitário acima porém com algumas modificações:

import { HttpClientTestingModule } from '@angular/common/http/testing';
import { async, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing';
import { of, throwError } from 'rxjs';

import { AppComponent } from './app.component';
import { EstadosService } from './services/estados.service';

describe('AppComponent', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [
        // IMPORTANTE
        // Como estamos utilizando EstadosService,
        // é necessário importar o módulo abaixo
        // por conta da dependência HttpClient:
        HttpClientTestingModule,
        RouterTestingModule,
      ],
      declarations: [
        AppComponent,
      ]
    }).compileComponents();
  }));

  it('should create the app', () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.componentInstance;
    expect(app).toBeTruthy();
  });

  it('deve renderizar a lista de estados', () => {
    const fixture = TestBed.createComponent(AppComponent);

    // Mock do retorno da nossa service (MockEstadosService)
    const dadosMockados = [
      { sigla: 'Te', nome: 'Teste' }
    ];

    // Pegamos a instância de EstadosService e efetuamos
    // o mock no método listar com o spyOn:
    const estadosService = TestBed.inject(EstadosService);
    spyOn(estadosService, 'listar')
      .and
      .returnValue(of(dadosMockados));

    fixture.detectChanges();
    const celulasDaTabela = fixture.debugElement
      .queryAll(By.css('td'));

    // Esperamos encontrar a seguinte estrutura HTML quando
    // a página estiver renderizada:
    // <table>
    //   <tr>
    //    <td>Te</td>
    //    <td>Teste</td>
    //  </tr>
    // </table>

    expect(celulasDaTabela[0].nativeElement.innerHTML)
      .toBe(dadosMockados[0].sigla);

    expect(celulasDaTabela[1].nativeElement.innerHTML)
      .toBe(dadosMockados[0].nome);
  });

  it('deve renderizar a mensagem de erro', () => {
    const fixture = TestBed.createComponent(AppComponent);

    // 'Mockamos' um erro, simulando que houve uma falha na
    // requisição
    const estadosService = TestBed.inject(EstadosService);
    spyOn(estadosService, 'listar')
      .and
      .returnValue(throwError('teste unitario erro'));

    fixture.detectChanges();
    const divTemplateErro = fixture.debugElement
      .query(By.css('div.erro'));

    // Esperamos encontrar a seguinte estrutura HTML quando
    // a página estiver renderizada:
    // <div class="erro">
    //   Ocorreu um erro inesperado
    // </div>

    expect(divTemplateErro.nativeElement.innerHTML)
      .toMatch(/Ocorreu um erro inesperado/);
  });
});

TestBed.inject

Para quem estava mais acostumado a pegar as instâncias via Dependecy Injector utilizando o comando  TestBed.get, este foi marcado como deprecated e deve-se utilizar em seu lugar o TestBed.inject a partir da versão 9.


Conclusão

Neste artigo abordamos uma forma de se isolar a unidade de código que esta sendo testada de suas dependências. Em nosso exemplo, efetuamos o mock da classe EstadosService que é uma dependência do app.component.ts.

Resultado da cobertura de código:

Resultado da cobertura de código

Não sabe como obter a cobertura do código? dê uma olhada neste outro link: https://consolelog.com.br/cobertura-de-testes-no-angular-utilizando-jest-e-istanbul/

Alguns links interessantes para leitura: