Como criar um teste unitário para um interceptor - 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);
}
}
Analisando o código do interceptor acima, podemos observar dois possíveis caminhos (fluxos):
- O
this.storageService.getJWT()
retornará umastring
, e assim, o conteúdo dentro do blocoif
será executado para injetar o valor no cabeçalho Authorization. - Se
this.storageService.getJWT()
retornarnull
ou estiver vazio, o conteúdo dentro do blocoif
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);
});
});
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',
}
);
});
});
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
:
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)...
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
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);
})
);
}
}
Assim como no interceptor do exemplo anterior, aqui temos duas ramificações (possibilidades) no código:
- 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. - 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);
});
});
Resultado:
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:
Links interessantes: