TDD y anti patrones - Capítulo 6

21 Jun 2022

Continuamos con nuestra serie de TDD y anti patrones. En el primer capítulo de esta serie cubrimos estos anti patrones; The Liar, Excessive Setup, The Giant and Slow Poke, como parte de los 22 que existen y que James Carr ha reconocido. 

En este blog vamos a centrarnos en los dos últimos antipatrones de la lista: Flash y Jumper. Ambos se centran en la práctica de testar el código en lugar de en el propio código en sí.

(En los enlaces anteriores puedes ver los vídeos de las sesiones on demand. Sólo tienes que dejar tu correo y podrás acceder al vídeo).

Conclusiones

  • El One es un conjunto de antipatrones
  • Tener que lidiar con el estado global durante la ejecución de las pruebas agrega complejidad
  • La mayoría de los antipatrones cubiertos en esta serie están relacionados con la codificación, el Flash se centra en la práctica de escribir pruebas
  • El Jumper está relacionado con el aprendizaje de cómo test drive el código

El One

A pesar del nombre, el One es el antipatrón que agrega diferentes antipatrones, por definición está relacionado con el Giant y el Free Ride. Hasta ahora, hemos visto que los antipatrones suelen combinarse y no es fácil trazar una línea clara cuando uno termina y el otro comienza.

Como ya vimos, el Giant aparece cuando un caso de prueba intenta hacer todo al mismo tiempo en un único caso de prueba. En el primer episodio vimos un fragmento de nuxtjs que se ajustaba a la definición de gigante.

Por lo tanto, hay algunas variantes sobre ello. El siguiente fragmento está extraído del libro xunit y representa al Giant, aunque el número de líneas no son tantas como en el primer episodio. El Giant se figura aquí porque el caso de prueba está tratando de ejercitar todos los métodos a la vez:

 

public void testFlightMileage_asKm2() throws Exception {
    // set up fixture
    // exercise constructor
    Flight newFlight = new Flight(validFlightNumber);
    // verify constructed object
    assertEquals(validFlightNumber, newFlight.number);
    assertEquals("", newFlight.airlineCode);
    assertNull(newFlight.airline);
    // set up mileage
    newFlight.setMileage(1122);
    // exercise mileage translator
    int actualKilometres = newFlight.getMileageAsKm();
    // verify results
    int expectedKilometres = 1810;
    assertEquals( expectedKilometres, actualKilometres);
    // now try it with a canceled flight
    newFlight.cancel();
 
    try {
        newFlight.getMileageAsKm();
        fail("Expected exception");
    } catch (InvalidRequestException e) {
        assertEquals( "Cannot get cancelled flight mileage",
        e.getMessage());
    }
}

 

Los comentarios incluso nos dan una pista sobre cómo dividir el único caso de prueba en varios. Asimismo, en este ejemplo también se puede observar el recorrido libre, ya que para cada configuración, hay aserciones que siguen.

El siguiente ejemplo de código puede ser más claro para ver cuando aparece el Free Ride, como ya se vio en el episodio anterior, el ejemplo que sigue fue extraído del repositorio de Jenkins. En este caso, la mezcla entre el Free Ride y el Giant es un poco borrosa, pero aún así es fácil detectar que un solo caso de prueba está haciendo demasiado.

public class ToolLocationTest {
    @Rule
    public JenkinsRule j = new JenkinsRule();
 
    @Test
    public void toolCompatibility() {
        Maven.MavenInstallation[] maven = j.jenkins.getDescriptorByType(Maven.DescriptorImpl.class).getInstallations();
        assertEquals(1, maven.length);
        assertEquals("bar", maven[0].getHome());
        assertEquals("Maven 1", maven[0].getName());
 
        Ant.AntInstallation[] ant = j.jenkins.getDescriptorByType(Ant.DescriptorImpl.class).getInstallations();
        assertEquals(1, ant.length);
        assertEquals("foo", ant[0].getHome());
        assertEquals("Ant 1", ant[0].getName());
 
        JDK[] jdk = j.jenkins.getDescriptorByType(JDK.DescriptorImpl.class).getInstallations();
        assertEquals(Arrays.asList(jdk), j.jenkins.getJDKs());
        assertEquals(2, jdk.length); // JenkinsRule adds a 'default' JDK
        assertEquals("default", jdk[1].getName()); // make sure it's really that we're seeing
        assertEquals("FOOBAR", jdk[0].getHome());
        assertEquals("FOOBAR", jdk[0].getJavaHome());
        assertEquals("1.6", jdk[0].getName());
    }
}
 

El Peeping Tom

Tener que lidiar con el estado global durante la ejecución de las pruebas agrega complejidad, por ejemplo, requiere una limpieza adecuada antes de cada prueba y también después de que se ejecute cada una, de forma que se eviten los efectos secundarios.

El Peeping Tom  expone el problema que trae el uso del estado global durante la ejecución de pruebas. En stack overflow hay un hilo dedicado a este tema que tiene unos cuantos comentarios que ayudan a entender mejor de qué se trata. Christian Posta también escribió en su blog que los métodos estáticos son smells de código.

Allí, hay un fragmento que fue extraído de este blog que representa cómo el uso de singleton y propiedades estáticas puede dañar el caso de prueba y mantener el estado entre las pruebas. Aquí, vamos a utilizar el mismo ejemplo con pequeños cambios para que el código se compile.

La idea detrás del singleton es crear y reutilizar una única instancia de cualquier clase de objeto. Para lograrlo podemos crear una clase (en este ejemplo llamada MySingleton) y bloquear la creación de un objeto a través de su constructor y permitir sólo la creación dentro de la clase, controlada por el método getInstance:

public class MySingleton {
 
    private static MySingleton instance;
    private String property;
 
    private MySingleton(String property) {
        this.property = property;
    }
 
    public static synchronized MySingleton getInstance() {
        if (instance == null) {
            instance = new MySingleton(System.getProperty("com.example"));
        }
        return instance;
    }
 
    public Object getSomething() {
        return this.property;
    }
}
 

Cuando se trata de testear, no hay mucho con lo que lidiar, así que por ejemplo, el método expuesto en MySingleton llamado getSomething puede ser invocado y afirmado contra un valor como se muestra en el siguiente fragmento:

import org.junit.jupiter.api.Test;
 
import static org.assertj.core.api.Assertions.assertThat;
 
class MySingletonTest {
    @Test
    public void somethingIsDoneWithAbcIsSetAsASystemProperty(){
        System.setProperty("com.example", "abc");
        MySingleton singleton = MySingleton.getInstance();
        assertThat(singleton.getSomething()).isEqualTo("abc");
    }
 
}
 

Un caso de prueba único pasará sin ningún problema, el caso de prueba crea la instancia singleton e invoca el getSomething para recuperar el valor de la propiedad definida cuando se definió la prueba. El problema surge cuando intentamos probar el mismo comportamiento pero con diferentes valores en el System.setProperty.

import org.junit.jupiter.api.Test;
 
import static org.assertj.core.api.Assertions.assertThat;
 
class MySingletonTest {
    @Test
    public void somethingIsDoneWithAbcIsSetAsASystemProperty(){
        System.setProperty("com.example", "abc");
        MySingleton singleton = MySingleton.getInstance();
        assertThat(singleton.getSomething()).isEqualTo("abc");
    }
 
    @Test
    public void somethingElseIsDoneWithXyzIsSetAsASystemProperty(){
        System.setProperty("com.example", "xyz");
        MySingleton singleton = MySingleton.getInstance();
        assertThat(singleton.getSomething()).isEqualTo("xyz");
    }
}
 

Dada la naturaleza del código, el segundo caso de prueba fallará y mostrará que aún mantiene el valor abc como se muestra en la siguiente imagen:

Como el singleton garantiza una sola instancia de un objeto dado, durante la ejecución de la prueba, la primera prueba que se ejecuta, crea la instancia y para las siguientes ejecuciones reutiliza la instancia previamente creada.

En este caso de prueba es "fácil" de ver, ya que un caso de prueba se ejecuta después del otro. Pero, los marcos de prueba que ejecutan tests en paralelo o que no garantizan el orden de las pruebas, pueden fallar por diferentes razones.

Como la clase singleton tiene una propiedad privada que controla la instancia creada, no es posible limpiarla sin cambiar el propio código (lo que sería un cambio sólo para probar). Por lo tanto, otro enfoque sería utilizar la reflexión para restablecer la propiedad y comenzar siempre con una instancia fresca, como representa el siguiente código:

class MySingletonTest {
 
    @BeforeEach
    public void resetSingleton() throws SecurityException, NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
        Field instance = MySingleton.class.getDeclaredField("instance");
        instance.setAccessible(true);
        instance.set(null, null);
    }
}
 

Haciendo uso de la reflexión, es posible restablecer la instancia antes de que se ejecute cada caso de prueba (utilizando la anotación @BeforeEach).  Aunque este enfoque es posible, debe llamar la atención sobre el código extra y los posibles efectos secundarios al intentar probar la aplicación utilizando dicho patrón.

Representar al Peeping Tom de esta manera, además de tener que utilizar la reflexión para restablecer una propiedad, podría no parecer perjudicial para probar el código de la unidad. Pero, puede llegar a ser aún más difícil cuando una pieza de código que queremos probar depende de un singleton.

Tal como lo compartió Rodaney Glitzel: el problema no es el singleton en sí mismo, sino el código que depende de él que se vuelve más difícil de probar.

El Flash

Los desarrolladores que se bloquean practicando TDD, y antes de dividir el test en trozos pequeños, están pensando en todos los casos de riesgo que el test cubriría.

El Flash suele ocurrir cuando alguien está comenzando a practicar TDD y se apresura antes de tener lo básico en su lugar, es decir, el flujo de TDD (que se relaciona con el siguiente anti-patrón introducido aquí).

Además, el Flash también se produce como consecuencia de lo siguiente:

Grandes pasos

Los pasos grandes suelen ser la primera barrera para empezar con TDD, ¿qué es un paso pequeño? ¿Cuál es el tamaño correcto de un paso en realidad? Esto podría interpretarse como una cuestión subjetiva, y lo es. Pero, llegar al menor esfuerzo posible para escribir algunas pruebas es algo que se puede practicar. En el TDD por ejemplo, Kent Beck relaciona los pasos pequeños de la siguiente manera:

Los métodos de prueba deben ser fáciles de leer, básicamente código de línea recta. Si un método de prueba se hace largo y complicado, entonces hay que jugar a "pasos de bebé".  TDD by Example

Centrarse en la generalización

Otro enfoque que suele llevar a un bloqueo en el flujo de TDD es tratar de generalizar en exceso desde el principio y no dialogar con la prueba (o el código de producción).

El Jumper

La práctica de intentar seguir el enfoque de desarrollo dirigido por pruebas, pero saltando a los siguientes pasos del flujo antes de completar el anterior.

Para entender por qué esto es un antipatrón, primero vamos a repasar el famoso flujo TDD y que se debe utilizar al codificar. El enfoque clásico es comenzar con una prueba que falla (rojo), escribir suficiente código para hacerla funcionar (verde) y luego refactorizar para cambiar cualquier cosa que sea necesaria para hacer el código mejor (si es aplicable, en el episodio número 90, el tío Bob dice que suele ir por el rojo-verde muchas veces antes de saltar a la fase de refactorización).

Por lo general, la fase de refactorización no es obligatoria como la roja-verde, la refactorización no es tan frecuente al iniciar, se produce a medida que el código evoluciona y que el código necesita cambios para adaptarse a los nuevos requisitos.

Por lo tanto, tratar de seguir esas reglas estrictamente suele conllevar algunas suposiciones y, por lo tanto, conduce a un flujo difícil o a saltarse pasos. La siguiente sección enumera lo que potencialmente debe evitarse al practicar TDD.

Rojo

  • Intentar que sea "perfecto" desde el principio
  • Bloquearse por no saber qué codificar
  • Refactorizar en el rojo
      Cambiar el nombre de la clase
      Corregir estilos (cualquier tipo de estilo)
      Modificación de archivos que no están relacionados con la prueba

Verde

  • No confiar en lo que está codificado ("sé que pasará")

Refactorizar

  • Realizar cambios que rompan las pruebas

Estos consejos al final se reducen a la práctica, si hacesTDD lo más probable es que te hayas enfrentado a esto antes y si no es así puede que te enfrentes a ellos a medida que avances. Lo más importante es seguir las reglas del flujo lo más estrictamente posible.

Conclusiones

Los dos últimos anti-patrones, el Flash y el Jumper, definidos por James Carl en su blog han sido abordados y una vez más están estrictamente relacionados con la forma en que el código está escrito.

Se trata de una combinación de diferentes antipatrones y, en este caso, utilizamos específicamente el Giant y el Free Ride y tratamos de representar cómo están estrechamente relacionados entre sí, pero recuerden que cualquier combinación es posible.

El Peeping Tom es el que representa cómo el estado global puede perjudicar la experiencia de escribir pruebas con diferentes conjuntos de datos, sin embargo, hay contextos en los que este comportamiento es necesario. Para evitarlo, el uso de una abstracción es muy útil.

Este es el último episodio de la serie de antipatrones, hemos cubierto un montón de cosas a lo largo de estos meses y, si has disfrutado de la serie como nosotros, seguro que te habrás enfrentado a algunos de ellos en tu propia base de código, y si no, no te preocupes pronto los verás - y esto está bien.

Esta serie fue un intento de difundir la lista de antipatrones y cómo impactan en la experiencia al probar el código de la unidad a través de proyectos de código abierto y también de nuestra experiencia.

Si quieres revisar nuestra última sesión, the flash and the jumper, aquí te dejamos un link.

Como siempre, ¡happy testing!