Minha folha de resumo de testes de unidade em Angular

Para aqueles que tiveram a oportunidade de ver o framework Angular (V2 e mais) você viu como a documentação no site oficial pode ser volumosa, especialmente em a parte de testes unitários. O objetivo deste artigo é fornecer uma folha de resumo das melhores práticas de teste de unidade em Angular.
O artigo pressupõe que você já tenha usado Angular e seus testes de unidade e que conheça o campo léxico de Testes de Unidade.
Os conselhos e soluções fornecidos aqui vêm de uma experiência de seis meses de desenvolvimento Angular com uma equipe de cinco pessoas. Claro que estão sujeitos a melhorias/discussões.

Preâmbulo: Como “zombar” corretamente com Angular?

Um pequeno preâmbulo é necessário para esclarecer um ponto que para mim é muitas vezes esquecido na literatura sobre o assunto, e que será vital para o resto do entendimento:
Mocks, Spy e outros esboços.
Muitas vezes, uma abordagem um tanto ingênua é oferecida em exemplos de teste de unidade Angular. Vamos levar O caso do teste de um componente no documento oficial:

class MockUserService {
  isLoggedIn = true;
  user = { name: "Test User" };
}
/* ... */
beforeEach(() => {
  TestBed.configureTestingModule({
    // provide the component-under-test and dependent service
    providers: [
      WelcomeComponent,
      { provide: UserService, useClass: MockUserService }
    ]
  });
  // inject both the component and the dependent service.
  comp = TestBed.get(WelcomeComponent);
  userService = TestBed.get(UserService);
});

Este código explica que para zombar da implementação de um serviço (aqui UserService) simplesmente crie uma classe falsa substituindo este serviço (aqui MockUserService).
Essa abordagem basicamente me irrita porque perdemos toda a verificação de tipos. Na verdade, não há garantia de que MockUserService respeita a interface da classe UserService. Adicionado a isso está o custo de escrever uma classe falsa...
[bctt tweet=”Em Angular, criar um mock à mão não é uma abordagem segura nem eficiente.”] Outra maneira de fazer isso, que pode ser vista aqui e ali, é estender a classe que queremos zombar então, sobrecarregar os comportamentos desejados. Se voltarmos ao nosso exemplo anterior, ficaria assim:

class MockUserService extends UserService {
  user = { name: "Another user" };
}
/* ... */
beforeEach(() => {
  TestBed.configureTestingModule({
    // provide the component-under-test and dependent service
    providers: [
      WelcomeComponent,
      FirstLevelNeededService,
      SecondLevelNeededService,
      { provide: UserService, useClass: MockUserService }
    ]
  });
  // inject both the component and the dependent service.
  comp = TestBed.get(WelcomeComponent);
  userService = TestBed.get(UserService);
});

Esse método é ainda pior que o anterior porque, embora você tenha corrigido o problema de digitação e verbosidade, você quebrou o isolamento necessário para um teste de unidade.
Como a implementação real de UserService é importado, TestBed exigirá que também forneçamos todas as classes nas quais UserService. Imagine o esquema de dependência abaixo:

+---------------+ +------------------------+ +---- - ----------+ | | | | | | | UserService +<---+ FirstLevelNeededService +<----+ SecondLevelNeededService | | | | | | | +---------------+ +------------------------+ +---- - ----------+

Seguindo este esquema, devemos fornecer o TestBed, FirstLevelNeededService et SecondLevelNeededService. Isso não faz sentido quando você pensa sobre isso porque o propósito de um mock é simular o comportamento de uma classe. Então, por que fornecer dependências simuladas?
[bctt tweet=”Nunca crie uma simulação de uma classe TypeScript estendendo sua implementação original em Angular.”] Caso contrário, você terá que fornecer um grande número de classes para cada suíte de teste.

Use uma biblioteca simulada

