TDD y anti-patrones - Capítulo 5

23 May 2022
Matheus Marabesi

Matheus Marabesi

See author's bio and posts

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 post nos centraremos en los siguientes: the stranger, the operating system evangelist, success against all odds and The free ride. Cada uno de ellos se enfoca en un aspecto específico del código que hace que los test sean más difíciles, a veces proviene de no hacer TDD, a veces solo proviene de la falta de conocimiento sobre el diseño del código. En cualquier caso, este es un intento de explorar esos defectos y evitarlos en nuestra propia base de código

(En los enlaces anteriores puedes ver los vídeos de las sesiones a la carta. Sólo tienes que dejar tu correo electrónico y podrás acceder al vídeo de la sesión).

Conclusiones

  • Encadenar diferentes object calls en el código de prueba puede ser un smell relacionado con la ley de demeter
  • Atar el código de prueba con el tipo de sistema operativo trae problemas de portabilidad
  • Evitar afirmaciones deslizantes que requieran un caso de prueba para sí mismo
  • Céntrate en que cada caso de prueba tenga una sola responsabilidad y no tantas aserciones

El stranger

En este blog de java revisited la explicación de la ley de demeter nos da una pista de por qué el strange es un anti-patrón, también podemos relacionarlo con el libro de código limpio "hablar con amigos, no con extraños". En su ejemplo, la cadena de métodos es la que más expone al extraño, el ejemplo, se está utilizando en el código de producción. Carlos Caballero en su blog "Demeter’s Law: Don’t talk to strangers!" también utiliza código de producción para representar lo que es y cuando no se sigue la ley demeter, da un fragmento que idealmente tendría que ser testeado, y aquí vamos a ampliarlo e implementar el código de prueba.

Para empezar, aquí va el código que representa la infracción en el código de producción:

person
    .getHouse() // return an House's object
    .getAddress() // return an Address's object
    .getZipCode() // return a ZipCode Object
 

Dicho código, podría potencialmente llevar a el strange en el código de prueba, por ejemplo, para probar si la persona dada tiene un código postal válido, podríamos potencialmente escribir algo como lo siguiente:

describe('Person', () => {
    it('should have valid zip code', () => {
        const person = FakeObject.createAPerson({ zipCode: '56565656' });
        person
            .getHouse()
            .getAddress()
            .getZipCode()
        expect('56565656').toEqual(person.house.address.zipCode);
    });
});

 

Ten en cuenta que si queremos acceder al código postal, tenemos que ir hasta el objeto ZipCode, potencialmente, esto podría mostrar que lo que queremos probar es la propia dirección y no la persona.

describe('Address', () => {
    it('should have valid zip code', () => {
        const address = new Address(
            '56565656',
            '1456',
            'Street X',
            'My city',
            'Great state',
            'The best country'
        );
        expect('56565656').toEqual(address.getZipCode());
    });
});
 

La prueba en sí tiene algo que podría mejorarse para evitar esta clase de prueba casi uno a uno y el código de producción, por ejemplo, la interacción entre el objeto persona, la dirección y el código postal podría ser "escondido" en una implementación y probar la salida de la misma, en lugar de ir todo el camino en la cadena.

Antes de pasar a la siguiente, recuerda que el stranger podría ser también uno de los antipatrones que están relacionados con los smells de las pruebas. Hay algunos indicios de que podrías estar ante el extraño:

  • Depende del contexto
  • Está relacionado con el patrón xUnit en la sección "Test smells"
  • También puede estar relacionado con los mocks

El Operating system evangelist

El operating system evangelist está relacionado con el grado de acoplamiento del código de pruebas con el sistema operativo, la forma de acoplamiento puede ser en diferentes aspectos del código, por ejemplo, utilizando una ruta específica que sólo existe en windows.

Para representar este caso, el fragmento que sigue fue extraído del proyecto de código abierto Lutris. Lutris, tiene como objetivo ejecutar juegos que son para windows, en linux, la premisa del proyecto ya da algunas restricciones que se esperan en el código base. El resultado, es el siguiente caso de prueba, que lanza un proceso de linux:

class LutrisWrapperTestCase(unittest.TestCase):
    def test_excluded_initial_process(self):
        "Test that an excluded process that starts a monitored process works"
        env = os.environ.copy()
        env['PYTHONPATH'] = ':'.join(sys.path)
        # run the lutris-wrapper with a bash subshell. bash is "excluded"
        wrapper_proc = subprocess.Popen(
            [
                sys.executable, lutris_wrapper_bin, 'title', '0', '1', 'bash', 'bash',
                '-c',
                "echo Hello World; exec 1>&-; while sleep infinity; do true; done"
            ],
            stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, env=env,
        )
 

El caso de prueba depende en gran medida de bash para ejecutar el caso de prueba y como resultado fallaría si se intentara ejecutar en windows por ejemplo. Esto, no quiere decir que sea malo, es un equilibrio entre el enfoque del proyecto y el costo de tener una abstracción.

En el libro "Cosmic python" (Capítulo 3) el autor comparte la idea detrás del acoplamiento y la abstracción, en él utiliza la ruta del archivo como ejemplo. El ejemplo utilizado también puede relacionarse con el patrón de diseño de estrategia.

El operating system evangelist también apareció en go lang, en un tema que trataba de mitigar la nueva línea en linux y windows, de hecho, este tema es parte de la definición de este anti-patrón "Un buen ejemplo sería un caso de prueba que utiliza la secuencia de nueva línea para windows en una aserción, sólo para romper cuando se ejecuta en Linux''. En ese tread un usuario se queja de los problemas que tiene al ejecutar las mismas pruebas en windows la mayoría de los errores se deben a la diferencia entre el código de la línea de alimentación.


Otro antipatrón que está relacionado con el operating system evangelist es el héroe local. El héroe local conocido por tener todo en su lugar localmente para ejecutar una aplicación, pero tan pronto como intente ejecutarla en otra máquina, fallará.

 

FLASH BACK - En el episodio 2, además del héroe local hablamos de los anti patrones la burla, el inspector y las sobras generosas, los ejemplos utilizados son en javascript, kotlin y php

Ya tuvimos una discusión sobre el héroe local en el episodio 2 de esta serie, pero para reforzar cómo están conectados, aquí va un ejemplo del código fuente de Jenkins:

@Test
public void testWithoutSOptionAndWithoutJENKINS_URL() throws Exception {
    Assume.assumeThat(System.getenv("JENKINS_URL"), is(nullValue()));
    // TODO instead remove it from the process env?
    assertNotEquals(0, launch("java",
            "-Duser.home=" + home,
            "-jar", jar.getAbsolutePath(),
            "who-am-i")
    );
}


Este fragmento es particularmente interesante, porque quien lo escribió, ya se dio cuenta de que había algo de smells en el comentario: TODO en vez de quitarlo de la envolvente del proceso?

Por último, pero no por ello menos importante, las katas suelen captar ese tipo de patrones desde el principio, y empujan hacia una abstracción durante la fase de refactorización. El WordWrap es un ejemplo de kata que pretende romper en nuevas líneas si el contenido es mayor que el esperado. Para una explicación sobre las diferencias en las líneas de alimentación y los sistemas operativos, consulta el post de Baeldung.com.

Success against all odds

A lo largo de esta serie, hemos visto diferentes anti-patrones que surgen de la falta de practicar el primer enfoque de la prueba, tal comportamiento conduce a diferentes aspectos que dificultan las pruebas, por ejemplo, la configuración excesiva y el gigante, que están relacionados con el objeto dios.

Aquí, una vez más, vamos a repasar un anti-patrón que está relacionado con la falta de un enfoque de prueba primero, pero en cambio, el desarrollador sigue la prueba primero y en lugar de fallar primero, sólo hace que la prueba pase desde el principio. Cuando este es el caso, se revela el Success against all odds. Ya que la práctica de empezar a pasar la prueba desde el principio lleva a que la prueba pase incluso cuando se espera el fracaso.

Para representar este escenario, el siguiente fragmento es un intento de implementar un repositorio desde SpringBoot que paginará y consultará en base a una cadena dada.

@Repository
class ProductsRepositoryWithPostgres(
    private val entityManager: EntityManager
) : Repository {
 
    override fun listFilteredProducts(query: String?, input: PagingQueryInput?) {
        val pageRequest: PageRequest = input.asPageRequest()
        val page: Page<Product> = if (query.isNullOrBlank()) {
            entityManager.findAll(pageRequest)
        } else {
            entityManager.findAllByName(query, pageRequest)
        }
        return page
    }
}
 

Una vez que hemos visto el código dado que realmente realiza el acceso a la base de datos y aplica los criterios, el siguiente código de prueba es el que se utiliza para probar el repositorio.

Ten en cuenta que desde el principio estamos haciendo algunas operaciones pesadas aquí para poblar la base de datos con diferentes datos. Lo que podría ser potencialmente un smell.

Nota: Para el ejemplo, se ha eliminado el desmontaje con el fin de mantener la sencillez. El desmontaje elimina todos los datos insertados en la base de datos utilizada durante la prueba.

private fun setupBeforeAll() {
    productIds = (1..100).map { db().productWithDependencies().apply().get<ProductId>() }
    productIdsContainingWood.addAll(
        (1..3).map { insertProductWithName("WoodyWoodOrange " + faker.funnyName().name()) }
    )
    productIdsContainingWood.addAll(
        (1..3).map {
            insertProductWithName(
                faker.funnyName().name() + " WoodyWoodOrange " + faker.funnyName().name()
            )
        }
    )
 

Con la configuración en su lugar, vamos a echar un vistazo al primer caso de prueba de esta clase. El objetivo del caso de prueba es comprobar que dado un parámetro de ordenación, el parámetro CREATED_AT_ASC (comentario número 1) es el que buscamos, una vez dado éste, los datos deben ordenarse en consecuencia.

@Test
fun `list products sorted by creation at date ascending`() {
    val pageQueryInput = PagingQueryInput(
        size = 30, page = 0, sort = listOf(Sort.CREATED_AT_ASC) // 1
    )
    val result = repository.listFilteredProducts("", pageQueryInput) // 2
 
    assertThat(result.currentPage).isEqualTo(0) // 3
    assertThat(result.totalPages).isEqualTo(4) // 4
    assertThat(result.totalElements).isEqualTo(112) // 5
 
    assertThat(result.content.size).isEqualTo(30) // 6
    assertThat(result.content).allSatisfy { productIds.subList(0, 29).contains(it.id) } // 7
}
 

Vamos a bucear un poco en lo que ocurre en el código guiándonos por los comentarios que hay:

  • El parámetro que enviamos al repositorio con el orden que queremos y la paginación
  • La ejecución del código que queremos probar
  • Comprobamos que la página devuelta por el repositorio es la primera
  • Comprobamos que hay 4 páginas en total
  • Comprobamos que hay 112 en total
  • Comprobamos que la lista de elementos devueltos es la misma que se pide en la paginación
  • Comprobamos que la lista devuelta es la misma que en la lista creada en la configuración de antes de todo

El siguiente caso de texto muestra una variante de lo que podríamos querer probar, que es el orden inverso. En lugar de ascendente, ahora probaremos descendente. Observa que la mayoría de las afirmaciones son las mismas que en el caso de prueba anterior.

@Test
fun `list products sorted by creation at date ascending`() {
    val pageQueryInput = PagingQueryInput(
        size = 30, page = 0, sort = listOf(Sort.CREATED_AT_ASC) // 1
    )
    val result = repository.listFilteredProducts("", pageQueryInput) // 2
 
    assertThat(result.currentPage).isEqualTo(0) // 3
    assertThat(result.totalPages).isEqualTo(4) // 4
    assertThat(result.totalElements).isEqualTo(112) // 5
 
    assertThat(result.content.size).isEqualTo(30) // 6
    assertThat(result.content).allSatisfy { productIds.subList(0, 29).contains(it.id) } // 7
}
 

Evitemos repetir la lista de viñetas anterior y centrémonos en los elementos que son importantes.

El primer punto que es importante, es el número de aserciones que podríamos no necesitar para cada caso de prueba, por ejemplo del punto 3 al 6, hay aserciones que verifican la paginación y los números relacionados con la lista, leyendo el nombre de la prueba, nuestro objetivo es probar primero la ordenación y no la paginación. En otras palabras, podríamos haber utilizado sólo la última aserción.

Hablando del punto 7, vamos a profundizar un poco más en él, porque tener una afirmación de este tipo es una de las posibles razones para afrontar el éxito contra viento y marea, y de hecho en el código de la prueba es una de ellas, ya que se afirma sobre un subconjunto de la lista que siempre será verdadera.

En el libro de patrones xunit, una forma de evitar ese comportamiento falso/positivo el camino a seguir es tener el código lo más simple posible, sin lógica en él, esto se llama prueba robusta [1].

Refactorizando success against all odds

La pregunta aquí es, ¿qué podríamos hacer entonces para evitar tal cosa? La alternativa propuesta para este caso de prueba y el código fuente están relacionados con la división de responsabilidades en el caso de prueba, podríamos centrarnos en la ordenación y posteriormente en la paginación.

El primer ejemplo aquí sería ordenar la lista en orden ascendente, vale la pena mencionar que con este enfoque, podríamos potencialmente eliminar la gran configuración que se mostró anteriormente en el hook setupBeforeAll. Para este enfoque, en su lugar, configuramos los datos que se requieren para la prueba dentro de ella.

@Test
fun `list products sorted by ascending creation date`() {
    db().productWithDependencies("created_at" to "2022-04-03T00:00:00.00Z").apply() // 1
    db().productWithDependencies("created_at" to "2022-04-02T00:00:00.00Z").apply() // 2
    db().productWithDependencies("created_at" to "2022-04-01T00:00:00.00Z").apply() // 3
 
    val pageQueryInput = PagingQueryInput(sort = listOf(SortOrder.CREATED_AT_ASC))
 
    val result = repository.listFilteredProducts("", pageQueryInput)
 
    assertThat(result.content[0].createdAt).isEqualTo("2022-04-01T00:00:00.00Z")
    assertThat(result.content[1].createdAt).isEqualTo("2022-04-02T00:00:00.00Z")
    assertThat(result.content[2].createdAt).isEqualTo("2022-04-03T00:00:00.00Z")
}
 

Una vez que esto está en su lugar, pasamos al caso de prueba de orden descendente, que es el mismo, pero la aserción y la configuración cambiaron:

@Test
fun `list products sorted by creation at date descending`() {
    db().productWithDependencies("created_at" to "2022-04-01T00:00:00.00Z").apply()
    db().productWithDependencies("created_at" to "2022-04-02T00:00:00.00Z").apply()
    db().productWithDependencies("created_at" to "2022-04-03T00:00:00.00Z").apply()
 
    val pageQueryInput = PagingQueryInput(sort = listOf(SortOrder.CREATED_AT_DESC))
 
    val result = repository.listFilteredProducts("", pageQueryInput)
 
    assertThat(result.content[0].createdAt).isEqualTo("2022-04-03T00:00:00.00Z")
    assertThat(result.content[1].createdAt).isEqualTo("2022-04-02T00:00:00.00Z")
    assertThat(result.content[2].createdAt).isEqualTo("2022-04-01T00:00:00.00Z")
}
 

Lo siguiente es la paginación, ahora podemos empezar a centrarnos en la paginación y los aspectos que aporta.

Una vez que tenemos la ordenación en su lugar, podemos empezar a echar un vistazo a la paginación, y por supuesto, intentar probar una cosa específica a la vez. El siguiente ejemplo muestra cómo podríamos afirmar que tenemos el número deseado de páginas al paginar el resultado.

@Test
fun `should have one page when the list is ten`() {
    insertTenProducts()
    val page = PagingQueryInput(size = 10)
 
    val result = repository.listFilteredProducts(
        null,
        null,
        Page
    )
 
    assertThat(result.totalPages).isEqualTo(1)
}


El enfoque de descomponer las pruebas en "unidades" más pequeñas ayudaría a la comunicación entre los miembros del equipo que se ocuparán de este código más adelante, así como las ya mencionadas pruebas robustas.

El free ride

El free ride es uno de los antipatrones menos populares que se encontraron en la encuesta, tal vez esto se deba a que el nombre no es tan acogedor cuando se trata de recordar el significado.

El free rideaparece en los casos de prueba que normalmente requieren un nuevo caso de prueba para probar el comportamiento deseado, pero en su lugar, se pone otra aserción y a veces incluso se utiliza la lógica dentro del caso de prueba para ese fin.

Veamos el siguiente ejemplo extraído del proyecto puppeteer:

it('Page.Events.RequestFailed', async () => {
  const { page, server, isChrome } = getTestState();
 
  await page.setRequestInterception(true);
  page.on('request', (request) => {
    if (request.url().endsWith('css')) request.abort();
    else request.continue();
  });
  const failedRequests = [];
  page.on('requestfailed', (request) => failedRequests.push(request));
  await page.goto(server.PREFIX + '/one-style.html');
  expect(failedRequests.length).toBe(1);
  expect(failedRequests[0].url()).toContain('one-style.css');
  expect(failedRequests[0].response()).toBe(null);
  expect(failedRequests[0].resourceType()).toBe('stylesheet');
 
  if (isChrome)
    expect(failedRequests[0].failure().errorText).toBe('net::ERR_FAILED');
  else
    expect(failedRequests[0].failure().errorText).toBe('NS_ERROR_FAILURE');
  expect(failedRequests[0].frame()).toBeTruthy();
});

 

Como ya se ha revelado, el free ride está justo en la declaración if/else. Hay dos casos de prueba en esta única prueba, pero probablemente, la idea era reutilizar el mismo código de configuración y deslizar en una aserción en el mismo caso de prueba.

Otro enfoque sería dividir el caso de prueba para centrarse en un solo escenario a la vez. Puppeteer ya ha mitigado este problema utilizando una función para manejar tal escenario, utilizando eso para dividir los casos de prueba, tendríamos el primer caso de prueba se centra en el navegador Chrome:

itChromeOnly('Page.Events.RequestFailed', async () => {
  const { page, server } = getTestState();
 
  await page.setRequestInterception(true);
  page.on('request', (request) => {
    if (request.url().endsWith('css')) request.abort();
    else request.continue();
  });
  const failedRequests = [];
  page.on('requestfailed', (request) => failedRequests.push(request));
  await page.goto(server.PREFIX + '/one-style.html');
  expect(failedRequests.length).toBe(1);
  expect(failedRequests[0].url()).toContain('one-style.css');
  expect(failedRequests[0].response()).toBe(null);
  expect(failedRequests[0].resourceType()).toBe('stylesheet');
  expect(failedRequests[0].failure().errorText).toBe('net::ERR_FAILED');
  expect(failedRequests[0].frame()).toBeTruthy();
});
 

Y luego, el segundo caso para firefox.

itFirefoxOnly('Page.Events.RequestFailed', async () => {
  const { page, server } = getTestState();
  await page.setRequestInterception(true);
  page.on('request', (request) => {
    if (request.url().endsWith('css')) request.abort();
    else request.continue();
  });
  const failedRequests = [];
  page.on('requestfailed', (request) => failedRequests.push(request));
  await page.goto(server.PREFIX + '/one-style.html');
  expect(failedRequests.length).toBe(1);
  expect(failedRequests[0].url()).toContain('one-style.css');
  expect(failedRequests[0].response()).toBe(null);
  expect(failedRequests[0].resourceType()).toBe('stylesheet');
  expect(failedRequests[0].failure().errorText).toBe('NS_ERROR_FAILURE');
  expect(failedRequests[0].frame()).toBeTruthy();
});


La lógica dentro del caso de prueba ya es una indicación de que el viaje gratuito está jugando un papel. El ejemplo del titiritero puede mejorarse aún más. Ahora que dividimos la lógica en dos casos de prueba, hay más código duplicado (eso podría ser un argumento a favor de adoptar el free ride). Si ese es el caso, el marco de pruebas puede ayudarnos aquí.

Para evitar la duplicación de código en este escenario, podríamos utilizar el gancho beforeEach y mover la configuración requerida allí.

Moviéndonos un poco desde puppeteer, hay otras formas en las que puede aparecer el free ride, el siguiente código del proyecto jenkins :

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());
    }
}
 

Otro enfoque para evitar el free ride en este caso, sería una vez más dividir los casos de prueba:

public class ToolLocationTest {
    @Test
    @LocalData
    public void shouldBeCompatibleWithMaven() {
        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());
    }
 
    @Test
    @LocalData
    public void shouldBeCompatibleWithAnt() {
        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());
    }
 
    @Test
    @LocalData
    public void shouldBeCompatibleWithJdk() {
        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());
    }
}
 
La división incluso ayudaría a la mitigación si algo falla en el caso de prueba.

Conclusiones

Estamos llegando casi al final del viaje de los anti-patrones de pruebas, y como tal puedes sentir que las pruebas no son sólo algo que ayuda a aumentar la confianza en el cambio del código o algo que se utiliza como una forma de regresión. Las pruebas también se pueden utilizar para desglosar la funcionalidad y mejorar el bucle de retroalimentación.

Puede ser una sensación (también conocida como smell) o algo ya compartido con la comunidad del software como el Strange, pero si ves algo que necesita ser mejorado, probablemente así sea.

También es importante mantener (cuando sea posible) una abstracción entre las "partes difíciles" en el código como el tipo de sistema operativo, o la ruta del archivo para guardar los datos, podemos referirnos al python cósmico para profundizar en el tema de Acoplamiento y Abstracción. Por supuesto, también tenemos que probarlas, pero para ello podríamos beneficiarnos de diferentes tipos de pruebas.

Por último, pero no menos importante, vimos que las aserciones también son objeto de debate, nos damos cuenta de que a veces utilizamos aserciones que no son el objetivo para probar un determinado trozo de código y puede suceder que simplemente deslizamos una aserción en lugar de crear un nuevo caso de prueba.

En definitiva, los antipatrones de pruebas están limitados por el contexto, por lo que tener algunos de ellos en una base de código podría ser conocido por el equipo y adoptado como un tread-off.

Independientemente de los motivos por los que pueda enfrentarse a ellos en su propia base de código, compartimos aquí otros cuatro antipatrones que podrían evitarse con la esperanza de aumentar el bucle de retroalimentación y disminuir el dolor percibido por los desarrolladores al comenzar con el enfoque de las pruebas primero.

Como siempre esperamos que hayáis disfrutado de este nuevo episodio y os esperamos en nuestra última sesión en la que hablaremos de otros dos antipatrones: El Saltador y El Relámpago.

¡Estad atentos y disfrutad de las pruebas!