Figura 1: What is beautiful architecture? Obtenido de https://sorbonad.hashnode.dev/transitional-service-architectures

Actualmente, los desarrolladores en el ámbito de testing creo que disponemos de una gran variedad de stacks que facilitan la realización de pruebas, y me di cuenta de esto cuando empecé a leer el libro de Test-Driven Development By Example, como muchos patrones o practicas ya la podemos hacer con frameworks que nos ofrecen una amplia gama de métodos, librerías o clases para cumplir con estos objetivos y buenas prácticas, también me encontré con un artículo muy bueno sobre anti patrones  y recientemente me he enfrentado a algunos, uno en especial bastante repetitivo, que después de googlear un poco(bastante realmente) lo logre encontrar con muchos nombres Chain Gang, Weet Floor , The Generous leftovers, test pollution todos los nombres totalmente diferentes, pero que definen la misma situación

Efectos secundarios

Todos los términos anteriormente mencionados se refieren a la siguiente situación, donde básicamente un test llamémoslo test one modifica una variable, un valor, o un estado de un recurso que otro test, digamos test two utiliza después para evaluar su valor, esto hace que exista un cambio en ejecución, lo cual ocasiona  que el test falle en conjunto (cuando se ejecuta test one y test two), pero que de forma individual sea exitoso, ya se imaginaran lo complejo que es encontrar el error

Para tener un poco más de contexto quiero dar una breve introducción a en que momento esto puede ocurrir, porque no necesariamente que los test compartan el valor de un mismo recurso está mal, solo que cuando un test modifica o toca algo, debe dejarlo como estaba (pensándolo como agrupación de test), tratando siempre de evitar este comportamiento compartido en estados de variables globales o de entorno, veamos un ejemplo

Ejemplo con pytest

Vamos a probar el concepto del setup y el teardown, en donde un test necesita de tener ciertos valores cargados, pero el siguiente test también depende de esos valores; sin embargo, lo importante es dejar los valores como estaban en un punto por default, veamos un ejemplo un poco sencillo

from dataclasses import dataclass
from typing import Dict
from dacite import from_dict
import os

class ConvertDataException(Exception):
    pass


@dataclass
class User:
    name:str
    age:int


def validate_data(user:Dict):
    if os.getenv("VALIDATE_DATA") == 'True':
        return from_dict(User, user)
    raise ConvertDataException
Archivo main.py
import os
import pytest


@pytest.fixture(scope="function")
def environment_validate_data():
    os.environ["VALIDATE_DATA"] = 'True'
    yield
    os.environ.pop('VALIDATE_DATA')
Archivo conftest.py
from src.main import validate_data, User, ConvertDataException
import pytest

user_dict = {"name":"Jairo", "age":25}


def test_validate_data_return_success(environment_validate_data):
    assert isinstance(validate_data(user_dict), User)


def test_validate_data_return_exception():
    with pytest.raises(ConvertDataException):
        validate_data(user_dict)

Archivo test_main.py

Ejecuto el código con el siguiente comando

 poetry run pytest tests/ -s -vv --setup-show

y obtengo el siguiente resultado

Figura 2: Resultado de ejecución de test exitosos

aquí me estoy apoyando sobre los fixtures, que son básicamente funciones que se ejecutan en diferentes instantes del tiempo de la ejecución de un test, uso un test compartido en el archivo conftest el cual crea y borra un variable de entorno que es usada para validar dos test, estos test usan el mismo recurso, pero si revisamos la Figura 2 de la ejecución, el primer test implementa el fixutre creado en conftest, por eso tiene tres secciones que se ven llamadas

  • SETUP
  • TEST
  • TEARDOWN

el primero y el tercer proceso, son los procesos que hacen "la magia", los cuales se encargan de ejecutar el cargado de la información y luego de borrarla, de tal forma que el segundo test llamado test_validate_data_return_exception():, se ejecute sin problema, si no invoco este fixture y ejecuto el test obtenemos lo siguiente

Figura 3: Resultado de ejecutar los test si utilizar el fixture

que acaba de suceder?, el test uno que requiere que el recurso esté modificado o cargado, no encuentra la variable de entorno y genera error, aquí nos acabamos de adentrar a en un primer vistazo a unos test dependientes

Test Pollution

Como vimos en el ejemplo los test de alguna manera pueden tener dependencias, algunas se pueden controlar con configuraciones en setup y teardown, pero algunas pueden llegar a necesitar modificar estados globales y ocasionar esta situación particular de "contaminación de test" donde un test cambio un estado global haciendo que los test en conjunto fallen, pero de forma individual pasen, lo cual complica encontrar el error, veamos un ejemplo donde modifico un poco el código anterior

from dataclasses import dataclass
from typing import Dict
from dacite import from_dict
import os

class ConvertDataException(Exception):
    pass


MINIMUM_AGE = 18


