Introducción
Contenido
- 1 Introducción
- 2 Pruebas automatizadas
- 3 El módulo pytest
- 4 ¿Qué es el desarrollo basado en pruebas?
- 5 ¿Por qué utilizar TDD para crear aplicaciones?
- 6 Cobertura de código
- 7 Prueba unitaria frente a pruebas de integración
- 8 Ejemplo básico: calcular la suma de números primos
- 9 Ejemplo avanzado: escribir un administrador de inventario
- 10 Conclusió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 unittest
biblioteca es rica en funciones y efectiva en su tarea, la usaremos pytest
como nuestra arma preferida en este artículo.
La mayoría de los desarrolladores encuentran pytest
más fácil de usar que unittest
. Una razón simple es que pytest
solo requiere funciones para escribir pruebas, mientras que el unittest
módulo requiere clases.
Para muchos desarrolladores nuevos, requerir clases para las pruebas puede resultar 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 unittest
mó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 (
if
declaraciones, 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.
Te puede interesar:Creación de un sistema de recomendación sencillo en Python con PandasA 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 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 nombrado nuestro archivo de prueba primes_test.py
).
Ahora en nuestro primes
directorio, 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 True
si es primo o False
no.
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 1
o 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.
pytest
incluso registra las pruebas fallidas en el color rojo si su shell está configurado para mostrar colores. Ahora agreguemos el código en nuestro primes.py
archivo 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.
Te puede interesar:Implementación de SVM y Kernel SVM con Scikit-Learn de PythonAhora corramos pytest
una 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.py
agreguemos lo siguiente después de nuestro primer caso de prueba:
def test_prime_prime_number():
assert is_prime(29)
Y corramos pytest
para 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 pytest
comando 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 None
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 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 pytest
para 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 False
un número compuesto mayor que 1.
Además, test_primes.py
agregue el siguiente caso de prueba a continuación:
def test_prime_composite_number():
assert is_prime(15) == False
Si ejecutamos pytest
veremos 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 NameError
error de prueba, ya que aún no definimos la función. En nuestro primes.py
archivo, agreguemos nuestra nueva función que simplemente devuelve la suma de una lista dada:
def sum_of_primes(nums):
return sum(nums)
Ejecutar ahora 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 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 pytest
para 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 pytest
para 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 pytest
que nos permiten ser más eficientes en la redacción de 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
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 Inventory
objeto, queremos que el usuario proporcione un limit
. El limit
tendrá un valor por defecto de 100. Nuestra primera prueba sería comprobar el limit
cuando una instancia de un objeto. Para asegurarnos de que no superamos nuestro límite, tendremos que realizar un seguimiento del total_items
contador. 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
, price
y 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 name
del stock y de los quantity
artí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 name
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:
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 pytest
y debería fallar con una clase NameError
ya que no Inventory
está definida.
Creemos nuestra Inventory
clase, 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 Inventory
objeto 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 Inventory
objeto 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.fixture
decorador. 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 pytest
para 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 pytest
para 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.
Te puede interesar:Python para PNL: Trabajar con la biblioteca Gensim (Parte 2)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 else
clá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 pytest
decoradores 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 pytest
para ver que nuestra prueba falla ya InvalidQuantityException
que no está definida. De vuelta inventory.py
, creemos una nueva excepción sobre la Inventory
clase:
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 pytest
para 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 pytest
para 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 None
como nuestra excepción.
Finalizando nuestro Gestor de Inventario
Con nuestro pytest
uso más avanzado , podemos desarrollar rápidamente la remove_stock
funció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.py
archivo 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 Inventory
clase:
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 pytest
una herramienta de prueba automatizada. Con pytest
podemos 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 pytest
herramienta 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.