TDD y anti patrones - Capítulo 4

11 Apr 2022

Continuamos con nuestra serie de anti patrones TDD. 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 greedy catcher, The sequencer, Hidden dependency and The enumerator. 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 los enlaces anteriores puedes ver los vídeos de las sesiones bajo demanda. Solo tienes que dejar tu email y podrás acceder al vídeo de la sesión.)

The greedy catcher

Manejar excepciones (o incluso usarlas) es complicado. Hay desarrolladores que no abogan por ninguna excepción, otros lo usan para informar que algo salió mal durante la ejecución.

Independientemente del tipo de desarrollador que seas, testear excepciones puede revelar algunos patrones que perjudican el primer enfoque del test. En el episodio anterior, echamos un vistazo al secret catcher, y ahora vamos a echar un vistazo a su primo, el greedy catcher.
El greedy catcher aparece cuando el sujeto bajo prueba maneja la excepción y oculta información útil sobre el tipo de excepción, el mensaje o el seguimiento de la pila. Dicha información es útil para mitigar posibles excepciones no deseadas que se generen.

A continuación, tenemos un ejemplo de un posible candidato para el greedy catcher, este fragmento de código se extrajo del proyecto Laravel/Cashier stripe - Laravel está escrito en PHP y es uno de los proyectos más populares en el ecosistema, el siguiente code es un paquete que envuelve el sdk de stripe en un paquete de Laravel. A pesar de tener un controlador de prueba/captura dentro del caso de prueba (que podría apuntar potencialmente a mejoras adicionales), cuando se lanza la excepción, el caso de prueba la detecta y tiene algo de lógica.

public function test_retrieve_the_latest_payment_for_a_subscription()

{

    $user = $this->createCustomer('retrieve_the_latest_payment_for_a_subscription');

    try {

        $user->newSubscription('main', static::$priceId)->create('pm_card_threeDSecure2Required');

       $this->fail('Expected exception '.IncompletePayment::class.' was not thrown.');

    } catch (IncompletePayment $e) {

        $subscription = $user->refresh()->subscription('main');

        $this->assertInstanceOf(Payment::class, $payment = $subscription->latestPayment());

        $this->assertTrue($payment->requiresAction());

    }

}

El greedy catcher surge no solo en el código de prueba, sino también en el código de producción, ocultar información útil para rastrear una excepción es una fuente de tiempo invertido que podría haber sido mucho menor. El siguiente ejemplo es una representación de un middleware javascript que analiza un token JWT y re-dirige al usuario si el token está vacío; el código usa jest y nuxtjs.

La línea 1 decodificó el token jwt a través del paquete jwt-decode, en caso de éxito, el middleware sigue el flujo, si el token es falso por algún motivo, invoca la función de cierre de sesión (líneas 2 y 3).

En lo que respecta al código, es difícil notar que debajo del bloque catch, la excepción se ignora y, si sucede algo, el resultado será lo que devuelve la función de cierre de sesión (línea 3).

export default function(context: Context) {

  try {

    const token = jwt_decode(req?.cookies['token']); // 1

    if (token) {

      return null;

    } else {

      return await logout($auth, redirect); // 2

    }

  } catch (e) {

    return await logout($auth, redirect); // 3

  }

}

El código de prueba utiliza algún contexto nuxtjs para crear la solicitud que el middleware procesará. El caso de prueba único representa un enfoque para verificar si el usuario está desconectado si el token no es válido. Ten en cuenta que la cookie está detrás de la variable serverParameters.

it('should logout when token is invalid', async () => {

  const redirect = jest.fn();

  const serverParameters: Partial<IContextCookie> = { // 1

    route: currentRoute as Route, $auth, redirect, req: { cookies: null },

  };

  await actions.nuxtServerInit(

    actionContext as ActionContext,

    serverParameters as IContextCookie

  );

  expect($auth.logout).toHaveBeenCalled(); // 2

});

