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<Item[]>
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