02 · Hexagonal
Tema 3 (parte 2) — Arquitectura Hexagonal Avanzada y Testing
Objetivo: finalizar la estructura hexagonal de inventory-service
, dominar ciclos de vida de dependencias y asegurar calidad con tests unitarios y de integración.
1. Preparación y verificación preliminar
1.1 Asegúrate de haber completado la Sesión 2:
El servicio compila (
npm run build
) y arranca (npm run dev
) sin errores.Prisma está configurado y la tabla
Inventory
existe en tu base de datos.
1.2 Abre el proyecto en tu IDE y confirma que la estructura de carpetas es similar a la siguiente:
project/services/inventory-service/
├── src/
│ ├── domain/
│ │ ├── model/
│ │ └── ports/
│ ├── application/
│ │ ├── use-cases/
│ │ └── container.ts
│ ├── infrastructure/
│ │ ├── http/
│ │ ├── postgres/
│ │ └── in-memory/
│ └── main.ts
├── tests/
│ ├── unit/
│ └── integration/
├── prisma/
│ ├── schema.prisma
│ └── migrations/
├── package.json
├── tsconfig.json
└── Dockerfile
2. Scopes y ciclo de vida en el contenedor DI (Awilix)
En src/application/container.ts
vamos a registrar tres tipos de dependencias:
singleton() para objetos globales:
PrismaClient
Configuración
scoped() para repositorios y casos de uso:
InventoryRepositoryPostgres
ReserveStockUseCase
transient() para objetos de prueba o stateful temporales:
InMemoryInventoryRepository
Ejemplo de registro:
export const container = createContainer({ injectionMode: 'CLASSIC' })
container.register({
prisma: asClass(PrismaClient).singleton(),
inventoryRepo: asClass(InventoryRepositoryPostgres).scoped(),
inMemoryRepo: asClass(InMemoryInventoryRepository).transient(),
reserveUseCase: asClass(ReserveStockUseCase).scoped()
})
Por qué importa: usar los scopes adecuados garantiza que tu aplicación no comparta estado indebidamente ni abra múltiples conexiones innecesarias.
3. Adapter de prueba: InMemoryInventoryRepository
Crea el archivo src/infrastructure/in-memory/InMemoryInventoryRepository.ts
con esta implementación:
import { InventoryRepositoryPort } from '../../domain/ports/InventoryRepositoryPort'
import { ProductInventory } from '../../domain/model/ProductInventory'
export class InMemoryInventoryRepository implements InventoryRepositoryPort {
private items: Map<string, ProductInventory>
constructor(initial: Array<{ sku: string; available: number }> = []) {
this.items = new Map(initial.map(i => [i.sku, new ProductInventory(i.sku, i.available)]))
}
async findBySku(sku: string): Promise<ProductInventory | null> {
const inv = this.items.get(sku)
return inv ? new ProductInventory(inv.sku, inv.getAvailable()) : null
}
async save(inventory: ProductInventory): Promise<void> {
this.items.set(inventory.sku, new ProductInventory(inventory.sku, inventory.getAvailable()))
}
}
Registra este adapter en el container bajo la clave inMemoryRepo
con .transient()
.
4. Tests unitarios de dominio y puerto
Crea tests/unit/ReserveStockUseCase.spec.ts
:
import { createContainer } from 'awilix'
import { InMemoryInventoryRepository } from '../../src/infrastructure/in-memory/InMemoryInventoryRepository'
import { ReserveStockUseCase } from '../../src/application/use-cases/ReserveStockUseCase'
describe('ReserveStockUseCase - Unit Tests', () => {
let container
beforeEach(() => {
container = createContainer({ injectionMode: 'CLASSIC' })
container.register({
inMemoryRepo: asClass(InMemoryInventoryRepository).transient(),
reserveUseCase: asClass(ReserveStockUseCase).scoped()
})
})
it('reduce stock cuando hay suficiente', async () => {
const repo = new InMemoryInventoryRepository([{ sku: 'ABC', available: 5 }])
container.register({ inventoryRepo: asValue(repo) })
const uc = container.resolve<ReserveStockUseCase>('reserveUseCase')
await uc.execute('ABC', 3)
const inv = await repo.findBySku('ABC')
expect(inv!.getAvailable()).toBe(2)
})
it('lanza error si no hay stock', async () => {
const repo = new InMemoryInventoryRepository([{ sku: 'XYZ', available: 1 }])
container.register({ inventoryRepo: asValue(repo) })
const uc = container.resolve<ReserveStockUseCase>('reserveUseCase')
await expect(uc.execute('XYZ', 5)).rejects.toThrow('insufficient stock')
})
})
Estos tests corren en milisegundos y no necesitan Docker ni base de datos.
5. Tests de integración con Postgres en memoria
Crea tests/integration/InventoryRepositoryPostgres.spec.ts
:
import { PrismaClient } from '@prisma/client'
import { InventoryRepositoryPostgres } from '../../src/infrastructure/postgres/InventoryRepositoryPostgres'
import { ProductInventory } from '../../src/domain/model/ProductInventory'
describe('InventoryRepositoryPostgres - Integration', () => {
let prisma: PrismaClient
let repo: InventoryRepositoryPostgres
beforeAll(async () => {
prisma = new PrismaClient({ datasources: { db: { url: 'file:./test.db?mode=memory&cache=shared' } } })
await prisma.$executeRaw`CREATE TABLE IF NOT EXISTS Inventory (sku TEXT PRIMARY KEY, available INTEGER NOT NULL)`
repo = new InventoryRepositoryPostgres(prisma)
})
afterAll(async () => {
await prisma.$disconnect()
})
it('almacena y recupera inventario', async () => {
const item = new ProductInventory('TEST', 10)
await repo.save(item)
const fetched = await repo.findBySku('TEST')
expect(fetched).not.toBeNull()
expect(fetched!.getAvailable()).toBe(10)
})
})
Última actualización