Como criar um teste unitário para um interceptor - Angular

Explore o passo a passo na criação de testes unitários para interceptors no Angular e descubra a importância desses testes para a qualidade e confiabilidade do seu código.

Como criar um teste unitário para um interceptor - Angular
Explore a Criação de Testes Unitários para Interceptors no Angular

Interceptors desempenham um papel importante na manipulação de requisições HTTP em aplicações Angular. Por exemplo, é comum encontrarmos interceptors que injetam cabeçalhos de requisição ou que padronizam o tratamento de erros.

Neste artigo, exploraremos como criar testes unitários para interceptors. Os exemplos a seguir utilizam a configuração padrão do Angular, utilizando o Jasmine para a execução dos testes unitários. Se você estiver utilizando o Jest, pode ser necessário fazer pequenos ajustes, mas a lógica empregada nos exemplos será a mesma, independentemente do framework utilizado para os testes unitários.

Teste unitário - Interceptor Authorization

Como primeiro exemplo vamos construir um teste unitário para um interceptor que injeta o cabeçalho Authorization. O código fonte é o seguinte:

import {
  HttpHandler,
  HttpInterceptor,
  HttpRequest,
} from '@angular/common/http';
import { Injectable } from '@angular/core';

import { StorageService } from './storage.service';

@Injectable()
export class AuthorizationInterceptor
  implements HttpInterceptor
{
  constructor(private storageService: StorageService) {}

  intercept(req: HttpRequest<any>, next: HttpHandler) {
    const jwt: string | null = this.storageService.getJWT();

    if (jwt) {
      req = req.clone({
        headers: req.headers.set('Authorization', jwt),
      });
    }

    return next.handle(req);
  }
}
authorization.interceptor.ts

Analisando o código do interceptor acima, podemos observar dois possíveis caminhos (fluxos):

  1. O this.storageService.getJWT() retornará uma string, e assim, o conteúdo dentro do bloco if será executado para injetar o valor no cabeçalho Authorization.
  2. Se this.storageService.getJWT() retornar null ou estiver vazio, o conteúdo dentro do bloco if não será executado, e o cabeçalho de requisição Authorization não será definido.

Observação: Esses dois caminhos são identificados como "branches" durante a análise de cobertura dos testes unitários.

Agora, vamos testar esses dois cenários!

Para iniciar a construção dos testes unitários, é necessário configurar um módulo de testes. Isso pode ser feito utilizando o TestBed fornecido pelo próprio Angular. Através do método configureTestingModule, importaremos as dependências para testar uma unidade de código. Essa configuração será realizada no arquivo chamado authorization.interceptor.spec.ts.

Uma abordagem para testar um interceptor é configurá-lo no módulo de testes para "dizer" ao Angular que deve usar o interceptor em todas as requisições. Em seguida, podemos disparar requisições "mockadas", esperando que o interceptor injete ou não o cabeçalho Authorization. Além disso, será necessário simular o retorno de storageService.getJWT(). Vamos para o código!

A seguir, elaborei a estrutura do teste unitário e adicionei vários comentários para facilitar o entendimento:

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

import {
  HTTP_INTERCEPTORS,
  HttpClient,
  HttpStatusCode,
} from '@angular/common/http';
import {
  HttpClientTestingModule,
  HttpTestingController,
} from '@angular/common/http/testing';

import { AuthorizationInterceptor } from './authorization.interceptor';
import { StorageService } from './storage.service';

describe('AuthorizationInterceptor', () => {
  let httpClient: HttpClient;
  let httpTestingController: HttpTestingController;
  let storageService: StorageService;

  // Este trecho é executado antes de cada teste unitário,
  // ou seja, antes de cada "it"
  beforeEach(() => {
    // Configurando o módulo de testes:
    TestBed.configureTestingModule({
      imports: [
        // Como vamos disparar requisições simuladas
        // ao longo deste arquivo de testes, precisamos
        // importar o módulo abaixo:
        HttpClientTestingModule,
      ],
      providers: [
        // Configurando o interceptor para
        // ser utilizado em todas as requisições:
        {
          provide: HTTP_INTERCEPTORS,
          useClass: AuthorizationInterceptor,
          multi: true,
        },
      ],
    });

    // Pegamos e guardamos alguns objetos para utilizar
    // ao longo dos testes:
    httpClient = TestBed.inject(HttpClient);
    httpTestingController = TestBed.inject(
      HttpTestingController
    );

    storageService = TestBed.inject(StorageService);
  });
});
authorization.interceptor.spec.ts

Com a estrutura do teste unitário construída, estamos prontos para criar o primeiro teste. A seguir escrevi um teste em que o AuthorizationInterceptor não injeta o cabeçalho Authorization devido ao retorno null do método getJWT():

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

import {
  HTTP_INTERCEPTORS,
  HttpClient,
  HttpStatusCode,
} from '@angular/common/http';
import {
  HttpClientTestingModule,
  HttpTestingController,
} from '@angular/common/http/testing';

import { AuthorizationInterceptor } from './authorization.interceptor';
import { StorageService } from './storage.service';

