Introducción a los tests de software

Introducción

Los tests de software son pruebas que se realizan sobre un fragmento de código o un software entero para validar que el comportamiento de éste es el esperado. El principal propósito de los tests es detectar errores en el código para poder corregirlos y garantizar la calidad del software. Tener un conjunto extenso y completo de tests que se puedan lanzar automáticamente es uno de los pasos principales para tener una entrega continua de software (continous delivery, CD).

Los test de software pueden realizarse a distintos niveles. En el primer nivel encontramos los tests unitarios. Estos se centran en probar unidades individuales de código, como pueden ser una función o una clase. Los test unitarios aíslan al componente de otros componentes y de sistemas externos para enfocarse en validar solamente la funcionalidad de la unidad.

En el siguiente nivel, los tests de integración se dedican a verificar las relaciones entre los diferentes componentes del software. Estos tests ayudan, por ejemplo, a comprobar si dos clases interactúan correctamente la una con la otra o si la secuencia de aplicar dos funciones una detrás de la otra devuelve el resultado esperado. Estos tests se pueden realizar sobre todos o casi todos los componentes del software a la vez o sobre conjuntos más pequeños. En muchas ocasiones es interesante hacer tests de integración aislándolo de sistemas externos (por ejemplo, una base de datos), sustituyendo la conexión por una conexión falsa. A estas imitaciones de conexiones o componentes se les llama mock.

Por último, una vez pasados todos los tests de integración, se evalúa el software entero con un test de sistema. En estos tests se prueba cómo el software interacciona con los sistemas externos y el entorno. Un ejemplo podría ser probar cómo el sistema se comporta cuando uno de los componentes se comunica con una base de datos o cuando otro componente recibe una llamada API.

En algunos casos, se define un cuarto nivel llamado test de aceptación, que consiste en probar que el sistema cumple la funcionalidad requerida por el usuario final del software.

Librería unittest de Python: ¿Qué es?

Existen diferentes librerías para los distintos lenguajes de programación que proporcionan una serie de funciones y clases que permiten escribir tests de forma sencilla. Facilitan la ejecución y el análisis de los resultados en caso de que, por ejemplo, el test falle y no devuelva el resultado esperado. 

Una de estas librerías es unittest, que es parte de las librerías estándar de Python. Para utilizarla, se genera una clase que herede de TestCase. Los métodos de esta clase cuyo nombre empiece por test, serán tratados como una prueba. En este ejemplo, se utiliza el método assertEqual de TestCase para verificar que 1 más 2 es 3.

import unittest


class MyTests(unittest.TestCase):

    def test_addition(self):
        self.assertEqual(3, 1 + 2)

Para ejecutar los tests se puede utilizar el siguiente comando por consola:

python -m unittest discovery

Por defecto, este comando busca instancias de TestCase en los ficheros que cumplan el siguiente patrón por test_*.py y que estén en paquetes de python en el directorio actual. Se puede cambiar el directorio de búsqueda y el patrón de ficheros con las opciones -s y -p, respectivamente.

La ejecución del test anterior, devuelve el siguiente resultado:

Si se cambia el resultado esperado por 4, el test fallido se muestra así:

Caso de uso

En este caso de uso se pretende realizar tests sobre un aplicativo de una pipeline ETL (Extract, Transform, Load). Para simplificar, los datos serán un único número y cada uno de los pasos de la pipeline son simples funciones. El aplicativo consiste en el siguiente código:

from random import randint


def get_data_from_database(connection):
    # Do something with connection
    # ...

    return randint(1, 6)  # Use random int to simulate db connection


def add_one(x):
    if x is None:
        return 1
    return x + 1



def times_two(x):
    return 2 * x


def write_data_to_database(x, connection):
    # Do something with connection
    # ...
    return
    
    
def pipeline():
    x = get_data_from_database('SourceDatabase')
    x = add_one(x)
    x = times_two(x)
    write_data_to_database(x, 'SinkDatabase')

Tests unitarios

En este apartado se verifica la funcionalidad de las transformaciones add_one y times_two, aislándolas de las demás funciones de la pipeline.

En los siguientes tests se prueba add_one:

import unittest

from my_app.my_app import add_one


class AddOneTest(unittest.TestCase):

    def test_add_one(self):
        input_data    = [-4, 0, 1, 3, 10]
        expected_data = [-3, 1, 2, 4, 11]

        for x, y in zip(input_data, expected_data):
            with self.subTest(input=x):
                self.assertEqual(y, add_one(x))

    def test_none(self):
        x = None
        y = 1

        self.assertEqual(y, add_one(x))

