Leccion 71 de 75 12 min de lectura

Mocking y Stubs

Cuando tu codigo depende de servicios externos como bases de datos o APIs, necesitas simular esas dependencias para poder testear en aislamiento. Los mocks y stubs te permiten hacerlo.

El problema de las dependencias

Imagina una clase que envia emails. No quieres enviar emails reales cada vez que ejecutas los tests. Necesitas simular el servicio de email.

PHP
<?php

declare(strict_types=1);

namespace App;

// Interfaz para el servicio de email
interface EnviadorEmailInterface
{
    public function enviar(string $destinatario, string $asunto, string $mensaje): bool;
}

// Clase que queremos testear
class RegistroUsuario
{
    public function __construct(
        private readonly EnviadorEmailInterface $enviadorEmail
    ) {}

    public function registrar(string $email, string $nombre): bool
    {
        // Logica de registro...

        // Enviar email de bienvenida
        return $this->enviadorEmail->enviar(
            $email,
            'Bienvenido',
            "Hola $nombre, gracias por registrarte."
        );
    }
}

Stubs: simular respuestas

Un stub es un objeto falso que devuelve respuestas predefinidas. Lo usas cuando solo te importa que la dependencia devuelva algo, no verificar como se usa.

PHP
<?php

declare(strict_types=1);

namespace Tests;

use App\EnviadorEmailInterface;
use App\RegistroUsuario;
use PHPUnit\Framework\TestCase;

class RegistroUsuarioTest extends TestCase
{
    public function testRegistrarDevuelveTrueCuandoEmailSeEnvia(): void
    {
        // Crear un stub del servicio de email
        $stubEmail = $this->createStub(EnviadorEmailInterface::class);

        // Configurar que siempre devuelva true
        $stubEmail->method('enviar')->willReturn(true);

        // Usar el stub en la clase que testeamos
        $registro = new RegistroUsuario($stubEmail);

        $resultado = $registro->registrar('user@test.com', 'Juan');

        $this->assertTrue($resultado);
    }

    public function testRegistrarDevuelveFalseCuandoEmailFalla(): void
    {
        $stubEmail = $this->createStub(EnviadorEmailInterface::class);
        $stubEmail->method('enviar')->willReturn(false);

        $registro = new RegistroUsuario($stubEmail);

        $resultado = $registro->registrar('user@test.com', 'Juan');

        $this->assertFalse($resultado);
    }
}

Mocks: verificar interacciones

Un mock verifica que se llamaron ciertos metodos con ciertos parametros. Lo usas cuando quieres asegurarte de que tu codigo interactua correctamente con la dependencia.

PHP
<?php

declare(strict_types=1);

namespace Tests;

use App\EnviadorEmailInterface;
use App\RegistroUsuario;
use PHPUnit\Framework\TestCase;

class RegistroUsuarioTest extends TestCase
{
    public function testRegistrarEnviaEmailConDatosCorrectos(): void
    {
        // Crear un mock del servicio de email
        $mockEmail = $this->createMock(EnviadorEmailInterface::class);

        // Verificar que enviar() se llama exactamente 1 vez
        // con los parametros correctos
        $mockEmail->expects($this->once())
            ->method('enviar')
            ->with(
                'user@test.com',             // destinatario
                'Bienvenido',                 // asunto
                'Hola Juan, gracias por registrarte.' // mensaje
            )
            ->willReturn(true);

        $registro = new RegistroUsuario($mockEmail);
        $registro->registrar('user@test.com', 'Juan');

        // El test falla si enviar() no se llama o se llama con otros parametros
    }
}
Stub vs Mock

Stub: Solo simula respuestas. Mock: Ademas verifica que se llamo correctamente.

Ejemplo practico: repositorio de usuarios

PHP
<?php

declare(strict_types=1);

namespace App;

interface UsuarioRepositoryInterface
{
    public function buscarPorId(int $id): ?array;
    public function guardar(array $usuario): bool;
}

class ServicioUsuario
{
    public function __construct(
        private readonly UsuarioRepositoryInterface $repository
    ) {}

    public function obtenerNombre(int $id): string
    {
        $usuario = $this->repository->buscarPorId($id);

        if ($usuario === null) {
            throw new \RuntimeException('Usuario no encontrado');
        }

        return $usuario['nombre'];
    }

    public function actualizarEmail(int $id, string $nuevoEmail): bool
    {
        $usuario = $this->repository->buscarPorId($id);

        if ($usuario === null) {
            return false;
        }

        $usuario['email'] = $nuevoEmail;

        return $this->repository->guardar($usuario);
    }
}
PHP
<?php

declare(strict_types=1);

namespace Tests;

use App\ServicioUsuario;
use App\UsuarioRepositoryInterface;
use PHPUnit\Framework\TestCase;

class ServicioUsuarioTest extends TestCase
{
    public function testObtenerNombreDevuelveNombreDelUsuario(): void
    {
        $stubRepo = $this->createStub(UsuarioRepositoryInterface::class);
        $stubRepo->method('buscarPorId')
            ->willReturn(['id' => 1, 'nombre' => 'Ana', 'email' => 'ana@test.com']);

        $servicio = new ServicioUsuario($stubRepo);

        $nombre = $servicio->obtenerNombre(1);

        $this->assertSame('Ana', $nombre);
    }

    public function testObtenerNombreLanzaExcepcionSiNoExiste(): void
    {
        $stubRepo = $this->createStub(UsuarioRepositoryInterface::class);
        $stubRepo->method('buscarPorId')->willReturn(null);

        $servicio = new ServicioUsuario($stubRepo);

        $this->expectException(\RuntimeException::class);
        $servicio->obtenerNombre(999);
    }

    public function testActualizarEmailGuardaConNuevoEmail(): void
    {
        $mockRepo = $this->createMock(UsuarioRepositoryInterface::class);

        // Configurar buscarPorId para devolver un usuario
        $mockRepo->method('buscarPorId')
            ->willReturn(['id' => 1, 'nombre' => 'Ana', 'email' => 'ana@test.com']);

        // Verificar que guardar se llama con el email actualizado
        $mockRepo->expects($this->once())
            ->method('guardar')
            ->with($this->callback(function ($usuario) {
                return $usuario['email'] === 'nuevo@test.com';
            }))
            ->willReturn(true);

        $servicio = new ServicioUsuario($mockRepo);
        $resultado = $servicio->actualizarEmail(1, 'nuevo@test.com');

        $this->assertTrue($resultado);
    }
}

Matchers de expectativas

PHPUnit ofrece varios matchers para controlar cuantas veces se debe llamar un metodo:

PHP
<?php

declare(strict_types=1);

// Exactamente una vez
$mock->expects($this->once())->method('enviar');

// Nunca (verifica que NO se llame)
$mock->expects($this->never())->method('enviar');

// Al menos una vez
$mock->expects($this->atLeastOnce())->method('enviar');

// Exactamente N veces
$mock->expects($this->exactly(3))->method('enviar');

// Cualquier numero de veces (incluyendo cero)
$mock->expects($this->any())->method('enviar');

Retornos condicionales

Puedes configurar diferentes respuestas segun los argumentos:

PHP
<?php

declare(strict_types=1);

// Retorno segun el argumento
$stub->method('buscarPorId')
    ->willReturnMap([
        [1, ['id' => 1, 'nombre' => 'Ana']],
        [2, ['id' => 2, 'nombre' => 'Juan']],
        [999, null],
    ]);

// Retorno con callback personalizado
$stub->method('buscarPorId')
    ->willReturnCallback(function (int $id) {
        if ($id === 1) {
            return ['id' => 1, 'nombre' => 'Ana'];
        }
        return null;
    });

// Retornos consecutivos (diferente cada llamada)
$stub->method('obtenerSiguiente')
    ->willReturnOnConsecutiveCalls('primero', 'segundo', 'tercero');

// Lanzar excepcion
$stub->method('conectar')
    ->willThrowException(new \RuntimeException('Error de conexion'));

Ejercicios

Ejercicio 1: Crear un stub basico

Crea un test con stub para esta clase:

PHP
<?php

declare(strict_types=1);

interface PrecioApiInterface
{
    public function obtenerPrecio(string $producto): float;
}

class CalculadorDescuento
{
    public function __construct(
        private readonly PrecioApiInterface $api
    ) {}

    public function calcularPrecioConDescuento(string $producto, float $descuento): float
    {
        $precio = $this->api->obtenerPrecio($producto);
        return $precio * (1 - $descuento);
    }
}
Ver solucion
PHP
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

