TDD y anti patrones - Capítulo 3

Este es un seguimiento de una serie de publicaciones sobre anti patrones TDD. En el episodio anterior cubrimos: The mockery, The inspector, The generous leftovers y The local hero, esos son parte de 22 antipatrones, enumerados por James Carr.

En esta publicación de blog nos vamos a centrar en cuatro más, llamados: The nitpicker, The secret catcher, The dodger y The Loudmouth. Cada uno de ellos se enfoca en un aspecto específico del código que dificulta las pruebas y están de alguna manera conectados con los principiantes en TDD. Son patrones que se encuentran en aquellos que no tienen experiencia escribiendo aplicaciones guiadas por pruebas. 

Además, según la encuesta, observamos que estos son cuatro de los anti patrones menos populares.

Si prefieres ver el workshop en el que hablamos sobre estos anti patrones te dejamos aquí un link al video.

The Secret Catcher

La encuesta reveló previamente que el captador de secretos está en la 7ª posición de los antipatrones de tdd más populares.

El Secret Catcher es un viejo amigo mío. He visto este anti patrón en diferentes bases de código, y muchas veces lo comparo con la "prisa" con la que se necesita liberar funcionalidades.

Se le llama 'secreto' porque ignora que el código debe generar una excepción y, en lugar de manejarlo (catch), el caso de prueba simplemente lo ignora. El Secret Catcher también está relacionado con The Greedy Catcher que aún no hemos cubierto.

El siguiente ejemplo escrito en vuejs con el cliente apollo es un intento de representar tal escenario, donde el método parece estar bien y hace lo que se supone que debe hacer. En otras palabras, envía una mutación al servidor para eliminar el tipo de pago del usuario asociado y al final actualiza la interfaz de usuario para reflejar que:

async removePaymentMethod(paymentMethodId: string) {
  this.isSavingCard = true;
 
  const { data } = await this.$apolloProvider.clients.defaultClient.mutate({
    mutation: DetachPaymentMethodMutation,
    variables: { input: { stripePaymentMethodId: paymentMethodId } },
  });
 
  if (this.selectedCreditCard === paymentMethodId) {
    this.selectedCreditCard = null;
  }
 
  this.isSavingCard = false;
}

 

La prueba javascript está escrita usando jest y la librería de pruebas. Aquí hay un desafío para ti, antes de seguir con el texto, ¿puedes detectar qué falta en el caso de prueba?

