loading...

Snapshot testing y DOM diffing: usos y abusos

usepotatoes profile image Kus Cámara ・8 min read

El snapshot testing y DOM diffing son dos técnicas de testing tan útiles como peligrosas. Pueden facilitarnos enormemente la tarea de probar un resultado o jugar en nuestra contra si abusamos de ellos o no los configuramos correctamente. En cualquier caso deberíamos considerarlos herramientas complementarias y tener en mente un par de principios de unit testing para que podamos hacer un buen uso de ellas:

  • Un test solo debería fallar cuando cambia la funcionalidad, NO en un refactor. Si necesitamos actualizar los tests en un refactor, no podremos tener la seguridad, por pequeño que sea el cambio, de que no estamos cambiando la funcionalidad. Es por esto que no debemos probar la implementación (métodos o propiedades privadas) sino el resultado.
  • Evita la sobre-especificación. Los tests muy específicos bloquean el escenario bajo test de manera que evitan la introducción de cambios que no suponen un cambio de la funcionalidad. Este tipo de test se rompe muy fácilmente (son frágiles) y requieren de una actualización constante que nos hace incumplir el punto anterior.

Snapshot testing

El snapshot testing consiste en comprobar que una pieza de software (un componente, una clase, un plugin, etc.) produce el output dado como válido en una ejecución concreta a partir de cierto input.

Es ideal para los casos en los que, lo que sea que estemos probando, genera o transforma algo, ya sea un JSON, un JavaScript, un archivo CSS o un conjunto de archivos.

Ejemplos en los que este tipo de testing puede ser ideal son plugins de Babel, codemods, plugins PostCSS, generadores (scaffolds o blueprints), etc.

La diferencia respecto a un test en el que comprobamos que el resultado de la ejecución de la cosa probada produce el output esperado, es que no somos nosotros los que escribimos ese output, sino que es la propia librería o framework de testing el que se encarga de generarlo y guardarlo en un archivo al ejecutar nuestros tests y encontrar alguna instrucción concreta que genere esos snapshot.

Por ejemplo, en el siguiente código obtenido de Theo, una utilidad que genera archivos de estilo en diferentes formatos a partir de design tokens, podemos ver que usan el método toMatchSnapshot() de Jest:

it("custom-properties.css", () => {
  expect(format("custom-properties.css")).toMatchSnapshot();
});

Cuando se ejecuten estos tests por primera vez, se generarán los archivos con el output que produce la herramienta en ese momento concreto y que hemos considerado válido.

En posteriores ejecuciones de los tests se comparará el resultado con el del snapshot que tenemos como referencia (debe estar en nuestro repositorio). En caso de que los tests fallen porque el resultado es distinto, sabremos que hemos modificado la funcionalidad accidentalmente o no, en cuyo caso tendremos que actualizar nuestros snapshot para que reflejen el nuevo output (jest --updateSnapshot) o bien arreglar lo que hayamos roto.

Snapshot testing de Web Components

El uso de esta técnica es muy habitual en componentes React usando Jest, que introdujo esta funcionalidad en 2016 y gracias a open-wc podemos usarla también en Web Components de diferentes sabores (LitElement, Stencil, vanilla) usando Mocha como librería de assertions en lugar de Jest y Karma como test runner.

Veamos cómo la usaríamos para generar el snapshot del Shadow DOM de un Web Component:

// file: acme-card.test.js

suite('acme-card', async() => {
  test('Renders correctly with "name" and "email"', async() => {
    const el = await(fixture(html`
      <acme-card name="John Doe" email="john@foo.com"></acme-card>
    `));

    assert.shadowDom.equalSnapshot(el);
  });
});

La ejecución de este test por primera vez generará un archivo markdown con el nombre de la suite (acme-card.md en este caso) dentro de una carpeta llamada __snapshots__.

Si vemos el contenido del snapshot generado, nos encontraremos con un HTML "perfectamente formateado" usando Prettier y sin detalles irrelevantes como saltos de línea o comentarios, pero que destripa todas las intimidades de nuestro componente e incluso las de terceros (detalles de la implementación) bajo un enunciado de test, que podría ser peor, pero que nos dice entre poco y nada de lo que ocurre al establecer name y email: "Renders correctly...".

<!-- file: __snapshots__/acme-card.md -->

# acme-card

## It renders correctly with "name" and "email"

<p class="foo">John Doe</p>
<acme-email 
  id="someAutoGeneratedIdByAcmeEmail"
  class="bar" 
  email="john@foo.com"
>
</acme-email>

Con este ejemplo, que podría ser aceptable para snapshot testing, podemos ver que esta técnica crea unos tests demasiado específicos y por lo tanto frágiles, que si requieren de una actualización constante, terminarán por no servirnos de nada o incluso darnos una falsa seguridad.

En mi opinión deberíamos usarlos solo en los siguientes casos:

  • El componente a probar tiene un DOM pequeño y es muy poco probable que vaya a cambiar.
  • El componente a probar tiene muy poca funcionalidad cuya implementación pueda variar frecuentemente.
  • Una vez que ya tenemos todos los OKs y validaciones para un desarrollo. En este momento, antes de sacar una release, hacemos una "foto" de cómo se pintaba el DOM cuando dimos el componente por válido.

Cosas que debemos evitar en snapshot testing

Input variable

Nuestros tests deben ser deterministas (para un input concreto se produce siempre el mismo output), por lo que debemos evitar incluir elementos variables como fechas o IDs generados dinámicamente por nosotros mismos o terceros como en el ejemplo. Para estos casos podemos optar por "mockear" esos elementos devolviendo siempre un resultado controlado, o bien excluirlos del snapshot.

El método equalSnapshot() de @open-wc/semantic-dom-diff nos permite pasar como parámetro atributos que queremos ignorar e incluso etiquetas o atributos en ciertas etiquetas.

Está disponible tanto para el estilo TDD con assert (suite, setup, test):

assert.shadowDom.equalSnapshot(el, {
  ignoreAttributes: [
    'class',
    { tags: ['acme-email'], attributes: ['id'] }
  ]
});

Como para el estilo BDD con expect (describe, context, it):

expect(el).shadowDom.to.equalSnapshot({
  ignoreAttributes: ['id'],
  ignoreTags: ['acme-element']
});

Detalles de la implementación visual

Este punto puede ser bastante difícil de cumplir para este tipo de tests y bastante cuestionable para el caso concreto que comento, pero en mi opinión, es recomendable ignorar las classes CSS.

Podemos considerar la existencia de una clase concreta en nuestro HTML como relevante o no. Si en el momento de hacer el snapshot, en la CSS de nuestro componente existía una clase .huge-text que establece el font-size de un texto a 30px, podemos considerar que esa clase debe estar presente cuando corresponda, pero aquí claramente estamos probando la implementación. Estamos probando el cómo en lugar del resultado.

El nombre de una clase puede cambiar por varias razones sin que ello suponga un cambio de funcionalidad y nuestro test probablemente no debería fallar por ello si tenemos otra forma más apropiada de probar el resultado visual, que es mediante tests visuales.

Ojo, no todas las clases CSS son irrelevantes y algunas sí deberíamos probarlas en nuestros tests.

Veredicto personal

En mi opinión, este tipo de test es prescindible e incluso no recomendable. Puede haber casos en los que tengan sentido, pero deberíamos tener en cuenta los siguientes puntos:

  • Son extremadamente específicos y por lo tanto frágiles.
  • Revelan los detalles de la implementación.
  • Sus enunciados frecuentemente no sirven como especificación de la funcionalidad y comportamiento ("Renders correctly").
  • No indican claramente lo que ha dejado de funcionar en caso de fallo. Que no se pinta correctamente, no me dice qué ha fallado.
  • Invitan a probar varias cosas en un mismo enunciado estableciendo un conjunto de propiedades para un snapshot, de manera que cuando una sola cosa deja de funcionar como se espera, el test entero falla sin indicar con precisión qué es lo que ha fallado.

No todo van a ser inconvenientes. Un punto a favor a tener en cuenta, es que pueden ser más rápidos y menos flaky al no requerir la participación de un navegador, al menos en componentes React, ya que lo que se compara es el resultado escrito en un archivo con el resultado que produce la ejecución de una función.

DOM diffing

A partir de toda la parrafada anterior (si alguien ha llegado hasta aquí :P) ha quedado claro que a mí personalmente no me termina de convencer el snapshot testing, al menos sobre Web Components y su Shadow DOM.

