DÍA 7 / 2020

Cómo escribir tests útiles y mantenibles para nuestro frontend

El frontend ha sufrido históricamente una carencia de testing en comparación con el software de backend. Este artículo explica la estrategia que me ha ayudado a evitarlo en mis últimos proyectos descartando además las excusas de que no son útiles o de que son difíciles de mantener.


He encontrado (y no me quito ni un ápice de culpa, también he desarrollado) un gran número de proyectos que tenían software muy bien testeado en el backend pero que carecían de testing en su frontend. Hay diversas razones por las que esto era así hace años (apenas había lógica en el frontend, carencia de herramientas, etc.), pero lo cierto es que aunque hoy en día hay muy pocas razones para continuar con este enfoque seguimos usando muchas excusas para perpetuarlo.

En las próximas líneas voy a intentar desmontar algunas de esas excusas para que si eres de esas personas que aún no han cambiado el chip te animes a hacerlo de una vez por todas.

Pero antes de seguir leyendo, y sobre todo si los conceptos sobre testing no te son familiares, estaría muy bien que primero le echaras un ojo a este artículo sobre testing en el front de Cristina Ponce que apareció en la edición de Octuweb de 2018.

Son difíciles de mantener

"Es que cada vez que toco una coma del código, y aunque no estoy cambiando la funcionalidad, tengo que cambiar los tests."

-- Don Excusitas --

Un pretexto recurrente para justificar lo difícil que es mantener los tests del frontend es que hay que estar continuamente modificándolos aunque la funcionalidad no cambie y que son muy dependientes del diseño o de la maquetación. Esta asociación es injusta y por lo general directamente proporcional a lo acoplados que estén tus tests con tu implementación.

Para evitarlo, una de las cosas más importantes que podemos hacer es intentar replicar en nuestros tests la manera de actuar de nuestros usuarios. Voy a intentar explicarlo con un ejemplo, imaginemos el campo Nombre de usuario de un formulario:

<!-- username-field.component.html -->
<label for="username">Nombre de usuario</label>
<input type="text" id="username" name="username" data-model="username" data-onchange="onUsernameChanged">
<div
   data-if="error"
   class="error"
   >
    {{ error }}
</div>

No quiero relacionar este artículo con ningún framework concreto, así que me voy a permitir escribir un pseudo-código que creo que será entendible sea cual sea el que tú uses. En este caso se trataría de un componente que renderizaría y validaría ese campo:

// username-field.component.js
export default {
    template: 'username-field.component.html',
    data: {
        username: '',
        error: this.data.username.length < 7
            ? 'El nombre de usuario debe ser de al menos 7 caracteres'
            : ''
    }
    methods: {
        onUsernameChanged: () => this.data.username = value
    }
})

Se entiende ¿no? Simplemente nuestro componente va a validar que el nombre de usuario tiene al menos 7 caracteres. Ahora vamos a ver cual podría ser el test que realizaríamos a este componente:

// username-field.test.js
test('Should not allow usernames with less than 7 characters', async () => {
  // Arrange
  const component = render('<UsernameField />')
  // Act
  component.setData({ username: 'albert' })
  // Assert
  expect(component.find('.error').exists()).toBe(true)
})

test('Should allow usernames with at least 7 characters', async () => {
  // Arrange
  const component = render('<UsernameField />')
  // Act
  component.setData({ username: 'alberto' })
  // Assert
  expect(component.find('.error').exists()).toBe(false)
})

La mayoría de frameworks incluyen utilidades para poder hacer testing de nuestros componentes que se asemejarían bastante a lo que he escrito arriba, pero muchas veces estas utilidades no nos fuerzan, o incluso no fomentan, el uso de ciertas buenas prácticas. En este caso ¿qué sucedería si por lo que fuera alguien hiciera un refactor y cambiara el nombre de la propiedad username por nickname? ¿Y si cambiásemos la clase del mensaje de error de error a form__error?

Ninguna de esas dos modificaciones estarían cambiando la funcionalidad de mi programa ni serían apreciadas por las personas usuarias, pero cualquiera de ellas obligaría a cambiar el código del test. Esto se debe a que dentro del test no hemos usado nuestro componente como lo haría alguien que utilizara nuestra web sino que hemos utilizado un conocimiento de implementación que sabemos por ser quien la ha desarrollado.

Vamos a hacer unos pequeños cambios en los tests a ver que os parecen:

// username-field.test.js
const errorText = 'El nombre de usuario debe ser de al menos 7 caracteres'

test('Should not allow usernames with less than 7 characters', async () => {
  // Arrange
  const component = render('<UsernameField />')
  // Act
  fireEvent.change(component.getByLabel(/Nombre de usuario/), 'albert')
  // Assert
  expect(component.getByText(errorText)).toBeInTheDocument()
})

test('Should allow usernames with at least 7 characters', async () => {
  // Arrange
  const component = render('<UsernameField />')
  // Act
  fireEvent.change(component.getByLabel(/Nombre de usuario/), 'alberto')
  // Assert
  expect(component.getByText(errorText)).not.toBeInTheDocument()
})

Habrá quien no le parezca un cambio sustancial, pero incluye dos cosas muy importantes:

  • Acceso al DOM de la misma forma que lo hace el usuario: por los textos que ve (o no). Es decir, por el contenido de nuestros tags, textos alternativos, títulos, etiquetas...
  • Interacción con el DOM mediante eventos: click, touch, changes, keydown, mousedown...

Si te preguntas como puedes hacer esto la respuesta actual es: usa Testing Library. Es una librería de testing bastante conocida entre usuarios de React, pero también es compatible con Javascript "a pelo" (vanilla) y con otros muchos frameworks (Vue, Angular, Svelte...).

Esta librería nos va a aportar mantenibilidad a nuestros tests dándonos sobretodo dos herramientas fundamentales:

  • Sus métodos de consulta del DOM totalmente orientados a como ve nuestra web una persona usuaria: getByLabelText, getByPlaceholderText, getByText, getByAltText, getByTitle, getByDisplayValue, etc.
  • Su disparador de eventos (fireEvent) que se basa siempre en elementos del DOM y no en propiedades de nuestros componentes.

No quería centrar uno de los grandes puntos del artículo en una librería concreta, pero a día de hoy creo que es la única que nos permite interactuar en nuestros tests tal como lo haría un usuario sea cual sea nuestro framework de moda preferido. Mañana puede salir otra, así que recuerda que más importante que la librería es que los tests de tus componentes sean un reflejo de la interacción del usuario e intenta obviar siempre la implementación (una buena manera de hacerlo es escribir el test antes que la propia implementación).

¿Y si mi aplicación usa textos localizados?

Puede que tu aplicación este localizada y que use diferentes idiomas. Una solución puede ser forzar el idioma de tus tests, pero en estos casos es probablemente más útil no utilizar los textos finales porque suelen ser escritos por otros departamentos y utilizar en cambio las etiquetas que luego se van a traducir que son normalmente controladas por el equipo de desarrollo.

<!-- username-field.component.html -->
<label for="username">{{ $t('form.username.label') }}</label>
<input type="text" id="username" name="username" data-model="username" data-onchange="onUsernameChanged">
<div
   data-if="error"
   class="error"
   >
    {{ error }}
</div>
// username-field.test.js
test('Should not allow usernames with less than 7 characters', async () => {
  // Arrange
  const component = render('<UsernameField />')
  // Act
  fireEvent.change(getByLabel('form.username.label'), 'albert')
  // Assert
  expect(getByText('form.username.error')).toBeInTheDocument()
})

test('Should allow usernames with at least 7 characters', async () => {
  // Arrange
  const component = render('<UsernameField />')
  // Act
  fireEvent.change(getByLabel('form.username.label'), 'alberto')
  // Assert
  expect(getByText('form.username.error')).not.toBeInTheDocument()
})

Esto es algo que también puedes hacer aunque uses un sólo idioma. Tener los textos localizados en unos ficheros de recursos y no directamente escritos en el código HTML te puede ahorrar muchos quebraderos de cabeza no solo en lo relativo al testing.

No son útiles

"Es que yo no sé para que sirven estos tests, lo único que hacen es testear el framework. Son sólo una perdida de tiempo porque no me generan confianza."

-- Don Excusitas --

Cuando en tu código tienes una función pura que implementa un algoritmo concreto es muy fácil ver la utilidad de un test unitario. Si por ejemplo tengo una función que calcula la distancia entre puntos basándose en coordenadas, crear un test usando resultados conocidos y asegurar que nadie la lía al refactorizar el algoritmo es algo cuya necesidad probablemente nadie vaya a poner en duda.

Pero... ¿qué pasa cuando creamos test 'unitarios' de todos nuestros componentes? ¿Son siempre útiles? Veamos un ejemplo.

Imaginad que queremos asegurar que todos nuestros label comparten un estilo. Probablemente eso nos llevaría a crear un componente presentacional muy simple parecido a este:

<!-- form-label.component.html -->
<label class="label" for="{{ for }}">{{ content }}</label>
// form-label.component.js
export default {
    template: 'form-label.component.html',
    props: ['content', 'for']
})

Que luego usariamos por ejemplo en nuestro anterior componente:

<!-- username-field.component.html -->
<FormLabel for="username" content="{{ $t('form.username.label') }}" />
<input type="text" id="username" name="username" data-model="username" data-onchange="onUsernameChanged">
<div
   data-if="error"
   class="error"
   >
    {{ error }}
</div>

No sé exactamente por qué, pero en varios proyectos con tests de frontend en los que he trabajado existía la regla de que todo componente debía tener su test unitario asociado. Porque sí. Eso en este caso nos hubiera llevado a crear el siguiente test:

// form-label.test.js
test('Should show content and add for attribute', async () => {
  // Arrange
  const component = render(`
    <FormLabel for="testfield" content="This is a label" />
    <input id="testfield" name="testfield" type="text" value="Test value" />
  `)
  // Assert
  expect(getByText('This is a label')).toBeInTheDocument()
  expect(getByLabel('This is a label')).toHaveValue('Test value')
})

El cambio es un refactor que no modifica la funcionalidad por lo que si hicimos bien el test de username-field ya deberíamos estar cubiertos, en cambio al haber hecho este nuevo test ¿qué pasaría si decidimos cambiar el nombre de la propiedad content por labelText? ¿No estaríamos acoplando de nuevo nuestro test a la implementación? Yo creo que sí, y creo que esta es una de las razones por las que un test puede parecer poco útil o incluso contraproducente.

Busca tu pirámide ideal

Diferentes representaciones de pirámides de test

Seguro que has oído hablar de la "pirámide de Cohn" o de la "pirámide de los tests", esa regla que dice que tu base de tests tiene que ser mayoritariamente de tests unitarios y por la que probablemente mucha gente cree que todo componente tiene que tener un test unitario sí o sí. Pues bueno, hoy en día yo diría que no tienes porque seguirla a pies juntillas, te puedo asegurar que no soy el único que piensa así y que existen otras propuestas:

De hecho, si volvemos al primer ejemplo del artículo, ¿sería necesario ese test para el componente que renderiza el campo username? Veamos una alternativa:

// register-form.test.js
test('Should allow user to register', async () => {
    const component = render('<RegisterForm />')
    const usernameField = component.getByLabelText('form.username.label');
    const passwordField = component.getByLabelText('form.password.label');
    const button = component.getByText('form.register');

    // Fill the form
    expect(button).toBeDisabled()
    fireEvent.change(usernameField, 'alberto')
    fireEvent.change(passwordField, 'this1s4$uper$ecret')
    expect(button).toBeEnabled()
    expect(getByText('form.username.error')).not.toBeInTheDocument()
    fireEvent.click(button)

    // It sets loading state
    expect(button).toBeDisabled();
    expect(button).toHaveTextContent('button.loading');

    // It register the user
    await waitFor(() => {
        expect(button).not.toBeInTheDocument();
        expect(emailField).not.toBeInTheDocument();
        expect(passwordField).not.toBeInTheDocument();
        expect(getByText('form.register.success')).toBeInTheDocument()
    })
})

test('Should show error message when the username is invalid', async () => {
    const component = render('<RegisterForm />')
    const usernameField = component.getByLabelText('form.username.label');
    const passwordField = component.getByLabelText('form.password.label');
    const button = component.getByText('form.register');

    // Fill the form
    fireEvent.change(usernameField, 'albert')
    fireEvent.change(passwordField, 'this1s4$uper$ecret')
    expect(button).toBeDisabled()
    expect(getByText('form.username.error')).toBeInTheDocument()
})

No he escrito el código completo que estaríamos testeando aquí porque no lo he visto necesario, pero puedes imaginar un componente RegisterForm que incluye un formulario completo de registro incluyendo el componente UsernameField que hemos visto antes, otro de contraseña y un botón.

Es muy probable que al ver este test lo hayas identificado como un test de integración y no un test unitario, y te diría que estás en lo cierto. Pero en mi opinión eso no importa mucho, lo importante de este test es que es mucho más parecido a lo que haría alguien que usa tu web y que no tiene conocimiento de si tu formulario está dividido en tres o en veinte componentes. Deberíamos poder reescribir y reestructurar nuestro código sin romper nuestros tests siempre que la funcionalidad siga siendo la misma, si tus tests unitarios te lo permiten adelante, pero no pasa nada porque te plantees otras opciones si ese no es el caso.

Conclusión

La utilidad del testing automático de software está más allá de toda duda, si aún no lo ves es que no lo estás haciendo bien y probablemente tengas que cambiar tu manera de hacerlo. Por supuesto, eso no quiere decir que lo tengas que hacer como yo digo, pero lo que sí creo es que entonces debes seguir buscando la manera que mejor se adapte a tu equipo y a tu proyecto.

Lo que a mí me ha funcionado en el frontend y es lo que he querido transmitirte en este artículo es que lo mejor es que tus tests usen tu aplicación tal y como lo haría una persona usuaria.

P.D. Sí, "Don Excusitas" es mi yo del pasado.

Alberto Varela

Si te gusta el anglicismo soy fullstack, si no te gusta soy programador generalista. "Aprendiz de todo, maestro de nada" también sirve. Cuando empecé en esto no existía el inspector web ni Stack Overflow. Él.