35. Pruebas unitarias de validaciones

35.1 Introducción

Las validaciones aparecen en casi todos los sistemas: formularios, APIs, procesos de negocio, importaciones de archivos, reglas de registro, pagos, pedidos y muchas otras partes de una aplicación. Su tarea es decidir si un dato puede aceptarse, rechazarse o transformarse antes de continuar.

Probar validaciones de manera unitaria es importante porque muchos errores reales nacen en condiciones mal expresadas: un límite que debería incluirse y queda excluido, un texto vacío que se acepta por error, un valor negativo que llega a un cálculo o una regla que rechaza un dato válido.

En este tema veremos cómo diseñar pruebas unitarias para validaciones simples y compuestas, qué casos elegir, cómo verificar errores esperados y cómo evitar pruebas frágiles que repiten la implementación en lugar de comprobar el comportamiento.

35.2 Qué entendemos por validación

Una validación es una comprobación que determina si un dato cumple una condición esperada. Esa condición puede ser técnica, como que un campo no esté vacío, o de negocio, como que una persona deba tener al menos 18 años para registrarse.

Algunos ejemplos habituales son:

  • Un nombre no debe estar vacío.
  • Una edad debe ser mayor o igual a 18.
  • Un importe debe ser positivo.
  • Una contraseña debe tener una longitud mínima.
  • Una lista de productos no debe estar vacía.
  • Un pedido solo puede confirmarse si tiene cliente y al menos un ítem.

Desde el punto de vista de las pruebas unitarias, una validación es una unidad interesante porque suele tener entradas claras, salidas verificables y casos límite bien definidos.

35.3 Qué debe comprobar una prueba de validación

Una prueba de validación debe comprobar la decisión observable de la unidad: si acepta un dato, si lo rechaza, si devuelve un mensaje, si lanza una excepción o si produce una estructura con errores.

Una prueba unitaria de validación no debe limitarse a ejecutar la función: debe verificar qué ocurre con un dato válido, inválido o límite.

Por ejemplo, si una función valida una edad mínima, no alcanza con llamarla. Debemos expresar qué resultado esperamos:

def es_edad_valida(edad):
    return edad >= 18


def test_edad_18_es_valida():
    assert es_edad_valida(18) is True

La prueba comunica una regla concreta: la edad 18 debe aceptarse. Si alguien cambia la comparación a edad > 18, esta prueba falla y muestra que se rompió el límite correcto.

35.4 Probar datos válidos

Un primer grupo de pruebas debe confirmar que la validación acepta datos correctos. Esto evita que una regla quede demasiado estricta y rechace entradas que el sistema debería permitir.

def es_nombre_valido(nombre):
    return len(nombre.strip()) > 0


def test_nombre_con_texto_es_valido():
    assert es_nombre_valido("Ana") is True

Esta prueba no busca cubrir todos los nombres posibles. Elige un caso representativo que debería pasar. Si el validador rechaza "Ana", hay un problema claro en la regla.

Los datos válidos deben ser simples y fáciles de leer. No conviene usar ejemplos complejos si un dato pequeño alcanza para demostrar el comportamiento.

35.5 Probar datos inválidos

También debemos comprobar que los datos incorrectos sean rechazados. Una validación que acepta cualquier entrada no protege al sistema.

def es_nombre_valido(nombre):
    return len(nombre.strip()) > 0


def test_nombre_vacio_no_es_valido():
    assert es_nombre_valido("") is False


def test_nombre_con_solo_espacios_no_es_valido():
    assert es_nombre_valido("   ") is False

Estos dos casos son distintos. Un texto vacío y un texto con espacios pueden parecer equivalentes para la regla, pero internamente requieren considerar el uso de strip(). La prueba protege esa intención sin verificar cómo está implementada.

35.6 Probar casos límite

Las validaciones suelen tener límites: mínimo, máximo, longitud exacta, fecha permitida, cantidad mínima de elementos o rango numérico. Los errores más comunes aparecen justo en esos bordes.

Si una contraseña debe tener al menos 8 caracteres, conviene probar 7, 8 y 9:

def contrasenia_tiene_longitud_valida(contrasenia):
    return len(contrasenia) >= 8


def test_contrasenia_de_7_caracteres_no_es_valida():
    assert contrasenia_tiene_longitud_valida("abc1234") is False


def test_contrasenia_de_8_caracteres_es_valida():
    assert contrasenia_tiene_longitud_valida("abc12345") is True


def test_contrasenia_de_9_caracteres_es_valida():
    assert contrasenia_tiene_longitud_valida("abc123456") is True

El caso más importante es 8, porque define si el límite se incluye o se excluye. Los casos vecinos ayudan a detectar comparaciones incorrectas.

