No post anterior falamos sobre os testes de Pipes e Services.
Outros posts da série:
- Parte 1 - Introdução aos Testes
- Parte 2 - Testes de Pipes e Services
- Parte 3 - Testes de Componentes
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>
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;
  }
}
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');
  });
});
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();
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!');
});
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!');
});
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 }
  ]
});
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');
  });
});
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.
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.
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>
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; }
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.')
      );
  }
}
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'));
  }
}
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);
  });
});
No próximo post iremos testar componentes com @Input e @Output, e a interação entre diferentes componentes.
 


 
    
Top comments (0)