DEV Community

Wesley Egberto
Wesley Egberto

Posted on • Updated on

Angular - Testes de Componentes

No post anterior falamos sobre os testes de Pipes e Services.

Outros posts da série:

Neste post, iniciaremos nossos testes em componentes do Angular.

Introdução

Diretamente da documentação do Angular, um componente combina um template HTML e uma classe TypeScript.
E para testar este componente adequadamente, deveríamos testar se combinação do template HTML e sua classe funcionam corretamente.

Esses testes requerem que criemos o elemento do componente na árvore DOM do browser e realizássemos as interações necessárias para cobrir todo o seu comportamento.

Para isso, o Angular nos disponibiliza a classe TestBed que facilita a configuração da base do Angular necessária para renderização do componente e todas as dependências do componente.

Mas, em alguns casos, realizar o teste unitário da classe do componente, sem a renderização em um DOM, pode validar todo o comportamento necessário.

Teste Unitário da Classe de Componente

A classe do componente, normalmente, contém todo o estado e comportamento do seu componente, caso o seu HTML esteja mantem estado e/ou executando ações - como em <button (click)="form.reset()">Limpar</button> - recomendo extrair para a classe de componente.
Ao realizar os testes unitários do estado e ações da classe do componente, estamos, praticamente, testando completamente o componente.

A situação pode ser bem mais complicada nos casos em que o componente utiliza outros componentes para realizar seus objetivos, nestes casos é recomendado realizar completo do componente visando interação com esses componentes.

Testando Componente

Testando Data Binding

Neste exemplo, iremos testar um componente cujo objetivo é exibir uma interface com um botão para ligar e desligar alguma coisa.
Nele simplesmente exibimos um botão que nos permite ligar ou desligar. Quando desligado, o botão fica vermelho e exibe o texto Turn on e, quando ligado, o botão fica verde e exibe o texto Turn off.

Código do Componente

Abaixo seguem os arquivos do componentes.

Arquivo simple-switch-button.component.html:

<button (click)="toggle()" [style.backgroundColor]="buttonColor">{{ buttonLabel }}</button>
Enter fullscreen mode Exit fullscreen mode

Arquivo simple-switch-button.component.ts:

import { Component } from '@angular/core';
import { ButtonState } from './button-state';

@Component({
  selector: 'app-simple-switch-button',
  templateUrl: './simple-switch-button.component.html'
})
export class SimpleSwitchButtonComponent {
  state: ButtonState = ButtonState.OFF;

  constructor() { }

  get buttonLabel() {
    return this.state === ButtonState.ON ? 'Turn off' : 'Turn on';
  }

  get buttonColor() {
    return this.state === ButtonState.ON ? 'green' : 'red';
  }

  toggle() {
    this.state = this.state === ButtonState.ON ? ButtonState.OFF : ButtonState.ON;
  }
}
Enter fullscreen mode Exit fullscreen mode

Teste da Classe do Componente

Caso precisássemos testar apenas a classe de componente, ao julgar que a cobertura do estado e comportamento é o bastante para garantir a funcionalidade, podemos escrever os testes igual faríamos em um teste de um service sem dependências.

Arquivo simple-switch-button.component-class.spec.ts:

import { SimpleSwitchButtonComponent } from './simple-switch-button.component';
import { ButtonState } from './button-state';

describe('SimpleSwitchButtonComponent (class-only)', () => {
  let component: SimpleSwitchButtonComponent;

  beforeEach(() => {
    // antes de cada teste criamos o componente para ter seu estado sem interferência de outros testes
    component = new SimpleSwitchButtonComponent();
  });

  it('should start in off state', () => {
    // testamos o estado inicial do componente
    expect(component.state).toBe(ButtonState.OFF);
  });

  // aqui testamos o comportamento de mudar do estado OFF para ON
  it('should turn on when the off state is toggled', () => {
    component.state = ButtonState.OFF;

    component.toggle();

    expect(component.state).toBe(ButtonState.ON);
  });

  // aqui testamos o comportamento de mudar do estado ON para OFF
  it('should turn off when the on state is toggled', () => {
    component.state = ButtonState.ON;

    component.toggle();

    expect(component.state).toBe(ButtonState.OFF);
  });

  // aqui testamos se o texto do botão é exibido corretamente de acordo com o estado
  it('should display the correct label for each state', () => {
    component.state = ButtonState.OFF;
    expect(component.buttonLabel).toBe('Turn on');

    component.state = ButtonState.ON;
    expect(component.buttonLabel).toBe('Turn off');
  });

  // aqui testamos se a cor do botão é exibida corretamente de acordo com o estado
  it('should display the correct color for each state', () => {
    component.state = ButtonState.OFF;
    expect(component.buttonColor).toBe('red');

    component.state = ButtonState.ON;
    expect(component.buttonColor).toBe('green');
  });
});
Enter fullscreen mode Exit fullscreen mode

