Testes unitários no Angular

Realização de testes unitários em componentes Angular
Voltar

Material complementar:


Conceito:

Utilização de tecnologias de testes unitários Angular Jasmine. TestBed, que prepara módulo de teste para ambiente de teste, e Spy, que permite a criação de espiões (spies) para simular comportamentos de objetos durante testes. Testes unitários realizados utilizando mock, ambiente específico de dados para testes, evitando testes com dados reais em produção.


Testes em componentes:

  1. Pré requisitos (Angular): npm install -g @angular/cli
  2. Criar projeto: ng new teste-unitario-aula
  3. Acessar projeto: cd teste-unitario-aula
  4. Em src/app/, criar diretório '_services'
  5. Criar service calculadora (Em src/app/_services): ng g s _services/calculadora.service
  6. Criar service logger: ng g s _services/logger.service
  7. Programar arquivo de service calculadora
  8. Executar testes: ng test

Testes em HttpClient:

  1. Testes através de HTTP requests, utilizando API REST pública JSONPlaceholder.
  2. No projeto, gerar arquivo de variáveis de ambiente: ng generate environments
    • Gerará src/environments/environment.ts
  3. Editar arquivos de environments, incluindo variáveis de apiUrl
  4. Em src/app/app.config.ts, configurar provider HttpClient
  5. Gerar service em src/app/_services: ng g s todos.service
  6. Em src/app, criar pasta _models
  7. Em _models, criar model Todo.ts
  8. Em src/app/app.ts, atualizar código do componente
  9. Executar projeto (ng serve) e verificar console
  10. Em src/app/_services/todos.service.spec.ts, implementar testes em requests HTTP
  11. Em src/app.ts, comentar trecho de código (conforme já comentado) e apagar 'implements OnInit'
  12. Executar testes: ng test
  13. Criação de mock:
  14. Na raíz do projeto, criar diretório server, com arquivo 'db-data.ts', informando resultados esperados através dos testes (copiado de https://jsonplaceholder.typicode.com/todos)

Arquivos:

  • Pasta raíz do projeto, diretório server/db-data.ts:
    
    export const TODO_STRING: string = `{
        "userId": 1,
        "id": 15,
        "title": "ab voluptatum amet voluptas",
        "completed": true
      }`;
    
    export const TODOS_STRING: string = `[
      {
        "userId": 1,
        "id": 1,
        "title": "delectus aut autem",
        "completed": false
      },
      {
        "userId": 1,
        "id": 2,
        "title": "quis ut nam facilis et officia qui",
        "completed": false
      },
      {
        "userId": 1,
        "id": 3,
        "title": "fugiat veniam minus",
        "completed": false
      },
      {
        "userId": 1,
        "id": 4,
        "title": "et porro tempora",
        "completed": true
      },
      {
        "userId": 1,
        "id": 5,
        "title": "laboriosam mollitia et enim quasi adipisci quia provident illum",
        "completed": false
      }
    ]`;
    
  • Em src/app/_models/Todo.ts:
    
    export interface Todo {
        userId: number;
        id: number;
        title: string;
        completed: boolean;
    };
    
  • Em src/app/_services/calculadora.service.spec.ts:
    
    import { TestBed } from '@angular/core/testing';
    import { CalculadoraService } from './calculadora.service';
    import { LoggerService } from './logger.service';
    
    describe('CalculadoraService', () => {
        let service: CalculadoraService;
        let loggerSpy: any;
    
        beforeEach(() => { // Executado sempre antes de cada teste (it), afterEach é executado depois de cada teste (it)
            loggerSpy = jasmine.createSpyObj('LoggerService', ['log']); // Cria espião para LoggerService
            TestBed.configureTestingModule({
                providers: [CalculadoraService,
                    { provide: LoggerService, useValue: loggerSpy }
                ], // Configura TestBed para fornecer serviço CalculadoraService
            });
            service = TestBed.inject(CalculadoraService); // Injeta serviço CalculadoraService para uso nos testes
        });
    
        it('should be created', () => {
            expect(service).toBeTruthy();
        });
    
        it('deve somar corretamente dois números', () => {
            expect(service).toBeTruthy();
            const result = service.calcular(2, 3, 'soma');
            expect(result).toBe(5, 'Resultado deve ser 5');
        });
    
        it('deve subtrair corretamente dois números', () => {
            expect(service).toBeTruthy();
            const result = service.calcular(5, 3, 'subtracao');
            expect(result).toBe(2, 'Resultado deve ser 2');
        });
    
        it('operação não existe', () => {
            expect(service).toBeTruthy();
            const result = service.calcular(5, 3, 'outro');
            expect(result).toBeNull();
            expect(loggerSpy.log).toHaveBeenCalledTimes(1); // Verifica se método log foi chamado única vez
        });
    });
    
  • Em src/app/_services/calculadora.service.ts:
    
    import { Injectable } from '@angular/core';
    import { LoggerService } from './logger.service';
    
    @Injectable({
        providedIn: 'root'
    })
    export class CalculadoraService {
        constructor(private loggerService: LoggerService) { }
    
        calcular(num1: number, num2: number, operacao: String) {
            switch (operacao) {
                case 'soma':
                    return num1 + num2;
                case 'subtracao':
                    return num1 - num2;
                case 'divisao':
                    return num1 / num2;
                case 'multiplicacao':
                    return num1 * num2;
                default:
                    this.loggerService.log(`Operação inválida: ${operacao}`);
                    // this.loggerService.log(`Operação inválida: ${operacao}`); // Loga operação inválida (chamado 2 vezes no teste)
                    return null;
            }
        }
    }
    
  • Em src/app/_services/logger.service.spec.ts:
    
    import { TestBed } from '@angular/core/testing';
    import { LoggerService } from './logger.service';
    
    describe('LoggerService', () => {
        let service: LoggerService;
    
        beforeEach(() => {
            TestBed.configureTestingModule({});
            service = TestBed.inject(LoggerService);
        });
    
        it('should be created', () => {
            expect(service).toBeTruthy();
        });
    });
    
  • Em src/app/_services/logger.service.ts:
    
    import { Injectable } from '@angular/core';
    
    @Injectable({
        providedIn: 'root'
    })
    export class LoggerService {
        constructor() { }
    
        log(msg: string) {
            console.log(msg);
        }
    }
    
  • Em src/app/_services/todos.service.spec.ts:
    
    import { TestBed } from '@angular/core/testing';
    import { TodosService } from './todos.service';
    import { provideHttpClient } from '@angular/common/http';
    import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
    import { environment } from '../../environments/environment.development';
    import { TODOS_STRING, TODO_STRING } from '../../../server/db-data';
    
    describe('TodosService', () => {
        let todosService: TodosService;
        let httpTestingController: HttpTestingController;
    
        beforeEach(() => {
            TestBed.configureTestingModule({
                providers: [
                    TodosService,
                    provideHttpClient(),
                    provideHttpClientTesting(),
                ]
            });
            todosService = TestBed.inject(TodosService);
            httpTestingController = TestBed.inject(HttpTestingController);
        });
    
        it('should be created', () => {
            expect(todosService).toBeTruthy();
        });
    
        it('Deve retornar lista TODOS', () => {
            /* Método Assíncrono direto - dados reais, não recomendado */
            todosService.getAll().subscribe(todos => {
                expect(todos).toBeTruthy('Nenhum TODO retornado');
                expect(todos.length).toEqual(200, 'Quantidade de TODOs diferente de 200');
    
                const todo = todos.find(todo => todo.id == 15);
                expect(todo?.title).toEqual('ab voluptatum amet voluptas');
            });
    
            /* Método acima, com Mock (acima id 15 pegará do db-data.ts, e não do http) */
            const req = httpTestingController.expectOne(environment.apiUrl + 'todos');
            expect(req.request.method).toEqual('GET');
            req.flush(JSON.parse(TODOS_STRING));
        });
    
    
        it('Deve retornar TODO por Id', () => {
            /* Método Assíncrono direto - dados reais, não recomendado */
            todosService.getById(15).subscribe(todo => {
                expect(todo).toBeTruthy();
                expect(todo.id).toEqual(15);
            });
    
            /* Método acima, com Mock (acima id 15 pegará do db-data.ts, e não do http) */
            const req = httpTestingController.expectOne(environment.apiUrl + 'todos/15');
            expect(req.request.method).toEqual('GET');
            req.flush(JSON.parse(TODO_STRING));
        });
    
        /* Método Assíncrono acima, com Promise - dados reais, não recomendado
        it('Deve retornar TODO por Id', async () => {
            const todo = await todosService.getById(15).toPromise();
            expect(todo).toBeTruthy();
            expect(todo?.id).toEqual(15);
        });
        */
    });
    
  • Em src/app/_services/todos.service.ts:
    
    import { Injectable } from '@angular/core';
    import { environment } from '../../environments/environment.development';
    import { HttpClient } from '@angular/common/http';
    import { map } from 'rxjs/operators';
    import { Todo } from '../_models/Todo';
    
    @Injectable({
        providedIn: 'root'
    })
    export class TodosService {
        baseUrl: string = environment.apiUrl;
        constructor(private http: HttpClient) { }
    
        getAll() {
            return this.http.get<Todo[]>(this.baseUrl + 'todos')
                .pipe(
                    map((response) => {
                        return response;
                    })
                );
        }
    
        getById(id: number) {
            return this.http.get<Todo>(this.baseUrl + 'todos/' + id)
                .pipe(
                    map((response) => {
                        return response;
                    })
                );
        }
    }
    
  • Em src/app/app.config.ts:
    
    import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
    import { provideRouter } from '@angular/router';
    import { routes } from './app.routes';
    import { provideHttpClient } from '@angular/common/http';
    
    export const appConfig: ApplicationConfig = {
        providers: [
            provideBrowserGlobalErrorListeners(),
            provideZoneChangeDetection({ eventCoalescing: true }),
            provideRouter(routes),
            provideHttpClient(),
        ]
    };
    
  • Em src/app/app.ts:
    
    import { Component } from '@angular/core';
    import { RouterOutlet } from '@angular/router';
    import { OnInit } from '@angular/core';
    import { TodosService } from './_services/todos.service';
    
    @Component({
        selector: 'app-root',
        imports: [RouterOutlet],
        templateUrl: './app.html',
        styleUrl: './app.css'
    })
    export class App {
        protected title = 'teste_unitario_aula';
    }
    
  • Em src/environments/environments.development.ts:
    
    export const environment = {
        apiUrl: 'http://jsonplaceholder.typicode.com/',
    };
    
  • Em src/environments/environments.ts:
    
    export const environment = {
        apiUrl: 'http://jsonplaceholder.typicode.com/',
    };
    
  • Em src/main.ts:
    
    import { bootstrapApplication } from '@angular/platform-browser';
    import { appConfig } from './app/app.config';
    import { App } from './app/app';
    
    bootstrapApplication(App, appConfig)
        .catch((err) => console.error(err));
    
  • Em src/tsconfig.app.json:
    
    {
        "extends": "./tsconfig.json",
        "compilerOptions": {
            "outDir": "./out-tsc/app",
            "types": []
        },
        "include": [
            "src/**/*.ts"
        ],
        "exclude": [
            "src/**/*.spec.ts"
        ]
    }
    
  • Em src/tsconfig.spec.json:
    
    {
        "extends": "./tsconfig.json",
        "compilerOptions": {
            "outDir": "./out-tsc/spec",
            "types": [
                "jasmine"
            ]
        },
        "include": [
            "src/**/*.ts"
        ]
    }
    

Elaborado por Mateus Schwede
ubsocial.github.io