La parte complicada aquí es que la prueba anterior pasa como debería, pero no por la razón esperada. serverParameters contiene el objeto req que tiene cookies establecidas en nulo (línea 1), cuando ese es el caso, javascript arrojará un error que no es posible acceder a un token de nulo, como nulo si no es una matriz u objeto. ¿Quieres probar este comportamiento en javascript?

Dicho comportamiento ejecuta el bloque catch, que llama a la función de cierre de sesión deseada (línea 2). El seguimiento del stack para este error no aparecerá en ningún lugar, ya que el bloque catch ignora la excepción en el código de producción.

Quizás te preguntes si esto es algo aceptable, ya que pasa los test, por lo tanto, hay una trampa aún más para en este escenario.

The sequencer

The sequencer arroja luz sobre un tema relacionado que se trató en la publicación de aserciones de prueba (en este caso, usando broma como marco de prueba). Más específicamente, la sección sobre Array Containing describe qué es el secuenciador.

En resumen, The sequencer aparece cuando una lista desordenada utilizada para la prueba aparece en el mismo orden durante las afirmaciones. En otras palabras, dar la idea de que los elementos de la lista deben estar ordenados.

El siguiente ejemplo muestra el secuenciador en la práctica, el caso de prueba verifica si la fruta deseada está dentro de la lista, el enfoque aquí es saber si la fruta está o no en la lista, independientemente de la posición:
const expectedFruits = ['banana', ¨'mango', 'watermelon']

expect(expectedFruits[0]).toEqual('banana')

expect(expectedFruits[1]).toEqual('mango')

expect(expectedFruits[0]).toEqual('watermelon')

Como no nos importa la posición, usar la utilidad arrayContaining podría encajar mejor y hace que la intención sea explícita para los lectores posteriores.

const expectedFruits = ['banana', 'mango', 'watermelon']
 
const actualFruits = () => ['banana', 'mango', 'watermelon']
 
expect(expectedFruits).toEqual(expect.arrayContaining(actualFruits))
 

Es importante tener en cuenta que arrayContaining también ignora la posición de los elementos y también si hay un elemento adicional. Si el código bajo prueba se estresa por la cantidad exacta de elementos, sería mejor usar una combinación de aserciones. Este comportamiento se describe en la documentación oficial de Jest.

El ejemplo con mock da una pista sobre qué puedes esperar en las bases de código que tienen este anti patrón, pero el siguiente ejemplo muestra un escenario en el que apareceThe sequencer para un archivo CSV.

def test_predictions_returns_a_dataframe_with_automatic_predictions(self,form):
   order_id = "51a64e87-a768-41ed-b6a5-bf0633435e20"
   order_info = pd.DataFrame({"order_id": [order_id], "form": [form],})
  file_path = Path("tests/data/prediction_data.csv") // 1
   service = FileRepository(file_path)
 
   result = get_predictions(main_service=service, order_info=order_info)
 
   assert list(result.columns) == ["id", "quantity", "country", "form", "order_id"] // 2

 

En la línea 1 se carga el archivo CSV para ser utilizado durante el test, luego la variable de resultado es contra lo que se afirmará y en la línea 2 tenemos la afirmación contra las columnas que se encuentran en el archivo.

Los archivos CSV usan la primera fila como encabezado del archivo separados por coma en la primera fila es el lugar en el que se define el nombre de las columnas y las líneas debajo que siguen los datos que debe tener cada columna. Si el CSV se cambia con un orden de columna diferente (en este caso, cambiando de país y de formulario), veríamos el siguiente error:

tests/test_predictions.py::TestPredictions::test_predictions_returns_a_dataframe_with_automatic_predictions FAILED [100%]
tests/test_predictions.py:16 (TestPredictions.test_predictions_returns_a_dataframe_with_automatic_predictions)
['id', 'quantity', 'form', 'country', 'order_id'] != ['id', 'quantity', 'country', 'form', 'order_id']
 
Expected :['id', 'quantity', 'country', 'form', 'order_id']
Actual   :['id', 'quantity', 'form', 'country', 'order_id']
 