describe('AuthorizationInterceptor', () => {
  let httpClient: HttpClient;
  let httpTestingController: HttpTestingController;
  let storageService: StorageService;

  // Este trecho é executado antes de cada teste unitário,
  // ou seja, antes de cada "it"
  beforeEach(() => {
    // Configurando o módulo de testes:
    TestBed.configureTestingModule({
      imports: [
        // Como vamos disparar requisições simuladas
        // ao longo deste arquivo de testes, precisamos
        // importar o módulo abaixo:
        HttpClientTestingModule,
      ],
      providers: [
        // Configurando o interceptor para
        // ser utilizado em todas as requisições:
        {
          provide: HTTP_INTERCEPTORS,
          useClass: AuthorizationInterceptor,
          multi: true,
        },
      ],
    });

    // Pegamos e guardamos alguns objetos para utilizar
    // ao longo dos testes:
    httpClient = TestBed.inject(HttpClient);
    httpTestingController = TestBed.inject(
      HttpTestingController
    );
    storageService = TestBed.inject(StorageService);
  });

  it(`não deve injetar o header Authorization
      quando getJWT() retornar null`, (done) => {
    // Simulamos o retorno do método getJWT()
    // de modo que o método retorne null.
    const storageServiceGetJWTSpy = spyOn(
      storageService,
      'getJWT'
    ).and.returnValue(null);

    // Dispara uma requisição para a URL abaixo.
    // Lembre-se de que estamos utilizando o módulo
    // HttpClientTestingModule, então as requisições
    // são simuladas ("mockadas")
    const url = 'http://localhost';
    httpClient.get(url).subscribe({
      complete: () => {
        // Quando a requisição for respondida, esperamos
        // que o storageService.getJWT() seja chamado
        // uma única vez:
        expect(
          storageServiceGetJWTSpy
        ).toHaveBeenCalledTimes(1);

        // Finalizamos o teste chamando o done():
        done();
      },
    });

    // Esperamos que tenha sido disparada uma requisição
    // para http://localhost e...
    const req = httpTestingController.expectOne(url);

    // ...também esperamos que o header Authorization não tenha
    // sido injetado, já que o retorno do storageService.getJWT()
    // é null
    expect(
      req.request.headers.has('Authorization')
    ).toBeFalsy();

    // Envia a resposta simulada da requisição.
    // Assim o método dentro do .subscribe(...método...)
    // será executado
    req.flush(
      {},
      {
        status: HttpStatusCode.Ok,
        statusText: 'Ok',
      }
    );
  });
});
authorization.interceptor.spec.ts

Para executar os testes unitários basta rodar o comando ng test:

O teste unitário foi executado com sucesso. Agora vamos analisar a cobertura de código, ou seja, quais partes do AuthorizationInterceptor foram cobertas pelo nosso teste unitário. Para realizar essa tarefa, basta executar o comando abaixo:

ng test --code-coverage --watch false

Após a execução, será gerada uma pasta chamada /coverage. Abra o arquivo index.html e navegue até o arquivo authorization.interceptor.ts:

Navegador mostrando o relatório de cobertura de testes unitários. É possível observar que parte do código não foi coberto pelos testes unitários
Relatório de cobertura dos testes

Observe na imagem acima que nosso teste unitário não entrou no bloco if, indicando que o código que desenvolvemos para testar o AuthorizationInterceptor não contemplou um cenário em que o conteúdo dentro do bloco if fosse executado. Por essa razão, é exibido um "I" na imagem, localizado à esquerda do bloco if. Esse "I" representa "if path not taken", ou seja, nenhum teste unitário passou pelo bloco if.

Na verdade, fizemos isso de propósito, uma vez que simulamos que o método storageService.getJWT() retorna o valor null. Agora, vamos escrever um segundo teste para contemplar este cenário:

// ...(conteúdo ocultado)...

it('deve injetar o JWT no header Authorization', (done) => {
  const jwtValue = '123';
  const storageServiceGetJWTSpy = spyOn(
    storageService,
    'getJWT'
  ).and.returnValue(jwtValue);

  // A URL utilizada no teste pode ser qualquer uma. O
  // motivo é que vamos criar uma resposta simulada para
  // este endereço utilizando o objeto httpTestingController
  const url = 'http://localhost';
  httpClient.get(url).subscribe({
    next: (response) => {
      // Esperamos que a resposta seja exatamente o corpo
      // que efetuamos o mock
      expect(response).toEqual(responseMock);
    },
    complete: () => {
      // Esperamos que o StorageService.getJWT seja chamado
      // uma única vez
      expect(
        storageServiceGetJWTSpy
      ).toHaveBeenCalledTimes(1);
      done();
    },
  });

  // Aqui criamos uma resposta "mockada" para 'http://localhost'
  const req = httpTestingController.expectOne(url);

  // Aqui disparamos a resposta "mockada". Essa resposta
  // chega no subscribe do httpClient.get
  const responseMock = { mensagem: 'ola' };
  req.flush(responseMock, {
    status: HttpStatusCode.Ok,
    statusText: 'Ok',
  });

  // Esperamos que o cabeçalho "Authorization" esteja presente
  // na requisição com o valor mockado que está na variável "jwtValue"
  expect(
    req.request.headers.has('Authorization')
  ).toBeTruthy();
  expect(req.request.headers.get('Authorization')).toBe(
    jwtValue
  );
});
  
  // ...(conteúdo ocultado)...