@dataclass
class User:
    name:str
    age:int

    def __setattr__(self, key, value):
        if key == 'age':
            if value<MINIMUM_AGE:
                raise ValueError


def validate_data(user:Dict):
    if os.getenv("VALIDATE_DATA") == 'True':
        return from_dict(User, user)
    raise ConvertDataException
Archivo main.py
from src.main import validate_data, User, ConvertDataException
import pytest

user_dict = {"name":"Jairo", "age":25}


def test_validate_data_return_success(environment_validate_data):
    assert isinstance(validate_data(user_dict), User)


def test_validate_data_return_exception():
    with pytest.raises(ConvertDataException):
        validate_data(user_dict)


def test_validate_data_age_return_exception(environment_validate_data):
    user_dict["age"] = 17
    with pytest.raises(ValueError):
        validate_data(user_dict)

def test_validate_data_age_return_success(environment_validate_data):
    assert isinstance(validate_data(user_dict), User)
Archivo test_main.py

Los otros archivos siguen igual, ahora realice un cambio para que se valide la edad del usuario y sea mayor de edad, si no es así genera una excepción, ahora  probaré el happy path y el caso donde se genera la excepción,  vamos a ejecutarlos para ver el resultado

Figura 4: Test fallidos por contaminación de test 

falla un test, y aquí es donde empieza el dolor de cabeza, voy a ejecutarlo de manera individual para ver que sucede, para ello primer lo marco utilizando un mark

@pytest.mark.data_age_success
def test_validate_data_age_return_success(environment_validate_data):
    assert isinstance(validate_data(user_dict), User)

y lo ejecuto con el siguiente comando

poetry run pytest tests/ -s -m "data_age_success" -vv --setup-show

en la consola obtengo el siguiente resultado

Figura 5: Test individual ejecutado con exito

Para nuestra sorpresa, el test funciona de manera correcta, pero iría en  dirección opuesta de lo que significa el test unitario, independiente del ambiente, entorno, o lugar deben devolver el mismo resultado, aquí es donde entra una librería que encontré buscando un poco, ya que en este ejemplo en particular se puede encontrar fácil, pero cuando son 100 test o 1000 test  Se vuelve muy complicado

instalo la librería detect-test-pollution utilizando poetry

poetry add detect-test-pollution

es importante aclarar que funciona con pytest > 7 porque si no les puede generar el siguiente error

_PYTEST_KEY = pytest.StashKey[__name__]()
AttributeError: module 'pytest' has no attribute 'StashKey'

Para probar la herramienta necesitamos entender dos cosas, hay un test que falla que es el test test_validate_data_age_return_success, ese lo conozco, ahora hay un test que contamina ese valor que no sé cuál es, ese test o esos tests son los que esta herramienta nos ayudara a encontrar, en mi ejemplo anterior podríamos decir que se busque cuál usa la variable user_dict, pero que pasa si es un valor más complejo, una variable de entorno? Un archivo temporal, más difícil todavía un mocker?, entonces esta herramienta nos facilita esa tarea, para ello ejecuto el siguiente comando

 poetry run detect-test-pollution --tests tests/ --failing-test tests/test_main.py::test_validate_data_age_return_success

y obtengo el siguiente resultado

Figura 6: Se encuentra de manera automatizada el test que contamina

Entendamos primero el comando, la primera bandera --test indica la carpeta en donde se encuentra los test a ejecutar, la segunda bandera --failing-test contienen él id del test que está fallando, para obtener estos ids se puede utilizar el comando

poetry run pytest --collect-only -qq

este comando retorna el id principal y utilizando la nomenclatura id_principal::nombre_test se construye él id individual, y con esto, como se aprecia en la Figura se encuentra el polluting test tests/test_main.py::test_validate_data_age_return_exception el cual es el test que está modificando la variable global y generan el error, así que se modifica el test y se obtiene un resultado exitoso

Figura 7: Corrección del test que contamina, y se ejecuta correctamente

Se ejecuta el pulling test ahora, y nos ratifica que toda esta ok

Figura 8: Validación con la herramienta de búsqueda pulling, todo ok

Conclusión

Actualmente, existen muchas herramientas que facilitan el proceso de realización de test; sin embargo, las tecnologías y las arquitecturas se actualizan constantemente, esto implica que  se estén agregando test a la misma velocidad, esto sin duda puede traer también que se agreguen bugs incluso en las pruebas y este caso de las dependencias ocultas puede ser muy frecuente, por lo tanto, encontrar donde sucede es una tarea bastante compleja, por ello es importante entenderlo y saber cuando se forma ese patrón que nos ayuda a llegar a la conclusión de lo que está sucediendo, esta herramienta es bastante nueva, pero para mí tiene un gran potencial, por lo que seguiré indagando sobre ella para mostrar más ejemplos.

¿Te gusto el post y quisieras poder aplicar lo aprendido?

Únete al equipo de Simetrik AQUI