Teste do Binding do DOM

Em alguns casos, testar somente a classe do componente pode não ser o bastante devido a necessidade de teste dos elementos em si da tela.

Ao testar o componente no DOM podemos fazer um teste mais completo dele, vamos conseguir validar:

  • Renderização dos elementos:
    • o texto é exibido corretamente
    • as formações estão sendo aplicadas
    • as cores esperadas estão sendo aplicadas
  • Interações do usuário:
    • o botão clicado está chamando o método correto
    • a mensagem de feedback está sendo exibida
    • as interações esperadas estão ocorrendo
  • Interações com componentes filhos:
    • a interação do usuário está sendo refletida nos outros componentes
Configuração do Módulo de Teste

Para testar o comportamento do componente no DOM, podemos fazer um configuração completa do teste do componente utilizando a classe TestBed.

A classe TestBed configura o módulo de testes com as configurações e importações básicas necessárias para rodar o teste (como importar o módulo BrowserModule).

// configura o módulo de teste com o nosso componente
TestBed.configureTestingModule({
    declarations: [ SimpleSwitchButtonComponent ],
    // caso nosso componente tenha utilize um service como dependência
    providers: [
      { provide: MyService, useValue: MyMockedService }
    ]
  })
  // compila o componente (html, ts e css)
  .compileComponents();

// cria o componente (o TestBed já adiciona no DOM do nosso test-runner)
const fixture: ComponentFixture<SimpleSwitchButtonComponent> = TestBed.createComponent(SimpleSwitchButtonComponent);
// obtém a instância do componente
const component: SimpleSwitchButtonComponent = fixture.componentInstance;
// dispara o ciclo de vida do componente no Angular
fixture.detectChanges();
Enter fullscreen mode Exit fullscreen mode

A classe instância de ComponentFixture que o TestBed nos retorna é um utilitário para facilitar a interação com o componente criado e todos os seus elementos.

Angular provê duas formas de acessar o elemento do componente:

  • const deElem: DebugElement = fixture.debugElement
  • const naElem: HTMLElement = fixture.nativeElement

nativeElement

O atributo fixture.nativeElement (atalho para fixture.debugElement.nativeElement) depende do ambiente em que está rodando (se é um runner com suporte a API HTML ou não).
Ele somente é definido nos casos em que os testes estão rodando em uma plataforma browser, caso esteja rodando fora dele não será definido visto que não haverá renderização completa (ex.: web worker).

Podemos utilizar a API padrão do HTML no nativeElement:

it('should have <p> with "banner works!"', () => {
  const bannerElement: HTMLElement = fixture.nativeElement;
  const p = bannerElement.querySelector('p')!;
  expect(p.textContent).toEqual('banner works!');
});
Enter fullscreen mode Exit fullscreen mode

debugElement

O Angular fornece a classe DebugElement como abstração do elemento para poder suportar todas as plataformas de forma segura.
Angular cria a árvore do DebugElement que encapsula os elementos nativos da plataforma que está rodando.

it('should have <p> with "banner works!"', () => {
  const p = fixture.debugElement.query(By.css('p'));
  expect(p.nativeElement.textContent).toEqual('banner works!');
});
Enter fullscreen mode Exit fullscreen mode

A classe utilitária By nos ajuda efetuar buscas no métodos de busca do DebugElement que suporte todas as plataformas (browser, server side rendering, etc) e sempre retorna um DebugElement.

Importamos a partir com import { By } from '@angular/platform-browser';.

Data Binding

Note que no teste estamos executando fixture.detectChanges() para que o Angular execute o ciclo de vida (e assim efetue o data binding).
Em alguns casos sempre vamos precisar utilizar o data binding nos testes, então o Angular disponibiliza uma forma de deixar a detecção de mudanças automática para que não precisemos ficar sempre chamando.

O service ComponentFixtureAutoDetect vai sempre disparar o data binding sempre que alguma atividade assíncrona finalizar (como resolução de promise, timers, eventos do DOM, criação do componente).
Para os casos que alterarmos o componente diretamente no teste, ainda vamos precisar chamar o detectChanges.

