Pruebas Estáticas vs Unitarias vs Integración vs E2E para Aplicaciones Frontend

06 de agosto de 2022

Esta es una traducción de la publicación original Static vs Unit vs Integration vs E2E Testing for Frontend Apps por Kent C. Dodds.

En mí entrevista "Prácticas para pruebas con J.B. Rainsberger" que está disponible en TestingJavascript.com, él me dijo una metáfora que me gustó mucho. Él dijo:

"Tu puedes arrojar pintura contra una pared y podrías haber alcanzado la mayor parte de ésta, pero hasta que no vayas a la pared con una brocha nunca alcanzarás las esquinas. 🖌️"

Me gusta esa metáfora por cómo aplica a las pruebas, porque básicamente dice que escoger la estrategia correcta para realizar las pruebas es el mismo tipo de escogencia que harías al escoger una brocha para pintar la pared. Usarías una brocha de punto fino para toda la pared? Claro que no. Eso tomaría muchísimo tiempo y el resultado final no sería muy bueno. Usarías un rodillo para pintar todo, incluyendo alrededor de los muebles que tu tatara abuela trajo desde el otro lado del océano hace 200 años? De ninguna manera. Hay diferentes brochas para distintos usos y esto mismo aplica para las pruebas.

Es por esto que creé el Trofeo de Pruebas. Desde entonces Maggie Appleton(la mente maestra detrás del diseño magistral de egghead.io) creó esto para TestingJavascript.com:

Trofeo de Pruebas

En el Trofeo de Pruebas hay 4 tipos tipos de pruebas. Eso se muestra en la imagen de arriba, pero para aquellos que usen tecnologías de asistencia (y aquellos que necesiten traducción o en caso de que la imagen falle al cargar), escribiré qué dice la imagen de arriba hacia abajo:

  • End to End o Pruebas de Extremo a Extremo: Un robot ayudante que se comporta como un usuario para usar la aplicación y verificar que funciona correctamente. Algunas veces llamadas "pruebas funcionales" o e2e.
  • Integración: Verifica que varias unidades trabajen juntas y en harmonía.
  • Unitarias: Verifica que las partes individuales y separadas funcionen como se espera.
  • Estáticas: Atrapa los typos y errores de tipado al momento de escribir el código.

El tamaño de estas formas para realizar pruebas según el trofeo, es relativo a la cantidad de enfoque que se les deben dar cuando se están probando las aplicaciones. Quiero profundizar en estas diferentes formas de hacer pruebas, lo que significa en la práctica y lo que podemos hacer para optimizar y obtener el mayor rendimiento de nuestro presupuesto para pruebas.

Tipos de Pruebas

Miremos en estos ejemplos lo que son estos tipos de pruebas, de arriba hacia abajo:

Extremo a Extremo

Estas pruebas van a correr la aplicación completa (frontend y backend) y tus pruebas van a interactuar con la aplicación justo como lo haría cualquier usuario. Estas pruebas son escritas con cypress.

import {generate} from 'todo-test-utils'

describe('todo app', () => {
  it('should work for a typical user', () => {
    const user = generate.user()
    const todo = generate.todo()
    // here we're going through the registration process.
    // I'll typically only have one e2e test that does this.
    // the rest of the tests will hit the same endpoint
    // that the app does so we can skip navigating through that experience.
    cy.visitApp()

    cy.findByText(/register/i).click()

    cy.findByLabelText(/username/i).type(user.username)

    cy.findByLabelText(/password/i).type(user.password)

    cy.findByText(/login/i).click()

    cy.findByLabelText(/add todo/i)
      .type(todo.description)
      .type('{enter}')

    cy.findByTestId('todo-0').should('have.value', todo.description)

    cy.findByLabelText('complete').click()

    cy.findByTestId('todo-0').should('have.class', 'complete')
    // etc...
    // My E2E tests typically behave similar to how a user would.
    // They can sometimes be quite long.
  })
})

Integración

La prueba renderiza la aplicación completa. Esto NO es un requerimiento para las pruebas de integración y la mayoría de mis pruebas de integración no renderizan la aplicación completa. Sin embargo, ellas se renderizarán con todos los proveedores usados en mi aplicación (eso es lo que hace el método 'render' del módulo imaginario 'test/app-test-utils'). La idea detrás de las pruebas de integración es moquear lo menos posible. Yo suelo moquear:

  1. Las peticiones de red (usando MSW)
  2. Componentes responsables de las animaciones (porque quién querría esperar por eso en tus pruebas?)
import * as React from 'react'
import {render, screen, waitForElementToBeRemoved} from 'test/app-test-utils'
import userEvent from '@testing-library/user-event'
import {build, fake} from '@jackfranklin/test-data-bot'
import {rest} from 'msw'
import {setupServer} from 'msw/node'
import {handlers} from 'test/server-handlers'
import App from '../app'

const buildLoginForm = build({
  fields: {
    username: fake(f => f.internet.userName()),
    password: fake(f => f.internet.password()),
  },
})

// integration tests typically only mock HTTP requests via MSW
const server = setupServer(...handlers)

beforeAll(() => server.listen())
afterAll(() => server.close())
afterEach(() => server.resetHandlers())

test(`logging in displays the user's username`, async () => {
  // The custom render returns a promise that resolves when the app has
  //   finished loading (if you're server rendering, you may not need this).
  // The custom render also allows you to specify your initial route
  await render(<App />, {route: '/login'})
  const {username, password} = buildLoginForm()

  userEvent.type(screen.getByLabelText(/username/i), username)
  userEvent.type(screen.getByLabelText(/password/i), password)
  userEvent.click(screen.getByRole('button', {name: /submit/i}))

  await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i))

  // assert whatever you need to verify the user is logged in
  expect(screen.getByText(username)).toBeInTheDocument()
})

Para estos, yo también tengo un par de cosas configuradas globalmente como el reseteo automático de todos los mocks entre pruebas.

Aprende cómo configurar un archivo utilitario como el de arriba en la documentación para configurar React Testing Library.

Unitario

import '@testing-library/jest-dom/extend-expect'
import * as React from 'react'
// if you have a test utils module like in the integration test example above
// then use that instead of @testing-library/react
import {render, screen} from '@testing-library/react'
import ItemList from '../item-list'

// Some people don't call these a unit test because we're rendering to the DOM with React.
// They'd tell you to use shallow rendering instead.
// When they tell you this, send them to https://kcd.im/shallow
test('renders "no items" when the item list is empty', () => {
  render(<ItemList items={[]} />)
  expect(screen.getByText(/no items/i)).toBeInTheDocument()
})

test('renders the items in a list', () => {
  render(<ItemList items={['apple', 'orange', 'pear']} />)
  // note: with something so simple I might consider using a snapshot instead, but only if:
  // 1. the snapshot is small
  // 2. we use toMatchInlineSnapshot()
  // Read more: https://kcd.im/snapshots
  expect(screen.getByText(/apple/i)).toBeInTheDocument()
  expect(screen.getByText(/orange/i)).toBeInTheDocument()
  expect(screen.getByText(/pear/i)).toBeInTheDocument()
  expect(screen.queryByText(/no items/i)).not.toBeInTheDocument()
})

Todos llaman esto una prueba unitaria y están bien:

// pure functions are the BEST for unit testing and I LOVE using jest-in-case for them!
import cases from 'jest-in-case'
import fizzbuzz from '../fizzbuzz'

cases(
  'fizzbuzz',
  ({input, output}) => expect(fizzbuzz(input)).toBe(output),
  [
    [1, '1'],
    [2, '2'],
    [3, 'Fizz'],
    [5, 'Buzz'],
    [9, 'Fizz'],
    [15, 'FizzBuzz'],
    [16, '16'],
  ].map(([input, output]) => ({title: `${input} => ${output}`, input, output})),
)

Estática

// can you spot the bug?
// I'll bet ESLint's for-direction rule could
// catch it faster than you in a code review 😉
for (var i = 0; i < 10; i--) {
  console.log(i)
}

const two = '2'
// ok, this one's contrived a bit,
// but TypeScript will tell you this is bad:
const result = add(1, two)

De nuevo, ¿por qué hacemos pruebas?

Pienso que es importante recordar, en primer lugar, por qué es que escribimos las pruebas. Por qué escribes pruebas? Es porque te dije que lo hicieras? Es porque tu PR será rechazada si no los incluyes? Es porque las pruebas mejoran tu workflow?

La razón más importante y más grande por la que yo escribo pruebas es CONFIANZA. Quiero estar confiado en que el código que estoy escribiendo para el futuro, no vaya a romper la aplicación que hoy corre en producción. Entonces, haga lo que haga, quiero asegurarme de que los tipos de pruebas que escribo me brindan la mayor confianza posible y necesito ser consciente de las compensaciones al agregar las pruebas.

Hablemos de las compensaciones

Hay elementos importantes para el trofeo de pruebas que quiero mostrar en esta imagen (tomado de mis diapositivas:

Coeficiente de Confianza

Las flechas en la imagen significan los intercambios que haces cuando escribes pruebas automatizadas:

Costo ➡ 💰🤑💰

Al moverte hacia arriba en el trofeo de pruebas, las pruebas se vuelven más costosas. Esto viene en forma de dinero al correr las pruebas en un ambiente de integración continua, pero también en el tiempo que le toma a los ingenieros escribir y mantener cada prueba.

Entre más alto del trofeo tu vayas, habrán más puntos de falla y por lo tanto tenderá a fallar más seguido, lo que conlleva más tiempo necesario para analizar y arreglar las pruebas. Ten presento esto porque es importante

Velocidad: 🏎💨 ➡ 🐢

Al moverte hacia arriba en el trofeo de pruebas, las pruebas usualmente corren más lento. Esto es porque entre más alto estés en el trofeo de pruebas, más código tus pruebas van a correr. Las pruebas unitarias usualmente prueban algo pequeño que no tiene dependencias o que va a moquear estas dependencias (efectivamente cambiando lo que serían miles de líneas de código por sólo unas cuantas). Ten presente esto porque es importante

Confianza: Problemas pequeños 👌 ➡ Problemas grandes 😖

La compensación por el costo y la velocidad son usualmente mencionados cuando las personas hablan sobre la pirámide de pruebas. Si esas fueran las únicas compensaciones, entonces concentraría el 100% de mis esfuerzos en las pruebas unitarias e ignoraría completamente alguna otra forma de realizar pruebas según la pirámide. Claro, no deberíamos hacer eso y esto es por un principio muy importante que probablemente me has escuchado decir antes:

"Entre más se parezcan las pruebas a la forma en la que tu software es usado, más confianza ellos te darán."

Qué significa esto? Significa que no hay mejor manera de asegurarse de que tu tía Marie pueda declarar sus impuestos usando tu software de impuestos que haciendo que ella lo utilice. Pero no queremos esperar a que la tía Marie encuentre nuestros errores por nosotros, cierto? Eso tomaría mucho tiempo y puede ser que ella omita algunas características que nosotros debieramos probar. Además que si estamos desplegando actualizaciones de nuestro software regularmente, no hay forma de que cualquier cantidad de personas pueda mantenerlo.

Entonces qué hacemos? Hacemos compensaciones. Y cómo lo hacemos eso? Escribimos software que pruebe nuestro software. Y la compensación que siempre hacemos cuando hacemos eso es que ahora nuestras pruebas no se parecen a la forma en que se usa nuestro software de manera tan confiable como cuando teníamos a la tía Marie probando nuestro software. Pero lo hacemos porque resolvemos problemas reales que teníamos con ese enfoque. Y eso es lo que hacemos en cada nivel del trofeo de pruebas.

Al moverte hacia arriba en el trofeo de pruebas, estás incrementando lo que yo llamo el "coeficiente de confianza." Esta es la confianza relativa que cada prueba puede darte a ese nivel. Puedes imaginarte que arriba del trofeo están las pruebas manuales. Eso puede darte una gran confianza, pero esas pruebas son realmente costosas y lentas.

Anteriormente te dije que recordaras dos cosas:

  • Entre más arriba del trofeo vayas, más puntos de falla habrán y las pruebas fallarán mas seguido.
  • Las pruebas unitarias usualmente prueban algo pequeño que no tiene dependencias o dependencias que serán moqueadas (efectivamente esto podría cambiar miles de líneas de código por unas cuantas).

Lo que dicen es que entre más bajo del trofeo de pruebas tu estés, entonces tus pruebas están probando menos código. Si estás operando en un nivel bajo, vas a necesitar más pruebas para cubrir el mismo número de líneas en tu aplicación que una misma prueba más arriba en el trofeo. De hecho, al ir más abajo en el trofeo de pruebas habrán algunas cosas que serán imposibles de probar.

En particular, las herramientas de análisis estático son incapaces de darte confianza en tu lógica de negocio. Las pruebas unitarias son incapaces de asegurar que cuando hagas una llamada a una dependencia que la estás llamando apropiadamente (aunque puedes hacer afirmaciones sobre cómo está siendo llamada, no puedes asegurar que está siendo llamada apropiadamente con una prueba unitaria). Las pruebas de integración de UI (Interfaz de Usuario) son incapaces de asegurar que estás pasando los datos correctos al backend y que tu respuesta y que responde a los errores adecuadamente. Las pruebas de Extremo a Extremo son bastante capaces, pero usualmente tu las correrás en un ambiente de no-producción (como producción, pero no producción) para intercambiar esa confianza por factibilidad.

Ahora vamos al otro extremo. En la cima del trofeo de pruebas, si tratas de usar pruebas E2E para verificar los tipos en cierto campo y hacer click en un botón de submit para un caso extremo en la integración entre un formulario y un generador de URLs, entonces estarás haciendo muchas configuraciones para correr la aplicación (incluyendo al backend). Esto es más apropiado para una prueba de integración. Si tratas de usar una prueba de integración para alcanzar un caso extremo para una calculadora de cupones, entonces estás haciendo una buena cantidad de trabajo de configuración para asegurarte que se renderizan los componentes que usan la calculadora de cupones y que podrías cubrir mejor ese caso extremo con una prueba unitaria. Si tratas de usar una prueba unitaria para verificar que pasaría si llamas tu función de sumar con un 'string' en vez de un número podrías estar mucho mejor servido usando una herramienta de verificación de tipos estática como TypeScript.

Conclusión

Cada nivel viene con sus propias compensaciones. Una prueba E2E tiene más puntos de falla, haciendo más difícil encontrar qué parte del código está ocasionando la rotura, pero eso también significa que tu prueba te está dando más confianza. Esto es bastante útil si tu no tienes mucho tiempo para escribir pruebas. Yo preferiría tener la confianza y enfrentarme con cosas que están fallando, que no haber capturado el problema a través de las pruebas en primer lugar.

Al final no me importan las distinciones. Si tu quieres llamar mis pruebas unitarias como pruebas de integración o incluso pruebas E2E (como algunas personas lo ha hecho), entonces hazlo. En lo que estoy interesado es si estoy confiado cuando mande mis cambios, mi código satisface los requerimientos de negocio y si usaré una mezcla de las distintas estrategias de prueba para cumplir mi meta.

Buena suerte!

Si quieres dejar un comentario, puedes hacerlo en el siguiente enlace: Comment on dev.to