Para desenvolvedores orientados a objetos, usar uma biblioteca simulada é óbvio no contexto de teste de unidade porque sua linguagem geralmente não é tão dinâmica quanto JavaScript e não permite que você escreva Mocks em tempo real ("on the fly"). – Quem disse isso?).
Uma biblioteca Mock usará a definição de tipo criada via TypeScript para criar uma simulação de uma classe.
Reflexo antigo de “Javaiste” talvez, eu pessoalmente gosto ts mockito como livreiro de Mock, mas há outros: Simulação de TS ou Digitemoq
Se pegarmos nosso exemplo com ts-mockito, nosso código ficaria assim:

import { instance, mock, when } from "ts-mockito";
/***/
let userServiceMock: UserService;
beforeEach(() => {
  userServiceMock = mock(UserService);
  when(userServiceMock.user).thenReturn({ name: "A user" });
  TestBed.configureTestingModule({
    providers: [
      WelcomeComponent,
      { provide: UserService, useValue: instance(userServiceMock) }
    ]
  });
  userService = TestBed.get(UserService);
});

Simples, não é? ts-mockito primeiro criará uma classe simulada usando a função mock em seguida, crie instâncias simuladas com base nessa classe com a função instance.
Para simular resultados, muitas vezes usaremos a função when. Sempre use when em uma aula simulada e ligue instance somente depois, caso contrário sua instância simulada não terá os comportamentos definidos.

No restante do artigo, usaremos sistematicamente o ts-mockito

O guia

Caso n°1: Testando um serviço sem dependência com Angular

Vamos começar com o mais simples, o serviço. Em Angular, um serviço não é nem mais nem menos que uma classe TypeScript que será instanciada pelo recipiente IOC do Angular e injetado em todos os outros elementos que o definirão como dependências.
Se o serviço não usa uma dependência vinda do Angular (como o HttpClient por exemplo) você pode testá-lo como qualquer classe TypeScript em qualquer projeto como este:

import { MyService } from "./my.service";
import { instance, mock, when } from "ts-mockito";
import { MathLib } from "./my.service";
describe("MyService", () => {
  it("should add correctly two positive integers and multiply them by two", () => {
    // given
    const mathMock = mock(MathLib);
    when(mathMock.add(1, 2)).thenReturn(3);
    when(mathMock.multiply(3)).thenReturn(6);
    const myService = new MyService(instance(mathMock));
    // when
    const result = myService.addAndMultiplyByTwo(1, 2);
    // then
    expect(result).toBe(6);
  });
});

Primeiro ponto, você notará que, ao contrário do que angular-cli não criamos um teste should be created. Este teste que seria usado para verificar a existência da classe é de fato já verificado pelos outros testes unitários, portanto inútil.
Segundo e potencialmente mais importante ponto deste artigo:
[bctt tweet=”Use o `TestBed` apenas raramente em testes de unidade Angular.”] O TestBed, mais especificamente a chamada ao seu método configureTestingModule, é longo e traz mais complexidade nos testes. E por uma boa razão, a chamada de método TestBed.configureTestingModule vai, como o próprio nome sugere, criar um Módulo angular a cada chamada e instanciar todos os elementos deste 'módulo falso' graças ao container IOC do Angular.
Observamos em nossos testes que o uso de TestBed potencialmente levará cinco vezes mais do que uma simples instanciação de objeto e seus mocks como em nosso exemplo (mais informações aqui): Angular TestBed é muito longa)
Não inventamos nada lá, chama-se Teste Isolado no doc do Angular (que não consigo mais encontrar o link porque é enorme :D)
Ok, mas o que acontece se eu usar Module vindo de Angular como o HttpModule ?

Caso n°2: Testando um serviço que depende de um módulo Angular

Vamos fazer um teste bastante padrão de um serviço de autenticação que usa o HttpClient :

import { AuthService } from "./auth.service";
import { inject, TestBed } from "@angular/core/testing";
import {
  HttpClientTestingModule,
  HttpTestingController
} from "@angular/common/http/testing";
describe("AuthService", () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [AuthService],
      imports: [HttpClientTestingModule]
    });
  });
  it(
    "should return null user when server responds HTTP error",
    inject(
      [HttpTestingController, AuthService],
      (httpMock: HttpTestingController, service: AuthService) => {
        service.user.subscribe(user => {
          expect(user).toBe(null);
        });
        httpMock.expectOne("auth/user").error(new ErrorEvent("401"));
        httpMock.verify();
      }
    )
  );
});

