< Ir a p谩gina principal 馃彙

1 de noviembre de 2020

Test-driven front-end development

Este art铆culo fue publicado originalmente en la edici贸n de 2020 de Octuweb.

En la edici贸n de 2018 Cristina Ponce public贸 Testing en el front, una gu铆a sobre los diferentes tipos de pruebas que podemos realizar en aplicaciones front-end. Apoy谩ndonos en ese art铆culo, vamos a hablar aqu铆 de c贸mo aprovechar la nueva generaci贸n de herramientas para alcanzar flujos de desarrollo que, hasta hace solo unos a帽os, no eran (tan) f谩cilmente aplicables en este contexto: Test-Driven Development (o TDD).

Pero primero, 驴qu茅 es TDD?

Test-Driven Development es una metodolog铆a que se basa en aplicar peque帽os ciclos de desarrollo con el objetivo de resolver casos de prueba. De manera m谩s concreta, lo podemos definir en 3 pasos:

  1. Codificar una prueba que defina el nuevo comportamiento que queremos a帽adir a nuestro sistema
  2. Escribir la menor cantidad de c贸digo posible que nos permita hacer pasar el test anterior
  3. Mejorar el c贸digo anterior utilizando las pruebas como red de seguridad (a.k.a refactoring)

En TDD cl谩sico, lo habitual es comenzar por las entidades m谩s internas de nuestro sistema para ir construyendo capas una encima de otra hasta terminar de implementar la funcionalidad. En front-end sin embargo, y con las herramientas actuales, lo m谩s natural es utilizar una variaci贸n denominada ATDD (inspirada en Outside-in, un enfoque de TDD que se origin贸 en la comunidad de eXtreme Programming de Londres), donde se empieza creando un primer test en la capa m谩s externa del sistema (en este caso, la interfaz o un componente) para ir construyendo desde ah铆 el resto de la funcionalidad.

Escribiendo nuestro primer test

En su sentido m谩s estricto, TDD es una herramienta de desarrollo (un flujo de trabajo, una metodolog铆a) y no tiene que verse como un instrumento de calidad (entendiendo esta como la disciplina para el control de defectos). Por tanto, su objetivo es ayudar a las programadoras a entregar valor lo antes posible, con confianza. En ATDD, por ejemplo, el primer test nos tiene que permitir poner el foco en el problema que vamos a resolver y servirnos como gu铆a durante el desarrollo de la funcionalidad. Lo habitual es intentar reflejar en este primer test los criterios de aceptaci贸n que perseguimos cumplir con la historia de usuario en curso.

Por ejemplo, supongamos que estamos trabajando en el front-end para la web de una editorial de libros y queremos a帽adir una p谩gina de contacto:

Es posible acceder a una p谩gina de contacto para, indicando email, asunto y mensaje, ponerse en contacto con la editorial. Una vez completada la operaci贸n, se realizar谩 una redirecci贸n a una p谩gina de 茅xito con un mensaje que indique que todo ha ido bien.

Hay disponible un endpoint /api/contact que completa la operaci贸n en el back-end.

Utilizando Cypress, podemos trasladar esta descripci贸n a una prueba de alto nivel como la siguiente:

// Location: cypress/integration/sendContactMessage.test.ts

context("sendContactMessage", () => {
  specify("A user can send a contact message", () => {
    // Setup
    cy.server();
    cy.route2("/api/contact", { statusCode: 200 });

    // Act
    cy.visit("/contact");
    cy.findByLabelText(/tu email/i).type("[email protected]");
    cy.findByLabelText(/asunto/i).type("Informaci贸n sobre pr贸ximos libros");
    cy.findByLabelText(/mensaje/i).type("Hola, me gustar铆a obtener m谩s informaci贸n sobre pr贸ximos libros.");
    cy.findByText(/enviar/i).click();

    // Assert
    cy.url().should("eql", Cypress.config().baseUrl + "/contact/success");
    cy.findByText(/tu mensaje ha sido enviado con 茅xito/i).should("exist");
  });
});

La prueba anterior codifica el escenario de aceptaci贸n b谩sico: primero, hacemos que Cypress simule la respuesta al endpoint /api/contact para indicar que todo ha ido bien (devolver谩 un c贸digo 200), y despu茅s interactuamos con la aplicaci贸n para ir a la p谩gina de contacto y completar el formulario. Por 煤ltimo, validamos que se ha llevado al usuario a la p谩gina de 茅xito tras enviar el formulario.

Ahora que hemos visto un ejemplo, seguramente nos sea m谩s sencillo explicar qu茅 es Cypress: un framework de testing para construir pruebas de aceptaci贸n sobre un navegador, utilizando JavaScript.

En el ejemplo anterior, la prueba se ejecutar谩 sobre nuestra propia aplicaci贸n. En este caso, como todav铆a no hemos escrito c贸digo de producci贸n, lo que esperamos es que el test falle. Es ahora cuando tenemos que escribir la m铆nima cantidad posible de c贸digo para que este test pueda pasar.

// Location: pages/contact.tsx

export default function Contact() {
  return (
    <main>
      <Formik
        initialValues={{ email: "", subject: "", message: "" }}
        onSubmit={async ({ email, subject, message }) => {
          const response = await fetch("/api/contact", {
            body: JSON.stringify({ email, subject, message }),
            headers: { "content-type": "application/json" },
            method: "post",
          });

          if (response.ok) {
            window.location.href = "/contact/success";
          }
        }}
      >
        {() => {
          return (
            <Form>
              <label>
                Tu email <Field id="email" name="email" />
              </label>
              <label>
                Asunto <Field name="subject" />
              </label>
              <label>
                Mensaje <Field name="message" />
              </label>
              <button type="submit">Enviar</button>
            </Form>
          );
        }}
      </Formik>
    </main>
  );
}
// Location: pages/contact/success.tsx

export default function ContactSuccess() {
  return <p>隆Enhorabuena! Tu mensaje ha sido enviado con 茅xito</p>;
}

Obviando algunos detalles de implementaci贸n (como el uso de Formik para el formulario), los dos fragmentos de c贸digo anteriores muestran una posible implementaci贸n (b谩sica pero funcional) que sirve para hacer pasar nuestro primer test de aceptaci贸n. En este punto, podemos a帽adir CSS a nuestra nueva p谩gina o extraer comportamientos a otro tipo de entidades (por ejemplo, mover la operaci贸n de onSubmit a un servicio). Si nuestro test sigue en verde al completar los cambios, tendremos la certeza de que no hemos roto nada 鉁

Aplicando ciclos de desarrollo m谩s peque帽os

Aunque Cypress es un magn铆fico framework de testing, las pruebas de tan alto nivel suelen venir con algunos compromisos: es dif铆cil poder ejercitar todos los caminos de ejecuci贸n posibles y son lentas. Recordad que con TDD estamos buscando mejorar nuestra productividad por lo que necesitamos que los ciclos de feedback sean lo m谩s cortos posibles. Para ello, mi consejo es utilizar pruebas de aceptaci贸n para cubrir el happy path de la funcionalidad (y quiz谩s alg煤n escenario de error clave) y despu茅s iterar en ciclos de desarrollo m谩s peque帽os facilitados por pruebas de una granularidad m谩s baja (en front-end, podr铆amos entenderlas como h铆bridos de integraci贸n + unitaria, centradas en componentes).

Si volvemos al ejemplo anterior, seguramente hay ciertas reglas de experiencia de usuario que queramos validar sobre el formulario de contacto: que no se pueda completar la operaci贸n sin un email de remite o sin el cuerpo del mensaje, que aparezcan correctamente los mensajes de error, que los campos permitan 煤nicamente ciertos patrones, etc. Aunque estas pruebas se podr铆an hacer tambi茅n utilizando Cypress, su latencia es a menudo lo suficientemente grande como para preferir moverlos a tests de m谩s bajo nivel (por lo general, m谩s r谩pidos).

Para hacer pruebas de componentes (en React, aunque las mismas herramientas est谩n disponibles en otros frameworks como Vue o Angular) mi consejo es utilizar Testing Library en conjunci贸n con Jest. Por ejemplo, vamos a a帽adir un nuevo comportamiento a nuestra p谩gina de Contacto, aunque esta vez iterando en un nivel de abstracci贸n m谩s bajo:

El formulario s贸lo debe enviarse si contiene un email de remite y un mensaje.

// Location: components/ContactForm.test.tsx

describe("ContactForm", () => {
  it("does not submit the form if some required fields are missing", async () => {
    // Setup
    const spy = jest.fn();
    render(<ContactForm onSubmit={spy} />);

    // Act
    await user.type(screen.getByLabelText(/asunto/i), "Informaci贸n pr贸ximos libros");
    user.click(screen.getByText(/enviar/i));

    // Assert
    await expect(screen.findAllByText("Este campo es obligatorio.")).resolves.toHaveLength(2);
    await waitFor(() => expect(spy).not.toHaveBeenCalled());
  });
});

El test anterior codifica el nuevo comportamiento que queremos validar. Ver茅is que, en un primer vistazo, la apariencia del test es similar al primero que creamos utilizando Cypress. La 煤nica diferencia es que aqu铆 se prueba el componente de manera aislada en lugar de simular a un usuario real utilizando la aplicaci贸n. En Testing Library, no hay ning煤n navegador ejecut谩ndose en paralelo, como s铆 ocurre con Cypress; as铆, mientras el 煤ltimo que hemos escrito podemos ejecutarlo en ~2 segundos, el primero necesita de ~22 segundos.

Cuando trabajamos a un nivel de granularidad m谩s bajo, nuestros tests pueden afectar al dise帽o de nuestros componentes (por lo general, haci茅ndolos m谩s sencillos de probar). En este caso, he decidido inyectar la funci贸n que gestiona el env铆o del formulario para poder reemplazarla por un esp铆a que me permita validar que no se ha invocado cuando no deb铆a (aunque tambi茅n podr铆amos haber espiado el m贸dulo HTTP con nock). Este es otro de los beneficios clave de TDD, ser capaces de ir definiendo el dise帽o de nuestras interfaces a medida que escribimos las pruebas.

Como todos estos cambios no est谩n repercutiendo en cambios de comportamiento a nivel de aceptaci贸n, nuestro test de Cypress deber铆a seguir pasando despu茅s de cada modificaci贸n, ayud谩ndonos de nuevo a garantizar que no hemos roto nada 鉁.

Ahora, al igual que en el ejercicio anterior, tendr铆amos que escribir la menor cantidad de c贸digo que sirva para hacer pasar este test.

Pss! Pod茅is ver el ejemplo completo en este repositorio de GitHub 馃憖

Conclusi贸n

驴Que conseguiremos aplicando un flujo similar al anterior? En la literatura de TDD, estar铆amos aplicando lo que se conoce como Outside-In development with Double Loop TDD o el proceso descrito en el libro Growing Object-Oriented Software, Guided by Tests.

Primero, arrancamos con un test de aceptaci贸n (o del nivel de granularidad m谩s alto que podamos) que nos ayudar谩 a mantener el foco en la funcionalidad que queremos resolver. En ocasiones, este test puede estar fallando durante varias horas porque necesite de otros ciclos complementarios m谩s peque帽os que nos ayuden avanzar (el ejemplo anterior era tan sencillo que no lo hemos necesitado). Para esos ciclos complementarios es donde podemos hacer uso de flujos como TDD cl谩sico (煤til para avanzar en piezas de bajo nivel como funciones, clases, servicios) y herramientas como Testing Library (para probar los diversos componentes que construyan la interfaz) o simplemente Jest (para l贸gica de negocio como controladores, funciones de utilidad, etc.).

Otras referencias

  • Adem谩s de los enlaces contenidos en el art铆culo, pod茅is encontrar m谩s referencias a TDD en esta p谩gina.
  • Cypress tiene una secci贸n de mejores pr谩cticas con algunos consejos muy 煤tiles sobre c贸mo escribir pruebas de aceptaci贸n.
  • Kent C. Dodds (creador de Testing Library) tiene bastantes art铆culos relacionados con testing de aplicaciones front-end (desde TDD hasta diferentes estrategias de testing). Pod茅is encontrarlos en su blog.

Si tienes cualquier duda o comentario, podemos continuar la conversaci贸n en