Testeando Servicios en Arquitectura Hexagonal
En la arquitectura de software, asegurar una comunicación efectiva entre los diferentes componentes de un sistema es vital. Una técnica efectiva para verificar estas conexiones es mediante la creación de mocks de interfaces. Esto implica crear varias implementaciones de las interfaces para imitar el comportamiento de los diferentes componentes. Al usar estos mocks, podemos probar los servicios de manera independiente, asegurándonos de que los mensajes que envían y reciben se adhieran a un estándar predefinido, comúnmente conocido como "contrato". Este enfoque nos permite validar las interacciones y la funcionalidad de cada servicio sin necesidad de las implementaciones o infraestructuras reales.
En la Arquitectura Hexagonal, seguimos la práctica de crear una interfaz o contrato distinto para cada servicio que pueda tener una o más implementaciones. Estas implementaciones se asignan luego a diferentes capas. Por ejemplo, consideremos un contrato UserRepository
que puede tener múltiples implementaciones como SQLiteUserRepository
, PostGreSQLUserRepository
o JSONFSUserRepository
, dependiendo de las necesidades de escalabilidad de nuestro sistema. La interfaz o contrato pertenece a la capa de dominio o aplicación, mientras que las implementaciones reales residen en la capa de infraestructura.
Al emplear mocks de interfaces, simplificamos el proceso de prueba de las capas de dominio y aplicación sin la complejidad de configurar un entorno de infraestructura real.
Supongamos que tenemos el siguiente servicio de aplicación del artículo anterior sobre Arquitectura Hexagonal:
final readonly class UserFinder
{
public function __construct(
private UserRepository $repository
) {
}
public function byId(int $id): User
{
if (!$user = $this->repository->find($id)) {
throw new UserNotFound("User $id not found");
}
return $user;
}
public function all(): array
{
return $this->repository->all();
}
}
El servicio anterior depende de un contrato UserRepository
que expone los siguientes métodos:
interface UserRepository
{
public function find(int $id): ?User;
public function all(): array
public function save(User $user): User;
}
Inicialmente, al probar el servicio UserFinder
, uno podría asumir que es necesario tener una base de datos funcional. Esto implicaría desplegar el esquema, cargar la base de datos con datos de prueba, crear una instancia de una implementación del servicio UserRepository
, y luego inyectarla en la nueva instancia de UserFinder
para la prueba.
Sin embargo, este proceso puede ser demasiado complejo para probar solo un servicio de aplicación. Afortunadamente, podemos simplificarlo realizando mocks de la interfaz UserRepository
. Este enfoque nos permite probar el código en las capas de dominio y aplicación sin la necesidad de ningún servicio de infraestructura real.
Para asegurar una prueba adecuada de los servicios de dominio y aplicación, debemos validar rigurosamente la API expuesta por el contrato que estamos probando. Esto significa probar exhaustivamente todas las entradas y salidas especificadas por el contrato.
Exploremos un ejemplo rápido usando los frameworks Pest y Mockery. Primero, necesitamos instalar ambos frameworks a través de composer:
composer require pestphp/pest --dev --with-all-dependencies
composer require mockery/mockery --dev
A continuación, necesitamos inicializar Pest:
vendor/bin/pest --init
Ahora, creemos nuestra primera prueba unitaria en tests/Unit/UserFinderTest.php
. El primer caso de uso que probaremos es encontrar un usuario por su ID único, lo que implica probar la función byId
. Esta función llama al método find
del contrato UserRepository
. Según el contrato, este método puede devolver una instancia válida de User
(éxito) o null si no se encuentra un usuario con el ID especificado (fallo). Con esto en mente, escribamos la prueba.
describe('UserFinder', function () {
test('should find a `User` by give ID.', function () {
$repository = Mockery::mock(UserRepository::class);
$repository->expects('find')
->with(Mockery::type('int'))
->once()
->andReturnUsing(fn(int $id) => new User($id, 'Vincent Vega'));
$service = new UserFinder($repository);
expect($service->byId(42))->toBeInstanceOf(User::class);
});
});
Vamos a revisar lo que hemos hecho:
- Creamos un mock del contrato
UserRepository
. - Especificamos que el mock debe esperar una llamada al método
find
con un argumento de tipoint
. - Definimos que el método
find
debe ser llamado exactamente una vez durante esta prueba. - Proporcionamos una función que se ejecutará cuando se llame al método
find
. Esta función devuelve una nueva instancia deUser
, que coincide con el resultado esperado definido por el contrato para un caso exitoso. - Instanciamos un nuevo
UserFinder
, nuestro sujeto de prueba. - Ejecutamos el método
byId
deUserFinder
y verificamos la respuesta. Dado que estamos probando el escenario exitoso, el valor devuelto debe ser una instancia deUser
.
Dado que nuestro sujeto de prueba es el UserFinder
, debemos verificar lo que este servicio expone. Al mockear el UserRepository
, podemos controlar el comportamiento de UserFinder
durante la prueba. En esta primera prueba, queremos que el UserRepository
siempre devuelva una instancia de User
, simulando un escenario donde existe un registro de usuario coincidente en una base de datos. Como podemos ver, no necesitamos una base de datos en funcionamiento para ejecutar la prueba; solo necesitamos adherirnos correctamente al contrato definido.
Ya hemos probado el caso de éxito, así que ahora probemos el caso de fallo. Según el contrato UserRepository
, si no se encuentra un usuario con el ID dado, el valor devuelto debe ser null
.
describe('UserFinder', function () {
//...
test('should throw `UserNotFound` exception when a `User` is not found by the given ID.', function () {
$repository = Mockery::mock(UserRepository::class);
$repository->expects('find')
->with(Mockery::type('int'))
->once()
->andReturnUsing(fn(int $id) => null);
$service = new UserFinder($repository);
$service->byId(42);
})->throws(UserNotFound::class, "User 42 not found");
});
Esta nueva prueba tiene algunas diferencias; vamos a revisar los cambios:
- El valor devuelto de la función ahora es
null
, simulando que la base de datos no tiene el registro de usuario solicitado. - En lugar de verificar directamente el valor devuelto del servicio, verificamos que se debe lanzar una excepción. Específicamente, esperamos una excepción
UserNotFound
.
Este caso de prueba verifica el escenario de fallo del UserFinder
. Al cambiar el comportamiento del UserRepository
y adherirse al contrato, podemos probar toda la funcionalidad sin ninguna implementación real de un UserRepository
.
Vamos a continuar añadiendo más pruebas a nuestro UserFinder
. Ahora es el momento de cubrir el método all
. Este método llama al método all
del UserRepository
. Según el contrato, este método siempre devuelve un array.
describe('UserFinder', function () {
// ...
test('should return all `User`s', function () {
$repository = Mockery::mock(UserRepository::class);
$repository->expects('all')
->withNoArgs()
->once()
->andReturnUsing(fn() => []);
$service = new UserFinder($repository);
expect($service->all())->toBeArray();
});});
En este caso, indicamos al mock que:
- No espere ningún argumento para el método
all
. - Devuelva un array vacío cuando se llame al método
all
. - Finalmente, afirmamos que el valor devuelto por el
UserFinder
es realmente un array.
En este punto, hemos probado exhaustivamente el servicio UserFinder
. A continuación, pasemos al servicio UserCreator
, que también depende del UserRepository
. Para este servicio, necesitamos probar el método save
. Este método toma un User
sin un ID como argumento y devuelve un User
con un ID.
describe('UserCreator', function () {
test('should create a new `User` from the given data.', function () {
$repository = Mockery::mock(UserRepository::class);
$repository->expects('save')
->with(Mockery::type(User::class))
->once()
->andReturnUsing(fn(User $user) => new User(1, $user->name));
$creator = new UserCreator($repository);
$user = $creator->create('Vincent Vega');
expect($user->name)->toBe('Vincent Vega')
->and($user->id())->toBeGreaterThan(0);
});
});
En el código anterior, hicimos lo siguiente:
- Creamos un simulacro del contrato
UserRepository
. - Especificamos que el simulacro debe esperar una llamada al método
save
con un argumentoUser
y que debe devolver unUser
. - Definimos que el método
save
debe ser llamado exactamente una vez durante el caso de prueba. - Aseguramos que el
User
devuelto tenga un IDint
válido. - Instanciamos el
UserCreator
. - Ejecutamos el método
save
y afirmamos la respuesta. Dado que estamos probando el escenario exitoso, el valor devuelto debe ser una instancia deUser
con un IDint
válido.
Revisa el codigo de ejemplo en el repositorio Hexagonal Architecture Example in PHP.
Conclusión
El uso de mocks de interfaz en la Arquitectura Hexagonal ayuda a garantizar que las diferentes partes de tu aplicación funcionen correctamente sin necesidad de una configuración compleja. Al enfocarnos en los contratos o interfaces, podemos probar la lógica central sin configurar una infraestructura real como bases de datos.
En este artículo, mostramos cómo usar los mocks de interfaz para verificar los servicios UserFinder
y UserCreator
. Al crear mocks del UserRepository
, controlamos cómo se comportaban estos servicios durante las pruebas. Esto nos permitió verificar que respondieran correctamente en diferentes situaciones sin necesidad de una base de datos real.