Angular Testing Library nos brinda un varias funciones para interactuar con los componentes de Angular, de la misma manera en que el usuario interactúa con él. Esto nos brinda mayor mantenibilidad a nuestro test, nos da más confianza a nosotros, ya que los componentes hacen lo que se supone que tiene que hacer, esto mejora la accesibilidad, lo cual es mejor para los usuarios. Además de todos esos beneficios, tú podrás ver que divertido es escribir test en esta forma.
Traducción en español del artículo original de Tim Deschryver [Good testing practices with 🦔 Angular Testing Library] (https://timdeschryver.dev/blog/good-testing-practices-with-angular-testing-library/) publicado el 17 marzo 2022
ANGULAR TESTING LIBRARY
Angular Testing Library is parte de la familia @testing-library con DOM Testing Library como parte principal.
Nosotros fomentamos:
- test mantenibles: Nosotros no queremos testear los detalles de implementación.
- confianza en nuestros componentes: Tú interactúas con los componentes de la misma manera como tus usuarios finales.
- accesibilidad: Nosotros queremos componentes inclusivos tomando en cuenta la accesibilidad.
Mientras mas tus tests se asemejen a la forma que tu aplicación es usada, mas confianza ellos te brindaran a ti.
Primeros pasos
Para empezar, el primer paso es instalar @testing-library/angular , con eso ya estamos listo para empezar.
npm install --save-dev @testing-library/angular
En este artículo, nosotros tomaremos como inicio escribir los test para un formulario de feedback, empezando desde lo más básico y continuaremos trabajando sobre él.
El formulario que le realizaremos los tests ha de tener un campo de nombre requerido, un campo de rating requerido con un rango entre 0 y 10, además de un select
para elegir el tamaño del t-shirt
.
Un formulario no es un formulario, si no contiene un botón de enviar, vamos a agregar esto también.
El código de nuestro formulario se ve de la siguiente manera.
export class FeedbackComponent { @Input() shirtSizes: string[] = []; @Output() submitForm = new EventEmitter<Feedback>(); form = this.formBuilder.group({ name: ['', [Validators.required]], rating: ['', [Validators.required, Validators.min(0), Validators.max(10)]], description: [''], shirtSize: ['', [Validators.required]] }); nameControl = this.form.get('name'); ratingControl = this.form.get('rating'); shirtSizeControl = this.form.get('shirtSize'); constructor(private formBuilder: FormBuilder) {} submit() { if (this.form.valid) { this.submitForm.emit(this.form.value); } } } <form [formGroup]="form" (ngSubmit)="submit()"> <legend>Feedback form</legend> <mat-form-field> <mat-label>Name</mat-label> <input matInput type="text" formControlName="name" /> <mat-error *ngIf="nameControl.hasError('required')"> Name is required </mat-error> </mat-form-field> <mat-form-field> <mat-label>Rating</mat-label> <input matInput type="number" formControlName="rating" /> <mat-error *ngIf="ratingControl.hasError('required')"> Rating is required </mat-error> <mat-error *ngIf="ratingControl.hasError('min') || ratingControl.hasError('max')"> Rating must be between 0 and 10 </mat-error> </mat-form-field> <mat-form-field> <mat-label>Description</mat-label> <textarea matInput formControlName="description"></textarea> </mat-form-field> <mat-form-field> <mat-label>T-shirt size</mat-label> <mat-select placeholder="Select" formControlName="shirtSize"> <mat-option *ngFor="let size of shirtSizes" [value]="size">{{ size }}</mat-option> </mat-select> <mat-error *ngIf="shirtSizeControl.hasError('required')"> T-shirt size is required </mat-error> </mat-form-field> <button type="submit" mat-stroked-button color="primary">Submit your feedback</button> </form>
NUESTRO PRIMER TEST
Para poder testear nuestro formulario de feedback, debemos poder renderizar el mismo, nosotros podemos hacer esto utilizando la función render.
La función render toma el componente que haremos el test como primer argumento y opcionalmente un segundo argumento para más opciones RenderOptions
, del cual hablaremos pronto.
Esto no debe tener mas nada si queremos testear un componente simple
Pero en nuestro caso, esto lanza una excepción porque nosotros estamos usando reactive forms y algunos componentes de Angular material. Para resolverlo nosotros debemos proveer los dos módulos faltantes. Para darles acceso a esos módulos utilizamos la propiedad imports
en el objeto de renderOptions, muy similar como TestBed.configureTestingModule
lo hace.
import { render } from '@testing-library/angular';
it('should render the form', async () => { await render(FeedbackComponent, { imports: [ReactiveFormsModule, MaterialModule] }); });
Ahora nuestro test funciona.
QUERIES
La función render
retorna un objeto de tipo RenderResult el cual contiene diferentes funciones para testear el componente.
Te darás cuenta de que nosotros testearemos nuestro componente de la misma manera en que el usuario final lo hace. Nosotros no vamos a testear la implementación en detalles, aunque Angular Testing Library
nos brinda una API para hacer test al componente desde fuera usando los DOM Nodes.
Para verificar los nodos de la manera que el usuario final lo hace, nosotros utilizamos querys las cuales están disponibles cuando renderizamos el componente.
Un query busca por el un texto ( como un string or una expression regular) in el componente, como primer argumento de query. El segundo argumento opcional es TextMatch.
En nuestro test, para comprobar que el formulario está renderizado con el título correcto, nosotros podemos usar el query getByText
. Para utilizar el este query, debemos importar el objeto screen primero, piensa que este objeto screen es como el usuario ve nuestro componente y contiene el DOM de la página.
import { render, screen } from '@testing-library/angular'; it('should render the form', async () => { await render(FeedbackComponent, { imports: [ReactiveFormsModule, MaterialModule] }); screen.getByText(/Feedback form/i); });
En el ejemplo anterior no vemos ninguna validación, esto es porque el getBy
y getAllBy
querys lanzan un error cuando el query puede encontrar el texto en el documento.
Si no quieres que Angular Testing Library lance un error, nosotros podemos utilizar queryBy
y queryAllBy
, estas retorna null
si los elementos no son encontrados.
Cuando nuestro código es asíncrono, es también posible esperar un momento hasta que los elementos son visibles o durante un tiempo de espera. Si quieres hacer test a código asíncrono, las debes usar las funciones findByText
y findAllByTest
.
Antes de cada verificación, si un elemento es visible, Angular Testing Library lanzar el change detection.
import { render, screen } from '@testing-library/angular'; it('should render the form', async () => { await render(FeedbackComponent, { imports: [ReactiveFormsModule, MaterialModule] }); await screen.findByText(/Feedback form/i); });
ASIGNANDO PROPIEDADES @INPUT Y @OUTPUT
Con nuestro componente ya renderizado, el siguiente paso es asignar que necesita nuestras propiedades de tipo @Input()
y @Output()
, para esto nosotros usamos componentProperties
desde el objeto renderOptions
.
En el caso del componente feedback, nosotros asignaremos un listado de tamaños de t-shirt a la propiedad @shirtSizes
y haremos un spy
de submitForm
, para luego más tarde verificar el envío del formulario.
import { render } from '@testing-library/angular';
it('form should display error messages and submit if valid', async () => { const submitSpy = jest.fn(); await render(FeedbackComponent, { imports: [ReactiveFormsModule, MaterialModule], componentProperties: { shirtSizes: ['XS', 'S', 'M', 'L', 'XL', 'XXL'], submitForm: { // Como la salida es un `EventEmitter` debemos //simular `emit`, ya que componente usa `output.emit` para //interactuar con el componente padre emit: submitSpy } as any } }); });
Otra forma de hacerlo es utilizando como una declaración y este envuelve el componente en un componente host.
import { render } from '@testing-library/angular';
it('form should display error messages and submit if valid', async () => { const submitSpy = jest.fn(); await render( '<feedback-form [shirtSizes]="shirtSizes" (submitForm)="submit($event)"></feedback-form>', { declarations: [FeedbackComponent], imports: [ReactiveFormsModule, MaterialModule], componentProperties: { shirtSizes: ['XS', 'S', 'M', 'L', 'XL', 'XXL'], submit: submitSpy } } );});
En este paso ya estamos listo para escribir nuestros tests.
EVENTOS
Hasta el momento hemos visto como testear nuestro componente renderizado con las funciones que provee query, pero nos falta poder interactuar. Nosotros podemos interactuar lanzando eventos. Muy similar a las funciones query, estos eventos están también disponibles cuando se renderiza el componente.
Para saber toda la lista de los eventos soportados, puedes mira el codigo fuente. En este articulo solo usaremos los necesarios para testear el componente feedback pero todos los eventos tiene una API similar.
El primer argumento de un evento es el nodo del DOM, el segundo parámetro opcional es para facilitar información extra al evento. Un ejemplo es cuál botón de mouse fue presionado o el texto en un input.
Nota importante: Un evento lanzará el change detection llamando detectChanges() después que se lanza.
HACER CLIC EN ELEMENTOS
Para hacer clic en un elemento utilizamos fireEvent y el método click.
import { render, screen, fireEvent } from '@testing-library/angular';
it('form should display error messages and submit if valid', async () => { const submitSpy = jest.fn(); await render( '<feedback-form [shirtSizes]="shirtSizes" (submitForm)="submit($event)"></feedback-form>', { declarations: [FeedbackComponent], imports: [ReactiveFormsModule, MaterialModule], componentProperties: { shirtSizes: ['XS', 'S', 'M', 'L', 'XL', 'XXL'], submit: submitSpy } } ); const submit = screen.getByText(/Submit your feedback/i); fireEvent.click(submit); expect(submitSpy).not.toHaveBeenCalled();});
Nosotros somos capaces de hacer clic en el botón submit, podemos verificar que el formulario no se ha enviado porque es inválido.
Nosotros también podemos usar el segundo parámetro(las opciones son la representación de las opciones de un clic en Javascript) para lanzar un clic derecho.
fireEvent.click(submit, { button: 2 });
COMPLETANDO LOS CAMPOS INPUTS
Para que nuestro formulario sea válido, nosotros tenemos que llenar los campos de tipo input y para eso podemos usar varios eventos y userEvent
de '@testing-library/user-event'
.
import { render, screen, fireEvent } from '@testing-library/angular';import userEvent from '@testing-library/user-event';it('form should display error messages and submit if valid', async () => { const submitSpy = jest.fn(); await render( '<feedback-form [shirtSizes]="shirtSizes" (submitForm)="submit($event)"></feedback-form>', { declarations: [FeedbackComponent], imports: [ReactiveFormsModule, MaterialModule], componentProperties: { shirtSizes: ['XS', 'S', 'M', 'L', 'XL', 'XXL'], submit: submitSpy } } ); const name = screen.getByLabelText(/name/i); const rating = screen.getByLabelText(/rating/i); const description = screen.getByLabelText(/description/i); const shirtSize = screen.getByLabelText(/t-shirt size/i); const submit = screen.getByText(/submit your feedback/i); const inputValues = { name: 'Tim', rating: 7, description: 'I really like @testing-library ♥', shirtSize: 'M' }; fireEvent.click(submit); expect(submitSpy).not.toHaveBeenCalled();// para llenar el input de nombre con el evento `input` pasamos un segundo argumento con el valor que deseamos , esto es muy similar al api de Javascript. fireEvent.input(name, { target: { value: inputValues.name } }); // una forma más fácil de lograr el mismo resultado es usar el evento `type` de userEvent userEvent.type(rating, inputValues.rating.toString()); userEvent.type(description, inputValues.description); // para seleccionar un valor del select, primero tenemos que hacer clic en el, antes de hacer clic en la opción. userEvent.click(shirtSize); userEvent.click(screen.getByText('L')); // una forma más fácil de seleccionar opciones es usar el evento `selectOptions` userEvent.selectOptions(shirtSize, inputValues.shirtSize); userEvent.click(submit); // nuestro formulario es válido, por lo que ahora podemos verificar que ha sido llamado con el valor del formulario expect(submitSpy).toHaveBeenCalledWith(inputValues);});
Al igual que antes, podemos obtener nuestros campos de formulario mediante queries. Esta vez obtenemos los campos del formulario por su label, esto tiene el beneficio de que estamos creando formularios accesibles.
Las funcionesgetByLabelText
yqueryByLabelText
nos permite buscar usandoaria-labels
para encontrar los elementos.
En el ejemplo anterior, vemos que hay dos diferentes APIS para llenar el input. La primera es usando el metodo imput y la segunda con el método type de userEvent.
La diferencia entre las dos APIS es que input laza el evento input para asignar el valor.
Mientras type de userEvent replica los mismos eventos de un usuario final para interactuar y llenar el campo. Esto quiere decir que el input recibe varios eventos como keydown
y keyup
. Además, que la API de userEvent es más fácil leer y trabajar con ella, por estas dos razones es recomendable utilizar userEvent para interactuar con los componentes en tus tests.
CONTROLES INVÁLIDOS
Hasta el momento nosotros hemos trabajado con el componente, pero como nosotros podemos testear los mensajes de validación? Hemos visto como verificar que nuestro componente se renderizó con queries
y hemos interactuado con el componente lanzado eventos, esto significa que tenemos todas las herramientas para verificar los controles inválidos en el formulario.
Si dejamos en blanco un campo, podemos ver que mensaje de validación. Algo como el siguiente:
userEvent.type(name, '');screen.getByText('Name is required');expect(name.getAttribute('aria-invalid')).toBe('true');userEvent.type(name, 'Bob');expect(screen.queryByText('Name is required')).toBeNull();expect(name.getAttribute('aria-invalid')).toBe('false');userEvent.type(rating, 15);screen.queryByText('Rating must be between 0 and 10');expect(rating.getAttribute('aria-invalid')).toBe('true');userEvent.type(rating, inputValues.rating);expect(rating.getAttribute('aria-invalid')).toBe('false');
Gracias a que query retorna un nodo del DOM, nosotros usar ese nodo para verificar si es valido o invalido.
USANDO COMPONENTES CONTENDORES Y COMPONENTES HIJOS
Nuestro test es solo del componente feedback, el cual es un solo y para algunos escenarios esto puede ser bueno, pero muchas veces yo soy de los que opina que este tipo de tests no aportan valor.
Lo que me gusta hacer es probar componentes de contenedores. Debido a que un contenedor consta de uno o más componentes, estos componentes también se probarán durante la prueba del contenedor. De lo contrario, normalmente terminará con la misma prueba dos veces y con el doble del trabajo de mantenimiento.
Para simplificar, envolvemos el componente de formulario en un contenedor. El contenedor tiene un servicio inyectado para proporcionar el listado de t-shirt size y el servicio también tiene la función submit.
@Component({ selector: 'feedback-container', template: ` <feedback-form [shirtSizes]="service.shirtSizes$ | async" (submitForm)="service.submit($event)" ></feedback-form> `})export class FeedbackContainer { constructor(public service: FeedbackService) {}}
En el test para el FeedbackContainer
tenemos que declarar el feedbackComponent
y hacer un provide FeedbackService con un stub. Para hacerlo usamos una API muy similar a TestBed.configureTestingModule
usamos declarations y providers en el RenderOptions
.
Además de la configuración, nuestro test se ve igual. En siguiente test, prefiero escribir el test de una manera más compacta, lo que me resulta útil para formularios más grandes.
import { render, screen, fireEvent } from '@testing-library/angular';import userEvent from '@testing-library/user-event';it('form should display error messages and submit if valid (container)', async () => { const submitSpy = jest.fn(); await render(FeedbackContainer, { declarations: [FeedbackComponent], imports: [ReactiveFormsModule, MaterialModule], providers: [ { provide: FeedbackService, useValue: { shirtSizes$: of(['XS', 'S', 'M', 'L', 'XL', 'XXL']), submit: submitSpy } } ] }); const submit = screen.getByText('Submit your feedback'); const inputValues = [ { value: 'Tim', label: /name/i, name: 'name' }, { value: 7, label: /rating/i, name: 'rating' }, { value: 'I really like @testing-library ♥', label: /description/i, name: 'description' }, { value: 'M', label: /T-shirt size/i, name: 'shirtSize' } ]; inputValues.forEach(({ value, label }) => { const control = screen.getByLabelText(label); if (control.tagName === 'MAT-SELECT') { userEvent.selectOptions(control, value.toString()); } else { userEvent.type(control, value.toString()); } }); userEvent.click(submit); expect(submitSpy).toHaveBeenCalledWith( inputValues.reduce((form, { value, name }) => { form[name] = value; return form; }, {}) );});
TIPS ESCRIBIR TESTS
USA CYPRESS TESTING LIBRARY PARA TEST END2END CON CYPRESS
Cypress testing library es parte de @testing-library
, esta utiliza la misma API usando cypress. Esta libreria exporta las mismas funciones y utilidades de DOM Testing Library
como funciones de Cypress.
Si quieres saber más puedes leer @testing-library/cypress.
USA @TESTING-LIBRARY/JEST-DOM PARA HACER LOS TEST MAS FACIL DE LEER.
Esto solo aplica si utilizas Jest como test runner. Esta librería tiene varias funciones utilitarias como toBeValid(), toBeVisible(), toHaveFormValues()
y muchas más.
Puedes encontrar más ejemplos en @testing-library/jest-dom.
ELIJE ESCRIBIR UN TEST EN VEZ DE MULTIPLES
Como te has dado cuenta en los ejemplos usando en este artículo, todos son parte de solo test. Esto va en contra de un principio popular de que únicamente debe tener una assert para un test. Yo por lo general tengo un it
que contiene el caso y varias assert en el tests.
Piense en un test case escenario incluye varios sus casos de test e incluye varias partes en el mismo. Esto a menudo da como resultado múltiples acciones y asserts, lo cual está bien.
Si quieres entender más sobre esta práctica te recomiendo el artículo (en ingles) Write fewer, longer tests by Kent C. Dodds.
NO USES BEFOREACH
Usar beforeEach
puede ser útil para ciertos tests, pero en la mayoría de los casos, prefiero usar una función de por ejemplo llamada setup más simple. Lo encuentro más legible, además es más flexible si desea usar una configuración diferente en varios tests, por ejemplo:
it('should show the dashboard for an admin', () => { const { handleClick } = setup({ name: 'Tim', roles: ['admin'] });});it('should show the dashboard for an employee', () => { const { handleClick } = setup({ name: 'Alicia', roles: ['employee'] });});async function setup(user, handleClick = jest.fn()) { const component = await render(DashboardComponent, { componentProperties: { user, handleClick } }); return { handleClick };}
CÓDIGOS DE EJEMPLO
El código del artículo está disponible en Github
Como ya sabemos como consultar los componentes renderizados usando los queries y como lanzar eventos, tenemos todo listo para probar sus componentes. La única diferencia entre el test de esta publicación y otros ejemplos de test es en la forma de configurar el render con la función de setup, pero puedes ver más ejemplos en el repositorio de Angular Testing Library.
Aqui una lista de varios de los ejemplos.
- Componente sin dependencias
- Componentes anidados
- @Input() y @Output()
- Formulario simple
- Formulario con Angular Material
- Componente con un provider
- Componente con NgRx
- Componente con NgRx MockStore
- Testing de una directiva
- Testing de navegacion con Router
- Testing de Injection Token como dependecia
- Puedes crear un ticket si lo que necesitas no esta en la lista
Thanks @timdeschryver
Opinión personal
Las siguientes lineas no son parte del post original.
En mi caso personal he adoptado testing library en angular como la forma de hacer testing de mis componentes, esto no quita que haga test unitarios de mis servicios usando jest.
Testing library me ha permitido hacer test de comportamiento asegurando que el componente funciona tal cual se espera, no solo los métodos sino también su comportamiento con el usuario.
Este artículo me ayudo muchísimo adoptar testing library y espero que te sirva a ti también.