Con frecuencia escucho que es difícil saber qué probar o hacer tests un componente en Angular. Esta queja a menudo se menciona junto con el hecho de que lleva mucho tiempo escribir y mantener hacer tests además que brindan poco o ningún valor. Al final, el equipo se pregunta si los tests valen la pena.

He estado en esta situación antes y hay dos síntomas para llegar a este punto. Casi no tiene tests, o al contrario, el código está inflado con tests que lo ralentizan. Ambas opciones no son buenas.

Traducción en español del artículo original de Tim Deschryver Getting the most value out of your Angular Component Tests publicado el 21 marzo 2022

En esta publicación, quiero compartir cómo creo que podemos obtener el máximo valor de nuestros tests. Pero, ¿qué es un test que aporte valor? Para mí, significa que el test puede prevenir un error en mi código (¡un poco obvio!). Pero también que el costo de escribir un test no obstaculice el proceso de desarrollo, ahora o en el futuro. En otras palabras, el test no tiene que sentirse como una tarea para escribir. En cambio, el test debe ser fácil de leer y debe ayudarme a enviar nuevas funciones con confianza.

Para lograr esto, quiero imitar de al usuario que usa mi aplicación. También significa que se hace lo más similar a él, porque ¿de qué otra manera podemos asegurar que la aplicación funciona como se espera?

