DEV Community

Cover image for Buenas prácticas con Angular Testing Library
ng-content
ng-content

Posted on • Updated on • Originally published at ng-content.com

Buenas prácticas con Angular Testing Library

Traducción en español del artículo original de Tim Deschryver Good testing practices with 🦔 Angular Testing Library publicado el 17 marzo 2022

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.

ANGULAR TESTING LIBRARY

Angular Testing Library is parte de la familia @testing-library con DOM Testing Library como parte principal. Nosotros estamos fomentamos las buenas prácticas de testing a través de los múltiples frameworks y librerías, brindado una API similar para todos ellos. Los tests puede ser escrito en el test runner que prefieras.

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 aplicacion 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
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
<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>
Enter fullscreen mode Exit fullscreen mode

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.

import { render } from '@testing-library/angular';

it('should render the form', async () => {
  await render(FeedbackComponent);
});
Enter fullscreen mode Exit fullscreen mode

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]
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

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
      }
    }
  );
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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 funciones getByLabelText y queryByLabelText nos permite buscar usando aria-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 input 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 queriesy 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');
Enter fullscreen mode Exit fullscreen mode

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) {}
}
Enter fullscreen mode Exit fullscreen mode

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;
    }, {})
  );
});
Enter fullscreen mode Exit fullscreen mode

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
  };
}
Enter fullscreen mode Exit fullscreen mode

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.

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.

Photo by Bambi Corro on Unsplash

Top comments (1)

Collapse
 
victorjsv profile image
Victor Sandoval Valladolid

Gran artículo. Muchas gracias por tu tiempo.

Una consulta. Has probado fireEvent en un componente mat-select?
Segun este issue github.com/testing-library/angular..., no se puede usar userEvent.selectOptions.

Has probado este caso?