TDD y anti patrones - Capítulo 1

El Test Driven Development (TDD) es una práctica utilizada por la mayoría de desarrolladores para ofrecer código de calidad a través de ciclos cortos de feedback (fail-pass-refactor).

Kent Beck popularizó esta metodología y se convirtió en un estándar del sector. Iniciarse en TDD no es fácil y mantenerlo correctamente para que el test suite se ejecute con rapidez es aún más complicado. Las bases de código que abordan problemas de negocio requieren una gran cantidad de código y, por tanto, un gran conjunto de tests. Como describe este informe, la industria en general tiene un proceso de automatización de pruebas inmaduro que conduce a procesos de feedback muy lentos.

En Codurance somos conscientes de ello y por eso intentamos impulsar buenas prácticas que ayuden a elevar el nivel de calidad en la industria del software. Por ejemplo, en el episodio 31 de Codurance Talks, Javier Martínez Alcántara, Alasdair Smith y José Enrique Rodríguez Huerta hablan sobre las claves para construir una buena cultura de testing.

En ese sentido, James Carr elaboró una lista de anti-patrones a evitar y a tener en cuenta para no caer en la trampa de los tests que generan bases de código demasiado grandes (y llevan a procesos de feedback muy lentos). Dave Farley repasó algunos de ellos en su canal de youtube y me hizo darme cuenta de que tenía que difundir este tema. Este post está inspirado en el video de Dave Farley, en un compañero de Codurance que hizo un workshop sobre anti patrones TDD ;) y también para compartir los datos obtenidos en la encuesta sobre antipatrones TDD realizada recientemente.

Datos sobre la encuesta

Antes del meetup, hicimos una encuesta para recopilar datos sobre los anti patrones TDD en la industria y que profesionales de todo el mundo nos dieran su opinión. Obtuvimos 22 respuestas y los datos muestran que los profesionales que respondieron la encuesta trabajaron para proyectos en Latino América: Argentina, Brasil y México y en Europa: Francia, Hungría, Irlanda, Portugal, España y Rumania.

También descubrimos que los lenguajes de programación más populares con los que los encuestados trabajan profesionalmente son:

  • Javascript
  • PHP
  • Java
  • Typescript
  • Python

Verás que la mayoría de los ejemplos utilizados en las siguientes secciones también están en javascript. Creemos que de esta manera será más fácil llegar a una audiencia más amplia y retribuir a quienes respondieron la encuesta. Por otro lado, también vemos otros lenguajes que aparecen en la encuesta que no son tan populares: Ruby, Rust y Groovy.

También encontramos que los profesionales generalmente aprenden TDD de manera informal, lo que significa que a partir de las respuestas, más del 50% de ellos aprendieron TDD solo a través de vídeos, libros o tutoriales que han encontrado en internet. Siguiendo la misma tendencia, solo el 50% de las empresas comprenden los pros y los contras de TDD y lo utilizan como práctica.

Excessive setup


Según los datos de la encuesta, excessive setup es el tercer antipatrón TDD más popular.

Con excessive setup me refiero a debido a la "no práctica" de TDD desde el principio y también a la falta de práctica de calistenia de objetos; también se aplicaría a la programación funcional. Aún así, por el momento, me quedaré con la programación orientada a objetos.

El enfoque clásico para excessive setup es cuando desea probar un comportamiento específico en tu base de código. Se vuelve difícil debido a las muchas dependencias que necesitas crear de antemano (como clases, dependencias del sistema operativo, bases de datos, básicamente cualquier cosa que quite la atención al objetivo de la prueba).

El siguiente código describe el caso de prueba del  nuxtjs framework para este asunto, el archivo de prueba para el servidor comienza con algunos mocks, y luego continúa hasta el método beforeEach que tiene más trabajo de configuración (los mocks).
 
jest.mock('compression')
jest.mock('connect')
jest.mock('serve-static')
jest.mock('serve-placeholder')
jest.mock('launch-editor-middleware')
jest.mock('@nuxt/utils')
jest.mock('@nuxt/vue-renderer')
jest.mock('../src/listener')
jest.mock('../src/context')
jest.mock('../src/jsdom')
jest.mock('../src/middleware/nuxt')
jest.mock('../src/middleware/error')
jest.mock('../src/middleware/timing')

 

 
El código anterior apunta a algunas dependencias de las que el caso de test se falsea para tomar un control más preciso, luego el código sigue:

 

describe('server: server', () => {
  const createNuxt = () => ({
    options: {
      dir: {
        static: 'var/nuxt/static'
      },
      srcDir: '/var/nuxt/src',
      buildDir: '/var/nuxt/build',
      globalName: 'test-global-name',
      globals: { id: 'test-globals' },
      build: {
        publicPath: '__nuxt_test'
      },
      router: {
        base: '/foo/'
      },
      render: {
        id: 'test-render',
        dist: {
          id: 'test-render-dist'
        },
        static: {
          id: 'test-render-static',
          prefix: 'test-render-static-prefix'
        }
      },
      server: {},
      serverMiddleware: []
    },
    hook: jest.fn(),
    ready: jest.fn(),
    callHook: jest.fn(),
    resolver: {
      requireModule: jest.fn(),
      resolvePath: jest.fn().mockImplementation(p => p)
    }
  })

 

En este punto, sabemos que estamos creando una aplicación nuxt e inyectando diferentes mocks. Entonces, el siguiente bit pega todo junto:

 

beforeAll(() => {
  jest.spyOn(path, 'join').mockImplementation((...args) => `join(${args.join(', ')})`)
  jest.spyOn(path, 'resolve').mockImplementation((...args) => `resolve(${args.join(', ')})`)
  connect.mockReturnValue({ use: jest.fn() })
  serveStatic.mockImplementation(dir => ({ id: 'test-serve-static', dir }))
  nuxtMiddleware.mockImplementation(options => ({
    id: 'test-nuxt-middleware',
    ...options
  }))
  errorMiddleware.mockImplementation(options => ({
    id: 'test-error-middleware',
    ...options
  }))
  createTimingMiddleware.mockImplementation(options => ({
    id: 'test-timing-middleware',
    ...options
  }))
  launchMiddleware.mockImplementation(options => ({
    id: 'test-open-in-editor-middleware',
    ...options
  }))
  servePlaceholder.mockImplementation(options => ({
    key: 'test-serve-placeholder',
    ...options
  }))
})
 
 
Leer el test desde el principio da una idea de que, para empezar, hay 13 invocaciones de jest.mock. Además de eso, hay más configuraciones en beforeEach, alrededor de nueve spies y configuraciones de stub. Probablemente, si quisiera crear un nuevo caso de prueba desde cero o mover pruebas a través de diferentes archivos, necesitaría mantener excessive setup como está ahora, ya sabes, la excessive setup.

La excessive setup es una trampa habitual. Yo también he sentido el dolor de tener que construir muchas dependencias antes de comenzar a probar un fragmento de código. El siguiente código es de mi proyecto de investigación llamado testable - es un fragmento de código reactjs que está orientado al estilo funcional:
import { Component } from 'react';
import PropTypes from 'prop-types';
import { Redirect } from 'react-router';
import Emitter, { PROGRESS_UP, LEVEL_UP } from '../../../../packages/emitter/Emitter';
import { track } from '../../../../packages/emitter/Tracking';
import { auth } from '../../../../pages/login/Auth';
import Reason from '../../../../packages/engine/Reason';
import EditorManager from '../editor-manager/EditorManager';
import Guide from '../guide/Guide';
import Intro from '../intro/Intro';
import DebugButton from '../../buttons/debug/Debug';
import {SOURCE_CODE, TEST_CODE} from '../editor-manager/constants';
import {executeTestCase} from '../../../../packages/engine/Tester';
 
const Wrapped = (
  code,
  test,
  testCaseTests,
  sourceCodeTests,
  guideContent,
  whenDoneRedirectTo,
  waitCodeToBeExecutedOnStep,
  enableEditorOnStep,
  trackSection,
 
  testCaseStrategy,
  sourceCodeStrategy,
 
  disableEditor,
  introContent,
  enableIntroOnStep,
  editorOptions,
  attentionAnimationTo = []
 ) => {
  class Rocket extends Component {
   // code that has component logic
  }
}
 

El número excesivo de parámetros para probar la función es tan grande que si alguien (o yo mismo) comenzamos a escribir un nuevo caso de test, nos olvidaríamos de inyectar en la función para recibir el resultado deseado.

Dave Farley muestra otro ejemplo de Jenkins, un proyecto ci / cd de código abierto. Él describe un caso de test particular que construye un navegador web para afirmar que la URL que se está utilizando es de desarrollo:

/**
* Makes sure the use of "localhost" in the Hudson URL reports a warning.
*/
@Test
public void localhostWarning() throws Exception {
    HtmlPage p = j.createWebClient().goTo("configure");
    HtmlInput url = p.getFormByName("config").getInputByName("_.url");
    url.setValueAttribute("http://localhost:1234/");
 
    assertThat(
        p.getDocumentElement().getTextContent(),
        containsString("instead of localhost")
    );
}

 

Él argumenta que el caso de test crea un cliente web para afirmar la URL, que podría haber sido una llamada de objeto regular y afirmar el valor devuelto, evitando la creación del cliente web.

La excessive setup puede mostrarse en diferentes contextos. Para una situación dada, la pregunta es: ¿estamos enfocados en probar el fragmento de código que necesitamos o estamos dedicando tiempo a configurar el escenario? Si la respuesta es hacia la segunda opción, entonces tienes un candidato para ser excesivo.

The liar

Es el cuarto antipatrón TDD más popular según los datos de la encuesta. The liar es uno de los anti-patrones más comunes que puedo recordar en mi vida profesional practicando TDD. Enumeraría al menos esas dos razones para detectar estos problemas entre las bases de código:

Casos de prueba orientados a async
Casos de prueba orientados al tiempo


El primero está bien explicado en la jest documentation oficial. Probar el código asincrónico se vuelve complicado ya que se basa en un valor futuro que puede recibir o no. El siguiente código es un ejemplo reproducido de la documentación de broma que describe el problema relacionado con las pruebas asíncronas.

// Don't do this!
test('the data is peanut butter', () => {
  function callback(data) {
    expect(data).toBe('peanut butter');
  }
 
  fetchData(callback);
});

 

Volviendo al anti-patrón, este test pasaría sin quejarse, "el mentiroso". El enfoque correcto es esperar a que la función asíncrona termine su ejecución y darle control jest sobre la ejecución del flujo nuevamente; esto se hace invocando un parámetro que inyecta Jest al ejecutar los test. En el ejemplo de código que sigue, este parámetro se llama "done".

test('the data is peanut butter', done => {
  function callback(data) {
    try {
      expect(data).toBe('peanut butter');
 
      done(); // <------ invokes jest flow again, saying: "look I am ready now!"
 
    } catch (error) {
      done(error);
    }
  }
 
  fetchData(callback);
});
 
El segundo, Martin Fowler, explica las razones por las que ese es el caso, y aquí me gustaría compartir algunas opiniones que concuerdan con lo que escribió.

Lo asincrónico es una fuente de no determinismo, y debemos tener cuidado con eso (como acabamos de explorar en el ejemplo anterior). Además de eso, otro candidato para tal no determinismo son los hilos.

Por otro lado, las pruebas orientadas al tiempo a veces pueden fallar sin motivo. Por ejemplo, he experimentado conjuntos de test que fallaron porque la prueba no estaba usando un mock para manejar fechas. Como resultado, el día en que se escribió el código, se aprobó el test, pero al día siguiente se rompió. Ya sabes, the liar otra vez.

The giant

Según los datos de la encuesta, the gigant es el quinto anti patrón TDD más popular.

The gigant también es un signo de falta en el diseño del código base. El diseño de código también es un tema de discusión entre los profesionales de TDD. Sandro Mancuso habló sobre la relación entre TDD y un buen diseño: ¿TDD realmente conduce a un buen diseño?

A diferencia de excessive setup, diría que este anti-patrón también puede ocurrir mientras se desarrolla en una forma TDD. Por ejemplo, Javier Chacana exploró los conceptos de TPP (Transformation Priority Premise) que muestran pequeñas transformaciones realizadas en el código fuente para evitar reflejar el código de prueba con el código de producción. The gigant a menudo se relaciona con la God class, lo que va en contra de los principios SOLID..

En TDD, the gigant a menudo se relaciona con muchas afirmaciones en un solo test case, como lo describe Dave Farley en su video. El mismo archivo de test utilizado por nuxtjs en la excessive setup muestra señales del "gigante". Inspeccionando el código más de cerca, vemos quince afirmaciones para un solo caso de prueba:

test('should setup middleware', async () => {
    const nuxt = createNuxt()
    const server = new Server(nuxt)
    server.useMiddleware = jest.fn()
    server.serverContext = { id: 'test-server-context' }
 
    await server.setupMiddleware()
 
    expect(server.nuxt.callHook).toBeCalledTimes(2) // 1
    expect(server.nuxt.callHook).nthCalledWith(1, 'render:setupMiddleware', server.app) // 2
    expect(server.nuxt.callHook).nthCalledWith(2, 'render:errorMiddleware', server.app) // 3
 
    expect(server.useMiddleware).toBeCalledTimes(4) // 4
    expect(serveStatic).toBeCalledTimes(2) // 5
    expect(serveStatic).nthCalledWith(1, 'resolve(/var/nuxt/src, var/nuxt/static)', server.options.render.static)  // 6
    expect(server.useMiddleware).nthCalledWith(1, {
      dir: 'resolve(/var/nuxt/src, var/nuxt/static)',
      id: 'test-serve-static',
      prefix: 'test-render-static-prefix'
    })  // 7
    expect(serveStatic).nthCalledWith(2, 'resolve(/var/nuxt/build, dist, client)', server.options.render.dist)  // 8
    expect(server.useMiddleware).nthCalledWith(2, {
      handler: {
        dir: 'resolve(/var/nuxt/build, dist, client)',
        id: 'test-serve-static'
      },
      path: '__nuxt_test'
    }) // 9
 
    const nuxtMiddlewareOpts = {
      options: server.options,
      nuxt: server.nuxt,
      renderRoute: expect.any(Function),
      resources: server.resources
    }
    expect(nuxtMiddleware).toBeCalledTimes(1// 10
    expect(nuxtMiddleware).toBeCalledWith(nuxtMiddlewareOpts) // 11
    expect(server.useMiddleware).nthCalledWith(3, {
      id: 'test-nuxt-middleware',
      ...nuxtMiddlewareOpts
    }) // 12
 
    const errorMiddlewareOpts = {
      resources: server.resources,
      options: server.options
    }
    expect(errorMiddleware).toBeCalledTimes(1) // 13
    expect(errorMiddleware).toBeCalledWith(errorMiddlewareOpts) // 14
    expect(server.useMiddleware).nthCalledWith(4, {
      id: 'test-error-middleware',
      ...errorMiddlewareOpts
    }) // 15
  })
 
El punto de atención aquí es reflexionar si tiene sentido dividir cada bloque de código y la afirmación en su caso de prueba. Requeriría una inspección adicional para verificar si es posible descifrar el código como se sugiere. Como Dave Farley describe en su video, es un excelente ejemplo del "gigante" que no se recomienda esta práctica.

The slow poke

De acuerdo con la encuesta el slow poke es el sexto tdd anti-patterns mas popular. Me recuerda a pokemon,  y al igual que la criatura, el empuje lento reduce la eficiencia del conjunto de test. Por lo general, pone en ejecución el conjunto de test y tarda más en finalizar y dar feedback al desarrollador.

Una de las causas es el time-related code, que es difícil de manejar en un caso de test; Implica manipularlo de diferentes formas.

Por ejemplo, si estamos tratando con sistemas de pago, nos gustaría activar alguna rutina para lanzar un pago a fin de mes. Para eso, necesitaríamos una forma de manejar el tiempo y verificar una fecha específica (el último día del mes) y una hora (¿en algún lugar alrededor de la mañana? ¿Tarde?). En otras palabras, necesitamos una forma de manejar el tiempo y lidiar con él sin la necesidad de esperar hasta el final del mes para ejecutar el test, o peor aún, ¿dejarías la prueba ejecutándose durante un mes para recibir feedback?

El código relacionado con el tiempo conduce al no determinismo, como ya se mencionó en la sección The lier.  Pero, por suerte, hay una manera de superar esta situación con mocks.

Avanzar hacia las pruebas de integración o end-to-end tests también puede transformar la suite de pruebas en una tarea difícil de ejecutar, lo que lleva muchas horas o incluso días. Este enfoque también está relacionado con el cono de helado en lugar de la pirámide de la prueba. En un escenario ideal, tendría como base más unit test, algunas pruebas de integración e incluso menos pruebas end-to-end.

En mi experiencia, el slow poke viene en dos modos diferentes:

  • Falta de conocimiento en TDD, más específicamente en mocking strategies.
  • Test con estrategias que el framework ya ofrece de forma inmediata.

Consideraciones finales

Los test y TDD son una práctica que los desarrolladores han adoptado y practicado durante algunos años. Por tanto, todavía hay margen de mejora. Partiendo de los anti-patrones que se exploraron aquí: 
  • El comportamiento asíncrono en el tiempo necesita un cuidado especial al escribir test.
  • Un diseño deficiente del código puede provocar excessive setup y casos de the gigant, lo que dificulta los test. 
  • Tener una suite de test lenta puede generar frustración para los desarrolladores.
  • Mantén la suite de test lo más rápida posible para mejorar el ciclo de feedback.
James Carr enumeró 22 anti patrones pero no hay una única fuente verdadera, ya que en comparación, podemos encontrar diferentes números para diferentes fuentes. Aquí, repasamos cuatro de ellos, lo que significa que hay muchos más por repasar; cada anti patrón de la lista se puede explorar en diferentes contextos y lenguajes de programación; el contexto es importante para esos anti patrones.

Además de eso, también obtuvimos información de campo por parte de los profesionales, en la que pudimos saber qué anti patrones se conocen más, qué lenguajes de programación usan más frecuentemente. Con suerte, también dejamos algunas ideas sobre cómo avanzar con este tema.

¡Adelante, sigue haciendo testing!