Sin embargo no tenemos por qué probar el output entero de un componente, sino que podemos probar pequeñas partes del DOM manualmente mediante la técnica de DOM diffing que usa el snapshot testing y esto sí que es especialmente útil para probar ciertos aspectos que normalmente dejamos de lado por ser demasiado costosos o simplemente nos facilita un montón la tarea de consultar el DOM y comprobar que es correcto.

Estructura y orden del DOM

Uno de los aspectos que solemos dejar de lado en tests unitarios, es comprobar que el DOM se pinta en el orden lineal correcto para que tenga sentido en medios no visuales como lectores de pantalla por ejemplo.

Podemos probar que si establecemos una propiedad text, el innerHTML de cierto elemento contiene ese text e incluso que la etiqueta es la adecuada, pero normalmente no comprobamos cosas como que un encabezado preceda al texto que encabeza (visualmente esto puede no ser así) por lo costoso de probar mediante métodos del DOM estas estructuras.

El DOM diffing para estos casos nos proporciona una forma fácil y expresiva de probar estas estructuras sin que nos tengamos que preocupar además por espaciado, comentarios, etc.:

test('The order of the heading and text is correct', async() => {
  const el = await(fixture(html`
    <acme-card heading="Card title">
      <p>Any</p>
    </acme-card>
  `));

  const expectedDom = `
    <h2>Card title</h2>
    <slot>
      <p>Any</p>
    </slot>
  `;

  // query only the specific node that renders the DOM to be tested
  const domNode = el.shadowRoot.querySelector('#some-node');

  assert.dom.equal(domNode, expectedDom);
});

Atributos aria

Cuando usamos atributos aria normalmente usamos varios relacionados que se actualizan a la vez en función de algún cambio de estado. En estos casos el uso de DOM diffing también es mucho más cómodo que el de métodos del DOM como getAttribute().

test('Has the proper aria attributes when "opened" is true', async() => {
  const el = await(fixture(html`
    <expandable-card opened></expandable-card>
  `));

  const expectedDom = `
    <button
      id="button"
      aria-expanded="true"
      aria-controls="content">
      ${el.toggleButtonText}
    </button>

    <div
      id="content"
      role="region"
      aria-labelledby="button"
      aria-hidden="false">
      <slot></slot>
    </div>    
  `;

  const domNode = el.shadowRoot.querySelector('#some-node');

  assert.dom.equal(domNode, expectedDom);
});

Estructuras HTML complejas

Supongamos que tenemos un componente que pinta una lista de definición (<dl>) a partir de una property items de tipo array, en la que pasamos un objeto con key y value por cada ítem. Mediante DOM diffing nos ahorramos tener que hacer varios querySelector() para el <dl>, cada <dt> y <dd>:

test('Setting "items" prints a definition list', async() => {
  const items = [{
    key: 'Any',
    value: 'Foo'
  }];

  const el = await(fixture(html`
    <definition-list .items="${items}"></definition-list>
  `));

  const expectedDom = `
    <dl>
      <dt>Any</dt>
      <dd>Foo</dd>
    </dl>
  `;

  assert.shadowDom.equal(el, expectedDom);
});

Pues estos han sido algunos ejemplos de lo que se podrían considerar casos de uso muy apropiados para probar mediante DOM diffing. Al igual que con el snapshot testing, debemos intentar que nuestros tests no sean extremadamente específicos excluyendo los atributos o etiquetas que sean irrelevantes para lo probado.

Como ocurre con casi todo, no hay ninguna herramienta que de por sí sea buena o mala, sino malos usos de herramientas (incluso del !important:)). El snapshot testing me parece ideal para los casos en que tenemos que probar un resultado concreto con cero posibilidades de cambio, como puede ser una transformación de un plugin Babel, un archivo generado, etc., pero muy arriesgado para componentes, donde no solo generamos un HTML sino que incluímos funcionalidad y presentación que puede ser implementada de diferentes maneras.

Más información


Publicado originalmente en Medium: https://medium.com/@usepotatoes/snapshot-testing-y-dom-diffing-usos-y-abusos-4f2aef32c45d

Discussion

markdown guide