Lección 41 de 45 20 min de lectura

Unit Tests

Los Unit tests prueban unidades individuales de código de forma aislada. Son rápidos, precisos y te permiten verificar que la lógica de tus clases y métodos funciona correctamente sin depender de la base de datos ni del framework.

¿Qué son los Unit tests?

Los Unit tests (tests unitarios) prueban la unidad más pequeña de código: un método o función individual. A diferencia de los Feature tests que prueban funcionalidades completas, los Unit tests se centran en verificar que una pieza específica de lógica funciona correctamente de forma aislada.

Características de los Unit tests:

  • Aislados: No dependen de base de datos, APIs ni servicios externos
  • Rápidos: Se ejecutan en milisegundos
  • Específicos: Prueban un único comportamiento
  • Deterministas: Siempre producen el mismo resultado

Unit tests vs Feature tests

La diferencia principal está en el alcance y las dependencias:

php
<?php

// FEATURE TEST: Prueba el flujo completo
// - Hace petición HTTP
// - Usa base de datos
// - Renderiza vistas
class PostFeatureTest extends TestCase
{
    use RefreshDatabase;

    public function test_can_create_post(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)->post('/posts', [
            'title' => 'Mi post',
            'content' => 'Contenido',
        ]);

        $response->assertRedirect();
        $this->assertDatabaseHas('posts', ['title' => 'Mi post']);
    }
}

// UNIT TEST: Prueba una clase específica
// - Sin HTTP
// - Sin base de datos
// - Solo la lógica de la clase
class PriceCalculatorTest extends TestCase
{
    public function test_calculates_discount(): void
    {
        $calculator = new PriceCalculator();

        $result = $calculator->applyDiscount(100, 20);

        $this->assertEquals(80, $result);
    }
}

Crear un Unit test

Usa el flag --unit para crear un Unit test:

bash
# Crear Unit test
php artisan make:test PriceCalculatorTest --unit

# Se crea en tests/Unit/PriceCalculatorTest.php

Estructura básica de un Unit test:

php
<?php

namespace Tests\Unit;

use App\Services\PriceCalculator;
use PHPUnit\Framework\TestCase;

class PriceCalculatorTest extends TestCase
{
    public function test_example(): void
    {
        $this->assertTrue(true);
    }
}

Nota: Los Unit tests extienden PHPUnit\Framework\TestCase directamente, no Tests\TestCase. Esto significa que no tienen acceso a helpers de Laravel como $this->get() o actingAs().

Probar clases de servicio

Los Unit tests son ideales para probar clases de servicio que contienen lógica de negocio:

php
<?php

// app/Services/PriceCalculator.php
namespace App\Services;

class PriceCalculator
{
    public function applyDiscount(float $price, float $percent): float
    {
        if ($percent < 0 || $percent > 100) {
            throw new \InvalidArgumentException('El descuento debe estar entre 0 y 100');
        }

        return $price - ($price * $percent / 100);
    }

    public function calculateTax(float $price, float $taxRate): float
    {
        return $price * (1 + $taxRate / 100);
    }

    public function calculateTotal(float $price, float $discount, float $taxRate): float
    {
        $discounted = $this->applyDiscount($price, $discount);
        return $this->calculateTax($discounted, $taxRate);
    }
}
php
<?php

// tests/Unit/PriceCalculatorTest.php
namespace Tests\Unit;

use App\Services\PriceCalculator;
use PHPUnit\Framework\TestCase;

class PriceCalculatorTest extends TestCase
{
    private PriceCalculator $calculator;

    protected function setUp(): void
    {
        parent::setUp();
        $this->calculator = new PriceCalculator();
    }

    public function test_applies_discount_correctly(): void
    {
        $result = $this->calculator->applyDiscount(100, 20);

        $this->assertEquals(80, $result);
    }

    public function test_applies_zero_discount(): void
    {
        $result = $this->calculator->applyDiscount(100, 0);

        $this->assertEquals(100, $result);
    }

    public function test_applies_full_discount(): void
    {
        $result = $this->calculator->applyDiscount(100, 100);

        $this->assertEquals(0, $result);
    }

    public function test_throws_exception_for_negative_discount(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        $this->calculator->applyDiscount(100, -10);
    }

    public function test_throws_exception_for_discount_over_100(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('El descuento debe estar entre 0 y 100');

        $this->calculator->applyDiscount(100, 150);
    }

