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
// 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:
# Crear Unit test
php artisan make:test PriceCalculatorTest --unit
# Se crea en tests/Unit/PriceCalculatorTest.php
Estructura básica de un Unit test:
<?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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
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
// 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
$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
$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
// 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
// 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
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
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
--unital 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());
}
}
¿Has encontrado un error o tienes una sugerencia para mejorar esta lección?
Escríbenos¿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