Testing Services — Guía y Patrones¶
Introducción¶
Los Services requieren un enfoque de testing específico que utiliza mocks para repositorios y dependencias externas, permitiendo pruebas rápidas y aisladas de la lógica de aplicación.
Configuración Base para Tests¶
Estructura de Test Típica¶
<?php
declare(strict_types=1);
namespace Tests\Feature\Services;
use App\Contracts\Repositories\RoleRepositoryInterface;
use App\Contracts\Exports\ExporterInterface;
use App\Services\RoleService;
use Illuminate\Support\Facades\DB;
use Mockery;
use Mockery\MockInterface;
use Psr\Container\ContainerInterface;
use Tests\TestCase;
class RoleServiceTest extends TestCase
{
private MockInterface $mockRepo;
private MockInterface $mockContainer;
private MockInterface $mockExporter;
private RoleService $service;
protected function setUp(): void
{
parent::setUp();
$this->mockRepo = Mockery::mock(RoleRepositoryInterface::class);
$this->mockContainer = Mockery::mock(ContainerInterface::class);
$this->mockExporter = Mockery::mock(ExporterInterface::class);
$this->service = new RoleService($this->mockRepo, $this->mockContainer);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
}
Ejecutar Tests¶
Comandos Artisan¶
# Ejecutar todos los tests
php artisan test
# Tests específicos de services
php artisan test tests/Feature/Services/
# Test específico con detalle
php artisan test tests/Feature/Services/RoleServiceTest.php --verbose
# Con coverage (requiere Xdebug)
php artisan test --coverage --min=80
Configuración PHPUnit¶
El archivo phpunit.xml
debe incluir:
<phpunit>
<testsuites>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="testing"/>
</php>
</phpunit>
Patrones de Testing por Método¶
1. Listado (list/listByIdsDesc)¶
public function test_list_returns_correct_shape_with_rows_and_meta(): void
{
// Arrange
$query = new ListQuery(['page' => 1, 'perPage' => 10]);
$with = ['permissions'];
$withCount = ['users'];
$role1 = $this->createMockRole(['id' => 1, 'name' => 'Admin']);
$role2 = $this->createMockRole(['id' => 2, 'name' => 'User']);
$paginator = new LengthAwarePaginator(
[$role1, $role2],
25, // total
10, // perPage
1, // currentPage
['path' => 'test']
);
// Act & Assert
$this->mockRepo->shouldReceive('paginate')
->once()
->with($query, $with, $withCount)
->andReturn($paginator);
$result = $this->service->list($query, $with, $withCount);
$this->assertArrayHasKey('rows', $result);
$this->assertArrayHasKey('meta', $result);
$this->assertCount(2, $result['rows']);
$this->assertEquals([
'currentPage' => 1,
'perPage' => 10,
'total' => 25,
'lastPage' => 3
], $result['meta']);
}
public function test_list_applies_custom_to_row_transformation(): void
{
$role = $this->createMockRole([
'id' => 1,
'name' => 'admin',
'display_name' => 'Administrator',
'created_at' => '2024-01-01 10:00:00'
]);
$paginator = new LengthAwarePaginator([$role], 1, 10, 1, ['path' => 'test']);
$this->mockRepo->shouldReceive('paginate')->once()->andReturn($paginator);
$result = $this->service->list(new ListQuery(['page' => 1]));
// Verificar que toRow() personalizado se aplicó
$this->assertEquals([
'id' => 1,
'name' => 'admin',
'display_name' => 'Administrator',
// ... otros campos según implementación de toRow()
], $result['rows'][0]);
}
2. Lecturas Puntuales (getById/getOrFailById)¶
public function test_get_by_id_delegates_to_repository(): void
{
$id = 1;
$with = ['permissions'];
$role = $this->createMockRole(['id' => $id]);
$this->mockRepo->shouldReceive('getById')
->once()
->with($id, $with)
->andReturn($role);
$result = $this->service->getById($id, $with);
$this->assertSame($role, $result);
}
public function test_get_by_id_returns_null_when_not_found(): void
{
$this->mockRepo->shouldReceive('getById')
->once()
->with(999, [])
->andReturn(null);
$result = $this->service->getById(999);
$this->assertNull($result);
}
public function test_get_or_fail_by_id_throws_exception_when_not_found(): void
{
$this->mockRepo->shouldReceive('getOrFailById')
->once()
->with(999, [])
->andThrow(new ModelNotFoundException());
$this->expectException(ModelNotFoundException::class);
$this->service->getOrFailById(999);
}
3. Operaciones de Escritura (create/update)¶
public function test_create_wraps_in_transaction(): void
{
$attributes = ['name' => 'manager', 'display_name' => 'Manager'];
$role = $this->createMockRole($attributes);
// Verificar que se usa transacción
DB::shouldReceive('transaction')
->once()
->andReturnUsing(function ($callback) {
return $callback();
});
$this->mockRepo->shouldReceive('create')
->once()
->with($attributes)
->andReturn($role);
$result = $this->service->create($attributes);
$this->assertSame($role, $result);
}
public function test_update_handles_model_and_id_parameter(): void
{
$attributes = ['display_name' => 'Updated Manager'];
$role = $this->createMockRole(['id' => 1] + $attributes);
DB::shouldReceive('transaction')
->once()
->andReturnUsing(function ($callback) {
return $callback();
});
// Test con ID
$this->mockRepo->shouldReceive('update')
->once()
->with(1, $attributes)
->andReturn($role);
$result = $this->service->update(1, $attributes);
$this->assertSame($role, $result);
// Test con Model
$existingRole = $this->createMockRole(['id' => 1]);
$this->mockRepo->shouldReceive('update')
->once()
->with($existingRole, $attributes)
->andReturn($role);
$result = $this->service->update($existingRole, $attributes);
$this->assertSame($role, $result);
}
4. Operaciones Masivas (bulk*)¶
public function test_bulk_operations_delegate_to_repository(): void
{
$ids = [1, 2, 3];
$uuids = ['uuid1', 'uuid2', 'uuid3'];
$affectedRows = 3;
// Test todas las operaciones masivas por IDs
$this->mockRepo->shouldReceive('bulkDeleteByIds')->once()->with($ids)->andReturn($affectedRows);
$this->mockRepo->shouldReceive('bulkForceDeleteByIds')->once()->with($ids)->andReturn($affectedRows);
$this->mockRepo->shouldReceive('bulkRestoreByIds')->once()->with($ids)->andReturn($affectedRows);
$this->mockRepo->shouldReceive('bulkSetActiveByIds')->once()->with($ids, true)->andReturn($affectedRows);
$this->assertEquals($affectedRows, $this->service->bulkDeleteByIds($ids));
$this->assertEquals($affectedRows, $this->service->bulkForceDeleteByIds($ids));
$this->assertEquals($affectedRows, $this->service->bulkRestoreByIds($ids));
$this->assertEquals($affectedRows, $this->service->bulkSetActiveByIds($ids, true));
// Test operaciones masivas por UUIDs
$this->mockRepo->shouldReceive('bulkDeleteByUuids')->once()->with($uuids)->andReturn($affectedRows);
$this->mockRepo->shouldReceive('bulkForceDeleteByUuids')->once()->with($uuids)->andReturn($affectedRows);
$this->mockRepo->shouldReceive('bulkRestoreByUuids')->once()->with($uuids)->andReturn($affectedRows);
$this->mockRepo->shouldReceive('bulkSetActiveByUuids')->once()->with($uuids, false)->andReturn($affectedRows);
$this->assertEquals($affectedRows, $this->service->bulkDeleteByUuids($uuids));
$this->assertEquals($affectedRows, $this->service->bulkForceDeleteByUuids($uuids));
$this->assertEquals($affectedRows, $this->service->bulkRestoreByUuids($uuids));
$this->assertEquals($affectedRows, $this->service->bulkSetActiveByUuids($uuids, false));
}
5. Transacciones y Concurrencia¶
public function test_transaction_executes_callback_and_returns_result(): void
{
$expectedResult = 'transaction-success';
$callback = function () use ($expectedResult) {
return $expectedResult;
};
DB::shouldReceive('transaction')
->once()
->with($callback)
->andReturn($expectedResult);
$result = $this->service->transaction($callback);
$this->assertEquals($expectedResult, $result);
}
public function test_transaction_rolls_back_on_exception(): void
{
$exception = new \Exception('Business rule violation');
$callback = function () use ($exception) {
throw $exception;
};
DB::shouldReceive('transaction')
->once()
->with($callback)
->andThrow($exception);
$this->expectException(\Exception::class);
$this->expectExceptionMessage('Business rule violation');
$this->service->transaction($callback);
}
public function test_pessimistic_lock_delegates_to_repository(): void
{
$id = 1;
$result = 'locked-operation-result';
$callback = fn() => $result;
$this->mockRepo->shouldReceive('withPessimisticLockById')
->once()
->with($id, $callback)
->andReturn($result);
$actualResult = $this->service->withPessimisticLockById($id, $callback);
$this->assertEquals($result, $actualResult);
}
6. Exportación¶
public function test_export_uses_default_columns_and_filename(): void
{
$query = new ListQuery(['page' => 1]);
$format = 'csv';
// Mock del response
$response = Mockery::mock(StreamedResponse::class);
$response->headers = Mockery::mock();
$response->headers->shouldReceive('set')
->once()
->with('Content-Disposition', Mockery::pattern('/attachment; filename=".*\.csv"/'));
// Mock del container y exporter
$this->mockContainer->shouldReceive('get')
->once()
->with('exporter.csv')
->andReturn($this->mockExporter);
$this->mockExporter->shouldReceive('stream')
->once()
->with(Mockery::type('Generator'), ['id', 'name', 'display_name']) // columnas por defecto
->andReturn($response);
// Mock del paginator para exportRows
$role = $this->createMockRole(['id' => 1, 'name' => 'admin']);
$paginator = new LengthAwarePaginator([$role], 1, 1000, 1, ['path' => 'test']);
$this->mockRepo->shouldReceive('paginate')->once()->andReturn($paginator);
$result = $this->service->export($query, $format);
$this->assertSame($response, $result);
}
public function test_export_filters_columns_correctly(): void
{
$query = new ListQuery(['page' => 1]);
$format = 'xlsx';
$columns = ['id', 'name']; // Solo estas columnas
$filename = 'custom_roles.xlsx';
$response = Mockery::mock(StreamedResponse::class);
$response->headers = Mockery::mock();
$response->headers->shouldReceive('set')
->once()
->with('Content-Disposition', 'attachment; filename="' . $filename . '"');
$this->mockContainer->shouldReceive('get')
->once()
->with('exporter.xlsx')
->andReturn($this->mockExporter);
$this->mockExporter->shouldReceive('stream')
->once()
->with(Mockery::type('Generator'), $columns)
->andReturn($response);
// Verificar que el generador filtra columnas
$role = $this->createMockRole([
'id' => 1,
'name' => 'admin',
'display_name' => 'Administrator', // Esta no debe aparecer
'description' => 'Admin role' // Esta tampoco
]);
$paginator = new LengthAwarePaginator([$role], 1, 1000, 1, ['path' => 'test']);
$this->mockRepo->shouldReceive('paginate')->once()->andReturn($paginator);
$result = $this->service->export($query, $format, $columns, $filename);
$this->assertSame($response, $result);
}
public function test_export_handles_multiple_pages(): void
{
$query = new ListQuery(['page' => 1]);
// Simular 2 páginas de resultados
$role1 = $this->createMockRole(['id' => 1, 'name' => 'admin']);
$role2 = $this->createMockRole(['id' => 2, 'name' => 'user']);
$paginator1 = new LengthAwarePaginator([$role1], 2, 1, 1, ['path' => 'test']);
$paginator2 = new LengthAwarePaginator([$role2], 2, 1, 2, ['path' => 'test']);
$this->mockRepo->shouldReceive('paginate')
->twice()
->andReturn($paginator1, $paginator2);
$response = Mockery::mock(StreamedResponse::class);
$response->headers = Mockery::mock();
$response->headers->shouldReceive('set')->once();
$this->mockContainer->shouldReceive('get')
->once()
->with('exporter.csv')
->andReturn($this->mockExporter);
$this->mockExporter->shouldReceive('stream')
->once()
->with(Mockery::type('Generator'), Mockery::any())
->andReturn($response);
$this->service->export($query, 'csv');
}
Testing de Métodos Específicos del Dominio¶
public function test_assign_permissions_validates_and_syncs(): void
{
$roleId = 1;
$permissionIds = [1, 2, 3];
$role = $this->createMockRole(['id' => $roleId]);
$role->shouldReceive('fresh')->once()->with(['permissions'])->andReturnSelf();
// Mock de relación permissions
$permissionsRelation = Mockery::mock();
$permissionsRelation->shouldReceive('sync')->once()->with($permissionIds);
$role->shouldReceive('permissions')->once()->andReturn($permissionsRelation);
DB::shouldReceive('transaction')
->once()
->andReturnUsing(function ($callback) {
return $callback();
});
$this->mockRepo->shouldReceive('getOrFailById')
->once()
->with($roleId, ['permissions'])
->andReturn($role);
// Simular validaciones internas (pueden requerir mocks adicionales)
$this->mockPermissionRepository = Mockery::mock();
// ... setup para validaciones
$result = $this->service->assignPermissions($roleId, $permissionIds);
$this->assertSame($role, $result);
}
public function test_assign_permissions_throws_exception_for_invalid_permissions(): void
{
$roleId = 1;
$invalidPermissionIds = [999, 1000]; // IDs que no existen
$role = $this->createMockRole(['id' => $roleId]);
DB::shouldReceive('transaction')
->once()
->andReturnUsing(function ($callback) {
return $callback();
});
$this->mockRepo->shouldReceive('getOrFailById')
->once()
->with($roleId, ['permissions'])
->andReturn($role);
// El método debe lanzar excepción al validar permisos inexistentes
$this->expectException(BusinessRuleException::class);
$this->expectExceptionMessage('Invalid permissions provided');
$this->service->assignPermissions($roleId, $invalidPermissionIds);
}
Helpers para Tests¶
Factory de Mocks¶
protected function createMockRole(array $attributes = []): MockInterface
{
$role = Mockery::mock(Role::class);
// Defaults
$defaults = [
'id' => 1,
'name' => 'test-role',
'display_name' => 'Test Role',
'active' => true,
'created_at' => now(),
'updated_at' => now(),
];
$attributes = array_merge($defaults, $attributes);
$role->shouldReceive('attributesToArray')
->andReturn($attributes);
// Setup properties if needed
foreach ($attributes as $key => $value) {
$role->{$key} = $value;
}
return $role;
}
protected function createMockCollection(array $items = []): MockInterface
{
$collection = Mockery::mock(Collection::class);
$collection->shouldReceive('count')->andReturn(count($items));
$collection->shouldReceive('isEmpty')->andReturn(empty($items));
$collection->shouldReceive('isNotEmpty')->andReturn(!empty($items));
return $collection;
}
Assertions Personalizadas¶
protected function assertValidListResult(array $result): void
{
$this->assertArrayHasKey('rows', $result);
$this->assertArrayHasKey('meta', $result);
$this->assertIsArray($result['rows']);
$this->assertIsArray($result['meta']);
// Verificar estructura de meta
$requiredMetaKeys = ['currentPage', 'perPage', 'total', 'lastPage'];
foreach ($requiredMetaKeys as $key) {
$this->assertArrayHasKey($key, $result['meta']);
$this->assertIsInt($result['meta'][$key]);
}
}
protected function assertValidExportResponse(StreamedResponse $response): void
{
$this->assertInstanceOf(StreamedResponse::class, $response);
$contentDisposition = $response->headers->get('Content-Disposition');
$this->assertStringContains('attachment', $contentDisposition);
$this->assertStringContains('filename=', $contentDisposition);
}
Coverage y Métricas¶
Comandos útiles¶
# Coverage mínimo requerido
php artisan test --coverage --min=85
# Coverage detallado por clase
php artisan test --coverage-html=storage/coverage
# Solo tests que fallen
php artisan test --stop-on-failure
# Ejecutar con profiling
php artisan test --profile
Métricas Objetivo¶
- Coverage: Mínimo 85% en services
- Assertions por test: 3-5 en promedio
- Tiempo por test: < 100ms para tests unitarios
- Casos por método público: Mínimo 2 (happy path + edge case)
Checklist de Testing¶
- [ ] Mock todas las dependencias externas (repos, exporters)
- [ ] Test happy path y edge cases para cada método público
- [ ] Verificar que transacciones se usan correctamente
- [ ] Probar manejo de excepciones
- [ ] Validar formato de respuesta en list() y export()
- [ ] Test métodos específicos del dominio
- [ ] Verificar que hooks personalizados funcionan
- [ ] Assertion de que mocks se llaman con parámetros correctos
- [ ] Test de concurrencia y locks pesimistas
- [ ] Coverage mínimo del 85%