2. Unit Testing

Las pruebas unitarias, o Unit testing, son una metodología para comprobar si tu código funciona como esperas. Intentando alcanzar los límites con el fin de garantizar la calidad del trabajo. Además te proporcionan tranquilidad a la hora de añadir nuevas características, porque te avisan en caso de que un código anterior deje de funcionar.

Aunque a simple vista da la sensación de que perdemos el tiempo, en realidad economiza cada línea de código y hace el proyecto más rentable. ¿Cómo es posible? A medida que construyes los distintos tests salen al descubierto posibilidades de error que nadie había previsto, lo que lleva a encontrar menos errores en el futuro. Ergo, dedicarás menos horas a reparar problemas. Al principio del proyecto la productividad es baja, cierto, pero mira la gráfica y observa qué pasa cuando el proyecto crece.

Gráfica de TDD contra proyecto sin testing

Anatomía de una prueba

Empecemos por lo más básico: cómo se escribe una prueba.

Creas un archivo separado, normalmente con el prefijo test_ o el sufijo _test.py. Dentro defines funciones que también siguen esa convención (empiezan por test_). Cada función contiene una o varias aserciones que verifican el comportamiento esperado.

Una aserción es una comprobación: le sigue una expresión que debe ser verdadera. Si lo es, el test continúa. Si no, la herramienta para y te muestra qué esperabas y qué obtuviste.

Por ejemplo, dentro de un archivo llamado test_sum.py:

def test_sum():
    assert sum([1, 2, 3]) == 6

{: .advice } El código de los ejemplos (nombres de variables, funciones y comentarios) va en inglés, como se hace en la industria. Las explicaciones, en español.

Para ejecutar la prueba instalamos la herramienta una sola vez:

pip install pytest

Y lanzamos:

pytest

pytest recorre la carpeta, encuentra los archivos y funciones que siguen la convención, los ejecuta y te dice qué pasa y qué falla. No hay que registrar nada en ningún sitio.

La estructura de un test: Given-When-Then

Suele decirse que hacer testing es artesanal, que cada caso es único. No es cierto. Disponemos de patrones que nos ayudan a empezar y a estructurar. El más sencillo y conocido es Given-When-Then.

Nació con BDD (Behavior-Driven Development) de la mano de Daniel Terhorst-North y Chris Matts. Propone dividir el test en 3 bloques informales de comentarios:

  1. Given (dado): preparas el escenario, los datos de entrada, las condiciones.
  2. When (cuando): ejecutas el código que quieres probar.
  3. Then (entonces): verificas el resultado final.

También se conoce como Arrange, Act, Assert (preparar, actuar, comprobar). Es la misma idea. Divide siempre tus tests así y se leerán solos.

Antes de picar código, describe el test en prosa. Vamos a probar el cuento de "los 3 cerditos". El objetivo es comprobar que están a salvo del lobo.

Dado 3 casas, ['straw', 'wood', 'bricks']...

Cuando el lobo sople sobre cada una...

Entonces debe quedar 1 o más casas en pie.

Con esa estructura ya puedes escribir el test en cualquier lenguaje. Veámoslo con una función que decide si una coordenada está en el trópico:

def test_is_tropic():
    # Given
    latitude = 0
    longitude = 0

    # When
    result = is_tropic(latitude, longitude)

    # Then
    assert result is True


def test_is_not_tropic():
    # Given
    latitude = 45
    longitude = 45

    # When
    result = is_tropic(latitude, longitude)

    # Then
    assert result is False

Nombres que describen el caso

El nombre de un test es la descripción de lo que verifica. test_is_not_tropic te dice al instante qué se rompió cuando se pone rojo. test_1 no te dice nada. Invierte medio segundo en el nombre y ahorrarás minutos de depuración.

Parametrización: muchos casos con un solo test

Imagina que necesito probar una gran cantidad de resultados sobre una misma función, is_full(), que me dice si una batería está llena:

Función Porcentaje Esperado
is_full() 0 False
is_full() 10 False
is_full() 30 False
is_full() 60 False
is_full() 99 False
is_full() 100 True

Tocaría escribir seis tests casi idénticos. Pensarás que tienes mejores cosas que hacer, y no te falta razón. Por suerte no fuiste el único: casi todos los frameworks permiten parametrizar, es decir, ejecutar el mismo test con distintos conjuntos de datos.

import pytest


@pytest.mark.parametrize(
    "percentage, expected",
    [
        pytest.param(0, False, id="empty"),
        pytest.param(10, False, id="low"),
        pytest.param(30, False, id="medium_low"),
        pytest.param(60, False, id="medium_high"),
        pytest.param(99, False, id="almost_full"),
        pytest.param(100, True, id="full"),
    ],
)
def test_is_full(percentage, expected):
    assert is_full(percentage) == expected

