El Patrón AAA (Arrange-Act-Assert) en Tests Unitarios con PHP
Introducción al Patrón AAA
En el desarrollo de software moderno, los tests unitarios son una práctica esencial para garantizar la calidad y robustez del código. Entre las metodologías más efectivas para estructurar estos tests se encuentra el patrón AAA (Arrange-Act-Assert), que proporciona un enfoque claro y consistente para la creación de pruebas. Este patrón, divide cada test en tres fases distintas y secuenciales, facilitando la legibilidad, mantenimiento y escalabilidad de las pruebas unitarias.
Los Tres Componentes del Patrón AAA
El patrón AAA se compone de tres fases bien definidas que estructuran la lógica de cada test unitario:
-
Arrange (Preparar): En esta fase inicial, se establecen todas las precondiciones necesarias para la prueba. Esto incluye la inicialización de objetos, configuración del entorno, preparación de datos de entrada y definición de expectativas.
-
Act (Actuar): La fase de acción es donde se ejecuta la funcionalidad que queremos probar. Normalmente, esta fase es concisa y se limita a invocar el método o función bajo prueba con los parámetros preparados en la fase anterior.
-
Assert (Afirmar): En la fase final, verificamos que el comportamiento del sistema bajo prueba coincide con nuestras expectativas. Aquí es donde comprobamos que los resultados obtenidos son los esperados, utilizando aserciones específicas.
Implementación Básica en PHP con PHPUnit
PHPUnit es el framework de testing más utilizado en el ecosistema PHP. Veamos cómo implementar el patrón AAA en un test básico:
<?php
use PHPUnit\Framework\TestCase;
class CalculadoraTest extends TestCase
{
public function testSuma()
{
// Arrange
$calculadora = new Calculadora();
$a = 5;
$b = 3;
$resultadoEsperado = 8;
// Act
$resultado = $calculadora->sumar($a, $b);
// Assert
$this->assertEquals($resultadoEsperado, $resultado);
}
}
class Calculadora
{
public function sumar($a, $b)
{
return $a + $b;
}
}
En este ejemplo, hemos separado claramente las tres fases del patrón AAA. Esta estructura hace que el test sea fácil de entender y mantener.
Manejo de Dependencias en la Fase “Arrange”
Cuando trabajamos con clases que tienen dependencias, la fase “Arrange” se vuelve más compleja. Aquí es donde los dobles de prueba (mocks, stubs) se vuelven esenciales. Veamos un ejemplo más elaborado:
<?php
use PHPUnit\Framework\TestCase;
class ServicioUsuarioTest extends TestCase
{
public function testObtenerNombreCompleto()
{
// Arrange
$repositorioMock = $this->createMock(RepositorioUsuario::class);
$repositorioMock->method('obtenerPorId')
->with(1)
->willReturn([
'nombre' => 'Juan',
'apellido' => 'Pérez'
]);
$servicio = new ServicioUsuario($repositorioMock);
// Act
$nombreCompleto = $servicio->obtenerNombreCompleto(1);
// Assert
$this->assertEquals('Juan Pérez', $nombreCompleto);
}
}
interface RepositorioUsuario
{
public function obtenerPorId($id);
}
class ServicioUsuario
{
private $repositorio;
public function __construct(RepositorioUsuario $repositorio)
{
$this->repositorio = $repositorio;
}
public function obtenerNombreCompleto($id)
{
$usuario = $this->repositorio->obtenerPorId($id);
return $usuario['nombre'] . ' ' . $usuario['apellido'];
}
}
En este caso, la fase “Arrange” implica la creación de un mock para la dependencia RepositorioUsuario
, estableciendo el comportamiento esperado cuando se llama al método obtenerPorId()
.
Pruebas con Excepciones en el Patrón AAA
El patrón AAA también se adapta perfectamente a las pruebas que verifican el lanzamiento de excepciones. Veamos cómo estructurar este tipo de tests:
<?php
use PHPUnit\Framework\TestCase;
class ValidadorTest extends TestCase
{
public function testValidarEmailInvalido()
{
// Arrange
$validador = new Validador();
$emailInvalido = "correo-invalido";
// Act & Assert (combinados para pruebas de excepciones)
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Email inválido');
$validador->validarEmail($emailInvalido);
}
}
class Validador
{
public function validarEmail($email)
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Email inválido');
}
return true;
}
}
Observe que en este caso, las fases “Act” y “Assert” se combinan, ya que PHPUnit requiere que las expectativas de excepciones se definan antes de la acción que las desencadena.
Aplicación del Patrón AAA en Tests de Integración
Aunque el patrón AAA fue concebido principalmente para tests unitarios, también puede aplicarse eficazmente en tests de integración. En estos casos, la fase “Arrange” a menudo implica la configuración de múltiples componentes y posiblemente la preparación de una base de datos de prueba:
<?php
use PHPUnit\Framework\TestCase;
class PedidoIntegrationTest extends TestCase
{
private $db;
protected function setUp(): void
{
// Inicialización de la base de datos de prueba
$this->db = new PDO('sqlite::memory:');
$this->db->exec('
CREATE TABLE productos (
id INTEGER PRIMARY KEY,
nombre TEXT,
precio REAL
);
INSERT INTO productos VALUES (1, "Producto A", 100.00);
INSERT INTO productos VALUES (2, "Producto B", 150.00);
CREATE TABLE pedidos (
id INTEGER PRIMARY KEY,
cliente_id INTEGER,
fecha TEXT
);
CREATE TABLE pedido_items (
id INTEGER PRIMARY KEY,
pedido_id INTEGER,
producto_id INTEGER,
cantidad INTEGER
);
');
}
public function testCrearPedidoConItems()
{
// Arrange
$repositorioProducto = new RepositorioProductoSQL($this->db);
$repositorioPedido = new RepositorioPedidoSQL($this->db);
$servicioPedido = new ServicioPedido($repositorioPedido, $repositorioProducto);
$clienteId = 1;
$items = [
['producto_id' => 1, 'cantidad' => 2],
['producto_id' => 2, 'cantidad' => 1]
];
// Act
$pedidoId = $servicioPedido->crearPedido($clienteId, $items);
// Assert
$stmt = $this->db->prepare('SELECT COUNT(*) FROM pedidos WHERE id = ?');
$stmt->execute([$pedidoId]);
$existePedido = (bool) $stmt->fetchColumn();
$this->assertTrue($existePedido);
$stmt = $this->db->prepare('SELECT COUNT(*) FROM pedido_items WHERE pedido_id = ?');
$stmt->execute([$pedidoId]);
$cantidadItems = (int) $stmt->fetchColumn();
$this->assertEquals(2, $cantidadItems);
}
}
// Clases simplificadas para el ejemplo
class RepositorioProductoSQL
{
private $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
public function obtenerPorId($id)
{
$stmt = $this->db->prepare('SELECT * FROM productos WHERE id = ?');
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
}
class RepositorioPedidoSQL
{
private $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
public function guardar($clienteId, $fecha, $items)
{
$this->db->beginTransaction();
try {
$stmt = $this->db->prepare('INSERT INTO pedidos (cliente_id, fecha) VALUES (?, ?)');
$stmt->execute([$clienteId, $fecha]);
$pedidoId = $this->db->lastInsertId();
foreach ($items as $item) {
$stmt = $this->db->prepare('INSERT INTO pedido_items (pedido_id, producto_id, cantidad) VALUES (?, ?, ?)');
$stmt->execute([$pedidoId, $item['producto_id'], $item['cantidad']]);
}
$this->db->commit();
return $pedidoId;
} catch (Exception $e) {
$this->db->rollBack();
throw $e;
}
}
}
class ServicioPedido
{
private $repositorioPedido;
private $repositorioProducto;
public function __construct(RepositorioPedidoSQL $repositorioPedido, RepositorioProductoSQL $repositorioProducto)
{
$this->repositorioPedido = $repositorioPedido;
$this->repositorioProducto = $repositorioProducto;
}
public function crearPedido($clienteId, $items)
{
// Validar que todos los productos existan
foreach ($items as $item) {
$producto = $this->repositorioProducto->obtenerPorId($item['producto_id']);
if (!$producto) {
throw new InvalidArgumentException("Producto no encontrado: " . $item['producto_id']);
}
}
$fecha = date('Y-m-d H:i:s');
return $this->repositorioPedido->guardar($clienteId, $fecha, $items);
}
}
Este ejemplo más complejo muestra cómo el patrón AAA proporciona claridad incluso en tests de integración que involucran múltiples componentes y una base de datos.
Beneficios del Patrón AAA en la Práctica Profesional
La adopción consistente del patrón AAA en el desarrollo de tests proporciona numerosos beneficios:
-
Legibilidad mejorada: La estructura clara separa la preparación, la acción y la verificación, haciendo que los tests sean más fáciles de entender.
-
Facilidad de mantenimiento: Cuando un test falla, la estructura AAA ayuda a identificar rápidamente en qué fase ocurrió el problema.
-
Documentación implícita: Un test bien estructurado actúa como documentación del comportamiento esperado del sistema.
-
Reducción de la duplicación: La separación clara facilita la identificación de código de preparación común que puede extraerse a métodos auxiliares o al método
setUp()
. -
Mejora la comunicación del equipo: Proporciona un vocabulario y estructura común para discutir sobre los tests.
Patrones Avanzados de Organización de Tests
El patrón AAA puede combinarse con otros patrones de diseño de tests para manejar escenarios más complejos. Una extensión popular es el patrón “Given-When-Then” del Behavior-Driven Development (BDD), que es esencialmente una reformulación del AAA con un enfoque más orientado al comportamiento:
<?php
use PHPUnit\Framework\TestCase;
class CarritoCompraTest extends TestCase
{
public function testAplicarDescuentoACantidadesMayores()
{
// Given (Arrange)
$carrito = new CarritoCompra();
$carrito->agregarProducto(new Producto("Laptop", 1200.00), 1);
$carrito->agregarProducto(new Producto("Mouse", 25.00), 5);
// When (Act)
$carrito->aplicarDescuentos();
// Then (Assert)
$this->assertEquals(1200.00, $carrito->obtenerSubtotalProducto("Laptop"));
$this->assertEquals(112.50, $carrito->obtenerSubtotalProducto("Mouse")); // 25 * 5 * 0.9 = 112.50 (10% descuento)
$this->assertEquals(1312.50, $carrito->obtenerTotal());
}
}
class Producto
{
private $nombre;
private $precio;
public function __construct($nombre, $precio)
{
$this->nombre = $nombre;
$this->precio = $precio;
}
public function getNombre()
{
return $this->nombre;
}
public function getPrecio()
{
return $this->precio;
}
}
class CarritoCompra
{
private $items = [];
private $descuentos = [];
public function agregarProducto(Producto $producto, $cantidad)
{
$this->items[$producto->getNombre()] = [
'producto' => $producto,
'cantidad' => $cantidad,
'subtotal' => $producto->getPrecio() * $cantidad
];
}
public function aplicarDescuentos()
{
foreach ($this->items as $nombre => $item) {
if ($item['cantidad'] >= 5) {
$descuento = $item['subtotal'] * 0.1; // 10% de descuento para cantidades >= 5
$this->items[$nombre]['subtotal'] -= $descuento;
$this->descuentos[$nombre] = $descuento;
}
}
}
public function obtenerSubtotalProducto($nombre)
{
return isset($this->items[$nombre]) ? $this->items[$nombre]['subtotal'] : 0;
}
public function obtenerTotal()
{
$total = 0;
foreach ($this->items as $item) {
$total += $item['subtotal'];
}
return $total;
}
}
Esta implementación muestra cómo el vocabulario puede cambiar ligeramente para adaptarse al enfoque BDD, manteniendo la esencia del patrón AAA.