Desarrollo dirigido por pruebas con pytest

D

Introducción

Un buen software es 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 que nos obliga a escribir pruebas de forma incremental para las funciones que queremos agregar. Aprovecha las suites de prueba automatizadas, como pytest , un marco de prueba para programas de Python.

  • Pruebas automatizadas
  • El módulo pytest
  • ¿Qué es el desarrollo basado en pruebas?
  • ¿Por qué utilizar TDD para crear aplicaciones?
  • Cobertura de código
  • Prueba unitaria frente a pruebas de integración
  • Ejemplo básico: calcular la suma de números primos
  • Ejemplo avanzado: escribir un administrador de inventario
  • Conclusión

Pruebas automatizadas

Los desarrolladores suelen escribir código, compilarlo si es necesario y luego ejecutar el código 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 minucioso con sus pruebas, tendrá que recordar cómo probar los distintos resultados de cada función.

¿Y si un nuevo desarrollador comenzara a agregar funciones al proyecto, tendría que aprender sus funciones para probarlo también? Las funciones nuevas a veces afectan a las funciones más antiguas, ¿va a comprobar manualmente que todas las funciones anteriores siguen funcionando cuando agregue una nueva?

Las pruebas manuales pueden darnos un rápido impulso 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 trasladan la carga de probar el código nosotros mismos y realizar un seguimiento de los resultados, para mantener los scripts que lo hacen 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 unittest . Si bien la unittestbiblioteca es rica en funciones y efectiva en su tarea, la usaremos pytestcomo nuestra arma preferida en este artículo.

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

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

¿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 oa un equipo de codificadores que sigan estos pasos del árbol para crear software:

  • Escribe una prueba para una función que falla
  • Escribe el código para que la prueba pase
  • Refactorice el código según sea necesario

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

  • Escribes una prueba automatizada sobre cómo debería comportarse el nuevo código y ves que falla – Rojo
  • Escriba el código en la aplicación hasta que pase la prueba: verde
  • Refactorice el código para hacerlo legible y eficiente. No hay necesidad de preocuparse de que su refactorización rompa la nueva función, simplemente necesita volver a ejecutar la prueba y asegurarse de que pase.

Una función está completa cuando ya no necesitamos escribir código para aprobar sus pruebas.

¿Por qué utilizar TDD para crear aplicaciones?

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

A medida que se vuelve más eficiente con las pruebas de escritura, el tiempo que necesita para mantenerlas disminuye. Además, TDD ofrece los siguientes beneficios, que puede encontrar que valen la pena invertir en tiempo:

  • 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 la base de código: 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 intentas corregir un error, puedes escribir una prueba para asegurarte de que esté corregido cuando termine de codificar.
  • Las pruebas se pueden utilizar como documentación adicional. Mientras escribimos las entradas y salidas de una característica, un desarrollador puede mirar la prueba y ver cómo se debe usar 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 formas diferentes; aquí hay algunas métricas populares:

  • Líneas de código probadas
  • Cuántas funciones definidas se prueban
  • Cuántas ramas ( ifdeclaraciones, por ejemplo) se prueban

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

A medida que hagamos un uso intensivo de pytest, usaremos el popular complemento pytest-cov para obtener cobertura de código.

La alta cobertura de código no significa que su aplicación no tenga errores. Es más que probable que el código no se haya probado en 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 aseguran que una colección de módulos interopere como esperamos.

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

TDD requiere que comencemos por escribir una única prueba que falle con el código base actual, luego trabajemos para completarla. No especifica que haya sido 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 esté construido y pase sus pruebas. Cuando pase la prueba de integración, si se diseñó correctamente, habríamos cumplido con un requisito del usuario para nuestro sistema.

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

La mejor manera de entender TDD es ponerlo en práctica. Comenzaremos escribiendo un programa 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 determina si un número es primo o no y otra que suma los números primos de una determinada secuencia de números.

Cree un directorio llamado primesen 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.

pytestrequiere que nuestros archivos de prueba comiencen con “test_” o terminen con “_test.py” (por lo tanto, también podríamos haber nombrado nuestro archivo de prueba primes_test.py).