Cada pytest.param es un caso, y el id le pone nombre para que, si uno falla, sepas cuál de un vistazo. Un solo test, seis escenarios, cero duplicación.

Fixtures: reutilizar objetos de prueba

A veces, para probar una función, necesitas preparar antes un objeto o unos datos. Si repites esa preparación en cada test, violas el principio DRY (Don't Repeat Yourself, no te repitas) y el mantenimiento se dispara.

Una fixture es una pieza de preparación reutilizable. La defines una vez y la pides como argumento en los tests que la necesiten:

import pytest


@pytest.fixture
def battery():
    return Battery(percentage=50)


def test_battery_is_not_full(battery):
    # Given: ya tenemos la batería lista, no hace falta crearla

    # When
    result = battery.is_full()

    # Then
    assert result is False

También sirve para leer archivos de prueba (CSV, JSON), constantes de configuración o datos compartidos. La idea es siempre la misma: prepara una vez, reutiliza muchas.

¿Y las dependencias externas?

Probar tu código es fácil cuando solo depende de sí mismo. ¿Pero qué pasa cuando hay una base de datos, una API o el reloj del sistema de por medio?

{: .advice } Nunca, nunca, nunca pruebes contra la base de datos de producción. No importa la razón: tiempo, economizar código, simplificar... Acaba siempre en desgracia. ¿Cómo le explicas a tu jefe que borraste todos los usuarios de la tienda porque un test salió mal?

Ese problema tiene solución, y es lo bastante importante como para dedicarle una lección entera. Lo veremos en detalle en Dobles de prueba.

Los casos que casi siempre olvidamos

Cuando escribes tests aparece un patrón. Estos son los casos que conviene tener en cuenta siempre:

  • Los datos son correctos y el resultado esperado es correcto. El caso ideal.
  • Los datos son incorrectos y el resultado esperado es un error controlado.
  • Los inputs son insuficientes. Por ejemplo, falta un campo obligatorio.
  • Los inputs tienen tipos incorrectos. Por ejemplo, un número en lugar de una lista.
  • Se devuelven todos los mensajes de error posibles.
  • Datos de entrada extremos. Por ejemplo, un email de 1000 caracteres.
  • Datos de entrada límite. Por ejemplo, un email de 254 caracteres, que es el máximo válido.
Actividad 1

Vamos a crear un sencillo objeto LudoGame para gestionar los jugadores de un parchís.

1. Añadir jugador (nombre y color).

2. Quitar jugador.

3. Empezar partida.

Ahora el testing.

1. Como máximo puede haber 4 jugadores.

2. Los colores son fijos: red, yellow, blue y green.

3. Solo se pueden quitar jugadores buscando por nombre.

4. No se pueden repetir los nombres.

5. Solo se puede iniciar la partida con 2 jugadores como mínimo.

6. Cualquier otra funcionalidad que creas conveniente.

Actividad 2

Te encargan un objeto Concert con la intención de gestionar las entradas.

1. Comprar (nombre y DNI).

2. Ver entradas disponibles y vendidas.

3. Dinero ganado (cada entrada cuesta 58 euros).

Testing:

1. Como máximo hay 100 entradas disponibles.

2. No pueden repetirse los DNIs.

3. Debe ser coherente el número de entradas disponibles y vendidas (20 libres, ergo 80 compradas).

4. Al comprar una entrada no pueden dejarse campos vacíos.

5. Cualquier otra funcionalidad que creas conveniente.

Actividad 3

Escribe una función que reciba una lista de temperaturas y devuelva la media.

1. Con una lista normal, devuelve la media.

2. Con una lista vacía, decide y prueba qué debe pasar (¿error controlado? ¿0?).

3. Con decimales, cuidado al comparar float: no uses == a secas, investiga cómo comparar aproximadamente.

4. Usa parametrización para cubrir varios casos con un solo test.

Este trabajo está bajo una licencia Attribution-NonCommercial-NoDerivatives 4.0 International.

Desafíos de programación atemporales y multiparadigmáticos

Desafíos de programación atemporales y multiparadigmáticos

Te encuentras ante un librillo de actividades, divididas en 2 niveles de dificultad. Te enfrentarás a los casos más comunes que te puedes encontrar en pruebas técnicas o aprender conceptos elementales de programación.

Comprar el libro

¿Me invitas a un café?

Así sigo escribiendo sin publicidad ni muros de pago.

Comentarios

Todavía no hay ningún comentario.