Buenas prácticas con Angular Testing Library
Tags: angular, javascript, beginners, webdev
Published: 2022-04-08 07:39:00

> 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](https://testing-library.com/docs/angular-testing-library/intro/) 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](https://testing-library.com/) con [DOM Testing Library](https://testing-library.com/docs/dom-testing-library/intro/) 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.

```bash
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.

```typescript
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);
    }
  }
}
```

```html
<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.

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

it('should render the form', async () => {
  await render(FeedbackComponent);
});
```

> 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.

```typescript
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](https://testing-library.com/docs/angular-testing-library/api/#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](https://testing-library.com/docs/queries/about/) 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](https://testing-library.com/docs/queries/about/#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.

```typescript
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. 

```typescript
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.

```typescript
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.

```typescript
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](https://testing-library.com/docs/dom-testing-library/api-events/). 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](https://github.com/testing-library/dom-testing-library/blob/main/src/events.js). 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.

```typescript
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.


```typescript
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'`.

```typescript
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 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](https://testing-library.com/docs/ecosystem-user-event/).

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:

```typescript
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.


```typescript
@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.

```typescript
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](https://github.com/testing-library/cypress-testing-library).


### 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](https://github.com/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](https://kentcdodds.com/blog/write-fewer-longer-tests).

### 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:

```typescript
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](https://github.com/timdeschryver/atl-good-testing-practices)

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](https://github.com/testing-library/angular-testing-library/tree/main/apps/example-app/src/app/examples).

Aqui una lista de varios de los ejemplos.
- [Componente sin dependencias](https://github.com/testing-library/angular-testing-library/blob/main/apps/example-app/src/app/examples/00-single-component.spec.ts)
- [Componentes anidados](https://github.com/testing-library/angular-testing-library/blob/main/apps/example-app/src/app/examples/01-nested-component.spec.ts)
- [@Input() y @Output()](https://github.com/testing-library/angular-testing-library/blob/main/apps/example-app/src/app/examples/02-input-output.spec.ts)
- [Formulario simple](https://github.com/testing-library/angular-testing-library/blob/main/apps/example-app/src/app/examples/03-forms.spec.ts)
- [Formulario con Angular Material](https://github.com/testing-library/angular-testing-library/blob/main/apps/example-app/src/app/examples/04-forms-with-material.spec.ts)
- [Componente con un provider](https://github.com/testing-library/angular-testing-library/blob/main/apps/example-app/src/app/examples/05-component-provider.spec.ts)
- [Componente con NgRx](https://github.com/testing-library/angular-testing-library/blob/main/apps/example-app/src/app/examples/06-with-ngrx-store.spec.ts)
- [Componente con NgRx MockStore](https://github.com/testing-library/angular-testing-library/blob/main/apps/example-app/src/app/examples/07-with-ngrx-mock-store.spec.ts)
- [Testing de una directiva](https://github.com/testing-library/angular-testing-library/blob/main/apps/example-app/src/app/examples/08-directive.spec.ts)
- [Testing de navegacion con Router](https://github.com/testing-library/angular-testing-library/blob/main/apps/example-app/src/app/examples/08-directive.spec.ts)
- [Testing de Injection Token como dependecia](https://github.com/testing-library/angular-testing-library/blob/main/apps/example-app/src/app/examples/09-router.spec.ts)
- [Puedes crear un ticket si lo que necesitas no esta en la lista](https://github.com/testing-library/angular-testing-library/issues/new)

> 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 <a href="https://unsplash.com/@bambicorro?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Bambi Corro</a> on <a href="https://unsplash.com/s/photos/picking?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a>