DEV Community

Wesley Egberto
Wesley Egberto

Posted on • Edited on

4 2

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.

AWS Security LIVE!

Tune in for AWS Security LIVE!

Join AWS Security LIVE! for expert insights and actionable tips to protect your organization and keep security teams prepared.

Learn More

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs