DEV Community

Andrew Rosário
Andrew Rosário

Posted on • Originally published at andrewrosario.Medium

Escrevendo testes eficientes de verdade no Angular

Testes + Eficientes

Quando falamos sobre testes unitários no front-end, eu vejo uma resistência muito grande das pessoas em implementá-los. Talvez pela opinião errônea de alguns que dizem que eles não agregam valor, ou pela dificuldade de entender como testar de forma unitária cada parte de uma aplicação.

Trazendo o assunto para o Angular, este ponto da dificuldade vem ainda mais à tona. Apesar do framework disponibilizar todo um ambiente propício para testes com o Karma e o Jasmine, ainda sim é desafiador entender todos os conceitos para escrever testes com qualidade.

Só para dar um exemplo, ao gerar um componente com o CLI do Angular, automaticamente é criado um arquivo spec, onde os testes do mesmo serão escritos.

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

import { TestComponent } from './test.component';

describe('TestComponent', () => {
  let component: TestComponent;
  let fixture: ComponentFixture<TestComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ TestComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(TestComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});
Enter fullscreen mode Exit fullscreen mode

Um pouco assustador para quem está começando, não? Confesso que no início pra mim estes arquivos soavam bastante ameaçadores. Há muita informação neles! Você precisa entender qual o papel do TestBed e o que o ComponentFixture está fazendo ali.

O objetivo deste artigo não é explicar todos os conceitos de testes unitários no Angular (Para isso, a documentação oficial fará muito melhor que eu), mas sim demonstrar uma abordagem que eu considero mais eficiente e muito mais simples ao testar os nossos códigos no Angular.

Evite detalhes de implementação

Depois que li o artigo Testing Implementation Details de Kent C. Dodds, a minha visão sobre testes unitários no front-end mudou bastante. É muito comum pensarmos que ao testar um componente, devemos testar seus atributos e suas funções. Mas fazendo isso, na verdade o que estamos testando são os detalhes de implementação.

Vamos nos colocar no lugar do usuário final. Será que ao testar a sua tela, ele está preocupado em saber se determinada variável mudou de valor ou se uma função foi chamada corretamente? Com certeza não. Pra ele o que importa é que ao interagir com a aplicação ela se comporte da maneira esperada. E é esse tipo de teste que deveríamos nos preocupar. O que realmente gera valor.

Além disso, ao escrever testes voltados para os detalhes de implementação, temos dois pontos negativos.

Testes falsos negativos

Este talvez seja o maior motivo das pessoas evitarem os testes. Isso acontece porque elas passam tempo demais escrevendo e principalmente corrigindo-os. Toda vez que alguém faz uma pequena alteração no código o teste quebra! É claro que dependendo desta alteração faz todo sentido o teste quebrar, mas existem casos onde a pessoa fez somente uma refatoração sem nenhum impacto visual. Neste caso o teste não deveria quebrar.

Testes falsos positivos

Para testar os elementos visuais do componente precisamos utilizar o temido ComponentFixture para ter acesso ao DOM. É chato de utilizá-lo pois precisamos dizer quando há mudanças (fixture.detectChanges). Além disso o código fica bastante verboso. A saída de alguns desenvolvedores é simplesmente não utilizar. Testar somente se as funções estão fazendo o papel delas. Mas aí temos o falso positivo: mesmo se alterar qualquer elemento no DOM o teste vai passar. E aí temos um teste que não testa de verdade!

Vamos analisar o seguinte componente:

@Component({
  selector: 'app-test',
  template: `
    <input [(ngModel)]="quoteText"/>
    <button [disabled]="!quoteText">Submit</button>
  `,
})
export class TestComponent {
  quoteText = '';

  constructor(private testService: TestService) {}

  sendData() {
    this.testService.sendData(this.quoteText);
  }
}
Enter fullscreen mode Exit fullscreen mode

E os seus testes:

it("should disable the button when input is empty", () => {
  fixture.detectChanges();
  const button = fixture.debugElement.query(By.css("button"));
  expect(button.nativeElement.disabled).toBeTruthy();
});

it("should enable button when input is not empty", () => {
  component.quoteText = "any text";
  fixture.detectChanges();
  const button = fixture.debugElement.query(By.css("button"));
  expect(button.nativeElement.disabled).toBeFalsy();
});

it("should call sendData with correct value", () => {
  spyOn(service, 'sendData');
  component.quoteText = "any text";
  fixture.detectChanges();
  component.sendData();
  expect(service.sendData).toHaveBeenCalledWith("any text");
});
Enter fullscreen mode Exit fullscreen mode

Temos aqui três testes:

  • Deve desabilitar o botão quando o input estiver vazio
  • Deve habilitar o botão quando o input não estiver vazio
  • Deve chamar o método sendData com o valor correto

Até aqui tudo bem, mas o teste está vulnerável. Se precisar alterar o nome da variável quoteText, o teste vai quebrar. Se incluir um novo botão no começo do template HTML, o teste vai quebrar. Esse tipo de alteração não deveria refletir em seus testes pois visualmente ele se comporta da mesma maneira. Por isso temos aqui o caso do falso negativo.

Vamos deixar essas questões pra lá e rodar esses testes… Maravilha! Todos eles passaram com sucesso! Já podemos fazer nosso deploy na sexta-feira e tomar nossa cerveja.

Um tempo depois você é informado que os clientes estão furiosos porque o botão importantíssimo daquela sua tela nova não faz absolutamente nada!

Como assim? Impossível! Rodei todos os testes antes de subir a feature! O componente está 100% coberto por testes. O que houve??

Se você é uma pessoa atenta, logo percebeu que o nosso amigo esqueceu de chamar a função sendData no click do botão. Por isso ele não está fazendo nada. Vamos corrigir então:

@Component({
  selector: 'app-test',
  template: `
    <input [(ngModel)]="quoteText"/>
    <button [disabled]="!quoteText" (click)="sendData()">Submit</button>
  `,
})
Enter fullscreen mode Exit fullscreen mode

E aqui temos o caso do falso positivo. De nada adianta um Code Coverage de 100% sendo que o mais importante não está sendo validado.

Não use o code coverage para medir a qualidade da sua aplicação

O relatório de cobertura de código nada mais é do que um número para ajudá-lo a identificar quais áreas da sua aplicação não foram testadas. Portanto, não almeje uma cobertura de 100%. Alcançar esse número não significa que tudo está funcionando, apenas que todo o código é executado em algum momento durante a execução dos testes. Busque escrever testes que realmente agreguem valor ao sistema.

Apresentando o Angular Testing Library

Angular Testing Library

Esta é uma biblioteca da família DOM Testing Library que tem como propósito oferecer uma abstração para testarmos componentes de UI de uma maneira mais voltada para o usuário. Trago aqui o problema que ela se propõe a resolver, diretamente da documentação oficial:

Você deseja escrever testes sustentáveis que forneçam alta confiança de que seus componentes estão funcionando para os usuários. Como parte desse objetivo, você deseja que seus testes evitem incluir detalhes de implementação para que a refatoração de seus componentes (mudanças na implementação, mas não na funcionalidade) não interrompam seus testes e deixem você e sua equipe mais lentos.

Ela parece estar bem alinhada com o nosso objetivo! Vamos instalar esta lib e a user-event que fornece uma simulação dos eventos do usuário.

npm install -D @testing-libray/angular @testing-library/user-event
Enter fullscreen mode Exit fullscreen mode

Boa prática: Ids de teste

No teste anterior, comentamos sobre o falso positivo quando capturamos o botão do componente diretamente pelo seletor button. Isso é ruim pois pode afetar nossos testes uma vez que alteramos nosso template.

Quando temos um elemento que é crucial para o funcionamento do nosso teste, precisamos garantir que ele possua um seletor que nunca mude e que seja exclusivo para este propósito. É aí que entra a convenção do data-testid.

O data-testid é um identificador dado a um elemento apenas com o propósito de localizá-lo em um teste. O teste ainda encontrará o elemento se o tipo de elemento ou atributos não relacionados forem alterados.

Inserimos esses ids de teste em nosso componente:

@Component({
  selector: 'app-test',
  template: `
    <input data-testid="input" [(ngModel)]="quoteText"/>
    <button data-testid="submit" [disabled]="!quoteText" (click)="sendData()">Submit</button>
  `,
})
Enter fullscreen mode Exit fullscreen mode

Teste voltado para o usuário

Agora vamos reescrever os dois primeiros testes do componente aplicando todos esses conceitos.

import { TestComponent, TestService } from './test.component';
import { FormsModule } from '@angular/forms';

import { render, screen } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';

const getSubmitButton = () => screen.getByTestId('submit') as HTMLButtonElement;
const getInput = () => screen.getByTestId('input') as HTMLInputElement;

describe('TestComponent', () => {
  it('button should be enabled only when typing some text', async () => {
    await render(TestComponent, { providers: [TestService], imports: [FormsModule] });
    expect(getSubmitButton().disabled).toBe(true);

    userEvent.type(getInput(), 'any_text');
    expect(getSubmitButton().disabled).toBe(false);
  });
});
Enter fullscreen mode Exit fullscreen mode

Veja que não precisamos utilizar mais o TestBed para apontar as configurações para o teste. Utilizamos a função render que nada mais é do que um wrapper do TestBed, só que mais voltada para a usabilidade do usuário. Perceba que agora não temos mais uma referência para o componente. Então nem adianta querer testar o que há dentro da classe dele! Podemos pensar que os testes escritos devem seguir o mesmo fluxo que um Q.A. seguiria para testar.

Tendo essa ideia em mente, utilizamos o objeto screen. Ele contém uma série de facilitadores para acessarmos o DOM. Com ele, podemos esquecer a existência do ComponentFixture! Ele fará esse trabalho de forma muito mais legível, fazendo com que até uma pessoa que nunca tenha trabalhado com Angular possa entender o que está sendo testado.

Para capturar o botão e o input, foi utilizada a função getByTestId do objeto screen. Ela funciona exatamente no contexto explicado dos ids de teste, tendo uma confiança maior na captura dos elementos do DOM. E de quebra, caso ele não encontre este id, é lançado um erro no teste.

Mais um detalhe que você talvez possa ter notado: não utilizamos mais o fixture.detectChanges. Antes de cada checagem de um elemento no DOM, o Angular Testing Library executará a detecção para você. Deixamos o teste mais limpo! E mais uma vez trago a reflexão do teste voltado para o usuário: ao testar sua tela, ele precisa falar manualmente pro Angular quando ele deve executar o ciclo de detecção de mudanças? Obviamente não! Ele espera que as ações aconteçam de maneira fluída. Então trazemos esse mesmo comportamento para o teste unitário.

Com o objeto userEvent, simulamos qualquer interação do usuário. Neste caso estamos dizendo para o usuário digitar (type) um texto qualquer no input. Este é um código que explica por si só o seu objetivo. E agora não estamos mais usando as propriedades do componente. Podemos refatorar futuramente de várias formas e o teste não irá quebrar.

Testando Inputs e Outputs

Ok, mas existem muitos casos onde nossos componentes possuem Inputs e Outputs. Como iremos testá-los dessa forma?

Dentro do método render podemos declarar o objeto componentProperties e informar as nossas propriedades iniciais.

describe('TestComponent', () => {
  it('button should be enabled only when typing some text (with component properties)', async () => {
    await render(TestComponent, { 
      providers: [TestService], 
      imports: [FormsModule],
      componentProperties: {
        quoteText: 'another value',
        submit: submitSpy
      }
    });
    expect(getSubmitButton().disabled).toBe(false);

    userEvent.clear(getInput());
    expect(getSubmitButton().disabled).toBe(true);
  });
});
Enter fullscreen mode Exit fullscreen mode

“Ainda não estou satisfeito”

“Beleza, mas o meu componente possui vários outros controles internos muito importantes que devem ser testados. O que foi mostrado até aqui não é suficiente pra mim!”

Se esse caso acontecer com você, sinto lhe informar, mas é bem provável que o seu componente esteja fazendo coisas demais. Componentes devem possuir somente regras de UI. O Angular já disponibiliza um sistema robusto de injeção de dependência. Utilize serviços para estes outros tipos de regras. Logo mais comentaremos sobre testes em serviços.

Componentes com dependências

Ao escrever testes de unidade, precisamos garantir que as suas dependências não afetem o nosso teste. Para isso existe um conceito muito conhecido: os dublês de testes (Mocks, Spies, Stubs, etc.).

Se você estiver utilizando o Jasmine, poderá facilmente entregar um Spy de um determinado serviço para o setup do seu teste com a função createSpyObj.

describe('TestComponent', () => {
  const testServiceSpy = jasmine.createSpyObj<TestService>('TestService', ['sendData']);
  it('sends data with correct value', async () => {
    await render(TestComponent, { 
      providers: [{provide: TestService, useValue: testServiceSpy}], 
      imports: [FormsModule] 
    });

    userEvent.type(getInput(), 'any_text');
    userEvent.click(getSubmitButton());
    expect(testServiceSpy.sendData).toHaveBeenCalledWith('any_text');
  });
});
Enter fullscreen mode Exit fullscreen mode

É possível informar retornos de métodos e valores de propriedades mockadas no segundo e terceiro parâmetro do createSpyObj.

Nos providers do componente, basta dizer que ao usar o TestService, na verdade ele será substituído pelo testServiceSpy com o useValue.

Componentes filhos

Geralmente ao construir aplicações Angular, criamos uma árvore de componentes e podemos dividi-los em duas categorias: Dumb e Smart Components (ou Presentational Components e Container Components).

Os Dumb Components costumam conter uma boa parte do HTML e CSS e não possuem muita lógica e nem dependências. Já os Smart Components reúnem vários desses Dumb Components e possuem várias dependências.

Existe uma certa polêmica ao testá-los. Ao se utilizar o princípio de evitar detalhes de implementação, recomenda-se executar testes de integração ao invés de testes unitários.

Para entender melhor vamos mover o botão do nosso exemplo para um componente filho chamado TestButtonComponent. Agora passamos esse filho no TestComponent.

@Component({
  selector: 'app-test',
  template: `
    <input data-testid="input" [(ngModel)]="quoteText"/>
    <app-test-button [disabled]="!quoteText" (click)="sendData()">
    </app-test-button>
  `,
})
Enter fullscreen mode Exit fullscreen mode

O nosso teste quebrou, mas para fazer voltar a passar é muito simples. Basta adicionar o componente filho no array de declarations.

await render(TestComponent, { 
  declarations: [TestButtonComponent],
  providers: [{provide: TestService, useValue: testServiceSpy}], 
  imports: [FormsModule] 
});
Enter fullscreen mode Exit fullscreen mode

Ótimo! Com essa abordagem podemos refatorar sem medo nossos componentes e a única coisa que precisamos alterar nos testes são as configurações iniciais.

O que acabamos de fazer foi transformar um teste de unidade em um teste de integração, pois agora testamos tanto o componente pai como o componente filho. Estamos testando como eles se comunicam.

Temos essas vantagens citadas, porém se quisermos testar esses componentes filhos de forma unitária podemos ter testes duplicados, e isso é muito ruim para a manutenção do nosso código.

Podemos testar os Smart Components de forma unitária também, para isso utilizamos a técnica de Shallow Rendering. Basicamente os componentes filhos não são renderizados de verdade, assim precisamos testar somente se eles estão sendo chamados.

A vantagem dessa segunda prática é que conseguimos escrever testes unitários para todos os componentes de forma mais objetiva. A desvantagem é que ela vai fugir dos princípios de se escrever testes voltados para o usuário, já que você precisará mockar componentes filhos e isso não reflete o que de fato será entregue. Além disso podemos nos deparar com os casos já citados do falso negativo e falso positivo.

Na maioria dos casos o teste de integração acaba sendo mais valioso para essas situações, mas em certos momentos o teste unitário pode ser mais útil para evitar duplicação de código, principalmente quando se tem um componente que é compartilhado com vários outros. Ambas abordagens possuem seus prós e contras. Escolha a que se encaixa melhor no contexto da sua aplicação.

Evite utilizar o “beforeEach”

Ao gerar um arquivo de teste, o Angular insere por padrão o bloco beforeEach que é o local onde serão feitas todas as configurações iniciais para cada bloco de teste. O problema é que cada teste pode precisar de configurações diferentes, e ao usar o beforeEach perdemos essa flexibilidade.

Uma solução mais interessante é a de se utilizar uma Factory Function para iniciar cada bloco. Passando a responsabilidade para uma função, ganha-se a vantagem de passar parâmetros para ela e também podemos retornar somente o que precisa ser testado. Por questões de padronização, utilize sempre o mesmo nome para esta função em toda a aplicação.

const setup = async (quoteText = '') => {
  const testServiceSpy = makeTestServiceSpy();
  await render(TestComponent, { 
    providers: [{provide: TestService, useValue: testServiceSpy}], 
    imports: [FormsModule],
    componentProperties: {
      quoteText
    }
  });

  return { testServiceSpy }
};

describe('TestComponent', () => {
  it('button should be enabled only when typing some text', async () => {
    await setup('any value');
    expect(getSubmitButton().disabled).toBe(false);
    userEvent.clear(getInput());
    expect(getSubmitButton().disabled).toBe(true);
  });

  it('sends data with correct value', async () => {
    const { testServiceSpy } = await setup();
    userEvent.type(getInput(), 'any_text');
    userEvent.click(getSubmitButton());
    expect(testServiceSpy.sendData).toHaveBeenCalledWith('any_text');
  });
});
Enter fullscreen mode Exit fullscreen mode

Perceba também que foi criada uma função makeTestServiceSpy. Ela pode ficar em um arquivo separado. Assim deixamos o código do teste mais limpo. Além disso deixamos ele reutilizável caso algum outro componente precise dele também.

Testando serviços

O Angular Testing Library não será muito útil para testar serviços, pois a biblioteca é voltada para testes de interface. Mas a verdade é que um serviço nada mais é do que uma classe comum do TypeScript. Na maioria dos casos você não precisará utilizar o TestBed. Podemos criá-los a partir dos conceitos já vistos até aqui.

const setup = () => {
  const otherServiceSpy = makeOtherServiceSpy(MOCKED_VALUE);
  const service = new TestService(otherServiceSpy);
  return { service, otherServiceSpy };
};

describe('TestService', () => {
  it('should call otherService with correct value', () => {
    const { service, otherServiceSpy } = setup();
    service.sendData('any_value');
    expect(otherServiceSpy.sendData).toHaveBeenCalledWith('any_value');
  });

  it('should return the right value on send data', () => {
    const { service } = setup();
    const value = service.sendData('any_value');
    expect(value).toEqual(MOCKED_VALUE);
  });
});
Enter fullscreen mode Exit fullscreen mode

Mockando requisições HTTP

O Angular disponibiliza alguns recursos para fazermos mocks das requisições (HttpClientTestingModule), já que não é interessante acessar os endpoints verdadeiros ao realizar testes unitários.

Como alternativa temos o Mock Service Worker (MSW). Ele é uma ótima ferramenta para mockar requisições, removendo a necessidade de fazer o mock diretamente no seu serviço. Um benefício adicional do MSW é que os mocks criados podem ser reutilizados ao servir a aplicação durante o desenvolvimento ou durante os testes end-to-end.

E as diretivas e pipes?

Felizmente podemos testar nossas diretivas da mesma maneira que testamos os componentes. A única diferença é que precisamos informar um template no método render. A documentação oficial do Angular Testing Library nos dá um bom exemplo, só que utilizando o Jest com o jest-dom:

test('it is possible to test directives', async () => {
    await render('<div appSpoiler data-testid="sut"></div>', {
        declarations: [SpoilerDirective],
    });

    const directive = screen.getByTestId('sut');

    expect(screen.queryByText('I am visible now...')).not.toBeInTheDocument();
    expect(screen.queryByText('SPOILER')).toBeInTheDocument();

    fireEvent.mouseOver(directive);
    expect(screen.queryByText('SPOILER')).not.toBeInTheDocument();
    expect(screen.queryByText('I am visible now...')).toBeInTheDocument();

    fireEvent.mouseLeave(directive);
    expect(screen.queryByText('SPOILER')).toBeInTheDocument();
    expect(screen.queryByText('I am visible now...')).not.toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

Já os pipes acabam entrando no mesmo exemplo dos serviços. O objetivo do pipe é basicamente retornar um determinado dado com o método transform. Basta escrevermos simples testes para este método.

Escreva menos blocos de teste

No primeiro exemplo deste artigo, estávamos testando nosso componente. Ele possuía um bloco de teste para testar quando o botão estava desabilitado e um bloco de teste para quando estava habilitado.

Ao reescrevermos, acabamos unindo esses dois testes em um só. Os exemplos a seguir também possuem várias asserções. Essa é uma prática que gera valor no nosso front-end.

Esta prática vai contra o princípio de que devemos possuir somente uma asserção por bloco de teste. Mas em testes de UI ela faz muito sentido, além de diminuir o custo de inicialização no Angular.

“Pense em um fluxo de testes para um Q.A. e tente fazer com que cada um de seus blocos de teste inclua todas as partes desse fluxo. Isso geralmente resulta em várias ações e afirmações, o que é uma coisa boa.”
- Kent C. Dodds

E não para por aqui

Mesmo seguindo todas essas práticas, em algum momento você possivelmente terá dúvidas de como testar o seu código da melhor forma em diferentes situações. No Repositório do Angular Testing Library, você encontrará vários exemplos para essas situações.

Conclusão

Espero que estes conceitos possam te ajudar a testar suas aplicações de maneira mais eficiente. O teste não precisa ser uma tarefa árdua, ao invés de evitá-los, simplifique-os. Encerro com a frase que é exposta no próprio repositório desta biblioteca:

“Quanto mais seus testes se assemelham à forma como o software é usado, mais confiança eles podem lhe dar.”

Referências:

Top comments (0)