Ahora en nuestro primesdirectorio, configuremos nuestro entorno virtual:

$ 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

Probando 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 debería tomar un número y regresar Truesi es primo o Falseno.

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

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

La assert()declaración es una palabra clave en Python (y en muchos otros lenguajes) que inmediatamente arroja 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 1o un número menor que 1, entonces no puede ser primo.

Ejecutemos ahora nuestra prueba. Ingrese lo siguiente en su línea de comando:

$ pytest

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

Debería notar un resultado como este:

    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 de refactorización rojo-verde.

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

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

Nota : En general, 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 código base, también mantiene al desarrollador de la prueba alejado del funcionamiento interno del código. Por lo tanto, las pruebas usan las interfaces de la aplicación de la misma manera que las usaría otro desarrollador.

Ahora corramos pytestuna vez más. Ahora deberíamos ver un resultado como este:

=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.9.0
rootdir: /Users/marcus/Pharos.sh/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 1 no es primo, pero por definición 0 no es primo ni ningún número negativo.

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

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

Si volvemos a correr pytest, nuestras pruebas aún pasarían.

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

def test_prime_prime_number():
    assert is_prime(29)

Y corramos pytestpara ver esta salida:

    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/Pharos.sh/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 pytestcomando 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 is_prime()función regresa Nonecomo 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 ciclo 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 hay 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 bucle, debe ser primo.

Actualicemos is_prime()con nuestra nueva lógica:

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 corremos pytestpara ver si nuestra prueba pasa:

=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.9.0
rootdir: /Users/marcus/Pharos.sh/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 Falseun número compuesto mayor que 1.

Además, test_primes.pyagregue el siguiente caso de prueba a continuación:

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

Si ejecutamos pytestveremos el siguiente resultado:

=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.9.0
rootdir: /Users/marcus/Pharos.sh/test-driven-development-with-pytest/primes
plugins: cov-2.6.1
collected 3 items

test_primes.py ...                                                                                                                   [100%]

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

Probando sum_of_primes ()

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

Eso garantiza que nuestra función siempre debe devolver 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, agreguemos el siguiente código al final de test_primes.py:

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

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

def sum_of_primes(nums):
    return sum(nums)

Ejecutar ahora pytestmostrarí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 sume los números primos:

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

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

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

Filtraremos los números primos con una Comprensión de lista:

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

Como es de rutina, corremos pytestpara verificar que arreglamos la prueba fallida: todo pasa.

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

$ 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 is_prime()función se le diera un valor flotante, ¿arrojaría un error? Nuestro is_prime()método 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 de código total, es posible que la función que se está implementando 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 pytestque nos permiten ser más eficientes en la redacción de pruebas.

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

Funciones y planificación de pruebas

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

  • Registre las 10 nuevas zapatillas Nike que compró recientemente. Cada uno vale $ 50,00.
  • Agrega 5 pantalones deportivos Adidas más que cuestan $ 70.00 cada uno.
  • Ella espera que un cliente compre 2 de las zapatillas Nike
  • Ella espera que otro cliente compre uno de los pantalones deportivos.

Podemos utilizar 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 averiguar 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á nombre, precio y cantidad. Podremos agregar nuevos artículos, agregar stock a los artículos existentes y, por supuesto, eliminar stock.

Cuando creamos una instancia de un Inventoryobjeto, queremos que el usuario proporcione un limit. El limittendrá un valor por defecto de 100. Nuestra primera prueba sería comprobar el limitcuando una instancia de un objeto. Para asegurarnos de que no superamos nuestro límite, tendremos que realizar un seguimiento del total_itemscontador. Cuando se inicializa, debe ser 0.

Necesitaremos agregar 10 zapatillas Nike y los 5 pantalones deportivos Adidas al sistema. Podemos crear un add_new_stock()método que acepta una name, pricey quantity.

Debemos 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 remove_stock()método. Esta función necesitaría la eliminación namedel stock y de los quantityartículos. Si la cantidad que se retira es negativa o si la cantidad total de las existencias es inferior a 0, entonces el método debería generar una excepción. Además, si lo nameproporcionado 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:

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ó.

