Desarrollo dirigido por pruebas con pytest

Un buen software es un software probado. Probar nuestro código puede ayudarnos a detectar errores o comportamientos no deseados. Test Driven Development (TDD) es una práctica de desarrollo de software...

Introducción

Un buen software es un software probado. Probar nuestro código puede ayudarnos a detectar errores o comportamientos no deseados.

El desarrollo basado en pruebas (TDD) es una práctica de desarrollo de software que requiere que escribamos pruebas de forma incremental para las características que queremos agregar. Aprovecha las suites de prueba automatizadas, como pytest, un marco de prueba para programas de Python.

Pruebas automatizadas

Los desarrolladores generalmente escriben código, lo compilan si es necesario y luego lo ejecutan para ver si funciona. Este es un ejemplo de prueba manual. En este método exploramos qué características del programa funcionan. Si desea ser exhaustivo con sus pruebas, deberá recordar cómo probar los diversos resultados de cada función.

¿Qué pasaría si un nuevo desarrollador comenzara a agregar funciones al proyecto? ¿Tendrías que aprender sus funciones para probarlo también? Las funciones nuevas a veces afectan a las funciones anteriores, ¿va a verificar manualmente que todas las funciones anteriores aún funcionen cuando agregue una nueva?

Las pruebas manuales pueden darnos un impulso rápido de confianza para continuar con el desarrollo. Sin embargo, a medida que nuestra aplicación crece, se vuelve exponencialmente más difícil y tedioso probar continuamente nuestra base de código manualmente.

Las pruebas automatizadas cambian la carga de probar el código nosotros mismos y hacer un seguimiento de los resultados, a mantener scripts que lo hagan por nosotros. Los scripts ejecutan módulos del código con entradas definidas por el desarrollador y comparan la salida con las expectativas definidas por el desarrollador.

El módulo pytest

La biblioteca estándar de Python viene con un marco de prueba automatizado: la biblioteca prueba de unidad. Si bien la biblioteca unittest tiene muchas funciones y es eficaz en su tarea, usaremos pytest como nuestra arma preferida en este artículo.

La mayoría de los desarrolladores encuentran que pytest es más fácil de usar que unittest. Una razón simple es que pytest solo requiere funciones para escribir pruebas, mientras que el módulo unittest requiere clases.

Para muchos desarrolladores nuevos, requerir clases para las pruebas puede ser un poco desagradable. pytest también incluye muchas otras características que usaremos más adelante en este tutorial que no están presentes en el módulo unittest.

¿Qué es el desarrollo basado en pruebas?

El desarrollo basado en pruebas es una práctica de desarrollo de software simple que le indica a usted o a un equipo de codificadores que siga estos pasos de árbol para crear software:

  1. Escriba una prueba para una característica que falla
  2. Escribir código para pasar la prueba
  3. Refactorice el código según sea necesario

Este proceso se conoce comúnmente como el ciclo Red-Green-Refactor:

  • Escribes una prueba automatizada de cómo debe comportarse el nuevo código y ves que falla - Rojo
  • Escribe código en la aplicación hasta que pase tu prueba - Verde
  • Refactorizar el código para hacerlo legible y eficiente. No hay necesidad de preocuparse de que su refactorización rompa la nueva característica, simplemente necesita volver a ejecutar la prueba y asegurarse de que pase.

Una característica está completa cuando ya no necesitamos escribir código para que pasen sus pruebas.

¿Por qué usar TDD para crear aplicaciones?

La queja común del uso de TDD es que lleva demasiado tiempo.

A medida que se vuelve más eficiente con la redacción de exámenes, el tiempo requerido por usted para mantenerlos disminuye. Además, TDD proporciona los siguientes beneficios, que puede encontrar que vale la pena el tiempo de compensación:

  • Las pruebas de escritura requieren que conozca las entradas y salidas para que la función funcione - TDD nos obliga a pensar en la interfaz de la aplicación antes de comenzar a codificar.
  • Mayor confianza en el código base: al tener pruebas automatizadas para todas las funciones, los desarrolladores se sienten más seguros al desarrollar nuevas funciones. Se vuelve trivial probar todo el sistema para ver si los nuevos cambios rompieron lo que existía antes.
  • TDD no elimina todos los errores, pero la probabilidad de encontrarlos es menor - Cuando intente corregir un error, puede escribir una prueba para asegurarse de que esté solucionado cuando termine la codificación.
  • Las pruebas se pueden utilizar como documentación adicional. A medida que escribimos las entradas y salidas de una característica, un desarrollador puede mirar la prueba y ver cómo se supone que debe usarse la interfaz del código.