    public function test_calculates_tax_correctly(): void
    {
        $result = $this->calculator->calculateTax(100, 21);

        $this->assertEquals(121, $result);
    }

    public function test_calculates_total_with_discount_and_tax(): void
    {
        // Precio: 100, Descuento: 20%, IVA: 21%
        // 100 - 20% = 80
        // 80 + 21% = 96.8
        $result = $this->calculator->calculateTotal(100, 20, 21);

        $this->assertEquals(96.8, $result);
    }
}

Probar Value Objects

Los Value Objects son clases inmutables que representan valores. Son perfectos para Unit tests:

php
<?php

// app/ValueObjects/Email.php
namespace App\ValueObjects;

class Email
{
    private string $value;

    public function __construct(string $email)
    {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException("Email inválido: {$email}");
        }

        $this->value = strtolower($email);
    }

    public function getValue(): string
    {
        return $this->value;
    }

    public function getDomain(): string
    {
        return substr($this->value, strpos($this->value, '@') + 1);
    }

    public function equals(Email $other): bool
    {
        return $this->value === $other->getValue();
    }
}
php
<?php

// tests/Unit/EmailTest.php
namespace Tests\Unit;

use App\ValueObjects\Email;
use PHPUnit\Framework\TestCase;

class EmailTest extends TestCase
{
    public function test_creates_email_from_valid_string(): void
    {
        $email = new Email('user@example.com');

        $this->assertEquals('user@example.com', $email->getValue());
    }

    public function test_normalizes_email_to_lowercase(): void
    {
        $email = new Email('User@Example.COM');

        $this->assertEquals('user@example.com', $email->getValue());
    }

    public function test_throws_exception_for_invalid_email(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Email inválido');

        new Email('not-an-email');
    }

    public function test_throws_exception_for_empty_string(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        new Email('');
    }

    public function test_extracts_domain(): void
    {
        $email = new Email('user@example.com');

        $this->assertEquals('example.com', $email->getDomain());
    }

    public function test_two_emails_are_equal(): void
    {
        $email1 = new Email('user@example.com');
        $email2 = new Email('USER@EXAMPLE.COM');

        $this->assertTrue($email1->equals($email2));
    }

    public function test_different_emails_are_not_equal(): void
    {
        $email1 = new Email('user1@example.com');
        $email2 = new Email('user2@example.com');

        $this->assertFalse($email1->equals($email2));
    }
}

Probar helpers y utilidades

Las funciones helper y clases de utilidad son candidatas ideales para Unit tests:

php
<?php

// app/Support/StringHelper.php
namespace App\Support;

class StringHelper
{
    public static function slug(string $text): string
    {
        $text = strtolower($text);
        $text = preg_replace('/[áàäâ]/u', 'a', $text);
        $text = preg_replace('/[éèëê]/u', 'e', $text);
        $text = preg_replace('/[íìïî]/u', 'i', $text);
        $text = preg_replace('/[óòöô]/u', 'o', $text);
        $text = preg_replace('/[úùüû]/u', 'u', $text);
        $text = preg_replace('/ñ/u', 'n', $text);
        $text = preg_replace('/[^a-z0-9]+/', '-', $text);
        return trim($text, '-');
    }

    public static function excerpt(string $text, int $length = 100): string
    {
        if (strlen($text) <= $length) {
            return $text;
        }

        $text = substr($text, 0, $length);
        $lastSpace = strrpos($text, ' ');

        if ($lastSpace !== false) {
            $text = substr($text, 0, $lastSpace);
        }

        return $text . '...';
    }

    public static function initials(string $name): string
    {
        $words = explode(' ', trim($name));
        $initials = '';

        foreach ($words as $word) {
            if (!empty($word)) {
                $initials .= strtoupper($word[0]);
            }
        }

        return $initials;
    }
}
php
<?php

// tests/Unit/StringHelperTest.php
namespace Tests\Unit;

use App\Support\StringHelper;
use PHPUnit\Framework\TestCase;

class StringHelperTest extends TestCase
{
    public function test_creates_slug_from_simple_text(): void
    {
        $result = StringHelper::slug('Hello World');

        $this->assertEquals('hello-world', $result);
    }

    public function test_creates_slug_with_accents(): void
    {
        $result = StringHelper::slug('Introducción a Laravel');

        $this->assertEquals('introduccion-a-laravel', $result);
    }

    public function test_creates_slug_with_special_characters(): void
    {
        $result = StringHelper::slug('¡Hola! ¿Cómo estás?');

        $this->assertEquals('hola-como-estas', $result);
    }

    public function test_excerpt_returns_full_text_if_short(): void
    {
        $result = StringHelper::excerpt('Texto corto', 100);

        $this->assertEquals('Texto corto', $result);
    }

    public function test_excerpt_truncates_at_word_boundary(): void
    {
        $text = 'Esta es una frase larga que debe ser truncada';

        $result = StringHelper::excerpt($text, 20);

        $this->assertEquals('Esta es una frase...', $result);
    }

    public function test_initials_from_full_name(): void
    {
        $result = StringHelper::initials('Juan García López');

        $this->assertEquals('JGL', $result);
    }

    public function test_initials_from_single_name(): void
    {
        $result = StringHelper::initials('Juan');

        $this->assertEquals('J', $result);
    }

    public function test_initials_handles_extra_spaces(): void
    {
        $result = StringHelper::initials('  Juan   García  ');

        $this->assertEquals('JG', $result);
    }
}

Data Providers

Los Data Providers son un mecanismo de PHPUnit que permite ejecutar el mismo test múltiples veces con diferentes conjuntos de datos. En lugar de escribir 10 tests casi idénticos que solo cambian los valores, escribes un único test y un método que proporciona los datos.

¿Por qué usar Data Providers?

  • Menos código repetido: Un test, múltiples casos
  • Fácil mantenimiento: Añadir casos es añadir una línea
  • Mejor legibilidad: Los casos de prueba están agrupados
  • Nombres descriptivos: Cada caso puede tener un nombre

Cómo funcionan

Un Data Provider es un método static que devuelve un array de arrays. Cada array interno contiene los argumentos que se pasarán al test:

php
<?php

// Sin Data Provider: 5 tests repetitivos
public function test_suma_2_mas_3(): void
{
    $this->assertEquals(5, Calculator::sum(2, 3));
}

public function test_suma_0_mas_0(): void
{
    $this->assertEquals(0, Calculator::sum(0, 0));
}

public function test_suma_negativos(): void
{
    $this->assertEquals(-5, Calculator::sum(-2, -3));
}
// ... y así sucesivamente

// Con Data Provider: 1 test, múltiples casos
public static function sumProvider(): array
{
    return [
        'positivos' => [2, 3, 5],      // $a=2, $b=3, $expected=5
        'ceros' => [0, 0, 0],
        'negativos' => [-2, -3, -5],
        'mixto' => [-5, 10, 5],
        'decimales' => [1.5, 2.5, 4.0],
    ];
}

#[DataProvider('sumProvider')]
public function test_suma(float $a, float $b, float $expected): void
{
    $this->assertEquals($expected, Calculator::sum($a, $b));
}

Nota: El método Data Provider debe ser public static y devolver un array. Las claves del array (como 'positivos', 'ceros') son opcionales pero muy útiles porque aparecen en el resultado del test si falla.

Ejemplo completo con Data Provider

php
<?php

namespace Tests\Unit;

use App\Services\PriceCalculator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

class PriceCalculatorDataProviderTest extends TestCase
{
    private PriceCalculator $calculator;

    protected function setUp(): void
    {
        parent::setUp();
        $this->calculator = new PriceCalculator();
    }

    public static function discountProvider(): array
    {
        return [
            'sin descuento' => [100, 0, 100],
            'descuento 10%' => [100, 10, 90],
            'descuento 25%' => [200, 25, 150],
            'descuento 50%' => [100, 50, 50],
            'descuento total' => [100, 100, 0],
            'precio con decimales' => [99.99, 10, 89.991],
        ];
    }

    #[DataProvider('discountProvider')]
    public function test_applies_discount(float $price, float $discount, float $expected): void
    {
        $result = $this->calculator->applyDiscount($price, $discount);

        $this->assertEquals($expected, $result);
    }

    public static function invalidDiscountProvider(): array
    {
        return [
            'descuento negativo' => [-10],
            'descuento mayor a 100' => [150],
            'descuento muy negativo' => [-100],
        ];
    }

    #[DataProvider('invalidDiscountProvider')]
    public function test_throws_for_invalid_discount(float $discount): void
    {
        $this->expectException(\InvalidArgumentException::class);

        $this->calculator->applyDiscount(100, $discount);
    }
}

Mocking (objetos simulados)

Un mock es un objeto "falso" que simula el comportamiento de una dependencia real. Los mocks son fundamentales en Unit testing porque permiten aislar la clase que estás probando de sus dependencias externas.

¿Por qué necesitamos mocks?

Imagina que tienes un servicio que procesa pagos. Este servicio depende de una pasarela de pago externa (Stripe, PayPal, etc.). Si en tus tests llamas a la pasarela real:

  • Los tests serían lentos (llamadas HTTP reales)
  • Necesitarías credenciales de prueba
  • Los tests fallarían si la API está caída
  • Podrías hacer cargos reales por error

Con un mock, simulas la respuesta de la pasarela sin hacer llamadas reales. Tu test es rápido, aislado y predecible.

Crear un mock básico

PHPUnit proporciona createMock() para crear mocks. El mock implementa la interfaz o extiende la clase que le indiques, pero sus métodos no hacen nada por defecto:

php
<?php

// La interfaz que queremos simular
interface PaymentGateway
{
    public function charge(int $customerId, float $amount): array;
}

// En el test:
$mock = $this->createMock(PaymentGateway::class);

// Configurar qué debe devolver cuando se llame a charge()
$mock->method('charge')
    ->willReturn(['success' => true, 'transaction_id' => 'ABC123']);

// Ahora $mock se comporta como un PaymentGateway real
// pero devuelve siempre lo que configuramos

Configurar respuestas del mock

php
<?php

$mock = $this->createMock(PaymentGateway::class);

// Devolver un valor fijo
$mock->method('charge')->willReturn(['success' => true]);

// Devolver diferentes valores en llamadas consecutivas
$mock->method('charge')->willReturnOnConsecutiveCalls(
    ['success' => true],   // Primera llamada
    ['success' => false],  // Segunda llamada
    ['success' => true],   // Tercera llamada
);

// Lanzar una excepción
$mock->method('charge')->willThrowException(
    new \Exception('Connection timeout')
);

// Devolver un valor basado en los argumentos
$mock->method('charge')->willReturnCallback(
    function (int $customerId, float $amount) {
        if ($amount > 1000) {
            return ['success' => false, 'error' => 'Límite excedido'];
        }
        return ['success' => true];
    }
);

Verificar que se llamó al mock

Además de simular respuestas, puedes verificar que el mock fue llamado correctamente. Esto es útil para asegurar que tu código interactúa con sus dependencias como esperas:

php
<?php

$mock = $this->createMock(PaymentGateway::class);

// Verificar que se llama exactamente una vez
$mock->expects($this->once())
    ->method('charge')
    ->willReturn(['success' => true]);

// Verificar que se llama con argumentos específicos
$mock->expects($this->once())
    ->method('charge')
    ->with(42, 99.99)  // customerId=42, amount=99.99
    ->willReturn(['success' => true]);

// Verificar que se llama exactamente 3 veces
$mock->expects($this->exactly(3))
    ->method('charge');

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

// Verificar que se llama al menos una vez
$mock->expects($this->atLeastOnce())
    ->method('charge');

Ejemplo práctico: servicio con dependencia

Veamos un ejemplo completo de cómo testear un servicio que depende de otro:

php
<?php

// app/Services/OrderService.php
namespace App\Services;

use App\Contracts\PaymentGateway;

class OrderService
{
    public function __construct(
        private PaymentGateway $paymentGateway
    ) {}

    public function processOrder(array $order): array
    {
        $total = $this->calculateTotal($order['items']);

        $paymentResult = $this->paymentGateway->charge(
            $order['customer_id'],
            $total
        );

        return [
            'success' => $paymentResult['success'],
            'total' => $total,
            'transaction_id' => $paymentResult['transaction_id'] ?? null,
        ];
    }

    private function calculateTotal(array $items): float
    {
        $total = 0;
        foreach ($items as $item) {
            $total += $item['price'] * $item['quantity'];
        }
        return $total;
    }
}
php
<?php

// tests/Unit/OrderServiceTest.php
namespace Tests\Unit;

use App\Contracts\PaymentGateway;
use App\Services\OrderService;
use PHPUnit\Framework\TestCase;