En este caso de prueba, la sugerencia es que nos gustaría afirmar que las columnas existen independientemente del orden. Al final, lo más importante es tener la columna y los datos de cada columna independientemente de su orden.

Un mejor enfoque sería reemplazar la línea 2, representada anteriormente por la siguiente afirmación:

assert set(result.columns) == {"id", "quantity", "country", "form", "order_id"}

The sequencer es un anti patrón que a menudo no se detecta por su naturaleza de ser fácil de escribir y que el conjunto de test a menudo está en verde; así que dicho anti patrón se revela cuando alguien tiene dificultades para depurar la falla que se supone que está pasando.

Hidden dependency

The hidden dependency es un anti patrón popular entre los desarrolladores, en particular, hace que los desarrolladores se sientan tristes por las pruebas en general. Puede ser la fuente de horas de depuración del código de test para averiguar por qué el test está fallando, ya que a veces proporciona poca o ninguna información sobre la causa raíz.

(Sugerencia: si no estás familiarizado con vuex o the flux pattern, te recomendamos que lo revises primero).

El ejemplo que sigue en esta sección está relacionado con vue y vuex, en este caso de test el objetivo es enumerar a los usuarios en un menú desplegable. Vuex se utiliza como fuente de verdad para los datos.

En la línea 1, se define la estructura necesaria para vuex y en la línea 2, se crea la tienda de administración. Una vez que la tienda de stubs está en su lugar, podemos comenzar a escribir la prueba en sí. Como sugerencia para el siguiente fragmento de código, ten en cuenta que la tienda no tiene parámetros.

it('should list admins in the administrator field to be able to pick one up', async () => {
  const store = Store(); // 1
 
  const { findByTestId, getByText } = render(AdminPage as any, {
    store,
    mocks: {
      $route: {
        query: {},
      },
    },
  });
  await fireEvent.click(await findByTestId('admin-list'));
  await waitFor(() => {
    expect(getByText('Admin')).toBeInTheDocument(); // 2
  });
});
 

En la línea 1 creamos la tienda para usar en el código bajo test y en la línea 2 intentamos buscar el texto Admin, si está en el texto asumimos que la lista está funcionando. El problema aquí es que, si el test no logra encontrar al administrador, tendremos que sumergirnos en el código dentro de la tienda para ver qué está pasando.

El siguiente ejemplo de código muestra un mejor enfoque para usar explícitamente los datos necesarios al configurar la prueba. Esta vez en la línea 1, se espera que el administrador exista de antemano.

it('should list admins in the administrator field to be able to pick one up', async () => {
  const store = Store({ admin: { name: 'Admin' } }); // 1
 
  const { findByTestId, getByText } = render(AdminPage as any, {
    store,
    mocks: {
      $route: {
        query: {},
      },
    },
  });
  await fireEvent.click(await findByTestId('admin-list'));
  await waitFor(() => {
    expect(getByText('Admin')).toBeInTheDocument();
  });
});

 

En general, the hidden dependency aparece de diferentes maneras y en diferentes estilos de pruebas, por ejemplo, el siguiente ejemplo muestra un problema oculto que surge al probar la integración con la base de datos.

 
def test_dbdatasource_is_able_to_load_products_related_only_to_manual_purchase(

   self, db_resource

):

   config_file_path = Path("./tests/data/configs/docker_config.json")    // 1

   expected_result = pd.read_csv("./tests/data/manual_product_info.csv") // 2

   datasource = DBDataSource(config_file_path=config_file_path)

   result = datasource.get_manual_purchases()                            // 3

   assert result.equals(expected_result)
 

 

En las líneas 1 y 2, la configuración se realiza a través de archivos de configuración, la 2 es importante ya que el resultado del test debe coincidir con su contenido. Luego, en la línea 3, se ejerce el código bajo test.

Dentro de este método, hay una consulta que se ejecuta para recuperar las compras manuales y afirmar que es el mismo que el resultado esperado:

query: str = """
   select
     product.id
       po.order_id,
       po.quantity,
       product.country
   from product
   join purchased as pur on pur.product_id = product.id
   join purchased_order as po on po.purchase_id = cur.id
   where product.completed is true and
   pur.type = 'MANUAL' and
   product.is_test is true
   ;
"""

 

Esta query tiene una cláusula where particular que está oculta del test case, lo que hace que el test falle. De forma predeterminada, los datos generados a partir del resultado esperado establecen el indicador is_test en falso, lo que hace que no se devuelvan resultados en el caso de test.

The enumerator

Enumerar los requisitos en un brainstorming suele ser una buena idea. Puede ser útil crear una lista de este tipo para su consumo posterior, que incluso pueden convertirse en nuevas funciones para un proyecto de software. Como en el software estamos tratando con funciones, parece una buena idea traducirlas en el mismo idioma y orden, de modo que la verificación de las mismas se convierta en una lista de verificación.

Por muy bueno que suene para la organización y el manejo de características, la traducción de tales listas numeradas directamente al código puede generar problemas de legibilidad no deseados, e incluso más para el código de test.

Por extraño que parezca, enumerar casos de test con números es un comportamiento común que he visto entre los principiantes. Por alguna razón, al principio, parece una buena idea que escriban la misma descripción de la prueba y agreguen un número para identificarla. El siguiente código muestra un ejemplo:

from status_processor import StatusProcessor
 
def test_set_status():
 
    row_with_status_inactive_1 = dict(
 
    row_with__status_inactive_2 = dict(
 
    row_with_status_inactive_3 = dict(
 
    row_with_status_inactive_3b = dict(
 
    row_with_status_inactive_4 = dict(
 
    row_with_status_inactive_5 = d

 

Para los nuevos desarrolladores dentro de la base de código, la pregunta que surgirá es: ¿qué significa 1? ¿Qué significa 2? ¿Son el mismo caso de test? En pocas palabras, el espacio para ser explícito sobre lo que se está probando es el punto clave aquí.


El primer ejemplo fue en python, pero este anti patrón surge en diferentes lenguajes de programación. El siguiente ejemplo en es otro tipo de enumeración de casos de test, en este escenario, los casos de prueba son nombres de archivo que se utilizan para ejecutar las pruebas.

 

La enumeración de escenarios de test podría ocultar algunos patrones comerciales que están siendo reemplazados por números. La intención de lo que se está probando no está clara. Otro problema que viene con eso es el problema de mitigación, si alguna de esas pruebas falla, el mensaje de error probablemente le dará un número, pero no la causa raíz de la falla.

Javascript code

Copia y pega este fragmento para reproducir el error en javascript al intentar acceder a una propiedad nula:

const req = { cookies: null }

req?.cookies['token']

 

Conclusiones


En esta publicación, repasamos cuatro antipatrones más. Entre ellos The hidden dependency es el más popular y ocupa la segunda posición entre los 22 antipatrones. Los otros tres anti patrones revisados en esta sesión son algunos de los menos populares, respectivamente: The greedy catcher (7), The sequencer (8) y The Enumerator (9).

The greedy catcher y The hidden dependency pueden potencialmente degradar la experiencia para escribir test y código de producción. El primero oculta información útil cuando sucede algo no deseado y se lanza una excepción, y el otro, como su nombre lo indica, oculta la dependencia y dificulta la resolución de problemas. Por otro lado, The secuencer y The Enumerator están relacionados con una forma más estilística de escribir test. También podrían degradar la solución de problemas, pero ambos apuntan a una experiencia degradada para comprender lo que se está probando. Tener números que enumeran casos de prueba o secuencias que están explícitamente en la prueba y no son necesariamente necesarios es un olor a que algo podría mejorarse.

Como siempre, esperamos que hayáis disfrutado este nuevo episodio de la serie Testing Anti-Pattern, ya casi terminamos y solo faltan 6 más para cubrir.

¡Estad atentos y disfrutad haciendo test!