Imitadores y espías

06 May 2021 - Nepo

En la presente nota mostraremos algunos ejemplos de los usos de la paquetería pytest-mock. El código que aquí presentamos lo podrás encontrar en pollos_petrel/tests/tests_petrel_age_predictor.py, en la consignación 66eb24. En la sección de las referencias está la lista de material en la que nos inspiramos para escribir esta nota.

Imitador genérico

El objetivo es probar la función get_subset_morphometric_data. Esta función tiene dos variables de entrada Cleaner_Morphometric y Predictor. Las dos variables son objetos de clases que aun no implementamos, pero sabemos que nos gustaría que tuvieran las propiedades data_subset y predictions respectivamente.


def test_get_subset_morphometric_data(mocker):
    predictions = [1, 2, 3]
    expected_data_subset_dictionary = {
        "Fecha": ["01/Jan/2021", "02/Feb/2021", "03/Mar/2021"],
        "age_predictions": predictions,
        "Fecha_dt": [datetime(2021, 1, 1), datetime(2021, 2, 2), datetime(2021, 3, 3)],
    }
    expected_data_subset = pd.DataFrame(expected_data_subset_dictionary)
    data_subset = pd.DataFrame({"Fecha": ["01/Ene/2021", "02/Feb/2021", "03/Mar/2021"]})
    Cleaner_Morphometric = mocker.Mock()
    Cleaner_Morphometric.data_subset = data_subset.copy()
    Predictor = mocker.Mock()
    Predictor.predictions = predictions
    obtained_data_subset = get_subset_morphometric_data(Cleaner_Morphometric, Predictor)
    assert_frame_equal(obtained_data_subset, expected_data_subset)

Lo que queremos es probar get_subset_morphometric_data y no desviarnos con la implementación de las clases a la que pertenecen Cleaner_Morphometric y Predictor. Sandi Metz propone en este video utilizar imitadores. Los imitadores son objetos que imitan el comportamiento de objetos reales en formas controladas. Así, creamos imitadores para probar el comportamiento de otro objeto, en la misma manera que los diseñadores de carros usan un maniquí de prueba de choque para simular la dinámica de un humano en una colisión de autos. A continuación veremos cómo los implementamos:

    Cleaner_Morphometric = mocker.Mock()
    Cleaner_Morphometric.data_subset = data_subset.copy()

Definimos el objeto genérico Cleaner_Morphometric con propiedad data_subset. Hacemos algo similar con objeto Predictor y su propiedad predictions:

    Predictor = mocker.Mock()
    Predictor.predictions = predictions

Stargirl sugiere que no usemos este tipo de imitadores. El motivo es que si al implementar las clases cambiamos las interfaces la prueba no se enterará del cambio y por lo tanto no fallará.

Imitando objetos de clases particulares

En esta sección mostramos dos ejemplos en donde los imitadores heredan la interfaz de alguna clase, para atender así la sugerencia de Stargirl. Con esta modificación si la interfaz cambia la prueba nos recordará que debemos actualizarla. En el primer ejemplo generamos un imitador de la clase Predictions_and_Parameters. Lo que nos interesa probar el funcionamiento de Plotter por lo que no debemos de distraernos con obtener el objeto Parameters:


def test_Plotter(mocker):
    Parameters = mocker.Mock(spec=Predictions_and_Parameters)
    Parameters.data_for_plot.return_value = [1, 2, 3], [1, 2, 3]
    Plotter_parameters = Plotter(Parameters)
    Plotter_parameters.plot()
    return Plotter_parameters.savefig("reports/figures/figura.png")

En las líneas subrayadas definimos un imitador que tiene la misma interfaz que la clase Predictions_and_Parameters. La clase Predictions_and_Parameters tiene un método llamado data_for_plot. El imitador no tiene ninguna de las funcionalidades que tendría un objeto de la clase Predictions_and_Parameters, pero tiene misma interfaz, es decir puedemos hacer un llamado al método data_for_plot. A continuación solo presentamos las lineas en las que definimos Parameters y le asignamos el valor que regresará el método data_for_plot:

    Parameters = mocker.Mock(spec=Predictions_and_Parameters)
    Parameters.data_for_plot.return_value = [1, 2, 3], [1, 2, 3]