class OrderServiceTest extends TestCase
{
    public function test_processes_order_successfully(): void
    {
        // Crear mock del PaymentGateway
        $paymentGateway = $this->createMock(PaymentGateway::class);
        $paymentGateway->method('charge')
            ->willReturn([
                'success' => true,
                'transaction_id' => 'TXN123',
            ]);

        $orderService = new OrderService($paymentGateway);

        $result = $orderService->processOrder([
            'customer_id' => 1,
            'items' => [
                ['price' => 10, 'quantity' => 2],
                ['price' => 5, 'quantity' => 3],
            ],
        ]);

        $this->assertTrue($result['success']);
        $this->assertEquals(35, $result['total']);
        $this->assertEquals('TXN123', $result['transaction_id']);
    }

    public function test_handles_payment_failure(): void
    {
        $paymentGateway = $this->createMock(PaymentGateway::class);
        $paymentGateway->method('charge')
            ->willReturn([
                'success' => false,
                'error' => 'Insufficient funds',
            ]);

        $orderService = new OrderService($paymentGateway);

        $result = $orderService->processOrder([
            'customer_id' => 1,
            'items' => [
                ['price' => 100, 'quantity' => 1],
            ],
        ]);

        $this->assertFalse($result['success']);
        $this->assertNull($result['transaction_id']);
    }

    public function test_calculates_total_correctly(): void
    {
        $paymentGateway = $this->createMock(PaymentGateway::class);
        $paymentGateway->expects($this->once())
            ->method('charge')
            ->with(1, 250.0)  // Verificar que se llama con el total correcto
            ->willReturn(['success' => true, 'transaction_id' => 'TXN']);

        $orderService = new OrderService($paymentGateway);

        $orderService->processOrder([
            'customer_id' => 1,
            'items' => [
                ['price' => 50, 'quantity' => 2],   // 100
                ['price' => 75, 'quantity' => 2],   // 150
            ],
        ]);
    }
}

Probar excepciones

Verifica que tu código lanza las excepciones correctas:

php
<?php

namespace Tests\Unit;

use App\Services\UserValidator;
use App\Exceptions\InvalidAgeException;
use App\Exceptions\InvalidEmailException;
use PHPUnit\Framework\TestCase;

class UserValidatorTest extends TestCase
{
    private UserValidator $validator;

    protected function setUp(): void
    {
        parent::setUp();
        $this->validator = new UserValidator();
    }

    public function test_throws_exception_for_underage_user(): void
    {
        $this->expectException(InvalidAgeException::class);
        $this->expectExceptionMessage('El usuario debe ser mayor de 18 años');

        $this->validator->validate([
            'name' => 'Juan',
            'age' => 16,
            'email' => 'juan@example.com',
        ]);
    }

    public function test_throws_exception_with_correct_code(): void
    {
        $this->expectException(InvalidAgeException::class);
        $this->expectExceptionCode(422);

        $this->validator->validate([
            'name' => 'Juan',
            'age' => 10,
            'email' => 'juan@example.com',
        ]);
    }

    public function test_invalid_email_throws_specific_exception(): void
    {
        try {
            $this->validator->validate([
                'name' => 'Juan',
                'age' => 25,
                'email' => 'not-valid',
            ]);
            $this->fail('Se esperaba InvalidEmailException');
        } catch (InvalidEmailException $e) {
            $this->assertStringContainsString('not-valid', $e->getMessage());
        }
    }
}

Assertions comunes

PHPUnit proporciona numerosas assertions para verificar resultados:

php
<?php

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

class AssertionsExampleTest extends TestCase
{
    // Igualdad
    public function test_equality_assertions(): void
    {
        $this->assertEquals(5, 2 + 3);
        $this->assertSame('hello', 'hello');  // Estricto (===)
        $this->assertNotEquals(5, 10);
    }

    // Booleanos
    public function test_boolean_assertions(): void
    {
        $this->assertTrue(true);
        $this->assertFalse(false);
    }

    // Null
    public function test_null_assertions(): void
    {
        $this->assertNull(null);
        $this->assertNotNull('value');
    }

    // Arrays
    public function test_array_assertions(): void
    {
        $array = ['a', 'b', 'c'];

        $this->assertCount(3, $array);
        $this->assertContains('b', $array);
        $this->assertNotContains('d', $array);
        $this->assertEmpty([]);
        $this->assertNotEmpty($array);
    }

    // Arrays asociativos
    public function test_associative_array_assertions(): void
    {
        $data = ['name' => 'Juan', 'age' => 30];

        $this->assertArrayHasKey('name', $data);
        $this->assertArrayNotHasKey('email', $data);
    }

    // Strings
    public function test_string_assertions(): void
    {
        $text = 'Hello World';

        $this->assertStringContainsString('World', $text);
        $this->assertStringStartsWith('Hello', $text);
        $this->assertStringEndsWith('World', $text);
        $this->assertMatchesRegularExpression('/^Hello/', $text);
    }

    // Tipos
    public function test_type_assertions(): void
    {
        $this->assertIsArray([1, 2, 3]);
        $this->assertIsString('text');
        $this->assertIsInt(42);
        $this->assertIsFloat(3.14);
        $this->assertIsBool(true);
        $this->assertIsObject(new \stdClass());
    }

    // Instancias
    public function test_instance_assertions(): void
    {
        $date = new \DateTime();

        $this->assertInstanceOf(\DateTime::class, $date);
        $this->assertInstanceOf(\DateTimeInterface::class, $date);
    }

    // Comparaciones numéricas
    public function test_numeric_assertions(): void
    {
        $this->assertGreaterThan(5, 10);
        $this->assertGreaterThanOrEqual(5, 5);
        $this->assertLessThan(10, 5);
        $this->assertLessThanOrEqual(10, 10);
    }
}

Resumen

  • Los Unit tests prueban unidades individuales de código de forma aislada
  • Usa --unit al crear el test: php artisan make:test NameTest --unit
  • Los Unit tests extienden PHPUnit\Framework\TestCase, no la clase de Laravel
  • Son ideales para probar servicios, Value Objects y helpers
  • Usa Data Providers para probar múltiples casos con el mismo test
  • Usa mocks para aislar las dependencias de tu clase
  • Verifica excepciones con expectException()
  • Los Unit tests son rápidos y deben ejecutarse en milisegundos

Ejercicios

Ejercicio 1: Calculadora de envío

Crea una clase ShippingCalculator que calcule el costo de envío basándose en el peso (kg) y la zona. Zona 1: 5€ base + 2€/kg. Zona 2: 8€ base + 3€/kg. Escribe Unit tests que verifiquen los cálculos y que lance excepción para pesos negativos o zonas inválidas.

Ver solución
<?php

// app/Services/ShippingCalculator.php
namespace App\Services;

class ShippingCalculator
{
    private const ZONES = [
        1 => ['base' => 5, 'per_kg' => 2],
        2 => ['base' => 8, 'per_kg' => 3],
    ];

    public function calculate(float $weight, int $zone): float
    {
        if ($weight < 0) {
            throw new \InvalidArgumentException('El peso no puede ser negativo');
        }

        if (!isset(self::ZONES[$zone])) {
            throw new \InvalidArgumentException("Zona inválida: {$zone}");
        }

        $config = self::ZONES[$zone];
        return $config['base'] + ($config['per_kg'] * $weight);
    }
}

// tests/Unit/ShippingCalculatorTest.php
namespace Tests\Unit;

use App\Services\ShippingCalculator;
use PHPUnit\Framework\TestCase;

class ShippingCalculatorTest extends TestCase
{
    private ShippingCalculator $calculator;

    protected function setUp(): void
    {
        parent::setUp();
        $this->calculator = new ShippingCalculator();
    }

    public function test_calculates_zone_1_shipping(): void
    {
        // 5€ base + 2€ * 3kg = 11€
        $result = $this->calculator->calculate(3, 1);

        $this->assertEquals(11, $result);
    }

    public function test_calculates_zone_2_shipping(): void
    {
        // 8€ base + 3€ * 3kg = 17€
        $result = $this->calculator->calculate(3, 2);

        $this->assertEquals(17, $result);
    }

    public function test_calculates_zero_weight(): void
    {
        $result = $this->calculator->calculate(0, 1);

        $this->assertEquals(5, $result);  // Solo base
    }

    public function test_calculates_decimal_weight(): void
    {
        // 5€ + 2€ * 2.5kg = 10€
        $result = $this->calculator->calculate(2.5, 1);

        $this->assertEquals(10, $result);
    }

    public function test_throws_for_negative_weight(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('El peso no puede ser negativo');

        $this->calculator->calculate(-1, 1);
    }

    public function test_throws_for_invalid_zone(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Zona inválida: 3');

        $this->calculator->calculate(1, 3);
    }

    public function test_throws_for_zone_zero(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        $this->calculator->calculate(1, 0);
    }
}

Ejercicio 2: Validador de contraseñas

Crea una clase PasswordValidator que verifique: mínimo 8 caracteres, al menos una mayúscula, al menos un número. Debe devolver un array de errores si no cumple los requisitos. Escribe tests con Data Provider para probar múltiples casos.

Ver solución
<?php

// app/Services/PasswordValidator.php
namespace App\Services;

class PasswordValidator
{
    public function validate(string $password): array
    {
        $errors = [];

        if (strlen($password) < 8) {
            $errors[] = 'La contraseña debe tener al menos 8 caracteres';
        }

        if (!preg_match('/[A-Z]/', $password)) {
            $errors[] = 'La contraseña debe tener al menos una mayúscula';
        }

        if (!preg_match('/[0-9]/', $password)) {
            $errors[] = 'La contraseña debe tener al menos un número';
        }

        return $errors;
    }

    public function isValid(string $password): bool
    {
        return empty($this->validate($password));
    }
}

// tests/Unit/PasswordValidatorTest.php
namespace Tests\Unit;

use App\Services\PasswordValidator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

class PasswordValidatorTest extends TestCase
{
    private PasswordValidator $validator;

    protected function setUp(): void
    {
        parent::setUp();
        $this->validator = new PasswordValidator();
    }

    public static function validPasswordsProvider(): array
    {
        return [
            'básica válida' => ['Password1'],
            'larga válida' => ['MiPassword123'],
            'con símbolos' => ['Password1!@#'],
            'exactamente 8 caracteres' => ['Abcdef1!'],
        ];
    }

    #[DataProvider('validPasswordsProvider')]
    public function test_valid_passwords(string $password): void
    {
        $this->assertTrue($this->validator->isValid($password));
        $this->assertEmpty($this->validator->validate($password));
    }

    public static function invalidPasswordsProvider(): array
    {
        return [
            'muy corta' => ['Pass1', ['La contraseña debe tener al menos 8 caracteres']],
            'sin mayúsculas' => ['password123', ['La contraseña debe tener al menos una mayúscula']],
            'sin números' => ['PasswordABC', ['La contraseña debe tener al menos un número']],
            'solo minúsculas' => ['password', [
                'La contraseña debe tener al menos una mayúscula',
                'La contraseña debe tener al menos un número',
            ]],
            'vacía' => ['', [
                'La contraseña debe tener al menos 8 caracteres',
                'La contraseña debe tener al menos una mayúscula',
                'La contraseña debe tener al menos un número',
            ]],
        ];
    }

    #[DataProvider('invalidPasswordsProvider')]
    public function test_invalid_passwords(string $password, array $expectedErrors): void
    {
        $this->assertFalse($this->validator->isValid($password));

        $errors = $this->validator->validate($password);
        $this->assertEquals($expectedErrors, $errors);
    }

    public function test_short_password_error_message(): void
    {
        $errors = $this->validator->validate('Ab1');

        $this->assertContains(
            'La contraseña debe tener al menos 8 caracteres',
            $errors
        );
    }

    public function test_missing_uppercase_error_message(): void
    {
        $errors = $this->validator->validate('password123');

        $this->assertContains(
            'La contraseña debe tener al menos una mayúscula',
            $errors
        );
    }

    public function test_missing_number_error_message(): void
    {
        $errors = $this->validator->validate('PasswordABC');

        $this->assertContains(
            'La contraseña debe tener al menos un número',
            $errors
        );
    }
}

Ejercicio 3: Carrito de compras

Crea una clase ShoppingCart con métodos para añadir items, eliminar items, obtener el total y aplicar un cupón de descuento. Escribe Unit tests que verifiquen todas las operaciones y casos límite.

Ver solución
<?php

// app/Services/ShoppingCart.php
namespace App\Services;

class ShoppingCart
{
    private array $items = [];
    private ?float $discountPercent = null;

    public function addItem(string $id, string $name, float $price, int $quantity = 1): void
    {
        if ($price < 0) {
            throw new \InvalidArgumentException('El precio no puede ser negativo');
        }

        if ($quantity < 1) {
            throw new \InvalidArgumentException('La cantidad debe ser al menos 1');
        }

        if (isset($this->items[$id])) {
            $this->items[$id]['quantity'] += $quantity;
        } else {
            $this->items[$id] = [
                'name' => $name,
                'price' => $price,
                'quantity' => $quantity,
            ];
        }
    }

    public function removeItem(string $id): void
    {
        unset($this->items[$id]);
    }

    public function getItems(): array
    {
        return $this->items;
    }

    public function getItemCount(): int
    {
        $count = 0;
        foreach ($this->items as $item) {
            $count += $item['quantity'];
        }
        return $count;
    }

    public function getSubtotal(): float
    {
        $total = 0;
        foreach ($this->items as $item) {
            $total += $item['price'] * $item['quantity'];
        }
        return $total;
    }

    public function applyCoupon(float $percent): void
    {
        if ($percent < 0 || $percent > 100) {
            throw new \InvalidArgumentException('El descuento debe estar entre 0 y 100');
        }
        $this->discountPercent = $percent;
    }

    public function getTotal(): float
    {
        $subtotal = $this->getSubtotal();

        if ($this->discountPercent !== null) {
            return $subtotal - ($subtotal * $this->discountPercent / 100);
        }

        return $subtotal;
    }

    public function clear(): void
    {
        $this->items = [];
        $this->discountPercent = null;
    }
}

// tests/Unit/ShoppingCartTest.php
namespace Tests\Unit;

use App\Services\ShoppingCart;
use PHPUnit\Framework\TestCase;

class ShoppingCartTest extends TestCase
{
    private ShoppingCart $cart;

    protected function setUp(): void
    {
        parent::setUp();
        $this->cart = new ShoppingCart();
    }

    public function test_cart_starts_empty(): void
    {
        $this->assertEmpty($this->cart->getItems());
        $this->assertEquals(0, $this->cart->getItemCount());
        $this->assertEquals(0, $this->cart->getTotal());
    }

    public function test_can_add_item(): void
    {
        $this->cart->addItem('product-1', 'Camiseta', 25.00, 2);

        $this->assertCount(1, $this->cart->getItems());
        $this->assertEquals(2, $this->cart->getItemCount());
    }

    public function test_adding_same_item_increases_quantity(): void
    {
        $this->cart->addItem('product-1', 'Camiseta', 25.00, 2);
        $this->cart->addItem('product-1', 'Camiseta', 25.00, 3);

        $this->assertCount(1, $this->cart->getItems());
        $this->assertEquals(5, $this->cart->getItemCount());
    }

    public function test_calculates_subtotal_correctly(): void
    {
        $this->cart->addItem('product-1', 'Camiseta', 25.00, 2);
        $this->cart->addItem('product-2', 'Pantalón', 50.00, 1);

        $this->assertEquals(100.00, $this->cart->getSubtotal());
    }

    public function test_can_remove_item(): void
    {
        $this->cart->addItem('product-1', 'Camiseta', 25.00, 2);
        $this->cart->addItem('product-2', 'Pantalón', 50.00, 1);

        $this->cart->removeItem('product-1');

        $this->assertCount(1, $this->cart->getItems());
        $this->assertEquals(50.00, $this->cart->getTotal());
    }

    public function test_removing_nonexistent_item_does_nothing(): void
    {
        $this->cart->addItem('product-1', 'Camiseta', 25.00);

        $this->cart->removeItem('nonexistent');

        $this->assertCount(1, $this->cart->getItems());
    }

    public function test_applies_coupon_discount(): void
    {
        $this->cart->addItem('product-1', 'Camiseta', 100.00, 1);
        $this->cart->applyCoupon(20);

        $this->assertEquals(100.00, $this->cart->getSubtotal());
        $this->assertEquals(80.00, $this->cart->getTotal());
    }

    public function test_throws_for_negative_price(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        $this->cart->addItem('product-1', 'Camiseta', -25.00);
    }

    public function test_throws_for_zero_quantity(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        $this->cart->addItem('product-1', 'Camiseta', 25.00, 0);
    }

    public function test_throws_for_invalid_coupon(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        $this->cart->applyCoupon(150);
    }

    public function test_clear_empties_cart(): void
    {
        $this->cart->addItem('product-1', 'Camiseta', 25.00);
        $this->cart->applyCoupon(10);

        $this->cart->clear();

        $this->assertEmpty($this->cart->getItems());
        $this->assertEquals(0, $this->cart->getTotal());
    }
}

¿Quieres dominar el testing en Laravel?

Aprende TDD, testing de APIs, mocking avanzado y más en nuestros cursos premium con proyectos reales.

Ver cursos premium