Las pruebas de caja blanca son un enfoque de pruebas donde los testers examinan la estructura interna, diseño e implementación del código. A diferencia de las pruebas de caja negra que se enfocan en entradas y salidas, las pruebas de caja blanca validan la lógica interna, rutas de código y algoritmos. También se llama pruebas estructurales, pruebas de caja clara o pruebas de caja de cristal.
¿Qué son las Pruebas de Caja Blanca?
Las pruebas de caja blanca involucran analizar el código fuente para diseñar casos de prueba que ejerciten rutas de código específicas, condiciones y lógica. Los testers necesitan conocimientos de programación y acceso al código fuente para realizar pruebas de caja blanca efectivamente.
Características Clave
- Visibilidad del código: Acceso completo al código fuente y estructura interna
- Enfoque estructural: Prueba lógica interna, algoritmos y rutas de código
- Centrado en desarrolladores: A menudo realizado por desarrolladores o testers técnicos
- Impulsado por cobertura: Apunta a métricas altas de cobertura de código
- Detección temprana de defectos: Encuentra bugs durante la fase de desarrollo
Cuándo Usar Pruebas de Caja Blanca
Las pruebas de caja blanca son ideales para:
- Pruebas unitarias: Probar funciones y métodos individuales
- Pruebas de integración: Verificar interacciones de componentes
- Pruebas de seguridad: Encontrar vulnerabilidades en la lógica del código
- Optimización de código: Identificar cuellos de botella de rendimiento
- Validación de algoritmos: Asegurar que los cálculos son correctos
Métricas de Cobertura de Código
1. Cobertura de Sentencias
La cobertura de sentencias mide el porcentaje de sentencias ejecutables ejecutadas por las pruebas. Cada línea de código debería ejecutarse al menos una vez.
Fórmula: Cobertura de Sentencias = (Sentencias Ejecutadas / Sentencias Totales) × 100%
Ejemplo:
def calculate_discount(price, is_member, purchase_count):
"""Calcular descuento basado en membresía e historial de compras"""
discount = 0 # Sentencia 1
if is_member: # Sentencia 2
discount = 10 # Sentencia 3
if purchase_count > 5: # Sentencia 4
discount += 5 # Sentencia 5
return discount # Sentencia 6
# Caso de prueba 1: No miembro, pocas compras
def test_no_discount():
result = calculate_discount(100, False, 2)
assert result == 0
# Cobertura: Sentencias 1, 2, 4, 6 = 4/6 = 67%
# Caso de prueba 2: Miembro con muchas compras
def test_full_discount():
result = calculate_discount(100, True, 10)
assert result == 15
# Cobertura: Todas las sentencias = 6/6 = 100%
Limitaciones: 100% de cobertura de sentencias no garantiza que todos los escenarios estén probados—solo que todas las líneas se ejecutaron.
2. Cobertura de Ramas (Cobertura de Decisiones)
La cobertura de ramas asegura que cada punto de decisión (if/else, switch, loops) se evalúe tanto a verdadero como a falso. Es más fuerte que la cobertura de sentencias.
Fórmula: Cobertura de Ramas = (Ramas Ejecutadas / Ramas Totales) × 100%
Ejemplo:
def validate_password(password):
"""Validar fortaleza de contraseña"""
if len(password) < 8: # Decisión 1
return "Demasiado corta"
if not any(c.isupper() for c in password): # Decisión 2
return "Necesita mayúsculas"
if not any(c.isdigit() for c in password): # Decisión 3
return "Necesita dígito"
return "Válida"
# Ramas:
# Decisión 1: Verdadero, Falso
# Decisión 2: Verdadero, Falso
# Decisión 3: Verdadero, Falso
# Total: 6 ramas
def test_branch_coverage():
# Prueba 1: Cubre D1=Verdadero
assert validate_password("corta") == "Demasiado corta"
# Prueba 2: Cubre D1=Falso, D2=Verdadero
assert validate_password("minusculas123") == "Necesita mayúsculas"
# Prueba 3: Cubre D1=Falso, D2=Falso, D3=Verdadero
assert validate_password("SinDigitos") == "Necesita dígito"
# Prueba 4: Cubre D1=Falso, D2=Falso, D3=Falso
assert validate_password("Valida123") == "Válida"
# Cobertura de Ramas: 6/6 = 100%
3. Cobertura de Caminos
La cobertura de caminos prueba todos los caminos posibles a través del código. Para código con múltiples decisiones, el número de caminos crece exponencialmente.
Ejemplo:
def process_order(item_count, is_premium, in_stock):
"""Procesar pedido con múltiples condiciones"""
message = ""
if item_count > 0: # Decisión A
if in_stock: # Decisión B
message = "Procesando"
if is_premium: # Decisión C
message += " con prioridad"
else:
message = "Fuera de stock"
else:
message = "Cantidad inválida"
return message
# Caminos posibles:
# Camino 1: A=Falso → "Cantidad inválida"
# Camino 2: A=Verdadero, B=Falso → "Fuera de stock"
# Camino 3: A=Verdadero, B=Verdadero, C=Falso → "Procesando"
# Camino 4: A=Verdadero, B=Verdadero, C=Verdadero → "Procesando con prioridad"
def test_all_paths():
# Camino 1
assert process_order(0, False, True) == "Cantidad inválida"
# Camino 2
assert process_order(5, False, False) == "Fuera de stock"
# Camino 3
assert process_order(5, False, True) == "Procesando"
# Camino 4
assert process_order(5, True, True) == "Procesando con prioridad"
# Cobertura de Caminos: 4/4 = 100%
Desafío: Para n decisiones independientes, hay 2^n caminos posibles. La cobertura de caminos se vuelve imprácti ca para código complejo.
4. Cobertura de Condiciones
La cobertura de condiciones asegura que cada sub-expresión booleana se evalúe tanto a verdadero como a falso independientemente.
Ejemplo:
def can_access(is_authenticated, has_permission, is_active):
"""Verificar si el usuario puede acceder al recurso"""
# Condición compuesta con 3 sub-expresiones booleanas
if is_authenticated and has_permission and is_active:
return "Acceso concedido"
return "Acceso denegado"
# Para 100% de cobertura de condiciones, cada variable debe ser Verdadero y Falso:
def test_condition_coverage():
# is_authenticated: Verdadero
can_access(True, True, True)
# is_authenticated: Falso
can_access(False, True, True)
# has_permission: Verdadero (ya cubierto arriba)
# has_permission: Falso
can_access(True, False, True)
# is_active: Verdadero (ya cubierto)
# is_active: Falso
can_access(True, True, False)
# Cobertura de Condiciones: 100%
5. Cobertura de Condición Múltiple (CCM)
La cobertura de condición múltiple prueba todas las combinaciones de sub-expresiones booleanas.
Ejemplo:
def authorize_action(is_admin, is_owner):
"""Autorizar acción basada en rol y propiedad"""
if is_admin or is_owner:
return True
return False
# Tabla de verdad para 'is_admin or is_owner':
# is_admin | is_owner | Resultado
# V | V | V
# V | F | V
# F | V | V
# F | F | F
def test_mcc():
assert authorize_action(True, True) == True # V, V
assert authorize_action(True, False) == True # V, F
assert authorize_action(False, True) == True # F, V
assert authorize_action(False, False) == False # F, F
# CCM: 4/4 = 100%
Técnicas de Pruebas de Caja Blanca
1. Pruebas de Flujo de Control
Las pruebas de flujo de control visualizan el código como un grafo donde los nodos son sentencias y las aristas son rutas de flujo de control.
Ejemplo: Grafo de flujo para validación de inicio de sesión
def validate_login(username, password):
# Nodo 1: Entrada
if not username: # Nodo 2
return "Usuario requerido" # Nodo 3
if len(password) < 8: # Nodo 4
return "Contraseña demasiado corta" # Nodo 5
user = find_user(username) # Nodo 6
if user and check_password(user, password): # Nodo 7
return "Éxito" # Nodo 8
return "Credenciales inválidas" # Nodo 9
# Grafo de flujo:
# 1
# ↓
# 2 → 3 (retorno)
# ↓
# 4 → 5 (retorno)
# ↓
# 6
# ↓
# 7 → 8 (retorno)
# ↓
# 9 (retorno)
# Complejidad Ciclomática = A - N + 2P
# A=aristas, N=nodos, P=componentes conectados
# Complejidad = 3 (3 puntos de decisión + 1)
2. Pruebas de Flujo de Datos
Las pruebas de flujo de datos rastrean definiciones y usos de variables para asegurar el manejo correcto de datos.
Ejemplo:
def calculate_total(items):
"""Calcular precio total con lógica de descuento"""
total = 0 # Definición de 'total'
for item in items:
total += item.price # Uso de 'total', luego re-definición
discount = 0 # Definición de 'discount'
if total > 100: # Uso de 'total'
discount = total * 0.1 # Re-definición de 'discount'
final = total - discount # Uso de 'total' y 'discount'
return final
# Anomalías de flujo de datos a probar:
# - Definida pero nunca usada (dd)
# - Usada pero nunca definida (ur)
# - Definida dos veces sin uso entre medio (dd)
def test_data_flow():
# Probar ciclo de vida de variables
items = [Item(50), Item(60)] # total=110, discount=11
assert calculate_total(items) == 99
items = [Item(30), Item(40)] # total=70, discount=0
assert calculate_total(items) == 70
3. Pruebas de Bucles
Las pruebas de bucles validan el comportamiento de bucles en límites y durante ejecución.
Estrategias de pruebas de bucles:
def process_batch(items, max_retries=3):
"""Procesar artículos con lógica de reintentos"""
processed = []
for item in items: # Bucle 1
retry_count = 0
while retry_count < max_retries: # Bucle 2 (anidado)
if process_item(item):
processed.append(item)
break
retry_count += 1
else:
# Ejecutado si el bucle completa sin break
log_failure(item)
return processed
def test_loops():
# Pruebas de bucle simple:
# 1. Saltar bucle (0 iteraciones)
assert len(process_batch([])) == 0
# 2. Una iteración
assert len(process_batch([Item(1)])) == 1
# 3. Dos iteraciones
assert len(process_batch([Item(1), Item(2)])) == 2
# 4. Iteraciones máximas
items = [Item(i) for i in range(100)]
assert len(process_batch(items)) <= 100
# Pruebas de bucle anidado:
# 5. Bucle interno ejecuta máximo de veces
failing_item = FailingItem()
result = process_batch([failing_item])
# Verificar que retry_count alcanzó max_retries
4. Pruebas de Mutación
Las pruebas de mutación introducen pequeños cambios de código (mutaciones) para verificar que las pruebas puedan detectarlos.
Ejemplo:
# Código original
def is_eligible(age, has_license):
return age >= 18 and has_license
# Mutación 1: Cambiar >= a >
def is_eligible_mutant1(age, has_license):
return age > 18 and has_license # Mutante
# Mutación 2: Cambiar 'and' a 'or'
def is_eligible_mutant2(age, has_license):
return age >= 18 or has_license # Mutante
# Las pruebas deben matar las mutaciones
def test_eligibility():
# Esta prueba mata Mutación 1 (age=18 debería pasar pero falla en mutante)
assert is_eligible(18, True) == True
# Esta prueba mata Mutación 2 (debería fallar sin licencia)
assert is_eligible(20, False) == False
# Puntaje de Mutación = Mutaciones Matadas / Mutaciones Totales
# Puntaje = 2/2 = 100%
Herramientas de Pruebas de Caja Blanca
Herramientas de Análisis Estático
# Ejemplo: Usando pylint para análisis estático
# Ejecutar: pylint my_module.py
def calculate_interest(principal, rate, time):
"""Calcular interés simple"""
result = principal * rate * time / 100
return result
# Pylint verifica:
# - Variables no usadas
# - Variables no definidas
# - Discrepancias de tipo
# - Code smells
# - Métricas de complejidad
Herramientas de Cobertura de Código
# Usando pytest-cov para reporte de cobertura
# Ejecutar: pytest --cov=myapp --cov-report=html
# ejemplo de reporte de cobertura:
"""
Name Stmts Miss Cover
-------------------------------------------
myapp/auth.py 45 2 96%
myapp/orders.py 67 8 88%
myapp/payment.py 34 0 100%
-------------------------------------------
TOTAL 146 10 93%
"""
# Configuración .coveragerc
"""
[run]
source = myapp
omit = */tests/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
"""
Herramientas de Perfilado
import cProfile
import pstats
def analyze_performance():
"""Perfilar ejecución de código"""
profiler = cProfile.Profile()
profiler.enable()
# Código a perfilar
result = expensive_operation()
profiler.disable()
stats = pstats.Stats(profiler)
stats.sort_stats('cumulative')
stats.print_stats()
# La salida muestra:
# - Llamadas a funciones
# - Tiempo por función
# - Conteos de llamadas
# - Puntos calientes para optimización
Mejores Prácticas de Pruebas de Caja Blanca
1. Apuntar a Alta Cobertura, No 100%
# Enfocarse en rutas críticas sobre cobertura completa
class PaymentProcessor:
def process_payment(self, amount, method):
"""Ruta crítica - debe tener 100% cobertura"""
if amount <= 0:
raise ValueError("Monto inválido")
if method == "credit_card":
return self._process_credit_card(amount)
elif method == "paypal":
return self._process_paypal(amount)
else:
raise ValueError("Método desconocido")
def format_receipt(self, transaction):
"""Bajo riesgo - 80% cobertura aceptable"""
# Lógica de formateo menos crítica
pass
# Priorizar cobertura para:
# - Procesamiento de pagos: 100%
# - Funciones de seguridad: 100%
# - Lógica de negocio: 95%+
# - Formateo de UI: 70-80%
2. Probar Casos Extremos y Límites
def get_age_category(age):
"""Categorizar grupos de edad"""
if age < 0:
raise ValueError("La edad no puede ser negativa")
elif age < 13:
return "niño"
elif age < 20:
return "adolescente"
elif age < 65:
return "adulto"
else:
return "senior"
def test_edge_cases():
# Entrada inválida
with pytest.raises(ValueError):
get_age_category(-1)
# Límites
assert get_age_category(0) == "niño"
assert get_age_category(12) == "niño"
assert get_age_category(13) == "adolescente"
assert get_age_category(19) == "adolescente"
assert get_age_category(20) == "adulto"
assert get_age_category(64) == "adulto"
assert get_age_category(65) == "senior"
assert get_age_category(100) == "senior"
3. Usar Mocking para Dependencias
from unittest.mock import Mock, patch
class UserService:
def __init__(self, db, email_service):
self.db = db
self.email_service = email_service
def register_user(self, email, password):
"""Registrar nuevo usuario"""
if self.db.user_exists(email):
return False
user = self.db.create_user(email, password)
self.email_service.send_welcome(user)
return True
def test_registration_with_mocks():
# Mock de dependencias
mock_db = Mock()
mock_email = Mock()
# Configurar comportamiento del mock
mock_db.user_exists.return_value = False
mock_db.create_user.return_value = {"id": 1, "email": "test@example.com"}
# Probar
service = UserService(mock_db, mock_email)
result = service.register_user("test@example.com", "password123")
# Verificar
assert result == True
mock_db.user_exists.assert_called_once_with("test@example.com")
mock_db.create_user.assert_called_once()
mock_email.send_welcome.assert_called_once()
4. Probar Manejo de Errores
def divide_numbers(a, b):
"""Dividir dos números con manejo de errores"""
try:
result = a / b
return {"success": True, "result": result}
except ZeroDivisionError:
return {"success": False, "error": "División por cero"}
except TypeError:
return {"success": False, "error": "Tipos inválidos"}
def test_error_handling():
# Ruta feliz
result = divide_numbers(10, 2)
assert result["success"] == True
assert result["result"] == 5
# Ruta ZeroDivisionError
result = divide_numbers(10, 0)
assert result["success"] == False
assert "cero" in result["error"]
# Ruta TypeError
result = divide_numbers("10", "2")
assert result["success"] == False
assert "tipos" in result["error"]
Ejemplo Real de Pruebas de Caja Blanca
Probando un Sistema de Carrito de Compras
class ShoppingCart:
def __init__(self):
self.items = []
self.discount_code = None
def add_item(self, product, quantity):
"""Agregar artículo al carrito"""
if quantity <= 0:
raise ValueError("La cantidad debe ser positiva")
# Verificar si el artículo ya existe
for item in self.items:
if item['product'].id == product.id:
item['quantity'] += quantity
return
self.items.append({
'product': product,
'quantity': quantity
})
def calculate_total(self):
"""Calcular total del carrito con descuentos"""
subtotal = sum(
item['product'].price * item['quantity']
for item in self.items
)
discount = 0
if self.discount_code:
if self.discount_code.is_valid():
if self.discount_code.type == "percentage":
discount = subtotal * (self.discount_code.value / 100)
elif self.discount_code.type == "fixed":
discount = min(self.discount_code.value, subtotal)
return max(subtotal - discount, 0)
# Pruebas integrales de caja blanca
class TestShoppingCart:
def test_add_item_new_product(self):
"""Probar agregar nuevo producto (camino: artículo no en carrito)"""
cart = ShoppingCart()
product = Product(id=1, price=10)
cart.add_item(product, 2)
assert len(cart.items) == 1
assert cart.items[0]['quantity'] == 2
def test_add_item_existing_product(self):
"""Probar agregar producto existente (camino: artículo en carrito)"""
cart = ShoppingCart()
product = Product(id=1, price=10)
cart.add_item(product, 2)
cart.add_item(product, 3)
assert len(cart.items) == 1
assert cart.items[0]['quantity'] == 5
def test_add_item_invalid_quantity(self):
"""Probar manejo de errores para cantidad inválida"""
cart = ShoppingCart()
product = Product(id=1, price=10)
with pytest.raises(ValueError):
cart.add_item(product, 0)
with pytest.raises(ValueError):
cart.add_item(product, -1)
def test_calculate_total_no_discount(self):
"""Probar cálculo de total sin descuento"""
cart = ShoppingCart()
cart.add_item(Product(id=1, price=10), 2)
cart.add_item(Product(id=2, price=15), 1)
assert cart.calculate_total() == 35
def test_calculate_total_percentage_discount(self):
"""Probar camino de descuento porcentual"""
cart = ShoppingCart()
cart.add_item(Product(id=1, price=100), 1)
cart.discount_code = DiscountCode(type="percentage", value=10, valid=True)
assert cart.calculate_total() == 90
def test_calculate_total_fixed_discount(self):
"""Probar camino de descuento fijo"""
cart = ShoppingCart()
cart.add_item(Product(id=1, price=100), 1)
cart.discount_code = DiscountCode(type="fixed", value=20, valid=True)
assert cart.calculate_total() == 80
def test_calculate_total_fixed_discount_exceeds_subtotal(self):
"""Probar que descuento fijo no va negativo"""
cart = ShoppingCart()
cart.add_item(Product(id=1, price=10), 1)
cart.discount_code = DiscountCode(type="fixed", value=50, valid=True)
assert cart.calculate_total() == 0
def test_calculate_total_invalid_discount_code(self):
"""Probar camino de código de descuento inválido"""
cart = ShoppingCart()
cart.add_item(Product(id=1, price=100), 1)
cart.discount_code = DiscountCode(type="percentage", value=10, valid=False)
assert cart.calculate_total() == 100
Análisis de Reporte de Cobertura
# Ejecutar cobertura
$ pytest --cov=shopping_cart --cov-report=term-missing
Name Stmts Miss Branch BrPart Cover Missing
---------------------------------------------------------------
shopping_cart.py 45 0 18 0 100%
---------------------------------------------------------------
TOTAL 45 0 18 0 100%
# Detalles de cobertura de ramas:
# - add_item: 4/4 ramas (100%)
# - calculate_total: 14/14 ramas (100%)
Ventajas y Limitaciones
Ventajas
- Detección temprana de bugs: Encuentra defectos durante el desarrollo
- Optimización de código: Identifica ineficiencias y código muerto
- Cobertura completa: Puede probar todas las rutas de código sistemáticamente
- Validación de algoritmos: Verifica cálculos complejos
- Seguridad: Revela vulnerabilidades en la lógica
Limitaciones
- Requiere acceso al código: No es adecuado para sistemas de terceros
- Conocimiento de programación: Los testers necesitan habilidades técnicas
- Consume tiempo: Las pruebas exhaustivas toman esfuerzo significativo
- Sobrecarga de mantenimiento: Las pruebas se rompen cuando el código cambia
- No valida requisitos: Puede perder brechas funcionales
Conclusión
Las pruebas de caja blanca proporcionan una visión profunda de la calidad del código al examinar estructuras internas y lógica. A través de técnicas como cobertura de sentencias, cobertura de ramas, cobertura de caminos y análisis de flujo de datos, puedes verificar sistemáticamente que el código se comporta correctamente bajo todas las condiciones.
La clave para pruebas de caja blanca efectivas es equilibrar los objetivos de cobertura con las restricciones prácticas. Apunta a alta cobertura en rutas críticas, usa herramientas apropiadas para medir y rastrear cobertura, y combina técnicas de caja blanca con pruebas de caja negra para aseguramiento de calidad integral.
Ya sea que estés escribiendo pruebas unitarias para funciones individuales o analizando algoritmos complejos, las pruebas de caja blanca aseguran que tu código es robusto, eficiente y mantenible.