ūüźć Property Based Testing en pandas

ūüĎ®‚ÄćūüíĽ Aprendizaje
ūüźć Python
ūüďÖ 2022-03-26

Llevo un tiempo pensando cómo poder sacarle partido a esta funcionalidad, en pandas una librería de transformaciones de datos, gracias a Carlos Blé por su consejo de intentar property based testing en las transformaciones explorando casos límite apoyados de una librería, en este caso hypothesis.

Voy a explicar algo qu√© a lo mejor las personas qu√© no trabajan en datos no conocen, pero la realidad es que lo ideal ser√≠a probar todos los casos l√≠mite de los datos del d√≠a a d√≠a, esto no sucede hasta realizar la transformaci√≥n de todos los datos, en nuestro caso un test unitario eso es inviable. Otra opci√≥n es generar un subset de datos peque√Īos a partir del grande pero lleva trabajo realizarlo correctamente, por lo qu√© tampoco es una opci√≥n rentable porque si ma√Īana cambian los datos no solo tendremos que rehacer ese subset sino tambi√©n el test ya qu√© no estaba contemplando los nuevos casos de uso, eso puede generar falsos positivos en algunos casos ya que est√° siendo probado contra datos predefinidos y est√°ticos.

Voy a ir explicando la evolución de menos más junto con las ventajas e inconvenientes de cada caso. En este caso imaginemos que tenemos una función legacy a testear:

class Transformation:

    def sum_two_columns_and_generate_result(self, df: pd.DataFrame, first_column_name: str,
                                            second_column_name: str):
        df['result'] = df[first_column_name] + df[second_column_name]
        return df

Para realizar un test unitario primero deberemos tener en cuenta qué pandas tiene su manera de interpretar los datos al leerlos al igual qué al transformarlos. Por ejemplo:

> np.nan + 0.0
> 0.0

Esto puede resultar confuso porqué por ejemplo si hacemos esto:

> None + 0.0
> TypeError: unsupported operand type(s) for +: 'NoneType' and 'float'

Así qué tanto si hacemos TDD cómo si estamos testeando lo que ya está deberemos tener mucho cuidado y conocimiento de la librería. La función en este caso es muy sencilla pero se puede complicar mucho testear transformaciones.

Cómo primero ejemplo podríamos usar este test:

def test_series_transformation_return_sum_of_columns():
    transformation = Transformation()
    val1 = 1.0
    val2 = 2.2
    df_given = pd.DataFrame(
        {
            'column_1': [val1],
            'column_2': [val2],
        }
    )
    expected_result = pd.Series([val1 + val2])

    df_calculated = transformation.sum_two_columns_and_generate_result(df_given, 'column_1', 'column_2')

    pd.testing.assert_series_equal(df_calculated.result, expected_result, check_names=False)

Aquí el problema de este test es qué los datos son estáticos y sólo contemplan un caso de uso por lo qué no es algo en lo qué podamos apoyarnos o confiar.

Vamos a probar pytest y pasarle algunos par√°metros m√°s:

@pytest.mark.parametrize('val1, val2', [
    (0.1, 0.0),
    (1, -1),
    (0.000000001, 7.0),
    (np.nan, 0.0),
])
def test_pytest_transformation_return_sum_of_columns(val1: float, val2: float):
    transformation = Transformation()
    df_given = pd.DataFrame(
        {
            'column_1': [val1],
            'column_2': [val2],
        }
    )
    expected_result = pd.Series([val1 + val2])

    df_calculated = transformation.sum_two_columns_and_generate_result(df_given, 'column_1', 'column_2')

    pd.testing.assert_series_equal(df_calculated.result, expected_result, check_names=False)

Aqu√≠ tenemos un problema, lo m√°s seguro es que alg√ļn caso se nos escape, pero nos da algo m√°s de confianza, est√° claro qu√© reflejamos todos los casos contemplados para estos datos. Aqu√≠ yo lo veo aceptable en el caso de querer probar unos datos espec√≠ficos. Lo ideal ser√≠a probar con esta librer√≠a:

@given(val1=st.floats(),
       val2=st.floats())
def test_hypothesis_transformation_return_sum_of_columns(val1: float, val2: float):
    transformation = Transformation()
    df_given = pd.DataFrame(
        {
            'column_1': [val1],
            'column_2': [val2],
        }
    )
    expected_result = pd.Series([val1 + val2])

    df_calculated = transformation.sum_two_columns_and_generate_result(df_given, 'column_1', 'column_2')

    pd.testing.assert_series_equal(df_calculated.result, expected_result, check_names=False)

En el given le indicamos explícitamente el tipo de propiedad a leer para poder probar, está librería se encargará de probar los casos límite de la propiedad, los convertirá a datos de pandas y finalmente los probaremos con las assertions de pandas la cuál comprobará absolutamente todo pero sin probar el nombre de la columna, eso incluye tipos de datos lo cuál nos ahorra bastante trabajo por si en medio de la transformación se volvieron a convertir.

El problema de los casos límite estaría solucionado con hypothesis pero por ejemplo no nos dejaría usar el @fixtures de pytest lo cual no me gusta, pero bueno siempre podemos usar una o la otra dependiendo del caso.

¬ŅTienes curiosidad y quieres ver qu√© se ha probado en hypothesis?

Hay una forma de verlo a√Īadiendo verbosity al test:

@settings(verbosity=Verbosity.verbose)
@given(val1=st.floats(),
       val2=st.floats())
def test_hypothesis_transformation_return_sum_of_columns(val1: float, val2: float):
    transformation = Transformation()
    df_given = pd.DataFrame(
        {
            'column_1': [val1],
            'column_2': [val2],
        }
    )
    expected_result = pd.Series([val1 + val2])

    df_calculated = transformation.sum_two_columns_and_generate_result(df_given, 'column_1', 'column_2')

    pd.testing.assert_series_equal(df_calculated.result, expected_result, check_names=False)

Una vez a√Īadido puede ejecutar...

 pytest [file_name].py -v -s

Y ver los par√°metros que ha probado en la consola:

.
.
test_transformation.py::test_hypothesis_transformation_return_sum_of_columns Trying example: test_hypothesis_transformation_return_sum_of_columns(
    val1=0.0, val2=0.0,
)
Trying example: test_hypothesis_transformation_return_sum_of_columns(
    val1=-1.2947680462195516e+16, val2=-nan,
)
Trying example: test_hypothesis_transformation_return_sum_of_columns(
    val1=-inf, val2=392874684777985.0,
)
.
.

¬ŅTienes feedback? Mi correo est√° en el footer de la p√°gina, gracias y un saludo.

ūüďĀReopositorio con el ejemplo