35.7 Validaciones que devuelven booleanos

Una forma simple de expresar una validación es devolver True si el dato es válido y False si no lo es. Este enfoque es útil cuando no necesitamos informar muchos detalles del error.

def es_importe_valido(importe):
    return importe > 0


def test_importe_positivo_es_valido():
    assert es_importe_valido(100) is True


def test_importe_cero_no_es_valido():
    assert es_importe_valido(0) is False


def test_importe_negativo_no_es_valido():
    assert es_importe_valido(-10) is False

Cuando la validación devuelve booleanos, las pruebas deben usar nombres claros porque el resultado por sí solo no explica la regla completa. El nombre test_importe_cero_no_es_valido comunica mejor la intención que un nombre genérico como test_importe.

35.8 Validaciones que lanzan excepciones

Otra estrategia consiste en lanzar una excepción cuando el dato no cumple la regla. Esto suele usarse cuando continuar con datos inválidos sería un error de programación o una condición que debe detener el flujo.

def validar_stock(stock):
    if stock < 0:
        raise ValueError("El stock no puede ser negativo")


def test_stock_positivo_no_genera_error():
    validar_stock(5)


def test_stock_negativo_genera_error():
    try:
        validar_stock(-1)
        assert False
    except ValueError as error:
        assert str(error) == "El stock no puede ser negativo"

En este ejemplo, la primera prueba comprueba que un dato válido no produce error. La segunda comprueba que el dato inválido genera el error esperado y que el mensaje comunica la causa.

Al usar un framework como pytest, el mismo caso podría escribirse con pytest.raises. Lo importante no es la sintaxis exacta, sino verificar explícitamente que el error forma parte del comportamiento esperado.

35.9 Validaciones que devuelven errores

En formularios o APIs, una validación puede devolver una lista de errores en lugar de un booleano o una excepción. Esto permite mostrar varios problemas a la vez.

def validar_usuario(datos):
    errores = []

    if datos["nombre"].strip() == "":
        errores.append("El nombre es obligatorio")

    if datos["edad"] < 18:
        errores.append("La edad minima es 18")

    return errores


def test_usuario_valido_no_tiene_errores():
    errores = validar_usuario({"nombre": "Ana", "edad": 20})

    assert errores == []


def test_usuario_sin_nombre_tiene_error_de_nombre():
    errores = validar_usuario({"nombre": "", "edad": 20})

    assert errores == ["El nombre es obligatorio"]

Cuando se devuelve una colección de errores, la prueba puede verificar la lista completa si el orden es parte del contrato. Si el orden no importa, conviene comparar como conjunto o verificar la presencia de mensajes específicos.

35.10 Probar mensajes de error

Los mensajes de error también pueden ser parte del comportamiento observable, especialmente si una API los devuelve, si una interfaz los muestra o si otros módulos dependen de ellos.

Sin embargo, hay que decidir con cuidado cuánto acoplar la prueba al texto exacto. Si el mensaje es parte del contrato público, tiene sentido probarlo. Si es solo una ayuda interna para depuración, tal vez alcance con verificar el tipo de error.

def validar_email(email):
    if "@" not in email:
        return "El email debe contener @"
    return None


def test_email_sin_arroba_devuelve_mensaje_de_error():
    error = validar_email("ana.example.com")

    assert error == "El email debe contener @"

Esta prueba es apropiada si el mensaje forma parte de la respuesta esperada. Si más adelante el texto puede cambiar libremente, la prueba podría volverse demasiado rígida.

35.11 Validaciones de texto

Las validaciones de texto suelen incluir reglas sobre vacío, espacios, longitud, caracteres permitidos, formato o coincidencia con un patrón.

Un ejemplo simple es validar un código que debe tener exactamente 4 caracteres alfanuméricos:

def es_codigo_valido(codigo):
    return len(codigo) == 4 and codigo.isalnum()


def test_codigo_de_4_caracteres_alfanumericos_es_valido():
    assert es_codigo_valido("A123") is True


def test_codigo_de_3_caracteres_no_es_valido():
    assert es_codigo_valido("A12") is False


def test_codigo_con_guion_no_es_valido():
    assert es_codigo_valido("A-12") is False

Estas pruebas cubren tres aspectos distintos: longitud correcta, longitud insuficiente y caracteres no permitidos. Separar las ideas ayuda a diagnosticar mejor las fallas.

35.12 Validaciones numéricas

Las validaciones numéricas suelen tener rangos, mínimos, máximos y restricciones especiales. En estos casos es fundamental probar el límite y sus vecinos.

def es_porcentaje_valido(valor):
    return 0 <= valor <= 100


