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)