Este método es la manera en la que la clase Plotter se comunica con Parameters. Así que le asignamos algún valor con el que sabemos que comportamiento esperamos de Plotter.

En la siguiente código presentamos un ejemplo más restrictivo. Lo que haremos ahora es “parchar” el comportamiento de una clase, así el objeto parchado no tendrá el comportamiento original.


def test_Fitter(mocker):
    def train_test_split(self):
        return (
            np.array([6, 4, 8]).reshape(-1, 1),
            np.array([1, 2, 3]).reshape(-1, 1),
            [3, 2, 4],
            pd.DataFrame({"y_train": [4]}),
        )
    mocker.patch(
        "pollos_petrel.petrel_age_predictor.Set_Morphometric.train_test_split", train_test_split
    )
    Morphometric_Data = Set_Morphometric(petrel_data)
    Fitter_model = Fitter(Morphometric_Data)
    assert Fitter_model.lineal_model.normalize

Como podemos suponer de la sección verde, la clase Set_Morphometric tiene un método llamado train_test_split. En el subrayado amarillo definimos la función con la que parchamos al método train_test_split. El objeto Morphometric_Data es una instancia de la clase Set_Morphometric. Pero lo que hicimos fue cambiar el comportamiento de la clase original. Sin importar cuál es el valor de petrel_data, el método train_test_split siempre tendrá el mismo comportamiento. La clave aquí es definir el parche antes de inicializar el objeto Morphometric_Data.

En los dos ejemplos anteriores, los objetos imitadores siguen una interfaz determinada. A Parameters no lo construimos utilizando el constructor de su clase. Morphometric_Data es una instancia de la clase Set_Morphometric, pero con el comportamiento modificado en el método train_test_split.

Espías

Habrá ocasiones en las que lo que nos interesa es saber si hicimos llamados a funciones de terceros y no probar estas funciones. Si confiamos en que estas funciones están bien hechas y probadas por sus desarrolladores, a nosotros lo que nos podría interesar es saber si estamos haciendo el llamado de ellas en la manera correcta. Para esos casos usamos espías. En las primeras líneas del siguiente bloque de código podemos notar que tenemos un imitador, tema que atendimos en los tres ejemplos anteriores. Al final de la prueba definimos un espía para la función makedirs del módulo os:


def test_Plotter_(mocker):
    delete_reports_figures()
    Parameters = mocker.Mock(spec=Predictions_and_Parameters)
    Parameters.data_for_plot.return_value = [1, 2, 3], [1, 2, 3]
    Plotter_parameters = Plotter(Parameters)
    Plotter_parameters.plot()
    makedirs = mocker.spy(os, "makedirs")
    Plotter_parameters.savefig("reports/figures/figura.png")
    makedirs.assert_called_once_with("reports/figures")

El método savefig es de la clase Plotter. Y lo que nos interesa preguntarle al espía makedirs es si fue llamado con el argumento "reports/figures". El espía tiene su método para probar esa información. Si no es llamado (solo una vez) o si es llamdo con otro valor de entrada la prueba fallará.

Conclusión

Vimos cuatro ejemplos de usos de la paquetería pytest-mock: tres ejemplos de imitadores y uno de espías. Los ejemplos son de cómo los usamos imitadores en la Dirección de Ciencia de Datos de GECI. Tratamos de seguir las recomendaciones de Stargirl en la manera de nombrarlon, pero presentamos un ejemplo en donde no agregar una interfaz no era tan mala idea. Parchamos el comportamiento de una clase y finalmente utilizamos espías para asegurarnos de que llamábamos a una función desarrollada por terceros de la manera esperada.

Referencias