Para ayudarme a escribir estos tests, estoy usando Testing library para Angular. Cuando usa Testing library, solo necesita el método render y el objeto screen para probar los aspectos básicos de nuestro componente. Para las interacciones con el componente, también uso userEvent de [@testing-library/user-event](https://testing-library.com/docs/ecosystem-user-event/).

Echemos un vistazo al primer test de un componente simple llamado EntitiesComponent. El componente contiene una colección de entidades y se encarga de mostrar las entidades en una tabla.

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

it('renders the entities', async () => {
  await render(EntitiesComponent);

  expect(screen.getByRole('heading', { name: /Entities Title/i })).toBeDefined();

  // Uso los matchers de  @testing-library/jest-dom
  // para hacerlos fácil de leer
  // ejemplo remplazo `toBeDefined` con `toBeInTheDocument`
  expect(screen.getByRole('cell', { name: /Entity 1/i })).toBeInTheDocument();
  expect(screen.getByRole('cell', { name: /Entity 2/i })).toBeInTheDocument();
  expect(screen.getByRole('cell', { name: /Entity 3/i })).toBeInTheDocument();
});

Aquí puede ver el primer uso del objeto screen. Puede pensar en screen como la pantalla real que vería un usuario final (los nodos del DOM), que contiene múltiples [querys](https://testing-library.com/docs/queries/about/#types-of-queries) para verificar que el componente se presenta correctamente. La query más importante es byRole, le permite seleccionar el elemento tal como lo haría un usuario (o lector de pantalla). Debido a esto, tiene el beneficio adicional de hacer que sus componentes sean más accesibles.

SUGERENCIA: use screen.debug() para registrar el HTML en la consola, o use screen.logTestingPlaygroundURL() un link interactivo para saber que selectores usar. Por ejemplo, la aplicación de ejemplo que se usa en este artículo está disponible con este vínculo de play ground,  el PlayGround nos permite usar el selector correcto.
CPU
1 vCPU
MEMORIA
1 GB
ALMACENAMIENTO
10 GB
TRANSFERENCIA
1 TB
PRECIO
$ 4 mes
Para obtener el servidor GRATIS debes de escribir el cupon "LEIFER"

Bastante simple y legible, ¿verdad? Por supuesto, es solo un componente simple, por lo que test también debería ser simple.

Agreguemos algunos extras al componente y veamos qué impacto tiene esto en test. En lugar de una colección de entidades estáticas, el componente ahora recupera las entidades con un servicio y usa un componente de tabla (TableComponent) para representar las entidades.

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

it('renders the entities', async () => {
    await render(EntitiesComponent, {
        declarations: [TableComponent],
        providers: [
            {
                provide: EntitiesService,
                value: {
                    fetchAll: jest.fn().mockReturnValue([...])
                }
            }
        ]
    });

    expect(
        screen.getByRole('heading', { name: /Entities Title/i })
    ).toBeInTheDocument();

    expect(
        screen.getByRole('cell', { name: /Entity 1/i })
    ).toBeInTheDocument();
    expect(
        screen.getByRole('cell', { name: /Entity 2/i })
    ).toBeInTheDocument();
    expect(
        screen.getByRole('cell', { name: /Entity 3/i })
    ).toBeInTheDocument();
})

Vemos que debido a cómo se escribió previamente el test del componente, no hay grandes cambios en el test. La única parte que se ve afectada es la configuración del test. El test no contiene los detalles internos del componente, por lo que es más fácil refactorizar el componente sin tener que preocuparse por volver tocar el test.

Si te gusta Angular TestBed, la configuración adicional de método render (el segundo argumento) debe sonarte familiar. Esto se debe a que render es un contenedor simple alrededor de TestBed y la API se mantiene idéntica, con algunos valores predeterminados.

En el test, el servicio EntitiesService se mockea para evitar que el test realice una solicitud de red real. Mientras escribimos los tests de componentes, no queremos que las dependencias externas afecten el test. En cambio, queremos tener control sobre los datos. El stub devuelve la colección de entidades que se proporcionan durante la configuración del test. Otra posibilidad sería usar Mock Service Worker (MSW). MSW intercepta solicitudes de red y las reemplaza con una implementación simulada. Un beneficio adicional de MSW es ​​que los mocks creados se pueden reutilizar en aplicación durante el desarrollo o durante los tests end to end.

Con la funcionalidad básica escrita, creo que es hora de interactuar con el componente. Agreguemos un cuadro de texto de búsqueda para filtrar las entidades en la tabla y ajustar el test para verificar la lógica.

import {
    render,
    screen,
    waitForElementToBeRemoved,
} from '@testing-library/angular';
import userEvent from '@testing-library/user-event';

it('renders the entities', async () => {
    await render(EntitiesComponent, {
        declarations: [TableComponent],
        providers: [
            {
                provide: EntitiesService,
                value: {
                    fetchAll: jest.fn().mockReturnValue([...])
                }
            }
        ]
    });

    expect(
        screen.getByRole('heading', { name: /Entities Title/i })
    ).toBeInTheDocument();

    expect(
        screen.getByRole('cell', { name: /Entity 1/i })
    ).toBeInTheDocument();
    expect(
        screen.getByRole('cell', { name: /Entity 2/i })
    ).toBeInTheDocument();
    expect(
        screen.getByRole('cell', { name: /Entity 3/i })
    ).toBeInTheDocument();

    userEvent.type(
        screen.getByRole('textbox', { name: /Search entities/i }),
        'Entity 2'
    );

    // dependiendo de la implementacion podemos usar
    // waitForElementToBeRemoved para esperar que el elemento se sea removido o usar el selector  queryBy
    await waitForElementToBeRemoved(
        () => screen.queryByRole('cell', { name: /Entity 1/i })
    );
    expect(
        screen.queryByRole('cell', { name: /Entity 1/i })
    ).not.toBeInTheDocument();

    expect(
        await screen.findByRole('cell', { name: /Entity 2/i })
    ).toBeInTheDocument();
})

Para simular un usuario que interactúa con el componente, use los métodos en el objeto userEvent. Estos métodos replican los eventos de un usuario real. Por ejemplo, para el método de type, se activan los siguientes eventos: focus, keyDown, keyPress, input y keyUp. Para los eventos que no están disponibles en userEvent, puede usar fireEvent de @testing-library/angular. Estos eventos son representaciones de eventos JavaScript reales que se envían al control.

El test también incluye el uso de un nuevo método, waitForElementToBeRemoved. El método waitForElementToBeRemoved solo debe usarse cuando un elemento se elimina de forma asíncrona del documento. Cuando el elemento se elimina inmediatamente, no tiene que esperar hasta que se elimine, por lo que puede usar el selector queryBy y confirmar que el elemento no existe en el documento.

La diferencia entre los selectores queryBy y getBy es que getBy lanza un error si el elemento DOM no existe, mientras que queryBy devuelve undefined si el elemento no existe.

El test también muestra cómo se pueden usar los selectores findBy. Estos selectores se pueden comparar con las los selectores queryBy, pero son asincrónicas. Podemos usarlos para esperar hasta que se agregue un elemento al documento.

Para hacer que nuestros tests sean resistentes a los pequeños detalles, prefiero usar los selectores findBy en lugar de las getByQuery.

El test sigue siendo fácil de leer después de estos cambios, así que continuemos con el siguiente paso.

Digamos que, por razones de rendimiento, hemos modificado la búsqueda interna y se agregó un retraso a la búsqueda. En el peor de los casos, cuando el retraso es alto, lo más probable es el test existente falle debido a un tiempo de espera. Pero incluso si el retraso fue lo suficientemente bajo como para no causar un tiempo de espera, el test tarda más en ejecutarse.

Como remedio, tenemos que introducir cronómetros falsos en el test para que el tiempo pase más rápido. Es un poco más avanzado, pero sin duda es una buena herramienta. Al principio, esto fue complicado para mí, pero una vez que me acostumbré, comencé a apreciar este concepto cada vez más. También empiezas a sentirte como un mago del tiempo, lo cual es una gran sensación.

El test a continuación usa los timers falsos de Jest, pero también puede usar los métodos de utilidad fakeAsync y tick de @angular/core/testing.

it('renders the table', async () => {
    jest.useFakeTimers();

    await render(EntitiesComponent, {
        declarations: [TableComponent],
        providers: [
            {
            provide: EntitiesService,
            useValue: {
                fetchAll: jest.fn().mockReturnValue(
                of([...]),
                ),
            },
            },
        ],
    });

    expect(
        await screen.findByRole('heading', { name: /Entities Title/i })
    ).toBeInTheDocument();

    expect(
        await screen.findByRole('cell', { name: /Entity 1/i })
    ).toBeInTheDocument();
    expect(
        await screen.findByRole('cell', { name: /Entity 2/i })
    ).toBeInTheDocument();
    expect(
        await screen.findByRole('cell', { name: /Entity 3/i })
    ).toBeInTheDocument();

    userEvent.type(
        await screen.findByRole('textbox', { name: /Search entities/i }),
        'Entity 2'
    );

    // jest.advanceTimersByTime(DEBOUNCE_TIME);
    // esto es mejor, ya que el test pasara si el debouce time se incrementa.
    jest.runOnlyPendingTimers();

    await waitForElementToBeRemoved(
        () => screen.queryByRole('cell', { name: /Entity 1/i })
    );
    expect(
        await screen.findByRole('cell', { name: /Entity 2/i })
    ).toBeInTheDocument();
});

En la última adición al componente, estamos agregando dos botones. Un botón para crear una nueva entidad y el segundo botón para editar una entidad existente. Ambas acciones dan como resultado que se abra un modal. Debido a que estamos probando el componente de entidades, no nos importa la implementación del modal, es por eso que mockea el modal en el test. Recordar que el modal se le realiza un test por separado.

El siguiente test confirma que el servicio modal se invoca cuando un usuario hace clic en estos botones.

import {
    render,
    screen,
    waitForElementToBeRemoved,
    within,
    waitFor,
} from '@testing-library/angular';
import { provideMock } from '@testing-library/angular/jest-utils';
import userEvent from '@testing-library/user-event';

it('renders the table', async () => {
    jest.useFakeTimers();

    await render(EntitiesComponent, {
        declarations: [TableComponent],
        providers: [
            {
                provide: EntitiesService,
                useValue: {
                    fetchAll: jest.fn().mockReturnValue(of(entities)),
                },
            },
            provideMock(ModalService),
        ],
    });
    const modalMock = TestBed.inject(ModalService);

    expect(
        await screen.findByRole('heading', { name: /Entities Title/i })
    ).toBeInTheDocument();

    expect(
        await screen.findByRole('cell', { name: /Entity 1/i })
    ).toBeInTheDocument();
    expect(
        await screen.findByRole('cell', { name: /Entity 2/i })
    ).toBeInTheDocument();
    expect(
        await screen.findByRole('cell', { name: /Entity 3/i })
    ).toBeInTheDocument();

    userEvent.type(
        await screen.findByRole('textbox', { name: /Search entities/i }),
        'Entity 2'
    );

    jest.advanceTimersByTime(DEBOUNCE_TIME);

    await waitForElementToBeRemoved(
        () => screen.queryByRole('cell', { name: /Entity 1/i })
    );
    expect(
        await screen.findByRole('cell', { name: /Entity 2/i })
    ).toBeInTheDocument();

    userEvent.click(
        await screen.findByRole('button', { name: /New Entity/i })
    );
    expect(modalMock.open).toHaveBeenCalledWith('new entity');

    const row = await screen.findByRole('row', {
        name: /Entity 2/i,
    });
    userEvent.click(
        await within(row).findByRole('button', {
            name: /edit/i,
        }),
    );
    // to have an example, let's say that there's a delay before the modal is opened
    waitFor(() =>
        expect(modalMock.open).toHaveBeenCalledWith('edit entity', 'Entity 2')
    );
});

Vemos muchas cosas nuevas en este test, echemos un vistazo más de cerca.

Hacer clic en el botón "new entity" no es nada interesante, y ya deberíamos haber sabido cómo hacerlo. Usamos el método userEvent.click para simular un clic del usuario en el botón. A continuación, verificamos que el servicio modal se haya invocado con los argumentos correctos.

Si observamos de cerca la configuración del test, notamos que provideMock se usa desde @testing-library/angular/jest-utils para simular un ModalService.provideMock envuelve todos los métodos del servicio provisto con una implementación simulada del mock. Esto hace que sea rápido y fácil afirmar si se ha llamado a un método.

Es una historia diferente para el botón "edit entity", donde podemos ver dos nuevos métodos, within y waitFor.

El método within se usa porque hay un botón de edición para cada fila de la tabla. Dentro podemos especificar en qué botón de edición queremos hacer clic, en el test anterior es el botón de edición que corresponde a la "Entity 2".

El segundo método, waitFor, se usa para esperar hasta que la afirmación dentro de su devolución de llamada sea exitosa. En este ejemplo, el componente usa un retraso entre el evento de clic del botón de edición antes de abrir el modal (solo para tener un ejemplo donde se puede usar waitFor). Con waitFor podemos esperar hasta que eso suceda.

EJEMPLOS ADICIONALES

DIRECTIVAS

Hasta ahora, solo hemos cubierto los tests de componentes. Afortunadamente, no hay muchas diferencias al probar las directivas. La única diferencia es que tenemos que proporcionar una plantilla para el método render. Si prefiere esta sintaxis, también puede usarla para representar un componente.

El resto del test sigue siendo la misma. El test usa el objeto de screen y los métodos de utilidad para afirmar que la directiva hace lo que se supone que debe hacer.

Por ejemplo, el siguiente test renderiza la directiva appSpoiler que oculta el contenido del texto hasta que se hace hover en el elemento.

test('it is possible to test directives', async () => {
    await render('<div appSpoiler data-testid="sut"></div>', {
        declarations: [SpoilerDirective],
    });

    const directive = screen.getByTestId('sut');

    expect(screen.queryByText('I am visible now...')).not.toBeInTheDocument();
    expect(screen.queryByText('SPOILER')).toBeInTheDocument();

    fireEvent.mouseOver(directive);
    expect(screen.queryByText('SPOILER')).not.toBeInTheDocument();
    expect(screen.queryByText('I am visible now...')).toBeInTheDocument();

    fireEvent.mouseLeave(directive);
    expect(screen.queryByText('SPOILER')).toBeInTheDocument();
    expect(screen.queryByText('I am visible now...')).not.toBeInTheDocument();
});

NGRX STORE

Nos tomó un tiempo hacer tests de componentes "correctos" que tienen una interacción con la NgRx Store. este finalmente se hace con al hacer click, para llamar el MockStore.

La primera versión de nuestros tests no se hacía mock NgRx Store y usaba toda la infraestructura NgRx (reducers, selectors, effects). Si bien esta configuración estaba probando todo el flujo, también significaba que el Store debía inicializarse para cada test. Al comienzo del proyecto, esto era factible, pero rápidamente creció hasta convertirse en un desastre inmanejable.

Como solución, los desarrolladores estaban recurriendo a wrapper de servicios alrededor de Store (un facade). Pero reescribir la lógica de su aplicación, solo para el test, no es una buena práctica.

Ahora, con MockStore tenemos lo mejor de ambos mundos. El test se centra en el componente y los detalles de NgRx Store se eliminan del test.

En el próximo test, veremos cómo usar MockStore en un test de componentes. Utiliza el mismo componente de ejemplo que tests anteriores, pero reemplaza el servicio de entidades y el servicio modal con NgRx Store.

Para crear el store se utiliza el método provideMockStore, en el que podemos sobrescribir los resultados de los selectores que se utilizan dentro del componente. Podemos asignar un mock al método dispatch para verificar que se envían las acciones. Cuando sea necesario, también puede actualizar el resultado del selector.

import { render, screen } from '@testing-library/angular';
import { MockStore, provideMockStore } from '@ngrx/store/testing';

it('renders the table', async () => {
    await render(EntitiesComponent, {
        declarations: [TableComponent],
        providers: [
            provideMockStore({
                selectors: [
                    {
                        selector: fromEntities.selectEntities,
                        value: [...],
                    },
                ],
            }),
        ],
    });

    // crea el mock del `dispatch`
    // este mock se ultilza para verificar las acciones hicieron dispatch
    const store = TestBed.inject(MockStore);
    store.dispatch = jest.fn();
    expect(store.dispatch).toHaveBeenCalledWith(fromEntities.newEntityClick());

    // esto provee un resultaod nuevo del selector.
    fromEntities.selectEntities.setResult([...]);
    store.refreshState();
});

CONCLUSIÓN

Debido a que los tests están escritas desde la perspectiva del usuario, son mucho más legibles y fáciles de entender.

Desde mi experiencia, siguiendo esta práctica, los tests son más robustas para cambios futuros. Un test es frágil cuando se prueba la implementación interna del componente, ejemplo: Cuando y cuándo se invocan los métodos (ciclo de vida).

Los cambios a los tests completos ocurren con menos frecuencia porque esto significaría que la interfaz de usuario del componente habría cambiado drásticamente. Estos cambios también son visibles para un usuario final. En ese momento, probablemente sea mejor escribir un nuevo componente y escribir un test nuevo, en lugar de intentar modificar el componente existente y los casos de prueba.

La única razón por la que tendría que cambiar un test después de una refactorización es cuando el componente se divide en varios componentes. En este caso, debe agregar todos los componentes/módulos/servicios nuevos a los tests afectados, pero el resto del test permanece igual (si la refactorización fue exitosa, de lo contrario, ¿puede incluso llamarse refactorización?). Incluso estos cambios pueden quedar obsoletos si está utilizando el patrón de módulos angulares de un solo componente (SCAM). Para una mirada detallada y los beneficios, lea Test tolerantes al cambio usando SCAMSs

Es posible que también hayas notado que estoy escribiendo múltiples bloques de arrange/act/assert blocks en un solo test. Este es un hábito que aprendí de Kent C. Dodds, para obtener más detalles, le recomiendo el articulo "Escribiendo menos tests y más largos (version traducida)". Debido a que la inicialización al test también es costosa dentro de Angular, este hábito también acelera el tiempo de ejecución de su conjunto de tests.

Después de que nuestro equipo cambió a este enfoque de escribir tests, noté que los nuevos tests se escribían más rápido que antes. Sencillamente, porque acaba de hacer clic para escribir nuestros tests de esta manera. Me atrevo a decir que incluso trajo un poco de alegría mientras los escribía.

Quiero terminar esta publicación de blog con una cita de Sandi Metz, "Pruebe la interfaz, no la implementación".

Si quiere continuar mejorando sobre el testing en Angular, puedo recomendar los siguientes enlaces:

Nota personal: Escribir este artículo me ayudó muchísimo a cambiar la forma escribir los tests, la verdad es un proceso que lleva tiempo y que recomiendo que integréis a todo el equipo, si te gusto el articulo no dudes compartirlo.