Cobertura de código

La cobertura de código es una métrica que mide la cantidad de código fuente que cubre su plan de prueba.

Cobertura de código del 100% significa que todo el código que ha escrito ha sido utilizado por algunas pruebas. Las herramientas miden la cobertura del código de muchas maneras diferentes, aquí hay algunas métricas populares:

  • Líneas de código probadas
  • ¿Cuántas funciones definidas se prueban?
  • Cuántas ramas (declaraciones if por ejemplo) se prueban

Es importante que sepa qué métricas utiliza su herramienta de cobertura de código.

Como hacemos un uso intensivo de pytest, usaremos el popular complemento pytest-cov para obtener cobertura de código.

Una alta cobertura de código no significa que su aplicación no tendrá errores. Es más que probable que el código no haya sido probado para todos los escenarios posibles.

Prueba unitaria frente a pruebas de integración

Las pruebas unitarias se utilizan para garantizar que un módulo individual se comporte como se espera, mientras que las pruebas de integración garantizan que una colección de módulos interactúe como también esperamos.

A medida que desarrollemos aplicaciones más grandes, tendremos que desarrollar muchos componentes. Si bien cada uno de estos componentes individuales puede tener sus pruebas unitarias correspondientes, también queremos una manera de asegurarnos de que estos múltiples componentes, cuando se usan juntos, cumplan con nuestras expectativas.

TDD requiere que comencemos escribiendo una sola prueba que falle con el código base actual, luego trabajemos para completarla. No especifica que sea una prueba unitaria, su primera prueba puede ser una prueba de integración si lo desea.

Cuando se escribe su primera prueba de integración fallida, podemos comenzar a desarrollar cada componente individual.

La prueba de integración fallará hasta que cada componente se construya y pase sus pruebas. Cuando pase la prueba de integración, si se diseñara correctamente, habríamos cumplido con un requisito de usuario para nuestro sistema.

Ejemplo básico: Cálculo de la suma de números primos

La mejor manera de entender TDD es ponerlo en práctica. Comenzaremos escribiendo un programa en Python que devuelva la suma de todos los números en una secuencia que son números primos.

Crearemos dos funciones para hacer esto, una que determine si un número es primo o no y otra que suma los números primos de una secuencia dada de números.

Cree un directorio llamado primes en un espacio de trabajo de su elección. Ahora agregue dos archivos: primes.py, test_primes.py. El primer archivo es donde escribiremos nuestro código de programa, el segundo archivo es donde estarán nuestras pruebas.

pytest requiere que nuestros archivos de prueba comiencen con "test_" o terminen con "_test.py" (por lo tanto, también podríamos haber llamado a nuestro archivo de prueba primes_test.py).

Ahora, en nuestro directorio primos, configuremos nuestro entorno virtual:

1
2
3
4
$ python3 -m venv env # Create a virtual environment for our modules
$ . env/bin/activate # Activate our virtual environment
$ pip install --upgrade pip # Upgrade pip
$ pip install pytest # Install pytest

Prueba de la función is_prime()

Un número primo es cualquier número natural mayor que 1 que solo es divisible por 1 y por sí mismo.

Nuestra función debe tomar un número y devolver “Verdadero” si es primo y “Falso” en caso contrario.

En nuestro test_primes.py, agreguemos nuestro primer caso de prueba:

1
2
def test_prime_low_number():
    assert is_prime(1) == False

La instrucción assert() es una palabra clave en Python (y en muchos otros lenguajes) que arroja inmediatamente un error si falla una condición. Esta palabra clave es útil al escribir pruebas porque señala exactamente qué condición falló.

Si ingresamos 1 o un número menor que 1, entonces no puede ser primo.

Vamos a ejecutar ahora nuestra prueba. Ingrese lo siguiente en su línea de comando:

1
$ pytest

Para una salida detallada, puede ejecutar pytest -v. Asegúrese de que su entorno virtual aún esté activo (debería ver (env) al comienzo de la línea en su terminal).

