Hypothesis Testing en Python


Hypothesis Testing en Python

Hypothesis es una librería para hacer Property-based testing, inspirada en la librería QuickCheck de Haskell.

Hypothesis ejecuta una prueba con múltiples inputs basados en una estrategia.

Las estrategias pueden generar valores de los tipos primitivos de datos.

La función de ejemplo

Se tiene una función que ejecuta cierto código en Python

def to_integer(s: str) -> int | str:
    if not s.isnumeric():
        return s
    try:
        return int(s)
    except Exception:
        return s

def parse(input: str) -> list[str | int]:
	""" 
	Parse a string and return a list of elements
	"""
    if input.strip() == "":
        return ['', '', '']
    return list(map(to_integer, input.split(' ')))

La función tiene diferentes comportamientos posibles:

  1. La lista podría contener espacios vacíos en todos o varios de sus elementos
  2. La lista podría tener caracteres numéricos y en ese caso la función retorna el elemento parseado a int
  3. La lista podría tener cadenas de caracteres.

Cómo usar Hypothesis

La hipótesis se crea con base al decorador given y ejecuta una prueba que hace el act y el assert.

...
import unittest
from hypothesis import given, strategies as st
from main import parse # Se importa la función a probar
...

Hypothesis puede utilizar Unittest o Pytest para crear las pruebas, en este caso se usará Unittest

class TestSum(unittest.TestCase):

    @given(st.text(), st.text(), st.text())
    def test_parse_with_strings(self, i1, i2, i3):
        test = f'{i1} {i2} {i3}'

        self.assertEqual(parse(test), [i1, i2, i3])

Si se llegara a ejecutar este código fallaría, porque el assert no hace parse de los elementos que son numéricos mientras que la función parse sí. Lo mejor es separar los casos de prueba y modificar el input del decorador given para que no genere textos con espacios en blanco o caracteres numéricos.

Se define una nueva estrategia, en hypothesis se usa el tipo SearchStrategy para tipar las estrategias que se crean. En este caso se crea una que evite la generación de espacios en blanco y números inclusives:

...
without_spaces_st = (
	st
	.text()
	.filter(lambda x: not re.search(r'[\s\d]', x))
)

class TestSum(unittest.TestCase):

    @given(without_spaces_st, without_spaces_st, without_spaces_st)
    def test_parse_with_strings(self, i1, i2, i3):
        test = f'{i1} {i2} {i3}'

        self.assertEqual(parse(test), [i1, i2, i3])

En este caso la nueva estrategia utiliza filter y una expresión regular que verifica si se está cumpliendo con lo que se necesita.

hypothesis tiene un decorador example que se puede utilizar para cuando hay casos que han generado errores en el pasado y que actúan como “regression tests”, en este caso se agregan casos de prueba que se ejecutan antes de que given ejecute alguna acción y se puede verificar. Por ejemplo:

...
from hypothesis import example
... 

class TestSum(unittest.TestCase):

    @given(without_spaces_st, without_spaces_st, without_spaces_st)
    @example('', '', '²')
    def test_parse_with_strings(self, i1, i2, i3):
        test = f'{i1} {i2} {i3}'

        self.assertEqual(parse(test), [i1, i2, i3])

Se crea otro caso de prueba para inputs que son únicamente numéricos mayores a cero, por lo que se crea otra estretegia y otra función de pruebas:

positive_numbers = st.integers(min_value=0)

class TestSum(unittest.TestCase):
	@given(positive_numbers, positive_numbers, positive_numbers)
    def test_parse_with_numbers(self, i1, i2, i3):
        test = f'{i1} {i2} {i3}'
        self.assertEqual(parse(test), [i1, i2, i3])

Conclusiones

Veo esto como el fin de Table Drive Testing, este manipula demasiado bien los posibles valores con los que una función debería ser probado.

Hypothesis también puede utilizarse para crear pruebas sobre módulos que actúan como una máquina de estados, en este video explican super bien cómo es posible hacerlo.