Ejecutar pytesty debería fallar con una clase NameErrorya que no Inventoryestá definida.

Creemos nuestra Inventoryclase, con un parámetro de límite que por defecto es 100, comenzando con las pruebas unitarias:

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í:

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:

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 nos obligaron a crear una instancia de un Inventoryobjeto antes de poder 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 conocido y fijo 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. Los resultados de un caso de prueba no deberían afectar los resultados de otro caso de prueba.

Creemos nuestro primer accesorio, un Inventoryobjeto sin stock.

test_inventory.py:

import pytest

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

Tenga en cuenta el uso del pytest.fixturedecorador. Para fines de prueba, podemos reducir el límite de inventario a 10.

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

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 el dispositivo que se aplicará. De lo contrario, lo usaría 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.

Corre pytestpara observar que ahora hay 2 fallos y 2 pases. Ahora agregaremos el add_new_stock()método:

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á que un objeto de existencias se inicializó en la __init__función. Nuevamente, ejecute pytestpara confirmar que la prueba pasó.

Pruebas de parametrización

Mencionamos anteriormente que el add_new_stock()método realiza una validación de entrada: generamos 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 try / except para detectar 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 asegurarnos de que nuestra validación de entrada funcione:

@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, falla la prueba. La elsecláusula es muy importante en este escenario. Sin él, una excepción que no se haya lanzado contaría como un pase. Por tanto, nuestra prueba tendría un falso positivo.

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

Ejecutar pytestpara ver que nuestra prueba falla ya InvalidQuantityExceptionque no está definida. De vuelta inventory.py, creemos una nueva excepción sobre la Inventoryclase:

class InvalidQuantityException(Exception):
    pass

Y cambia el add_new_stock()método:

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 pytestpara ver que nuestra prueba más reciente ahora pasa. 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:

@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 necesario para agregar nuevos casos de prueba.

En inventory.py, primero agregaremos nuestra nueva excepción a continuación InvalidQuantityException:

class NoSpaceException(Exception):
    pass

Y cambia el add_new_stock()método:

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 pytestpara ver que su nuevo caso de prueba también se apruebe.

Podemos usar dispositivos con nuestra función parametrizada. Refactoricemos nuestra prueba para usar el accesorio de inventario vacío:

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 parametrizar.

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

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

@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 test_add_new_stock_success()función separada ahora se ejecuta mediante un parámetro tuplado. Como no esperamos que se lance una excepción en el caso exitoso, lo especificamos Nonecomo nuestra excepción.

Finalizando nuestro Gestor de Inventario

Con nuestro pytestuso más avanzado , podemos desarrollar rápidamente la remove_stockfunción con TDD. En inventory_test.py:

# 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 inventory.pyarchivo primero creamos la nueva excepción para cuando los usuarios intentan modificar un stock que no existe:

class ItemNotFoundException(Exception):
    pass

Y luego agregamos este método a nuestra Inventoryclase:

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 ejecute 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 característica 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, utilizamos pytestuna herramienta de prueba automatizada. Con pytestpodemos realizar pruebas de script, lo que nos ahorra tiempo de tener que probar manualmente nuestro código en 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 aseguran que una colección de módulos interopere como esperamos. Tanto la pytestherramienta como la metodología TDD permiten el uso de ambos tipos de prueba, y se anima a los desarrolladores a utilizar 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 redacción proporcionan 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 necesiten nuestros requisitos.

 

About the author

Ramiro de la Vega

Bienvenido a Pharos.sh

Soy Ramiro de la Vega, Estadounidense con raíces Españolas. Empecé a programar hace casi 20 años cuando era muy jovencito.

Espero que en mi web encuentres la inspiración y ayuda que necesitas para adentrarte en el fantástico mundo de la programación y conseguir tus objetivos por difíciles que sean.

Add comment

Sobre mi

Últimos Post

Etiquetas

Esta web utiliza cookies propias para su correcto funcionamiento. Al hacer clic en el botón Aceptar, aceptas el uso de estas tecnologías y el procesamiento de tus datos para estos propósitos. Más información
Privacidad