class CalculadorDescuentoTest extends TestCase
{
    public function testCalculaPrecioConDescuento20Porciento(): void
    {
        // Crear stub que devuelve precio fijo
        $stubApi = $this->createStub(PrecioApiInterface::class);
        $stubApi->method('obtenerPrecio')->willReturn(100.0);

        $calculador = new CalculadorDescuento($stubApi);

        // 100 * (1 - 0.20) = 80
        $resultado = $calculador->calcularPrecioConDescuento('libro', 0.20);

        $this->assertSame(80.0, $resultado);
    }

    public function testSinDescuentoDevuelvePrecioCompleto(): void
    {
        $stubApi = $this->createStub(PrecioApiInterface::class);
        $stubApi->method('obtenerPrecio')->willReturn(50.0);

        $calculador = new CalculadorDescuento($stubApi);

        $resultado = $calculador->calcularPrecioConDescuento('cuaderno', 0.0);

        $this->assertSame(50.0, $resultado);
    }
}

Ejercicio 2: Verificar con mock

Crea un test que verifique que el logger se llama correctamente:

PHP
<?php

declare(strict_types=1);

interface LoggerInterface
{
    public function info(string $mensaje): void;
    public function error(string $mensaje): void;
}

class ProcesadorPago
{
    public function __construct(
        private readonly LoggerInterface $logger
    ) {}

    public function procesar(float $monto): bool
    {
        if ($monto <= 0) {
            $this->logger->error("Monto invalido: $monto");
            return false;
        }

        $this->logger->info("Pago procesado: $monto");
        return true;
    }
}
Ver solucion
PHP
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

class ProcesadorPagoTest extends TestCase
{
    public function testPagoExitosoLogueaInfo(): void
    {
        $mockLogger = $this->createMock(LoggerInterface::class);

        // Verificar que info() se llama con el mensaje correcto
        $mockLogger->expects($this->once())
            ->method('info')
            ->with('Pago procesado: 100');

        // Verificar que error() nunca se llama
        $mockLogger->expects($this->never())
            ->method('error');

        $procesador = new ProcesadorPago($mockLogger);
        $resultado = $procesador->procesar(100.0);

        $this->assertTrue($resultado);
    }

    public function testMontoInvalidoLogueaError(): void
    {
        $mockLogger = $this->createMock(LoggerInterface::class);

        // Verificar que error() se llama
        $mockLogger->expects($this->once())
            ->method('error')
            ->with('Monto invalido: -50');

        // Verificar que info() nunca se llama
        $mockLogger->expects($this->never())
            ->method('info');

        $procesador = new ProcesadorPago($mockLogger);
        $resultado = $procesador->procesar(-50.0);

        $this->assertFalse($resultado);
    }
}

Ejercicio 3: Retornos condicionales

Configura un stub que devuelva diferentes valores segun el argumento:

PHP
<?php

declare(strict_types=1);

interface TraductorInterface
{
    public function traducir(string $clave): string;
}

// Crea un stub que traduzca:
// 'saludo' -> 'Hola'
// 'despedida' -> 'Adios'
// cualquier otra clave -> 'Sin traduccion'
Ver solucion
PHP
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

class TraductorTest extends TestCase
{
    public function testTraduccionConCallback(): void
    {
        $stub = $this->createStub(TraductorInterface::class);

        $stub->method('traducir')
            ->willReturnCallback(function (string $clave): string {
                return match ($clave) {
                    'saludo' => 'Hola',
                    'despedida' => 'Adios',
                    default => 'Sin traduccion',
                };
            });

        $this->assertSame('Hola', $stub->traducir('saludo'));
        $this->assertSame('Adios', $stub->traducir('despedida'));
        $this->assertSame('Sin traduccion', $stub->traducir('otra'));
    }

    // Alternativa con willReturnMap
    public function testTraduccionConMap(): void
    {
        $stub = $this->createStub(TraductorInterface::class);

        $stub->method('traducir')
            ->willReturnMap([
                ['saludo', 'Hola'],
                ['despedida', 'Adios'],
            ]);

        $this->assertSame('Hola', $stub->traducir('saludo'));
        $this->assertSame('Adios', $stub->traducir('despedida'));
    }
}

Te está gustando el curso?

Tenemos cursos premium con proyectos reales y soporte personalizado.

Descubrir cursos premium