def test_porcentaje_cero_es_valido():
    assert es_porcentaje_valido(0) is True


def test_porcentaje_cien_es_valido():
    assert es_porcentaje_valido(100) is True


def test_porcentaje_mayor_a_cien_no_es_valido():
    assert es_porcentaje_valido(101) is False


def test_porcentaje_negativo_no_es_valido():
    assert es_porcentaje_valido(-1) is False

El objetivo no es probar todos los números posibles, sino elegir valores que representen las zonas importantes: dentro del rango, debajo del mínimo, encima del máximo y exactamente en los bordes.

35.13 Validaciones de colecciones

Algunas reglas validan listas, conjuntos o colecciones de elementos. Es común comprobar si la colección está vacía, si supera una cantidad máxima, si contiene duplicados o si todos sus elementos cumplen cierta condición.

def carrito_es_valido(productos):
    return len(productos) > 0


def test_carrito_con_un_producto_es_valido():
    assert carrito_es_valido(["teclado"]) is True


def test_carrito_vacio_no_es_valido():
    assert carrito_es_valido([]) is False

Para una regla mínima, una lista con un elemento y una lista vacía suelen ser casos suficientes. Si luego agregamos una cantidad máxima, aparecerán nuevos casos límite.

35.14 Validaciones de objetos o diccionarios

Muchas validaciones reciben un objeto completo o un diccionario con varios campos. En esos casos conviene usar datos de prueba pequeños y modificar solo el campo relevante para cada prueba.

def pedido_es_valido(pedido):
    return (
        pedido["cliente"] is not None
        and len(pedido["items"]) > 0
        and pedido["total"] > 0
    )


def test_pedido_con_cliente_items_y_total_positivo_es_valido():
    pedido = {"cliente": "Ana", "items": ["mouse"], "total": 500}

    assert pedido_es_valido(pedido) is True


def test_pedido_sin_cliente_no_es_valido():
    pedido = {"cliente": None, "items": ["mouse"], "total": 500}

    assert pedido_es_valido(pedido) is False

La segunda prueba cambia solo el cliente. Esto permite entender rápidamente qué regla se está verificando. Si en cada prueba cambian muchos campos a la vez, la intención se vuelve menos clara.

35.15 Validaciones con varias condiciones

Una validación puede combinar varias condiciones. El riesgo es escribir una sola prueba enorme que no permita saber qué condición falló. Conviene separar los casos por regla o por situación relevante.

def puede_registrarse(usuario):
    return (
        usuario["email_confirmado"]
        and usuario["edad"] >= 18
        and usuario["bloqueado"] is False
    )


def test_usuario_confirmado_mayor_y_no_bloqueado_puede_registrarse():
    usuario = {"email_confirmado": True, "edad": 20, "bloqueado": False}

    assert puede_registrarse(usuario) is True


def test_usuario_sin_email_confirmado_no_puede_registrarse():
    usuario = {"email_confirmado": False, "edad": 20, "bloqueado": False}

    assert puede_registrarse(usuario) is False


def test_usuario_bloqueado_no_puede_registrarse():
    usuario = {"email_confirmado": True, "edad": 20, "bloqueado": True}

    assert puede_registrarse(usuario) is False

Cada prueba deja una condición bajo observación. Esa separación mejora el diagnóstico y evita que una falla obligue a revisar demasiadas posibilidades.

35.16 No duplicar la lógica de validación en la prueba

Un error frecuente es escribir una prueba que repite la misma lógica que la función validada. Eso reduce el valor de la prueba, porque si copiamos el mismo error en ambos lugares, la prueba puede pasar aunque la regla esté mal.

def es_descuento_valido(descuento):
    return descuento >= 0 and descuento <= 50


def test_descuento_valido_ejemplo_poco_util():
    descuento = 40

    assert es_descuento_valido(descuento) == (descuento >= 0 and descuento <= 50)

La prueba anterior calcula el esperado usando la misma condición que la implementación. Una versión más útil expresa ejemplos concretos:

def test_descuento_40_es_valido():
    assert es_descuento_valido(40) is True


def test_descuento_51_no_es_valido():
    assert es_descuento_valido(51) is False

Las pruebas deben representar la regla mediante casos elegidos, no copiar el algoritmo interno.

35.17 Validación y normalización

A veces una función no solo valida, sino que también normaliza datos. Por ejemplo, puede quitar espacios, convertir a minúsculas o transformar un formato. Es importante distinguir ambos comportamientos.

def normalizar_y_validar_email(email):
    email_normalizado = email.strip().lower()

    if "@" not in email_normalizado:
        raise ValueError("Email invalido")

    return email_normalizado


def test_email_se_normaliza_a_minusculas_y_sin_espacios():
    resultado = normalizar_y_validar_email("  ANA@EXAMPLE.COM  ")

    assert resultado == "ana@example.com"


def test_email_sin_arroba_genera_error():
    try:
        normalizar_y_validar_email("ana.example.com")
        assert False
    except ValueError as error:
        assert str(error) == "Email invalido"

Una prueba verifica la normalización y otra verifica el rechazo de un dato inválido. Separar estos comportamientos evita mezclar demasiadas expectativas en un solo caso.

35.18 Validaciones y dependencias externas

Una prueba unitaria de validación debe evitar depender de bases de datos, red, archivos o servicios externos cuando la regla puede comprobarse con datos en memoria.

Por ejemplo, validar que un texto tenga formato de email puede hacerse sin consultar ningún servidor. En cambio, verificar si el email ya existe en una base de datos es otra responsabilidad y probablemente requiera una estrategia distinta, como una prueba de integración o una dependencia falsa.

Si la validación depende de un recurso externo, separa la regla pura de la consulta externa siempre que sea posible.

Esta separación produce pruebas más rápidas, determinísticas y fáciles de diagnosticar.

35.19 Usar pruebas parametrizadas cuando hay muchos casos similares

Cuando una misma validación debe probarse con varios valores parecidos, las pruebas parametrizadas pueden evitar repetición. Este tema se desarrollará con mayor detalle más adelante, pero conviene anticipar la idea.

import pytest


def es_calificacion_valida(valor):
    return 1 <= valor <= 10


@pytest.mark.parametrize("valor", [1, 5, 10])
def test_calificaciones_validas(valor):
    assert es_calificacion_valida(valor) is True


@pytest.mark.parametrize("valor", [0, 11, -3])
def test_calificaciones_invalidas(valor):
    assert es_calificacion_valida(valor) is False

La parametrización es útil cuando todos los casos verifican la misma idea. Si cada caso necesita una explicación distinta, puede ser mejor escribir pruebas separadas con nombres descriptivos.

35.20 Tabla de casos frecuentes

Esta tabla resume situaciones comunes al probar validaciones unitarias.

Tipo de validación Casos importantes Ejemplo
Campo obligatorio Texto válido, texto vacío, texto con espacios. "Ana", "", " "
Longitud mínima Un valor debajo del mínimo, el mínimo exacto y uno superior. 7, 8 y 9 caracteres.
Rango numérico Mínimo, máximo, debajo del mínimo y encima del máximo. 0, 100, -1, 101
Colección requerida Colección vacía y colección con al menos un elemento. [], ["item"]
Regla compuesta Caso válido y un caso inválido por cada condición relevante. Email confirmado, edad mínima, usuario no bloqueado.

35.21 Errores frecuentes al probar validaciones

Al escribir pruebas unitarias de validación conviene evitar estos problemas:

  • Probar solo datos válidos y olvidar los inválidos.
  • Probar solo datos inválidos y no confirmar que los válidos pasen.
  • No cubrir los límites exactos de una regla.
  • Repetir la misma lógica de validación dentro de la prueba.
  • Usar datos demasiado complejos para una regla simple.
  • Mezclar muchas condiciones en una sola prueba.
  • Depender de mensajes exactos cuando el texto no forma parte del contrato.
  • Consultar recursos externos para validar reglas que podrían probarse en memoria.

La mayoría de estos errores se corrige eligiendo casos pequeños, nombres claros y expectativas explícitas.

35.22 Qué debes recordar de este tema

  • Una validación decide si un dato debe aceptarse o rechazarse.
  • Las pruebas deben cubrir datos válidos, datos inválidos y casos límite.
  • El límite exacto de una regla suele ser el caso más importante.
  • La prueba debe verificar el comportamiento observable: booleano, error, mensaje o lista de errores.
  • No conviene duplicar la lógica interna de la validación en la prueba.
  • Las reglas compuestas se entienden mejor con pruebas separadas por condición.
  • Las validaciones puras son excelentes candidatas para pruebas unitarias rápidas y determinísticas.

35.23 Conclusión

Las validaciones son una de las áreas donde las pruebas unitarias aportan mucho valor. Con pocos casos bien elegidos podemos proteger reglas importantes, detectar errores en límites, evitar que datos inválidos entren al sistema y documentar ejemplos concretos de uso.

La clave está en probar la decisión de la validación, no su implementación interna. Un buen conjunto de pruebas debe mostrar qué datos se aceptan, qué datos se rechazan y qué ocurre en los bordes de la regla.

En el próximo tema profundizaremos en las pruebas unitarias de reglas de negocio, donde las validaciones suelen combinarse con decisiones más amplias del dominio de la aplicación.