trecho do authorization.interceptor.spec.ts

Executando novamente o comando abaixo, é possível ver nossos testes unitários cobrem 100% do código do AuthorizationInterceptor:

ng test --code-coverage --watch false
Navegador mostrando a cobertura de testes unitários do interceptor AuthorizationInterceptor. 100% do código foi coberto
Relatório de cobertura dos testes

Teste unitário - Interceptor para tratamento de erros

Como segundo caso de estudo, copiei e alterei um trecho de código da documentação oficial para ilustrar a implementação de um interceptor destinado a lidar com erros durante as requisições:

import {
  HttpErrorResponse,
  HttpEvent,
  HttpHandler,
  HttpInterceptor,
  HttpRequest,
} from '@angular/common/http';
import { Injectable } from '@angular/core';

import { Observable, catchError, throwError } from 'rxjs';

@Injectable()
export class HandleErrorInterceptor
  implements HttpInterceptor
{
  intercept(
    request: HttpRequest<unknown>,
    next: HttpHandler
  ): Observable<HttpEvent<unknown>> {
    return next.handle(request).pipe(
      catchError((error: HttpErrorResponse) => {
        let errorMsg = '';

        if (error.status === 0) {
          // A client-side or network error
          // occurred. Handle it accordingly.
          errorMsg = `An error occurred: ${JSON.stringify(
            error.error
          )}`;
        } else {
          // The backend returned an unsuccessful response
          // code. The response body may contain clues as
          // to what went wrong.
          errorMsg = `Backend returned code ${error.status}`;
        }

        return throwError(() => errorMsg);
      })
    );
  }
}
handle-error.interceptor.ts

Assim como no interceptor do exemplo anterior, aqui temos duas ramificações (possibilidades) no código:

  1. Tratamento de erros quando error.status === 0: Isso ocorre em erros originados no lado do cliente devido à falta de conexão com a internet ou a alguma exceção JavaScript.
  2. Tratamento de erros oriundos de uma resposta do backend.

Vamos abordar o teste unitário para essas situações:

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

import {
  HTTP_INTERCEPTORS,
  HttpClient,
  HttpStatusCode,
} from '@angular/common/http';
import {
  HttpClientTestingModule,
  HttpTestingController,
} from '@angular/common/http/testing';

import { HandleErrorInterceptor } from './handle-error.interceptor';

describe('AuthInterceptor', () => {
  let httpClient: HttpClient;
  let httpTestingController: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [
        HandleErrorInterceptor,
        {
          provide: HTTP_INTERCEPTORS,
          useClass: HandleErrorInterceptor,
          multi: true,
        },
      ],
    });

    httpClient = TestBed.inject(HttpClient);
    httpTestingController = TestBed.inject(
      HttpTestingController
    );
  });

  it('deve tratar erros 4xx', (done) => {
    const url = 'http://localhost';
    httpClient.get(url).subscribe({
      error: (responseError) => {
        // Esperamos que o interceptor formate a mensagem
        // de erro como abaixo:
        const expectedMessage =
          `Backend returned code ${HttpStatusCode.BadRequest}`;

        expect(responseError).toEqual(expectedMessage);
        done();
      },
    });

    const req = httpTestingController.expectOne(url);

    // Simulamos uma resposta para a requisição
    // ao endereço http://localhost com o status
    // 400 (bad request):
    req.flush(
      {},
      {
        status: HttpStatusCode.BadRequest,
        statusText: 'Erro na validação',
      }
    );
  });

  it('deve tratar erros no lado do cliente', (done) => {
    const mockError = new ProgressEvent('error');

    const url = 'http://localhost';
    httpClient.get(url).subscribe({
      error: (responseError) => {
        // Esperamos que o interceptor formate a
        // mensagem de erro e inclua o seguinte
        // trecho no retorno:
        const expectedMessage = `An error occurred: `;
        expect(responseError).toContain(expectedMessage);
        done();
      },
    });

    const req = httpTestingController.expectOne(url);

    // Simulamos um erro local, ou seja, um erro no lado
    // do cliente. Neste caso a requisição nem chega no
    // servidor:
    req.error(mockError);
  });
});
handle-error.interceptor.spec.ts

Resultado:

Navegador mostrando o resultado de execução dos testes unitários
Resultado da execução dos testes unitários

Considerações

A criação de testes unitários para interceptors não é tão complicada quanto parece. Pessoalmente, considero isso uma espécie de "receita de bolo", pois, basicamente, todo teste unitário segue a mesma estrutura. Como sugestão, sempre que possível, desenvolva testes unitários para garantir que o seu código atenda a todos os requisitos. Isso contribuirá para a entrega de uma aplicação mais resiliente e de alta qualidade.

Link do repositório onde vou incluir mais exemplos de testes unitários além dos citados neste post:

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
Repositório onde estão os exemplos utilizados neste texto

Links interessantes: