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 con assert.
  • 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

  1. 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 @.
  2. Escribe una prueba parametrizada para una función celsius_to_fahrenheit() con al menos cuatro temperaturas, incluidas 0 y -40.
  3. Escribe una función average(numbers) que lance un ValueError con una lista vacía, y prueba tanto un promedio normal como la excepción.
  4. 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.
  5. 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_path para revisar que al guardar y cargar se obtiene la misma lista.
  6. 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:

Es una base sólida. A partir de aquí, la mejor forma de seguir mejorando es construir cosas. Algunos buenos siguientes pasos:

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