TestBed.configureTestingModule({
  declarations: [ BannerComponent ],
  providers: [
    { provide: ComponentFixtureAutoDetect, useValue: true }
  ]
});
Enter fullscreen mode Exit fullscreen mode
Testando o Estado e Interações com DOM
// Para Angular 10+, recomenda utilizar a função `waitForAsync` que tem o mesmo comportamento.
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';

import { SimpleSwitchButtonComponent } from './simple-switch-button.component';
import { ButtonState } from './button-state';

describe('SimpleSwitchButtonComponent', () => {
  // criamos as variáveis com os elementos que vamos interagir nos testes
  let component: SimpleSwitchButtonComponent;
  let fixture: ComponentFixture<SimpleSwitchButtonComponent>;

  // utilizamos a função `async` (ou `waitForAsync` no Angular 10+) para aguardar a construção do módulo de teste
  beforeEach(async(() => {
    TestBed.configureTestingModule({
        declarations: [ SimpleSwitchButtonComponent ]
      })
      .compileComponents();

    // criamos o componente que vamos testar
    fixture = TestBed.createComponent(SimpleSwitchButtonComponent);
    component = fixture.componentInstance;
    // já iniciamos o ciclo de vida do Angular
    fixture.detectChanges();
  }));

  // testamos se o componente pode ser construído
  it('should create the component', () => {
    expect(component).toBeTruthy();
  });

  // testamos o estado inicial do componente
  it('should start displaying a button with text `Turn on` and with red color', () => {
    // usamos `By.css` para pesquisar um elemento do componente
    const button: DebugElement = fixture.debugElement.query(By.css('button'));
    // testamos se o botão foi criado
    expect(button).toBeTruthy();

    // testamos o texto do botão (diferentes formas de acessar o texto do elemento)
    expect(button.nativeElement.innerText).toBe('Turn on');
    expect(button.nativeElement.textContent).toBe('Turn on');
    expect(button.nativeNode.innerText).toBe('Turn on');

    // testamos o estilo do botão (diferentes formas de verificar, sempre prefira acessar através de DebugElement)
    expect(button.styles.backgroundColor).toBe('red');
    expect(button.nativeElement.style.backgroundColor).toBe('red');
  });

  // testamos o comportamento do click no botão quando o estado é OFF
  it('should display text `Turn on` and be red when state is off', () => {
    component.state = ButtonState.OFF;

    const button: DebugElement = fixture.debugElement.query(By.css('button'));
    fixture.detectChanges();

    expect(button.nativeElement.innerText).toBe('Turn on');
    expect(button.styles.backgroundColor).toBe('red');
  });

  // testamos o comportamento do click no botão quando o estado é ON
  it('should display text `Turn off` and be green when state is on', () => {
    component.state = ButtonState.ON;

    const button: DebugElement = fixture.debugElement.query(By.css('button'));
    fixture.detectChanges();

    expect(button.nativeElement.innerText).toBe('Turn off');
    expect(button.styles.backgroundColor).toBe('green');
  });

  // testamos o comportamento do click duas vezes (toggle)
  it('should change the button text and color when clicked', () => {
    component.state = ButtonState.OFF;
    const button: DebugElement = fixture.debugElement.query(By.css('button'));

    button.triggerEventHandler('click', null);
    fixture.detectChanges();

    expect(button.nativeElement.innerText).toBe('Turn off');
    expect(button.styles.backgroundColor).toBe('green');

    button.triggerEventHandler('click', null);
    fixture.detectChanges();

    expect(button.nativeElement.innerText).toBe('Turn on');
    expect(button.styles.backgroundColor).toBe('red');
  });
});
Enter fullscreen mode Exit fullscreen mode
Karma e o DOM

Abaixo segue um print do que é exibido no Karma após rodar os testes.
Note que o o botão está renderizado no meio do relatório porque o Karma é o nosso test runner, então é o responsável pela criação e renderização do DOM.

Resultado do teste no Karma

Testando Formulário

O Angular disponibiliza duas formas de criar formulário: Reactive Form e Template Driven.

  • Reactive Form: forma que facilita a escrita dos testes;
  • Template Driven: os testes são um pouco mais verbosos de se escrever por precisar ficar obtendo instância de cada campo.

Neste exemplo, testaremos um formulário template-driven que envia contato para uma API qualquer.
O formulário de contato envia uma mensagem para a API, se tudo ocorrer bem exibe uma mensagem de sucesso em verde e se der algum erro exibe mensagem de erro em vermelho.

Formulário que será testado

Código do Componente e do Service

Arquivo contact-form.component.html:

<form #contactForm="ngForm" (ngSubmit)="sendContact()">
  <fieldset>
    <div class="field">
      <label>Full name</label>
      <input type="text" name="name" [(ngModel)]="contact.name" required pattern="\w+ \w+">
    </div>

    <div class="field">
      <label>E-mail</label>
      <input type="email" name="email" [(ngModel)]="contact.email"
        required pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$"
      />
    </div>

    <div class="field">
      <label>Subject</label>
      <input type="text" name="subject" [(ngModel)]="contact.subject" required>
    </div>

    <div class="field">
      <label>Message</label>
      <textarea name="message" [(ngModel)]="contact.message" required></textarea>
    </div>

    <div>
      <button type="submit" [disabled]="!contactForm.form.valid">Send</button>
    </div>

    <div *ngIf="hasMessageToDisplay">
      <p class="feedback-message" [class.success]="!errorOccurred" [class.error]="errorOccurred">{{ message }}</p>
    </div>
  </fieldset>
</form>
Enter fullscreen mode Exit fullscreen mode

Arquivo contact-form.component.css:

label { display: block; }

input, textarea { min-width: 250px; }

.feedback-message {
  font-size: 1.1em;
  font-weight: bold;
}

.feedback-message.success { color: green; }

.feedback-message.error { color: red; }
Enter fullscreen mode Exit fullscreen mode

Arquivo contact-form.component.ts:

import { Component } from '@angular/core';

import { ContactService } from './contact.service';

@Component({
  selector: 'app-contact-form',
  templateUrl: './contact-form.component.html',
  styleUrls: ['./contact-form.component.css']
})
export class ContactFormComponent {
  contact = {
    name: '',
    email: '',
    subject: '',
    message: ''
  };
  errorOccurred: boolean = false;
  message: string = null;

  constructor(private _contactService: ContactService) { }

  get hasMessageToDisplay(): boolean {
    return !!this.message;
  }

  private showSuccessMessage(message: string) {
    this.errorOccurred = false;
    this.message = message;
  }

  private showErrorMessage(message: string) {
    this.errorOccurred = true;
    this.message = message;
  }

  sendContact() {
    this._contactService.sendContact(this.contact)
      .subscribe(
        result => this.showSuccessMessage('Your message has been sent!'),
        err => this.showErrorMessage('An error occurred while sending your message.')
      );
  }
}
Enter fullscreen mode Exit fullscreen mode

Arquivo contact.service.ts:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class ContactService {
  constructor(private _http: HttpClient) { }

  sendContact(contact: any): Observable<any> {
    return this._http
      .post('https://httpbin.org/post', contact)
      .pipe(map(result => 'OK'));
  }
}
Enter fullscreen mode Exit fullscreen mode

Testando o Formulário

Arquivo de teste contact-form.component.spec.ts:

import { async, ComponentFixture, ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing';
import { DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { of, throwError } from 'rxjs';

import { ContactFormComponent } from './contact-form.component';
import { ContactService } from './contact.service';

describe('ContactFormComponent', () => {
  let mockedService: jasmine.SpyObj<ContactService>;

  let fixture: ComponentFixture<ContactFormComponent>;
  let component: ContactFormComponent;

  let inputName: DebugElement;
  let inputEmail: DebugElement;
  let inputSubject: DebugElement;
  let inputMessage: DebugElement;
  let buttonSubmit: DebugElement;

  beforeEach(async(() => {
    mockedService = jasmine.createSpyObj('ContactService', ['sendContact']);

    TestBed.configureTestingModule({
        imports: [ FormsModule ],
        declarations: [ ContactFormComponent ],
        providers: [
          // usamos o auto detect para facilitar algumas interações com formulário e RxJS
          { provide: ComponentFixtureAutoDetect, useValue: true },
          { provide: ContactService, useValue: mockedService }
        ]
      })
      .compileComponents();

    fixture = TestBed.createComponent(ContactFormComponent);
    component = fixture.componentInstance;

    // obtemos as instâncias dos inputs que iremos manipular
    inputName = fixture.debugElement.query(By.css('input[name=name]'));
    inputEmail = fixture.debugElement.query(By.css('input[name=email]'));
    inputSubject = fixture.debugElement.query(By.css('input[name=subject]'));
    inputMessage = fixture.debugElement.query(By.css('textarea[name=message]'));
    buttonSubmit = fixture.debugElement.query(By.css('button[type=submit]'));
  }));

  // função auxiliar para preencher o form com dados válidos
  function fillValidContactInfo() {
    // define o texto do input
    inputName.nativeElement.value = 'John Doe';
    // dispara o evento de input simulando o usuário
    inputName.nativeElement.dispatchEvent(new Event('input'));

    inputEmail.nativeElement.value = 'john.doe@server.com';
    inputEmail.nativeElement.dispatchEvent(new Event('input'));

    inputSubject.nativeElement.value = 'Test subject';
    inputSubject.nativeElement.dispatchEvent(new Event('input'));

    inputMessage.nativeElement.value = 'Test message';
    inputMessage.nativeElement.dispatchEvent(new Event('input'));
  }

  // testa se todos os campos foram criados corretamente
  it('should create the component with contact fields visible and disabled button', () => {
    expect(component).toBeTruthy();

    expect(inputName).toBeTruthy();
    expect(inputEmail).toBeTruthy();
    expect(inputSubject).toBeTruthy();
    expect(inputMessage).toBeTruthy();

    expect(buttonSubmit).toBeTruthy();
    // verificarmos se o botão inicia desabilitado (atributo `DebugElement.properties` com os atributos do elemento)
    expect(buttonSubmit.properties.disabled).toBe(true);
  });

  // testa se o botão será habilitado caso preencha o form com dados válidos
  it('should accept valid inputs and bind to model', () => {
    fillValidContactInfo();

    // verificamos se os inputs estão linkados corretamente na model esperada do componente
    expect(component.contact.name).toBe('John Doe');
    expect(component.contact.email).toBe('john.doe@server.com');
    expect(component.contact.subject).toBe('Test subject');
    expect(component.contact.message).toBe('Test message');

    // verificamos se o botão foi habilitado para o usuário
    expect(buttonSubmit.properties.disabled).toBe(false);
  });

  // testa se o botão será desabilitado caso preencha um e-mail inválido
  it('should not allow sent e-mail', () => {
    fillValidContactInfo();

    // atualizamos apenas o campo que queremos invalidar
    inputEmail.nativeElement.value = 'invalid.mail@mailcom';
    inputEmail.nativeElement.dispatchEvent(new Event('input'));

    expect(buttonSubmit.properties.disabled).toBe(true);
  });

  // testa se permite enviar o formulário após preencher com dados válidos
  it('should allow send contact with valid info', () => {
    // aqui espiamos o método `sendContact` do form para ver se ele foi chamado
    // e também configuramos para seguir sua implementação real (já que queremos ver se tudo foi chamado corretamente)
    spyOn(component, 'sendContact').and.callThrough();
    // aqui mockamos o método `sendContact` da nossa service para retornar um OK
    mockedService.sendContact.and.returnValue(of('OK'));

    fillValidContactInfo();

    // recuperarmos o formulário para disparar o evento de submit
    const form = fixture.debugElement.query(By.css('form'));
    form.triggerEventHandler('submit', {});
    // dispara o ciclo de vida para a tela refletir o resultado da chamada
    fixture.detectChanges();

    // verificamos se o método do componente e da service foram chamados
    expect(component.sendContact).toHaveBeenCalled();
    // além de verificar se foi chamado, também vale a pena testar se a model foi passada corretamente (igual fizemos na service no post anterior)
    expect(mockedService.sendContact).toHaveBeenCalled();

    // recuperamos o elemento de mensagem de feedback para verificar se está exibindo o caso de sucesso
    const message = fixture.debugElement.query(By.css('p.feedback-message'));
    expect(message).toBeTruthy();
    expect(message.nativeElement.textContent).toBe('Your message has been sent!');
    // verificamos se a classe CSS foi aplicado corretamente
    expect(message.classes['success']).toBe(true);
  });

  // testa se exibe o feedback da mensagem de erro
  it('should show error when it is thrown', () => {
    // aqui repetimos o spy para chamar o método da service
    spyOn(component, 'sendContact').and.callThrough();
    // mockamos um retorno de erro
    mockedService.sendContact.and.returnValue(throwError('Error for testing'));

    fillValidContactInfo();

    const form = fixture.debugElement.query(By.css('form'));
    form.triggerEventHandler('submit', {});
    fixture.detectChanges();

    expect(component.sendContact).toHaveBeenCalled();
    expect(mockedService.sendContact).toHaveBeenCalled();

    // recuperamos o elemento de mensagem para verificar se a mensagem de erro foi exibida
    const message = fixture.debugElement.query(By.css('p.feedback-message'));
    expect(message).toBeTruthy();
    expect(message.nativeElement.textContent).toBe('An error occurred while sending your message.');
    expect(message.classes['error']).toBe(true);
  });
});
Enter fullscreen mode Exit fullscreen mode

No próximo post iremos testar componentes com @Input e @Output, e a interação entre diferentes componentes.

Discussion (0)