Serie Fundamentos de Python — Parte 9 (última parte). Esta publicación cierra la serie que empezó con la Parte 1: Lo que Todo Principiante Debería Saber y continuó con variables y tipos de datos, listas y diccionarios, la sentencia if, entrada del usuario y ciclos while, funciones, clases y archivos y excepciones. Esta última parte trata sobre cómo probar tu código.
¿Cómo sabes que tu código funciona? Hasta ahora en esta serie lo has comprobado ejecutándolo y mirando el resultado. Eso está bien para un script de diez líneas. Pero a medida que los programas crecen, cada cambio puede romper algo que antes funcionaba, y revisarlo todo a mano después de cada cambio pronto se vuelve imposible.
Las pruebas automatizadas resuelven esto. Una prueba (test) es un pequeño fragmento de código que ejecuta una parte de tu programa con datos conocidos y revisa que el resultado sea el esperado. Una vez que escribes una prueba, puede revisar tu código de nuevo en una fracción de segundo, tantas veces como quieras. Cambia una función, ejecuta tus pruebas y sabrás de inmediato si rompiste algo.
En esta última publicación de la serie aprenderás a escribir pruebas con pytest, la herramienta de pruebas más popular de Python. Probarás funciones, excepciones, clases y código que trabaja con archivos, usando ejemplos de las partes anteriores de la serie.
La Sentencia assert
Las pruebas se basan en la sentencia assert de Python. Revisa que algo sea verdadero. Si lo es, no pasa nada. Si no lo es, Python lanza un AssertionError:
total = 5 * 340
assert total == 1700 # verdadero: no pasa nada
assert total == 2000 # falso: AssertionError
Un assert es una afirmación sobre tu código: “en este punto, total debe valer 1700”. Una prueba es simplemente una función llena de estas afirmaciones, ejecutada por una herramienta que informa cuáles se cumplieron.
Instalar pytest
Python incluye un módulo de pruebas llamado unittest, pero la mayoría de los desarrolladores de Python usan pytest, que es más sencillo de escribir y da resultados mucho más útiles. Es un paquete aparte, así que instálalo en el entorno virtual de tu proyecto:
python3 -m venv .venv
source .venv/bin/activate # En Windows: .venv\Scripts\activate
pip install pytest
¿Usas uv? Ejecuta
uv add --dev pytest. Consulta Python uv: Una Forma Más Rápida de Administrar Proyectos y Dependencias.
Tu Primera Prueba
Vamos a probar una función pequeña. Guarda esto en un archivo llamado names.py:
def format_name(first, last):
"""Return a neatly formatted full name."""
full_name = f"{first} {last}"
return full_name.title()
Ahora crea un segundo archivo en la misma carpeta, llamado test_names.py:
from names import format_name
def test_first_last_name():
"""Do names like 'maria lopez' work?"""
formatted = format_name("maria", "lopez")
assert formatted == "Maria Lopez"
La prueba importa la función, la llama con datos conocidos y afirma cuál debe ser el resultado. Dos reglas de nombres hacen que funcione:
- El nombre del archivo de prueba debe empezar con
test_(o terminar en_test.py). - El nombre de cada función de prueba debe empezar con
test_.
pytest usa esos nombres para encontrar tus pruebas automáticamente. Ejecútalo desde la carpeta donde están tus archivos:
pytest
============================= test session starts ==============================
platform darwin -- Python 3.12.9, pytest-9.1.1, pluggy-1.7.0
rootdir: /Users/you/python_testing
collected 1 item
test_names.py . [100%]
============================== 1 passed in 0.01s ===============================
Las primeras líneas muestran tus versiones de Python y de pytest, así que en tu computadora se verán un poco distintas. Lo importante es collected 1 item (pytest encontró una prueba), el punto después de test_names.py (una prueba pasó) y el resumen final: 1 passed.
Cuando una Prueba Falla
Las pruebas son más valiosas cuando detectan un error. Supongamos que decides que format_name() también debe manejar segundos nombres, y la cambias así:
def format_name(first, middle, last):
"""Return a neatly formatted full name."""
full_name = f"{first} {middle} {last}"
return full_name.title()
Los segundos nombres ya funcionan, pero ejecuta las pruebas otra vez:
============================= test session starts ==============================
platform darwin -- Python 3.12.9, pytest-9.1.1, pluggy-1.7.0
rootdir: /Users/you/python_testing
collected 1 item
test_names.py F [100%]
=================================== FAILURES ===================================
_____________________________ test_first_last_name _____________________________
def test_first_last_name():
"""Do names like 'maria lopez' work?"""
> formatted = format_name("maria", "lopez")
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E TypeError: format_name() missing 1 required positional argument: 'last'
test_names.py:6: TypeError
=========================== short test summary info ============================
FAILED test_names.py::test_first_last_name - TypeError: format_name() missing...
============================== 1 failed in 0.03s ===============================
La F significa que una prueba falló. Luego pytest te muestra exactamente qué salió mal: el nombre de la prueba que falló, la línea marcada con > donde falló y el error en las líneas que empiezan con E. Reconocerás este TypeError de la Parte 6: ahora la función exige tres argumentos, así que todas las llamadas que ya existían con dos argumentos quedaron rotas.
Sin la prueba, tal vez no lo habrías notado hasta que lo notara un usuario. Para esto sirven las pruebas: para detectar los efectos secundarios de un cambio.
Corrige el Código, No la Prueba
Cuando una prueba falla, tu primer impulso debe ser corregir el código, no cambiar la prueba para que pase. La prueba describe un comportamiento que decidiste que era correcto; es el código el que ya no coincide. Aquí, la solución es hacer que el segundo nombre sea opcional con un valor predeterminado, para que los nombres de dos partes sigan funcionando:
def format_name(first, last, middle=""):
"""Return a neatly formatted full name."""
if middle:
full_name = f"{first} {middle} {last}"
else:
full_name = f"{first} {last}"
return full_name.title()
Ahora agrega una segunda prueba para la nueva función en test_names.py:
def test_first_last_middle_name():
"""Do names like 'maria elena lopez' work?"""
formatted = format_name("maria", "lopez", "elena")
assert formatted == "Maria Elena Lopez"
Ejecuta pytest de nuevo:
test_names.py .. [100%]
============================== 2 passed in 0.01s ===============================
Dos puntos, dos pruebas que pasan. El comportamiento anterior sigue funcionando, y el nuevo también queda revisado.
Cuando el Valor Es Incorrecto
El otro tipo común de fallo es un assert que no se cumple. Supongamos que escribes una prueba para la calculadora de cotizaciones de partes anteriores de la serie, pero te equivocas en el precio esperado con entrega urgente:
from quote import calculate_quote
def test_rush_fee():
assert calculate_quote(5, rush=True) == 2000
pytest muestra ambos lados de la comparación:
def test_rush_fee():
> assert calculate_quote(5, rush=True) == 2000
E assert 2125.0 == 2000
E + where 2125.0 = calculate_quote(5, rush=True)
La función devolvió 2125.0, y la prueba esperaba 2000. Ahora tienes que decidir cuál de los dos está mal. Cinco páginas a $1,700 más un 25% por urgencia dan $2,125, así que en este caso el error estaba en la prueba. Cuando un fallo apunta a una expectativa equivocada, corregir la prueba es lo correcto. La clave es decidirlo a conciencia, nunca solo para que desaparezca el fallo.
Probar Más Situaciones
Las buenas pruebas revisan algo más que el caso obvio. Esta es la función de cotizaciones que vamos a probar, guardada como quote.py:
HOURLY_RATE = 85
HOURS_PER_PAGE = 4
def calculate_quote(pages, rush=False):
"""Return the price for a website with the given number of pages."""
if pages < 1:
raise ValueError("A project needs at least 1 page.")
total = pages * HOURS_PER_PAGE * HOURLY_RATE
if rush:
total = total * 1.25
return total
Y este es test_quote.py, que revisa un caso normal, un argumento opcional y un caso de error:
import pytest
from quote import calculate_quote
def test_one_page():
assert calculate_quote(1) == 340
def test_rush_adds_25_percent():
assert calculate_quote(5, rush=True) == 2125
def test_zero_pages_raises_error():
with pytest.raises(ValueError):
calculate_quote(0)
Probar que se Lanza una Excepción
En la Parte 8 aprendiste a lanzar excepciones con raise cuando una función recibe datos incorrectos. Ese comportamiento también merece una prueba. pytest.raises() solo pasa si el código dentro del bloque with lanza la excepción que indicas. Si calculate_quote(0) dejara algún día de lanzar ValueError, esta prueba fallaría.
Probar Números Decimales con pytest.approx
Como viste en la Parte 2, los flotantes no son perfectamente precisos. Eso puede hacer que un cálculo correcto falle en una comparación exacta:
import pytest
def add_tax(price, rate=0.0775):
return price * (1 + rate)
def test_add_tax():
assert add_tax(19.99) == pytest.approx(21.539225)
19.99 * 1.0775 debería dar 21.539225, pero Python en realidad calcula 21.539224999999995. Una comparación exacta con == falla, aunque la función sea correcta. pytest.approx() compara los números con una tolerancia diminuta, así que la prueba pasa. Úsalo siempre que compares flotantes.
Ejecutar una Prueba con Muchos Datos
Cuando quieres revisar lo mismo con varios datos distintos, no necesitas copiar la prueba. @pytest.mark.parametrize ejecuta una misma función de prueba una vez por cada conjunto de valores que le des:
@pytest.mark.parametrize("pages, expected", [
(1, 340),
(5, 1700),
(10, 3400),
])
def test_prices_for_page_counts(pages, expected):
assert calculate_quote(pages) == expected
El primer argumento nombra los parámetros, y la lista da una tupla de valores por cada ejecución. pytest informa cada una como una prueba separada, así que si falla un dato, sabrás exactamente cuál.
Probar Clases con Fixtures
Para probar una clase de la Parte 7, necesitas un objeto que probar. Esta es una versión simplificada de la clase Project, guardada como project.py:
class Project:
"""A website project with a list of pages."""
def __init__(self, client):
self.client = client
self.pages = []
def add_page(self, name):
"""Add a page, ignoring blanks and duplicates."""
name = name.strip().title()
if name and name not in self.pages:
self.pages.append(name)
def page_count(self):
return len(self.pages)
Varias pruebas necesitan el mismo objeto inicial. En lugar de construirlo en cada prueba, escribe un fixture: una función marcada con @pytest.fixture que crea lo que tus pruebas necesitan. Cualquier prueba que tenga el nombre del fixture como parámetro recibe una copia nueva de él:
import pytest
from project import Project
@pytest.fixture
def project():
"""A project with two pages already added."""
project = Project("Bright Bakery")
project.add_page("home")
project.add_page("menu")
return project
def test_pages_are_title_case(project):
assert project.pages == ["Home", "Menu"]
def test_duplicate_pages_are_ignored(project):
project.add_page("Home")
assert project.page_count() == 2
def test_blank_pages_are_ignored(project):
project.add_page(" ")
assert project.page_count() == 2
Cada prueba recibe su propio proyecto nuevo, así que si una prueba cambia su proyecto, nunca afecta a otra. Las pruebas que no dependen unas de otras son mucho más confiables.
Probar Código que Usa Archivos
El código que lee y escribe archivos es difícil de probar: no quieres que tus pruebas creen archivos por todo tu proyecto ni que dependan de archivos que quedaron de la ejecución anterior. pytest tiene un fixture integrado para esto, tmp_path, que le da a cada prueba su propia carpeta temporal vacía como un objeto Path (de la Parte 8).
Estas son dos funciones que guardan y cargan clientes, guardadas como clients.py. Fíjate en que reciben la ruta del archivo como parámetro: eso es lo que las hace fáciles de probar:
import json
def save_clients(clients, path):
"""Save a list of clients to a JSON file."""
with open(path, "w", encoding="utf-8") as file:
json.dump(clients, file, indent=2)
def load_clients(path):
"""Return the saved clients, or an empty list if the file doesn't exist."""
try:
with open(path, encoding="utf-8") as file:
return json.load(file)
except FileNotFoundError:
return []
Y las pruebas, en test_clients.py:
from clients import load_clients, save_clients
def test_save_and_load(tmp_path):
path = tmp_path / "clients.json"
clients = [{"name": "Bright Bakery", "budget": 2500}]
save_clients(clients, path)
assert load_clients(path) == clients
def test_missing_file_returns_empty_list(tmp_path):
assert load_clients(tmp_path / "nope.json") == []
La primera prueba revisa un viaje de ida y vuelta: lo que guardas es exactamente lo que vuelves a cargar. La segunda revisa el manejo de errores de la Parte 8. pytest crea las carpetas temporales y las limpia por ti.
Ejecutar tus Pruebas
Con todos estos archivos en una carpeta, un solo comando pytest ejecuta todas las pruebas. Agrega -v (verbose, detallado) para ver el nombre de cada prueba:
pytest -v
collected 11 items
test_clients.py::test_save_and_load PASSED [ 9%]
test_clients.py::test_missing_file_returns_empty_list PASSED [ 18%]
test_project.py::test_pages_are_title_case PASSED [ 27%]
test_project.py::test_duplicate_pages_are_ignored PASSED [ 36%]
test_project.py::test_blank_pages_are_ignored PASSED [ 45%]
test_quote.py::test_one_page PASSED [ 54%]
test_quote.py::test_rush_adds_25_percent PASSED [ 63%]
test_quote.py::test_zero_pages_raises_error PASSED [ 72%]
test_quote.py::test_prices_for_page_counts[1-340] PASSED [ 81%]
test_quote.py::test_prices_for_page_counts[5-1700] PASSED [ 90%]
test_quote.py::test_prices_for_page_counts[10-3400] PASSED [100%]
============================== 11 passed in 0.04s ==============================
Observa que la prueba parametrizada aparece tres veces, una por cada conjunto de datos. Otras opciones útiles:
pytest test_quote.py # ejecuta solo las pruebas de un archivo
pytest -k rush # ejecuta solo las pruebas con "rush" en su nombre
pytest -x # se detiene en el primer fallo
¿Qué Deberías Probar?
No necesitas probar cada línea. Concentra tus pruebas donde te den más confianza:
- El propósito principal de cada función. Si
calculate_quote()calcula mal los precios, nada más importa. - Los casos límite. Cero, uno, listas vacías, cadenas vacías, los valores más grandes y más pequeños permitidos. A los errores les encantan los límites.
- El manejo de errores. Revisa que los datos incorrectos lancen la excepción que esperas, como en la Parte 8.
- Los errores que ya corregiste. Cuando encuentres un error, escribe una prueba que lo reproduzca antes de corregirlo. La prueba demuestra que la corrección funciona y asegura que el error nunca vuelva en silencio.
Algunos hábitos mantienen útiles las pruebas:
- Prueba una sola cosa por prueba, y dale a cada prueba un nombre que diga lo que revisa, como
test_duplicate_pages_are_ignored. Cuando falle, el nombre por sí solo te dirá qué se rompió. - Mantén las pruebas independientes. Cada prueba debe preparar lo que necesita, muchas veces con un fixture, y nunca depender de que otra prueba se ejecute antes.
- Prueba valores devueltos, no lo que se imprime. Es una razón más para que las funciones devuelvan sus resultados con
return, como se recomendó en la Parte 6: los valores devueltos son fáciles de revisar conassert. - Ejecuta tus pruebas seguido, sobre todo antes y después de cada cambio.
Un Vistazo a unittest
También verás pruebas escritas con el módulo integrado unittest de Python, sobre todo en proyectos más antiguos. No necesita instalarse, pero es más extenso: las pruebas son métodos dentro de una clase, y se usan métodos especiales de afirmación en lugar de assert:
import unittest
from names import format_name
class TestFormatName(unittest.TestCase):
def test_first_last_name(self):
self.assertEqual(format_name("maria", "lopez"), "Maria Lopez")
if __name__ == "__main__":
unittest.main()
Una buena noticia: pytest también ejecuta las pruebas de unittest, así que puedes usar pytest incluso en proyectos que empezaron con unittest.
Errores Comunes de Principiantes
Nombrar archivos o funciones de forma que pytest no los encuentre. Un archivo llamado names_tests.py o una función llamada check_name() se ignoran sin aviso. Empieza ambos con test_. Si pytest dice collected 0 items, revisa tus nombres.
Mover las pruebas a otra carpeta sin configurarla. Si mueves tus pruebas a una carpeta tests/ y pytest de pronto muestra ModuleNotFoundError: No module named 'names', ya no puede encontrar el código que está probando. Mientras aprendes, mantén cada archivo de prueba en la misma carpeta que el código que prueba, como en esta publicación. Cuando quieras usar una carpeta tests/, consulta la guía de pytest sobre la estructura de proyectos.
Cambiar la prueba solo para que pase. Una prueba que falla es información. Decide si el error está en el código o en la expectativa antes de cambiar algo.
Probar demasiado en una sola prueba. Si una sola prueba revisa diez cosas distintas, un fallo no te dice cuál se rompió. Divídela en pruebas más pequeñas.
Comparar flotantes con ==. Usa pytest.approx() para cualquier resultado que incluya operaciones con decimales.
Probar funciones que solo imprimen. No puedes revisar fácilmente con assert lo que se imprime. Haz que las funciones devuelvan valores, y prueba esos valores.
Escribir pruebas y nunca ejecutarlas. Las pruebas solo ayudan si las ejecutas. Haz de pytest parte de tu rutina antes de compartir o publicar tu código.
Ejercicios de Práctica
- Escribe una función
is_valid_email(email)(de los ejercicios de la Parte 6) y tres pruebas para ella: un correo válido, uno sin@y uno sin un punto después de la@. - Escribe una prueba parametrizada para una función
celsius_to_fahrenheit()con al menos cuatro temperaturas, incluidas0y-40. - Escribe una función
average(numbers)que lance unValueErrorcon una lista vacía, y prueba tanto un promedio normal como la excepción. - Escribe una clase
BankAccount(de los ejercicios de la Parte 7), un fixture que cree una cuenta con un saldo inicial y pruebas para depósitos, retiros y para rechazar los retiros sin fondos suficientes. - Escribe una función que guarde una lista de tareas en un archivo de texto, una por línea, y una prueba que use
tmp_pathpara revisar que al guardar y cargar se obtiene la misma lista. - Elige un programa que hayas escrito durante esta serie, encuentra un error o un caso límite que no maneje, escribe una prueba que falle por ese motivo y luego corrige el código hasta que la prueba pase.
Conclusión
Las pruebas convierten “creo que funciona” en “sé que funciona”. Con pytest, una prueba es solo una función cuyo nombre empieza con test_ y que usa assert para afirmar lo que debe ser verdadero. pytest encuentra y ejecuta tus pruebas, y cuando una falla, te muestra exactamente dónde y por qué. pytest.raises() revisa el manejo de errores, pytest.approx() resuelve los decimales, parametrize ejecuta una prueba con muchos datos, los fixtures dan a cada prueba un punto de partida nuevo y tmp_path hace que el código con archivos sea seguro de probar.
Cierre de la Serie Fundamentos de Python
Esta publicación completa la serie Fundamentos de Python. A lo largo de nueve partes aprendiste a:
- Guardar datos en variables y elegir los tipos de datos correctos.
- Organizar datos con listas y diccionarios.
- Tomar decisiones con la sentencia if.
- Interactuar con las personas usando entrada del usuario y ciclos while.
- Escribir código reutilizable con funciones.
- Modelar cosas del mundo real con clases.
- Guardar datos y manejar errores con archivos y excepciones.
- Demostrar que tu código funciona con pruebas.
Es una base sólida. A partir de aquí, la mejor forma de seguir mejorando es construir cosas. Algunos buenos siguientes pasos:
- Practica con un proyecto al día en el Reto de 100 Días de Python.
- Analiza datos reales con Python Pandas y la visualización de datos con Python.
- Crea sitios web y APIs con Flask, y guarda sus datos con SQLAlchemy.
- Configura bien tus proyectos con entornos virtuales, uv y archivos .env.
Elige algo pequeño que haría más fácil tu propio trabajo, escríbelo, pruébalo y sigue adelante. ¡Gracias por seguir la serie!
Serie Fundamentos de Python: Parte 1: Lo que Todo Principiante Debería Saber · Parte 2: Variables y Tipos de Datos · Parte 3: Listas y Diccionarios · Parte 4: La Sentencia If · Parte 5: Entrada del Usuario y Ciclos While · Parte 6: Funciones · Parte 7: Clases · Parte 8: Archivos y Excepciones · Parte 9: Cómo Probar tu Código