Deberías notar un resultado como este:

1
2
3
4
5
6
    def test_prime_low_number():
>       assert is_prime(1) == False
E       NameError: name 'is_prime' is not defined

test_primes.py:2: NameError
========================================================= 1 failed in 0.12 seconds =========================================================

Tiene sentido obtener un NameError, aún no hemos creado nuestra función. Este es el aspecto "rojo" del ciclo rojo-verde-refactor.

pytest incluso registra las pruebas fallidas en color rojo si su shell está configurado para mostrar colores. Ahora agreguemos el código en nuestro archivo primes.py para hacer que esta prueba pase:

1
2
3
def is_prime(num):
    if num == 1:
        return False

Nota: Generalmente es una buena práctica mantener sus pruebas en archivos separados de su código. Además de mejorar la legibilidad y la separación de preocupaciones a medida que crece su base de código, también mantiene al desarrollador de la prueba alejado del funcionamiento interno del código. Por lo tanto, las pruebas utilizan las interfaces de la aplicación de la misma manera que lo haría otro desarrollador.

Ahora ejecutemos pytest una vez más. Ahora deberíamos ver una salida como esta:

1
2
3
4
5
6
7
8
9
=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.9.0
rootdir: /Users/marcus/wikihtp/test-driven-development-with-pytest/primes
plugins: cov-2.6.1
collected 1 item

test_primes.py .                                                                                                                     [100%]

========================================================= 1 passed in 0.04 seconds =========================================================

¡Nuestra primera prueba pasó! Sabemos que el 1 no es primo, pero por definición el 0 no es primo, ni es ningún número negativo.

Deberíamos refactorizar nuestra aplicación para reflejar eso y cambiar is_prime() a:

1
2
3
4
def is_prime(num):
    # Prime numbers must be greater than 1
    if num < 2:
        return False

Si ejecutamos pytest nuevamente, nuestras pruebas aún pasarían.

Ahora agreguemos un caso de prueba para un número primo, en test_primes.py agregue lo siguiente después de nuestro primer caso de prueba:

1
2
def test_prime_prime_number():
    assert is_prime(29)

Y ejecutemos pytest para ver este resultado:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
    def test_prime_prime_number():
>       assert is_prime(29)
E       assert None
E        +  where None = is_prime(29)

test_primes.py:9: AssertionError
============================================================= warnings summary =============================================================
test_primes.py::test_prime_prime_number
  /Users/marcus/wikihtp/test-driven-development-with-pytest/primes/test_primes.py:9: PytestWarning: asserting the value None, please use "assert is None"
    assert is_prime(29)

-- Docs: https://docs.pytest.org/en/latest/warnings.html
============================================== 1 failed, 1 passed, 1 warnings in 0.12 seconds ==============================================

Tenga en cuenta que el comando pytest ahora ejecuta las dos pruebas que hemos escrito.

El nuevo caso falla porque en realidad no calculamos si el número es primo o no. La función is_prime() devuelve Ninguno como lo hacen otras funciones por defecto para cualquier número mayor que 1.

La salida aún falla, o vemos rojo en la salida.

Pensemos en cómo determinamos dónde un número es primo o no. El método más simple sería hacer un bucle desde 2 hasta uno menos que el número, dividiendo el número por el valor actual de la iteración.

Para hacer esto más eficiente, podemos verificar dividiendo números entre 2 y la raíz cuadrada del número.

Si no queda resto de la división, entonces tiene un divisor que no es ni 1 ni él mismo y, por lo tanto, no es primo. Si no encuentra un divisor en el ciclo, entonces debe ser primo.

Actualicemos is_prime() con nuestra nueva lógica:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import math

def is_prime(num):
    # Prime numbers must be greater than 1
    if num < 2:
        return False
    for n in range(2, math.floor(math.sqrt(num) + 1)):
        if num % n == 0:
            return False
    return True

Ahora ejecutamos pytest para ver si nuestra prueba pasa:

1
2
3
4
5
6
7
8
9
=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.9.0
rootdir: /Users/marcus/wikihtp/test-driven-development-with-pytest/primes
plugins: cov-2.6.1
collected 2 items

test_primes.py ..                                                                                                                    [100%]

========================================================= 2 passed in 0.04 seconds =========================================================