Se utiliza el método self.subTest para probar diferentes casos. El caso None se ha separado en un test distinto ya que se quiere ver una funcionalidad específica de la transformación.

En los siguientes tests se prueba times_two, utilizando también los subtests:

import unittest

from my_app.my_app import times_two


class TimesTwoTest(unittest.TestCase):

    def test_times_two(self):
        input_data    = [-4, 0, 1, 3, 10]
        expected_data = [-8, 0, 2, 6, 20]

        for x, y in zip(input_data, expected_data):
            with self.subTest(input=x):
                self.assertEqual(y, times_two(x))

    def test_none(self):
        x = None
        self.assertRaises(TypeError, times_two, x)

En este caso, el valor None no se trata específicamente en la función. Multiplicar este valor por 2 no es una operación permitida en Python y levanta la excepción TypeError. Se quiere que este sea el comportamiento del aplicativo. Entonces se verifica que se devuelve este error utilizando self.assertRaises.

Tests de integración

En este apartado se verifica la funcionalidad de la pipeline, verificando que todos los componentes funcionen correctamente entre ellos. En este caso, no se quiere depender de las conexiones a las bases de datos y el test de integración se quiere ejecutar en local. Para conseguir tal propósito, se utiliza unittest.mock, que permite reemplazar partes del código. 

En el siguiente ejemplo, se utiliza el decorador @patch, para sustituir dos partes del código. Este decorador nos pasa por parámetro el objeto mock que sustituye a la parte de código que se ha especificado. Después se puede especificar el comportamiento que sustituimos utilizando este parámetro dentro del test.

import unittest
from unittest import mock

from my_app.my_app import pipeline


class PipelineTest(unittest.TestCase):

    @mock.patch('my_app.my_app.write_data_to_database')
    @mock.patch('my_app.my_app.randint')
    def test_pipeline(self, mock_randint, mock_write_data_to_database):
        input_data    = [-4, 0, 1, 3, 10]
        expected_data = [-6, 2, 4, 8, 22]

        for x, y in zip(input_data, expected_data):
            with self.subTest(input=x):
                mock_randint.return_value = x
                mock_write_data_to_database.side_effect = \
                    lambda z, *args, **kwargs: self.assertEqual(y, z)

                pipeline()

    @mock.patch('my_app.my_app.write_data_to_database')
    @mock.patch('my_app.my_app.randint')
    def test_none(self, mock_randint, mock_write_data_to_database):
        mock_randint.return_value = None
        mock_write_data_to_database.side_effect = \
            lambda x, *args, **kwargs: self.assertEqual(2, x)

        pipeline()

La primera función que se sustituye es randint, que se utiliza en get_data_from_database (simula una conexión a una base de datos). En este caso, se sustituye esta función en el contexto de my_app.my_app, y no en la librería random. Esto es porque en el fichero my_app.py se importa randint y después no tiene manera de saber que se ha sustituido la función por un objeto mock. En el caso de esta función se reemplaza el valor devuelto (return_value).

La segunda función que se sustituye es write_data_to_database. En este caso, cuando se llame a esta función se evaluará el test con self.assertEqual (side_effect), en vez de escribir en la base de datos. 

En resumen, se ha probado la pipeline sustituyendo la entrada y la salida por objetos mock, testeando así las transformaciones add_one y times_two conjuntamente. Los objetos mock tienen también otras funcionalidades para revisar cómo se han llamado estos objetos. Por ejemplo, assert_not_called permite asegurar que la función o el objeto reemplazado no se ha llamado.

Conclusión

En este post se han presentado los conceptos básicos de los tests de software, así como la librería unittest de Python. Los tests ayudan a implementar código más robusto y permiten un despliegue más fluido. Si bien solo se han visto ejemplos muy simples y utilizando un solo lenguaje de programación, los conceptos se aplican a softwares más complejos y las metodologías se asemejan a otras utilizadas en otras librerías y lenguajes.

Hasta aquí nuestro post de hoy. Si te ha parecido interesante, te animamos a visitar la categoría Software para ver artículos similares y a compartirlo en redes con tus contactos. ¡Hasta pronto!
Guillermo Camps
Guillermo Camps
Artículos: 14