test('it handles error when removing credit card ', async () => {
  const data = await Payment.asyncData(asyncDataContext);
  data.paymentMethod = PaymentMethod.CREDIT_CARD;
 
  const { getAllByText } = render(Payment, {
    mocks,
    data() {
      return { ...data };
    },
  });
 
  const [removeButton] = getAllByText(Remove');
  await fireEvent.click(removeButton);
});

 

Comencemos por la afirmación que falta al final del caso de prueba. Si no te has dado cuenta el último paso que hace la prueba es esperar el evento click y listo. Para alguna función que elimina un método de pago de un usuario, sería una buena idea afirmar que se muestra un mensaje.

Además, el caso de prueba se basa en una configuración específica de que la mutación de graphql siempre funcionará. En otras palabras, si la mutación de graphql arroja una excepción en el código de producción, pero no hay ninguna señal de que se maneje. En este escenario, el caso de prueba depende de jest para informar del error, si lo hay.

The Nitpicker

Según los datos de la encuesta, el quisquilloso está en la octava posición de los anti patrones más populares de la tdd.

El quisquilloso, como dice la definición, se observa en las aplicaciones web donde la necesidad de afirmar la salida se centra en un objeto completo, en lugar de la propiedad específica necesaria. Esto es común para las estructuras JSON, como se muestra en el primer ejemplo.

El siguiente código afirma que se ha eliminado una aplicación. En este contexto, una aplicación es una entrada regular en la base de datos con la etiqueta "aplicación". Tengan en cuenta que este ejemplo en PHP se usa para afirmar el resultado exacto de la solicitud HTTP, nada más y nada menos.

public function testDeleteApplication()
{
    $response = $this->postApplication();
 
    $this->assertFalse($response->error);
 
    $this->delete('api/application/' . $response->data)
        ->assertExactJson([ //  <------------- is this needed?
        'data' => (string) $response->data,
        'error' => false
    ]);
}

 

En este sentido, esta prueba es frágil por una razón específica: si agregamos otra propiedad a la respuesta fallará quejándose de que el json ha cambiado. Para eliminar esas propiedades, tal falla sería útil. Por otro lado, agregar una nueva propiedad no debería ser el caso.

La "solución" sería reemplazar la idea "exacta" en esta afirmación para que sea menos estricta, como la siguiente:

public function testDeleteApplication()
{
    $response = $this->postApplication();
 
    $this->assertFalse($response->error);
 
    $this->delete('api/application/' . $response->data)
        ->assertJson([    // <----------- changing this assertion
        'data' => (string) $response->data,
        'error' => false
    ]);
}

 

El cambio aquí es afirmar que el fragmento deseado está en la salida, sin importar si hay otras propiedades en la salida, siempre que la deseada esté allí. Este simple cambio abre la puerta para alejarnos de la frágil prueba que teníamos en primer lugar.

Otra forma de no enfrentarse al quisquilloso es buscar las propiedades que deseas. El siguiente código es de un proyecto de código abierto que maneja el proceso de sining s3 para acceder a un recurso en amazon s3:

describe('#getSignedCookies()', function() {
  it('should create cookies object', function(done) {
    var result = CloudfrontUtil.getSignedCookies(
      'http://foo.com', defaultParams);
 
    expect(result).to.have.property('CloudFront-Policy');
    expect(result).to.have.property('CloudFront-Signature');
    expect(result).to.have.property('CloudFront-Key-Pair-Id');
    done();
  });
});

 

El código tiene tres aserciones para afirmar que tiene la propiedad deseada en lugar de verificar todas a la vez, independientemente de la salida.

Otro ejemplo de cómo abordar tales afirmaciones es el código extraído de un proyecto de código abierto que tiene como objetivo recopilar y procesar las cuatro métricas clave:

@Test
fun `should calculate CFR correctly by monthly and the time split works well ( cross a calendar month)`() {
    val requestBody = """ { skipped code } """.trimIndent()
    RestAssured
        .given()
        .contentType(ContentType.JSON)
        .body(requestBody)
        .post("/api/pipeline/metrics")
        .then()
        .statusCode(200)
        .body("changeFailureRate.summary.value", equalTo(30.0F))
        .body("changeFailureRate.summary.level", equalTo("MEDIUM"))
        .body("changeFailureRate.details[0].value", equalTo("NaN"))
        .body("changeFailureRate.details[1].value", equalTo("NaN"))
        .body("changeFailureRate.details[2].value", equalTo(30.0F))
}

 

Una vez más, RestAssured busca propiedad por propiedad en la salida en lugar de comparar todo en la salida como se muestra en el primer ejemplo.

Los marcos de prueba suelen ofrecer dicha utilidad para ayudar a los desarrolladores a probar. Por ejemplo, en el primer escenario, Laravel usa la sintaxis assertJson/assertExactJson. El segundo caso usa chai para representar cómo afirmar propiedades específicas en un objeto. Por último, pero no menos importante, RestAssured es el que se usa para describir cómo lidiar con el quisquilloso en el ecosistema kotlin.

The Dodger

Según los datos de la encuesta el dodger se encuentra en la octava posición de los anti patrones tdd más populares. 

El dodger, según mi experiencia, es el anti patrón más común cuando se comienza primero con las pruebas. Antes de profundizar en el ejemplo del código, me gustaría elaborar un poco más sobre por qué podría aparecer el dodger.

Escribir código de manera TDD implica redactar en primer lugar la prueba, para cualquier código. La regla es: comienza con una prueba reprobatoria. Hazlo pasar, y luego refactoriza. Tan simple como parece, hay algunos momentos específicos durante la práctica de este flujo en los que surge la pregunta, ¿qué debo probar?

Como dice la regla, el enfoque común es comenzar a escribir pruebas por una clase y una clase de producción, lo que significa que tendrás una relación 1-1. Entonces, la siguiente pregunta es: ¿qué tan pequeño debe ser el paso para escribir una prueba? Como este 'pequeño' depende del contexto, no está tan claro qué es un código de prueba mínimo para pasar al código de producción. Aunque hay algunos videos y practicantes que lo hacen muy fácil.

Esas dos preguntas, al comenzar a practicar TDD, son comunes y podrían conducir a la esquiva. Ya que se puso el foco en probar el código de implementación específico en lugar del comportamiento deseado. Para representar eso, toma el siguiente código de producción:

namespace Drupal\druki_author\Data;
 
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Language\LanguageManager;
use Drupal\Core\Locale\CountryManager;
 
final class Author {
 
  public static function createFromArray(string $id, array $values): self {
    $instance = new self();
    if (!\preg_match('/^[a-zA-Z0-9_-]{1,64}$/', $id)) {
      throw new \InvalidArgumentException('Author ID contains not allowed characters, please fix it.');
    }
    $instance->id = $id;
 
    if (!isset($values['name']) || !\is_array($values['name'])) {
      throw new \InvalidArgumentException("The 'name' value is missing or incorrect.");
    }
    if (\array_diff(['given', 'family'], \array_keys($values['name']))) {
      throw new \InvalidArgumentException("Author name should contains 'given' and 'family' values.");
    }
    $instance->nameGiven = $values['name']['given'];
    $instance->nameFamily = $values['name']['family'];
 
    if (!isset($values['country'])) {
      throw new \InvalidArgumentException("Missing required value 'country'.");
    }
    $country_list = \array_keys(CountryManager::getStandardList());
    if (!\in_array($values['country'], $country_list)) {
      throw new \InvalidArgumentException('Country value is incorrect. It should be valid ISO 3166-1 alpha-2 value.');
    }
    $instance->country = $values['country'];
 
    if (isset($values['org'])) {
      if (!\is_array($values['org'])) {
        throw new \InvalidArgumentException('Organization value should be an array.');
      }
      if (\array_diff(['name', 'unit'], \array_keys($values['org']))) {
        throw new \InvalidArgumentException("Organization should contains 'name' and 'unit' values.");
      }
      $instance->orgName = $values['org']['name'];
      $instance->orgUnit = $values['org']['unit'];
    }
 
    if (isset($values['homepage'])) {
      if (!UrlHelper::isValid($values['homepage']) || !UrlHelper::isExternal($values['homepage'])) {
        throw new \InvalidArgumentException('Homepage must be valid external URL.');
      }
      $instance->homepage = $values['homepage'];
    }
 
    if (isset($values['description'])) {
      if (!\is_array($values['description'])) {
        throw new \InvalidArgumentException('The description should be an array with descriptions keyed by a language code.');
      }
      $allowed_languages = \array_keys(LanguageManager::getStandardLanguageList());
      $provided_languages = \array_keys($values['description']);
      if (\array_diff($provided_languages, $allowed_languages)) {
        throw new \InvalidArgumentException('The descriptions should be keyed by a valid language code.');
      }
      foreach ($values['description'] as $langcode => $description) {
        if (!\is_string($description)) {
          throw new \InvalidArgumentException('Description should be a string.');
        }
        $instance->description[$langcode] = $description;
      }
    }
 
    if (isset($values['image'])) {
      if (!\file_exists($values['image'])) {
        throw new \InvalidArgumentException('The image URI is incorrect.');
      }
      $instance->image = $values['image'];
    }
 
    if (isset($values['identification'])) {
      if (isset($values['identification']['email'])) {
        if (!\is_array($values['identification']['email'])) {
          throw new \InvalidArgumentException('Identification email should be an array.');
        }
        $instance->identification['email'] = $values['identification']['email'];
      }
    }
 
    return $instance;
  }
 
  public function getId(): string {
    return $this->id;
  }
 
  public function getNameFamily(): string {
    return $this->nameFamily;
  }
 
  public function getNameGiven(): string {
    return $this->nameGiven;
  }
 
  public function getCountry(): string {
    return $this->country;
  }
 
  public function getOrgName(): ?string {
    return $this->orgName;
  }
 
  public function getOrgUnit(): ?string {
    return $this->orgUnit;
  }
 
  public function getHomepage(): ?string {
    return $this->homepage;
  }
 
  public function getDescription(): array {
    return $this->description;
  }
 
  public function getImage(): ?string {
    return $this->image;
  }
 
  public function checksum(): string {
    return \md5(\serialize($this));
  }
 
  public function getIdentification(?string $type = NULL): array {
    if ($type) {
      if (!isset($this->identification[$type])) {
        return [];
      }
      return $this->identification[$type];
    }
    return $this->identification;
  }
}

 

El objetivo aquí es validar este objeto Autor, para ser creado, la matriz dada debe contener datos válidos y, de lo contrario, se lanzará una excepción. Luego, el siguiente es el código de prueba:

public function testObject(): void {
  $author = Author::createFromArray($this->getSampleId(), $this->getSampleValues());
  $this->assertEquals($this->getSampleId(), $author->getId());
  $this->assertEquals($this->getSampleValues()['name']['given'], $author->getNameGiven());
  $this->assertEquals($this->getSampleValues()['name']['family'], $author->getNameFamily());
  $this->assertEquals($this->getSampleValues()['country'], $author->getCountry());
  $this->assertEquals($this->getSampleValues()['org']['name'], $author->getOrgName());
  $this->assertEquals($this->getSampleValues()['org']['unit'], $author->getOrgUnit());
  $this->assertEquals($this->getSampleValues()['homepage'], $author->getHomepage());
  $this->assertEquals($this->getSampleValues()['description'], $author->getDescription());
  $this->assertEquals($this->getSampleValues()['image'], $author->getImage());
  $this->assertEquals($this->getSampleValues()['identification'], $author->getIdentification());
  $this->assertEquals($this->getSampleValues()['identification']['email'], $author->getIdentification('email'));
  $this->assertEquals([], $author->getIdentification('not exist'));
  $this->assertEquals($author->checksum(), $author->checksum());
}

 

Lo primero que noté al hojear el código es que si necesito cambiar la forma en que obtengo el nombre del autor (cambiar el nombre del método), es necesario cambiar el código de prueba, aunque el comportamiento deseado no ha cambiado, la validación todavía se requiere.

Otro enfoque sería reescribir el caso de prueba único, por múltiples casos de prueba, capturando la excepción deseada si se pasa un valor no deseado. Luego, encapsularlo en una clase de validación para evitar el acoplamiento del código de prueba y producción.

The Loudmouth

Según los datos de la encuesta, el bocazas se encuentra en la octava posición de los anti patrones tdd más populares .

Al desarrollar, es algo común agregar algunos rastros para ver si lo que está haciendo el código coincide con la comprensión del desarrollador. Podemos llamar a eso depuración, que a menudo se usa cuando un desarrollador necesita comprender una parte del código.

Los practicantes de TDD argumentan que una vez que se practica TDD, no se necesita ninguna herramienta de depuración, ya sea una declaración de impresión o agregando puntos de interrupción en el código. Entonces, qué pasa si no tienes tanta experiencia con TDD?

A menudo la respuesta es una combinación de ambos, la depuración y el uso de las pruebas para hablar contigo. Por ejemplo, el siguiente código muestra un código de prueba que puede manejar un error si recibe un código javascript no válido. Ten en cuenta que el código se utiliza para analizar el código javascript y actuar sobre su resultado:

test.each([['function']])(
  'should not bubble up the error when a invalid source code is provided',
  (code) => {
    const strategy = jest.fn();
 
    const result = Reason(code, strategy);
    expect(strategy).toHaveBeenCalledTimes(0);
    expect(result).toBeFalsy();
  }
);

 

La verificación es sencilla. Comprueba si la estrategia deseada no se llamó ya que el código es un código javascript no válido y también verifica si el resultado fue falso booleano. Veamos ahora cómo se ve la implementación de esta prueba:

const reason = function(code, strategy) {
  try {
    const ast = esprima.parseScript(code);
 
    if (ast.body.length > 0) {
      return strategy(ast);
    }
  } catch (error) {
    console.warn(error); // <------------------ this is loud
    return false;
  }
};

 

Idealmente, al trabajar de forma TDD, el archivo console.log utilizado se burlaría desde el principio, ya que requeriría una verificación de cuándo fue llamado y con qué mensaje. Esta primera pista ya apunta a un enfoque que no es probar primero. La siguiente imagen muestra lo que causa el bocazas, aunque las pruebas son verdes, hay un mensaje de advertencia: ¿pasó la prueba?, ¿el cambio rompió algo?

Steve Freeman y Nat Pryce dan una idea de por qué el registro (como el console.log) debe tratarse como una función en lugar de un registro aleatorio que se usa por cualquier motivo.

El siguiente fragmento muestra una posible implementación que se burla de console.log y evita que se muestre el mensaje durante la ejecución de la prueba:

const originalConsole = globalThis.console;
 
beforeEach(() => {
  globalThis.console = {
    warn: jest.fn(),
    error: jest.fn(),
    log: jest.fn()
  };
});
 
afterEach(() => {
    globalThis.console = originalConsole;
});

 

Con la consola burlada, ahora es posible afirmar el uso de la misma en lugar de imprimir la salida mientras se ejecutan las pruebas. La versión sin el bocazas sería la siguiente:

const originalConsole = globalThis.console;
 
beforeEach(() => {
  globalThis.console.warn = jest.fn()
});
 
afterEach(() => {
    globalThis.console = originalConsole;
});
 
test.each([['function']])(
  'should not bubble up the error when a invalid source code is provided',
  (code) => {
    const strategy = jest.fn();
 
    const result = Reason(code, strategy);
    expect(strategy).toHaveBeenCalledTimes(0);
    expect(result).toBeFalsy();
    expect(globalThis.console.warn).toHaveBeenCalled(); // < -- did it warn?
  }
);

Consideraciones finales

En esta publicación, revisamos cuatro anti patrones más que están relacionados con los desarrolladores que están comenzando con TDD. Éstos también son los anti patrones menos populares que surgieron en la encuesta, The secret catcher en 7º, The nitpicker, The dodge y the loudmouth en 8º.

Vimos que el receptor secreto es engañoso y parece estar probando lo que se supone que debe probar, pero en una inspección minuciosa detectamos la excepción del manejo fallido. El quisquilloso, por otro lado, apunta a pruebas frágiles, en las que la afirmación utilizada para probar se enfoca en más cosas de las que necesita.

El dodge es el clásico anti patrón cuando se empieza en TDD, lleva a una relación uno a uno entre el código de prueba y el de producción. Por último, pero no menos importante, el bocazas puede hacer que el desarrollador dude de si la prueba que está pasando es verde por la razón correcta, ya que la salida contamina la salida mientras se ejecutan las pruebas.

Con todo, llegamos a un punto en el que cubrimos más del 50% de los 22 anti patrones enumerados por James Carr. Estos cuatro agregaron a nuestro kit de herramientas más información mientras probaban aplicaciones de manejo.