Não temos escolha a não ser usar o TestBed porque é ele quem permite o acesso ao httpMock responsável por simular as respostas do servidor.
[bctt tweet=”Nós só usaremos o `TestBed` para testar componentes ou classes dependentes do módulo Angular.”] Você também notará o uso do pequeno auxiliar inject, útil para recuperar instâncias criadas dentro do contêiner IOC do Angular. Você também deve ter notado que meu beforeEach não contém uma chamada para async. Async tende a ser usado indiscriminadamente, mas esta função está aí para esperar uma assincronia.
Se você olhar para o documentação oficial configureTestingModule não volte de Promise, então não é assíncrono, então não vale a pena chamar async neste caso.

Caso nº 3: Testando um componente “básico”

Este é o tipo de componente que você escreve muito em Angular.
Veremos no caso a seguir que os componentes localizados no alto da hierarquia também serão objeto de processamento adicional.
Para ilustrar este teste, vamos imaginar estar no processo de verificação de um componente ListComponent que exibe uma lista carregada de um serviço ItemsService : um caso de livro…
A forma correta de fazer seria assim:

import { ComponentFixture, TestBed } from "@angular/core/testing";
import { anyString, instance, mock, when } from "ts-mockito";
import { ListComponent } from "./list.component";
import { ItemsService } from "./item/items.service";
import { Item } from "./item/item.model";
import { of } from "rxjs/observable/of";
describe("ListComponent", () => {
  let component: ListComponent;
  let fixture: ComponentFixture<ListComponent>;
  let mockedItemsService: ItemsService;
  beforeEach(() => {
    mockedItemsService = mock(ItemsService);
  });
  async function configureTestingModule() {
    await TestBed.configureTestingModule({
      providers: [
        { provide: ItemsService, useValue: instance(mockedItemsService) }
      ],
      declarations: [ListComponent]
    }).compileComponents();
    fixture = TestBed.createComponent(ListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  }
  it("should display an empty list", async () => {
    // given
    when(mockedItemsService.loadItems()).thenReturn(of([new Item()]));
    // when
    await configureTestingModule();
    // then
    const nbEl = fixture.debugElement.queryAll(By.css("li")).length;
    expect(nbEl).toEqual(0);
  });
});

A primeira coisa que pode ter chocado você foi a função configureTestingModule fora de beforeEach. De fato, será necessário chamá-lo em nossos testes de unidade e não antes para manter a ordem de chamada das funções mockito mencionadas no preâmbulo:

mock -> quando -> instância

mock será chamado no beforeEach depois when no início do teste unitário e finalmente instance através da chamada de configureTestingModule no meio do teste unitário.
Se nos concentrarmos em configureTestingModule, vemos que é assíncrono porque invocamos compileComponents que retorna uma promessa. que async “contamina” nosso teste unitário porque temos que marcá-lo como async também.
Notaremos também que estamos sobrecarregando o provedor com ItemsService para dar a instância do nosso mock ts-mockito:

{fornecer: ItemsService, useValue: instance(mockedItemsService) }

Isso garantirá que o TestBed injeta esse simulacro de ItemsService ao componente quando ele é criado.
Por fim, simulamos o retorno do método loadItems do serviço para que ele retorne um Observable&lt;Item[]&gt; vazio e verificamos se a lista html de elementos está realmente vazia.

Caso nº 4: Testando um componente de alto nível

O que eu chamo de componentes de “alto nível”, são os componentes que estão no topo da hierarquia de componentes do nosso aplicativo. As páginas componentes ou contendo muitos filhos correspondem perfeitamente a esta denominação. a AppComponent será o exemplo utilizado.
O chato é verificar o comportamento desse tipo de componente: eles dependem por definição de muitos subcomponentes e submódulos e será necessário TOUS adicione-os no TestBed. Para o AppComponent é como dar a ele quase todo o aplicativo na dependência ...
Se, além disso, eu lhe dissesse que o TestBed.configureTestingModule foi lento no caso 1, fica mais lento à medida que o número de dependências aumenta.
Para contornar este problema, existe o parâmetro schemas ser fornecido a TestBed.configureTestingModule com o valor NO_ERRORS_SCHEMA :

import { TestBed } from "@angular/core/testing";
import { AppComponent } from "./app.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
describe("AppComponent", () => {
  let component: ListComponent;
  let fixture: ComponentFixture<ListComponent>;
  beforeEach(async () => {
    TestBed.configureTestingModule({
      providers: [],
      imports: [],
      declarations: [AppComponent],
      schemas: [NO_ERRORS_SCHEMA]
    }).compileComponents();
    fixture = TestBed.createComponent(ListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
  it("should render title in a h1 tag", async () => {
    // given
    const selectorText = compiled.querySelector("h1").textContent;
    // then
    expect(selectorText).toBe("My awesome app");
  });
});

Este parâmetro mágico, configura o TestBed para que não lance um erro se não encontrar o provedor de um componente ou módulo usado no(s) modelo(s) do(s) componente(s) testado(s). Este método é chamado shallow testing. Testamos apenas o componente AppComponent e não interprete seus subcomponentes.
Le NO_ERRORS_SCHEMA deve ser usado com cuidado, porque aplicado em todos os lugares, ele torna as verificações do sistema de dependência inertes e pode fazer com que você esqueça de carregar um componente quando for necessário em seu caso de teste.
[bctt tweet=”Em Angular, `NO_ERRORS_SCHEMA` deve ser usado com cautela e apenas para testar componentes de alta hierarquia.”]

Caso nº 5: Tubos, classes, o resto

Os pipes, assim como os demais elementos que podem compor sua aplicação, serão testados como serviços sem dependência Angular, ou seja, classes básicas de TypeScript.
Vamos imaginar um pipe cujo objetivo é juntar na forma de uma string de caracteres todas as chaves de um objeto cujo valor não seja nulo. Nós o chamaríamos AliciaKeys… (sim, estávamos bastante orgulhosos do nome)

import { Pipe, PipeTransform } from "@angular/core";
@Pipe({
  name: "aliciaKeys"
})
export class AliciaKeys implements PipeTransform {
  transform(obj: any, arg: string[]): string {
    return obj
      ? Object.entries(obj)
          .filter(([key, value]) => !!value)
          .map(([key]) => key)
          .join(", ")
      : "";
  }
}

O arquivo de teste de unidade anexado é trivial, pois é uma simples instanciação da classe AliciaKeys com duas ligações de transform em dois casos diferentes:

import { AliciaKeys } from "./aliciakeys.pipe";
describe("AliciaKeys", () => {
  it("should return valued keys", () => {
    const result = new AliciaKeys().transform({
      validKey: {},
      inValidKey: null,
      anotherValidKey: 1
    });
    expect(result).toEqual("validKey, anotherValidKey");
  });
  it("should return blank when input is falsy", () => {
    const result = new AliciaKeys().transform(null;
    expect(result).toEqual("");
  });
});

Conclusão e indo além

Esperamos que a descrição de cada caso também o ajude a entender melhor como testar corretamente a unidade de seus aplicativos Angular.
Os pontos importantes a serem lembrados são que as classes em Angular permanecem classes clássicas do TypeScript e que podem ser testadas facilmente. O segundo ponto é que o TestBed não é sistemático, que deve ser usado apenas quando você não tem escolha. Finalmente, mocks em Angular devem ser gerenciados com uma biblioteca de mocks: No nosso caso ts-mockito.
Você deve saber que em nosso projeto, tivemos cerca de 700 testes unitários que levaram mais de 30 segundos para serem executados. Agilizar os testes e acelerá-los tornou-se crucial para nós.
Também continuamos essa busca pela aceleração passando por estes últimos Brincadeira. Isso é totalmente possível seguindo as instruções no repositório jest-preset-angular e economiza muito tempo!
Voltaremos a você com mais detalhes sobre esses assuntos durante nosso feedback “JS-Talks” (dia de observação de tecnologia que fazemos todos os meses). Algumas fotos finais para ver como ficou:

Publicado por Matheus Breton CTO na JS-Republic