Pasó. Sabemos que esta función puede obtener un número primo y un número bajo. Agreguemos una prueba para asegurarnos de que devuelve Falso para un número compuesto mayor que 1.

En test_primes.py agregue el siguiente caso de prueba a continuación:

1
2
def test_prime_composite_number():
    assert is_prime(15) == False

Si ejecutamos pytest veremos el siguiente resultado:

1
2
3
4
5
6
7
8
9
=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.9.0
rootdir: /Users/marcus/wikihtp/test-driven-development-with-pytest/primes
plugins: cov-2.6.1
collected 3 items

test_primes.py ...                                                                                                                   [100%]

========================================================= 3 passed in 0.04 seconds =========================================================

Prueba de suma_de_primos() {#prueba de suma_de_primos}

Al igual que con is_prime(), pensemos en los resultados de esta función. Si a la función se le da una lista vacía, entonces la suma debe ser cero.

Eso garantiza que nuestra función siempre devuelva un valor con una entrada válida. Después, querremos probar que solo agrega números primos en una lista de números.

Escribamos nuestra primera prueba fallida, agregue el siguiente código al final de test_primes.py:

1
2
def test_sum_of_primes_empty_list():
    assert sum_of_primes([]) == 0

Si ejecutamos pytest obtendremos el familiar error de prueba NameError, ya que aún no definimos la función. En nuestro archivo primes.py agreguemos nuestra nueva función que simplemente devuelve la suma de una lista dada:

1
2
def sum_of_primes(nums):
    return sum(nums)

Ahora ejecutar pytest mostraría que todas las pruebas pasan. Nuestra próxima prueba debería garantizar que solo se agreguen números primos.

Mezclaremos números primos y compuestos y esperamos que la función solo agregue los números primos:

1
2
def test_sum_of_primes_mixed_list():
    assert sum_of_primes([11, 15, 17, 18, 20, 100]) == 28

Los números primos en la lista que estamos probando son 11 y 17, que suman 28.

Ejecutando pytest para validar que la nueva prueba falla. Ahora modifiquemos nuestra sum_of_primes() para que solo se sumen los números primos.

We'll filter the prime numbers with a Lista de comprensión:

1
2
def sum_of_primes(nums):
    return sum([x for x in nums if is_prime(x)])

Como es rutina, ejecutamos pytest para verificar que solucionamos la prueba fallida: todo pasa.

Una vez completado, revisemos nuestra cobertura de código:

1
$ pytest --cov=primes

¡Para este paquete, nuestra cobertura de código es del 100%! Si no fue así, podemos dedicar un tiempo a agregar algunas pruebas más a nuestro código para asegurarnos de que nuestro plan de prueba sea completo.

Por ejemplo, si a nuestra función is_prime() se le dio un valor flotante, ¿arrojaría un error? Nuestro método is_prime() no aplica la regla de que un número primo debe ser un número natural, solo verifica que sea mayor que 1.

Aunque tenemos una cobertura total del código, es posible que la función que se implemente no funcione correctamente en todas las situaciones.

Ejemplo avanzado: escribir un administrador de inventario

Ahora que comprendemos los conceptos básicos de TDD, profundicemos en algunas características útiles de pytest que nos permiten ser más eficientes al escribir pruebas.

Al igual que antes en nuestro ejemplo básico, inventory.py, y un archivo de prueba, test_inventory.py, serán nuestros dos archivos principales.

Funciones y planificación de pruebas

A una tienda de ropa y calzado le gustaría trasladar la gestión de sus artículos del papel a una nueva computadora que compró el propietario.
Si bien al propietario le gustaría tener muchas funciones, está contenta con un software que podría realizar las siguientes tareas próximas de inmediato.

  • Registre las 10 zapatillas Nike nuevas que compró recientemente. Cada uno vale $50.00.
  • Agregue 5 pantalones deportivos Adidas más que cuestan $70.00 cada uno.
  • Ella espera que un cliente compre 2 de las zapatillas Nike.
  • Está esperando que otro cliente compre 1 de los pantalones de chándal.

Podemos usar estos requisitos para crear nuestra primera prueba de integración. Antes de comenzar a escribirlo, desarrollemos un poco los componentes más pequeños para descubrir cuáles serían nuestras entradas y salidas, firmas de funciones y otros elementos de diseño del sistema.

Cada artículo de stock tendrá un nombre, precio y cantidad. Podremos agregar nuevos artículos, agregar existencias a los artículos existentes y, por supuesto, eliminar existencias.

Cuando creamos una instancia de un objeto Inventario, queremos que el usuario proporcione un límite. El límite tendrá un valor predeterminado de 100. Nuestra primera prueba sería comprobar el límite al instanciar un objeto. Para asegurarnos de no sobrepasar nuestro límite, necesitaremos realizar un seguimiento del contador total_items. Cuando se inicializa, debe ser 0.

Tendremos que agregar 10 zapatillas Nike y 5 pantalones de chándal Adidas al sistema. Podemos crear un método add_new_stock() que acepte un nombre, precio y cantidad.

Deberíamos probar que podemos agregar un artículo a nuestro objeto de inventario. No deberíamos poder agregar un artículo con una cantidad negativa, el método debería generar una excepción. Tampoco deberíamos poder agregar más elementos si estamos en nuestro límite, eso también debería generar una excepción.

Los clientes comprarán estos artículos poco después de la entrada, por lo que también necesitaremos un método remove_stock(). Esta función necesitaría el “nombre” del stock y la “cantidad” de artículos que se están eliminando. Si la cantidad que se elimina es negativa o si hace que la cantidad total del stock sea inferior a 0, el método debería generar una excepción. Además, si el “nombre” proporcionado no se encuentra en nuestro inventario, el método debería generar una excepción.

Primeras pruebas

Prepararnos para hacer nuestras pruebas primero nos ha ayudado a diseñar nuestro sistema. Comencemos creando nuestra primera prueba de integración:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
def test_buy_and_sell_nikes_adidas():
    # Create inventory object
    inventory = Inventory()
    assert inventory.limit == 100
    assert inventory.total_items == 0

    # Add the new Nike sneakers
    inventory.add_new_stock('Nike Sneakers', 50.00, 10)
    assert inventory.total_items == 10

    # Add the new Adidas sweatpants
    inventory.add_new_stock('Adidas Sweatpants', 70.00, 5)
    assert inventory.total_items == 15

    # Remove 2 sneakers to sell to the first customer
    inventory.remove_stock('Nike Sneakers', 2)
    assert inventory.total_items == 13

    # Remove 1 sweatpants to sell to the next customer
    inventory.remove_stock('Adidas Sweatpants', 1)
    assert inventory.total_items == 12

En cada acción hacemos una afirmación sobre el estado del inventario. Es mejor afirmar después de realizar una acción, de modo que cuando esté depurando, sabrá el último paso que se tomó.

Ejecute pytest y debería fallar con un NameError ya que no se ha definido ninguna clase de Inventario.

Vamos a crear nuestra clase Inventario, con un parámetro de límite que por defecto es 100, comenzando con las pruebas unitarias:

1
2
3
4
5
def test_default_inventory():
    """Test that the default limit is 100"""
    inventory = Inventory()
    assert inventory.limit == 100
    assert inventory.total_items == 0

Y ahora, la clase en sí:

1
2
3
4
class Inventory:
    def __init__(self, limit=100):
        self.limit = limit
        self.total_items = 0

Antes de pasar a los métodos, queremos estar seguros de que nuestro objeto se puede inicializar con un límite personalizado y debe configurarse correctamente:

1
2
3
4
5
def test_custom_inventory_limit():
    """Test that we can set a custom limit"""
    inventory = Inventory(limit=25)
    assert inventory.limit == 25
    assert inventory.total_items == 0

La integración continúa fallando pero esta prueba pasa.

Accesorios

Nuestras dos primeras pruebas requirieron que instanciamos un objeto Inventario antes de que pudiéramos comenzar. Lo más probable es que tengamos que hacer lo mismo para todas las pruebas futuras. Esto es un poco repetitivo.

Podemos usar accesorios para ayudar a resolver este problema. Un accesorio es un estado fijo y conocido contra el que se ejecutan las pruebas para garantizar que los resultados sean repetibles.

Es una buena práctica que las pruebas se ejecuten de forma aislada unas de otras. Los resultados de un caso de prueba no deberían afectar los resultados de otro caso de prueba.

Vamos a crear nuestro primer accesorio, un objeto Inventario sin stock.

prueba_inventario.py:

1
2
3
4
5
6
import pytest

@pytest.fixture
def no_stock_inventory():
    """Returns an empty inventory that can store 10 items"""
    return Inventory(10)

Nótese el uso del pytest.fixture decorador. Para fines de prueba, podemos reducir el límite de inventario a 10.

Usemos este dispositivo para agregar una prueba para el método add_new_stock():

1
2
3
4
5
def test_add_new_stock_success(no_stock_inventory):
    no_stock_inventory.add_new_stock('Test Jacket', 10.00, 5)
    assert no_stock_inventory.total_items == 5
    assert no_stock_inventory.stocks['Test Jacket']['price'] == 10.00
    assert no_stock_inventory.stocks['Test Jacket']['quantity'] == 5

Observe que el nombre de la función es el argumento de la prueba, deben ser el mismo nombre para que se aplique el dispositivo. De lo contrario, lo usarías como un objeto normal.

Para asegurarnos de que se agregó el stock, tenemos que probar un poco más que el total de artículos almacenados hasta ahora. Escribir esta prueba nos ha obligado a considerar cómo mostramos el precio de una acción y la cantidad restante.

Ejecute pytest para observar que ahora hay 2 fallas y 2 pases. Ahora agregaremos el método add_new_stock():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Inventory:
    def __init__(self, limit=100):
        self.limit = limit
        self.total_items = 0
        self.stocks = {}

    def add_new_stock(self, name, price, quantity):
        self.stocks[name] = {
            'price': price,
            'quantity': quantity
        }
        self.total_items += quantity

Notarás que un objeto de acciones fue inicializado en la función __init__. Nuevamente, ejecute pytest para confirmar que la prueba pasó.

Pruebas de parametrización

Mencionamos anteriormente que el método add_new_stock() hace la validación de entrada: lanzamos una excepción si la cantidad es cero o negativa, o si nos lleva por encima del límite de nuestro inventario.

Podemos agregar fácilmente más casos de prueba, usando prueba/excepto para capturar cada excepción. Esto también se siente repetitivo.

Pytest proporciona funciones parametrizadas que nos permiten probar múltiples escenarios usando una función. Escribamos una función de prueba parametrizada para garantizar que nuestra validación de entrada funcione:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@pytest.mark.parametrize('name,price,quantity,exception', [
    ('Test Jacket', 10.00, 0, InvalidQuantityException(
        'Cannot add a quantity of 0. All new stocks must have at least 1 item'))
])
def test_add_new_stock_bad_input(name, price, quantity, exception):
    inventory = Inventory(10)
    try:
        inventory.add_new_stock(name, price, quantity)
    except InvalidQuantityException as inst:
        # First ensure the exception is of the right type
        assert isinstance(inst, type(exception))
        # Ensure that exceptions have the same message
        assert inst.args == exception.args
    else:
        pytest.fail("Expected error but found none")

Esta prueba intenta agregar una acción, obtiene la excepción y luego verifica que sea la excepción correcta. Si no obtenemos una excepción, reprobar la prueba. La cláusula else es muy importante en este escenario. Sin él, una excepción que no se haya lanzado contaría como un aprobado. Nuestra prueba, por lo tanto, tendría un falso positivo.

Usamos decoradores pytest para agregar un parámetro a la función. El primer argumento contiene una cadena de todos los nombres de parámetros. El segundo argumento es una lista de tuplas donde cada tupla es un caso de prueba.

Ejecute pytest para ver que nuestra prueba falla ya que InvalidQuantityException no está definida. De vuelta en inventario.py vamos a crear una nueva excepción sobre la clase Inventario:

1
2
class InvalidQuantityException(Exception):
    pass

Y cambia el método add_new_stock():

1
2
3
4
5
6
7
8
9
def add_new_stock(self, name, price, quantity):
        if quantity <= 0:
            raise InvalidQuantityException(
                'Cannot add a quantity of {}. All new stocks must have at least 1 item'.format(quantity))
        self.stocks[name] = {
            'price': price,
            'quantity': quantity
        }
        self.total_items += quantity

Ejecute pytest para ver si ahora pasa nuestra prueba más reciente. Ahora agreguemos el segundo caso de prueba de error, se genera una excepción si nuestro inventario no puede almacenarlo. Cambie la prueba de la siguiente manera:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@pytest.mark.parametrize('name,price,quantity,exception', [
    ('Test Jacket', 10.00, 0, InvalidQuantityException(
        'Cannot add a quantity of 0. All new stocks must have at least 1 item')),
    ('Test Jacket', 10.00, 25, NoSpaceException(
        'Cannot add these 25 items. Only 10 more items can be stored'))
])
def test_add_new_stock_bad_input(name, price, quantity, exception):
    inventory = Inventory(10)
    try:
        inventory.add_new_stock(name, price, quantity)
    except (InvalidQuantityException, NoSpaceException) as inst:
        # First ensure the exception is of the right type
        assert isinstance(inst, type(exception))
        # Ensure that exceptions have the same message
        assert inst.args == exception.args
    else:
        pytest.fail("Expected error but found none")

¡En lugar de crear una función completamente nueva, modificamos esta ligeramente para recoger nuestra nueva excepción y agregar otra tupla al decorador! Ahora se ejecutan dos pruebas en una sola función.

Las funciones parametrizadas reducen el tiempo que lleva agregar nuevos casos de prueba.

En inventory.py, primero agregaremos nuestra nueva excepción debajo de InvalidQuantityException:

1
2
class NoSpaceException(Exception):
    pass

Y cambia el método add_new_stock():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def add_new_stock(self, name, price, quantity):
    if quantity <= 0:
        raise InvalidQuantityException(
            'Cannot add a quantity of {}. All new stocks must have at least 1 item'.format(quantity))
    if self.total_items + quantity > self.limit:
        remaining_space = self.limit - self.total_items
        raise NoSpaceException(
            'Cannot add these {} items. Only {} more items can be stored'.format(quantity, remaining_space))
    self.stocks[name] = {
        'price': price,
        'quantity': quantity
    }
    self.total_items += quantity

Ejecute pytest para ver si su nuevo caso de prueba también pasa.

Podemos usar accesorios con nuestra función parametrizada. Vamos a refactorizar nuestra prueba para usar el accesorio de inventario vacío:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def test_add_new_stock_bad_input(no_stock_inventory, name, price, quantity, exception):
    try:
        no_stock_inventory.add_new_stock(name, price, quantity)
    except (InvalidQuantityException, NoSpaceException) as inst:
        # First ensure the exception is of the right type
        assert isinstance(inst, type(exception))
        # Ensure that exceptions have the same message
        assert inst.args == exception.args
    else:
        pytest.fail("Expected error but found none")

Como antes, es solo otro argumento que usa el nombre de una función. La clave es excluirlo en el decorador parametrizado.

Mirando el código un poco más, no hay ninguna razón por la que deba haber dos métodos para agregar nuevas acciones. Podemos probar los errores y el éxito en una función.

Elimine test_add_new_stock_bad_input() y test_add_new_stock_success() y agreguemos una nueva función:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@pytest.mark.parametrize('name,price,quantity,exception', [
    ('Test Jacket', 10.00, 0, InvalidQuantityException(
        'Cannot add a quantity of 0. All new stocks must have at least 1 item')),
    ('Test Jacket', 10.00, 25, NoSpaceException(
        'Cannot add these 25 items. Only 10 more items can be stored')),
    ('Test Jacket', 10.00, 5, None)
])
def test_add_new_stock(no_stock_inventory, name, price, quantity, exception):
    try:
        no_stock_inventory.add_new_stock(name, price, quantity)
    except (InvalidQuantityException, NoSpaceException) as inst:
        # First ensure the exception is of the right type
        assert isinstance(inst, type(exception))
        # Ensure that exceptions have the same message
        assert inst.args == exception.args
    else:
        assert no_stock_inventory.total_items == quantity
        assert no_stock_inventory.stocks[name]['price'] == price
        assert no_stock_inventory.stocks[name]['quantity'] == quantity

Esta función de prueba primero verifica las excepciones conocidas, si no se encuentra ninguna, nos aseguramos de que la adición coincida con nuestras expectativas. La función separada test_add_new_stock_success() ahora solo se ejecuta a través de un parámetro tuplado. Como no esperamos que se lance una excepción en el caso exitoso, especificamos Ninguno como nuestra excepción.

Terminando nuestro administrador de inventario {#resumiendonuestroadministrador de inventario}

Con nuestro uso más avanzado de pytest, podemos desarrollar rápidamente la función remove_stock con TDD. En inventario_prueba.py:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# The import statement needs one more exception
from inventory import Inventory, InvalidQuantityException, NoSpaceException, ItemNotFoundException

# ...
# Add a new fixture that contains stocks by default
# This makes writing tests easier for our remove function
@pytest.fixture
def ten_stock_inventory():
    """Returns an inventory with some test stock items"""
    inventory = Inventory(20)
    inventory.add_new_stock('Puma Test', 100.00, 8)
    inventory.add_new_stock('Reebok Test', 25.50, 2)
    return inventory

# ...
# Note the extra parameters, we need to set our expectation of
# what totals should be after our remove action
@pytest.mark.parametrize('name,quantity,exception,new_quantity,new_total', [
    ('Puma Test', 0,
     InvalidQuantityException(
         'Cannot remove a quantity of 0. Must remove at least 1 item'),
        0, 0),
    ('Not Here', 5,
     ItemNotFoundException(
         'Could not find Not Here in our stocks. Cannot remove non-existing stock'),
        0, 0),
    ('Puma Test', 25,
     InvalidQuantityException(
         'Cannot remove these 25 items. Only 8 items are in stock'),
     0, 0),
    ('Puma Test', 5, None, 3, 5)
])
def test_remove_stock(ten_stock_inventory, name, quantity, exception,
                      new_quantity, new_total):
    try:
        ten_stock_inventory.remove_stock(name, quantity)
    except (InvalidQuantityException, NoSpaceException, ItemNotFoundException) as inst:
        assert isinstance(inst, type(exception))
        assert inst.args == exception.args
    else:
        assert ten_stock_inventory.stocks[name]['quantity'] == new_quantity
        assert ten_stock_inventory.total_items == new_total

Y en nuestro archivo inventory.py primero creamos la nueva excepción para cuando los usuarios intentan modificar un stock que no existe:

1
2
class ItemNotFoundException(Exception):
    pass

Y luego agregamos este método a nuestra clase Inventario:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def remove_stock(self, name, quantity):
    if quantity <= 0:
        raise InvalidQuantityException(
            'Cannot remove a quantity of {}. Must remove at least 1 item'.format(quantity))
    if name not in self.stocks:
        raise ItemNotFoundException(
            'Could not find {} in our stocks. Cannot remove non-existing stock'.format(name))
    if self.stocks[name]['quantity'] - quantity <= 0:
        raise InvalidQuantityException(
            'Cannot remove these {} items. Only {} items are in stock'.format(
                quantity, self.stocks[name]['quantity']))
    self.stocks[name]['quantity'] -= quantity
    self.total_items -= quantity

¡Cuando ejecuta pytest debería ver que la prueba de integración y todas las demás pasan!

Conclusión

El desarrollo basado en pruebas es un proceso de desarrollo de software en el que se utilizan pruebas para guiar el diseño de un sistema. TDD exige que para cada función que tengamos que implementar, escribamos una prueba que falle, agreguemos la menor cantidad de código para que la prueba pase y, finalmente, refactoricemos ese código para que sea más limpio.

Para que este proceso sea posible y eficiente, aprovechamos pytest, una herramienta de prueba automatizada. Con pytest podemos hacer scripts de pruebas, ahorrándonos el tiempo de tener que probar manualmente nuestro código cada cambio.

Las pruebas unitarias se utilizan para garantizar que un módulo individual se comporte como se espera, mientras que las pruebas de integración garantizan que una colección de módulos interactúe como también esperamos. Tanto la herramienta pytest como la metodología TDD permiten el uso de ambos tipos de prueba, y se anima a los desarrolladores a usar ambos.

Con TDD, nos vemos obligados a pensar en las entradas y salidas de nuestro sistema y, por lo tanto, en su diseño general. Las pruebas de escritura brindan beneficios adicionales, como una mayor confianza en la funcionalidad de nuestro programa después de los cambios. TDD exige un proceso altamente iterativo que puede ser eficiente al aprovechar un conjunto de pruebas automatizado como pytest. Con características como accesorios y funciones parametrizadas, podemos escribir rápidamente casos de prueba según lo requieran nuestros requisitos. itos.