In-Memory Fakes
¿Hay alguna forma rápida y sencilla de crear fakes para repositorios? El tipo MapRef de cats-effect parece ser la solución:
It is conceptually similar to a
Ref[F, Map[K, V]], but with better ergonomics when working on a per key basis.
Quiero probar esa ergonomía, y para eso voy a partir de un ejemplo.
La shopping cart kata es muy parecida a la bank kata, sólo que en lugar de una cuenta bancaria se trata de hacer la compra: tras añadir varios productos al carro de la compra y un código de descuento, se pide imprimir el listado de productos, además de un resumen con el número de productos y el precio total de la compra.
Son varios los diseños posibles para darle solución a este problema. El que yo voy a proponer me permitió experimentar con varias estrategias para probar los repositorios:
trait ShoppingCart:
def addProductBy(name: Product.Name): OptionT[IO, Double]
def applyCoupon(code: Coupon.Code): OptionT[IO, Coupon.Discount]
def deleteProductBy(name: Product.Name): OptionT[IO, Double]
def printStatus(): IO[Unit]
La operación addProductBy toma como parámetro el nombre de un producto (por ejemplo, Iceberg 🥬) y devuelve el precio final del mismo (2.17 €), después de añadirlo al carro de la compra. Suponiendo que todos los productos se pueden identificar por su nombre, esta operación necesita recuperar el precio del producto a partir de su nombre.
Como el carro de la compra no debería conocer detalles del producto, voy a introducir un colaborador para separar las responsabilidades. El repositorio ProductsRepository define la operación findProductBy que permite recuperar un producto a través de su nombre:
trait ProductsRepository:
def add(product: Product): IO[Unit]
def findProductBy(name: Product.Name): OptionT[IO, Product]
Y efectivamente, MapRef permite hacer un in-memory fake de este repositorio con muy poco esfuerzo:
final class FakeProductsRepository private (
mapRef: MapRef[IO, Product.Name, Option[Product]],
) extends ProductsRepository:
override def add(product: Product): IO[Unit] =
IO.raiseError(IllegalAccessError("Cannot modify a read-only repository"))
override def findProductBy(name: Product.Name): OptionT[IO, Product] =
OptionT:
mapRef(name).get
object FakeProductsRepository:
def fillWith(products: Map[Product.Name, Product]): IO[FakeProductsRepository] =
for
mapRef <- MapRef.ofSingleImmutableMap[IO, Product.Name, Product](products)
productsRepository = FakeProductsRepository(mapRef)
yield productsRepository
Para probar ShoppingCart solamente necesitamos buscar productos, pero nunca modificarlos. Por eso, el smart constructor fillWith es la única forma de añadir productos a FakeProductsRepository, evitando que se puedan modificar los elementos añadidos a este repositorio.
Aplicando un descuento#
Si bien es cierto que comparado con Ref[F, Map[K, V]], MapRef simplifica mucho la creación de in-memory fakes, para casos tan sencillos como el anterior se puede conseguir la misma ergonomía y el mismo nivel de protección contra modificaciones accidentales utilizando un simple Map.
Por ejemplo, el colaborador CouponsRepository define la operación findCouponBy que permite recuperar un cupón de descuento a través de su código:
trait CouponsRepository:
def add(coupon: Coupon): IO[Unit]
def findCouponBy(code: Coupon.Code): OptionT[IO, Coupon]
Y su fake, esta vez utilizando un Map:
final class FakeCouponsRepository(
coupons: Map[Coupon.Code, Coupon],
) extends CouponsRepository:
override def add(coupon: Coupon): IO[Unit] =
IO.raiseError(IllegalAccessError("Cannot modify a read-only repository"))
override def findCouponBy(code: Coupon.Code): OptionT[IO, Coupon] =
OptionT:
IO.pure(coupons.get(code))
Listando los productos#
Voy a aprovechar lo que aprendí sobre MapRef durante el desarrollo los fakes para implementar ShoppingCart.
No tuve ningún problema para añadir, eliminar o actualizar elementos en MapRef. Pude confirmar que esta estructura cuenta con operaciones muy útiles y fáciles de usar para todos estos casos. En cambio, no encontré cómo realizar operaciones que no van por clave. Por ejemplo, no encontré ninguna opción que me permitiera listar todos los productos del carro de la compra.
Al parecer, para realizar este tipo de operaciones se necesita mantener una referencia al ConcurrentHashMap de Java que utiliza internamente MapRef, como se puede apreciar en este fragmento de la implementación:
import java.util.concurrent.ConcurrentHashMap
final class InMemory private (
concurrentHashMap: ConcurrentHashMap[Product.Name, Int],
mapRef: MapRef[IO, Product.Name, Option[Int]],
productsRepository: ProductsRepository,
) extends ShoppingCart:
override def addProductBy(name: Product.Name): OptionT[IO, Double] =
for
product <- productsRepository.findProductBy(name)
_ <- OptionT.liftF:
mapRef(name).update: quantity =>
quantity.map(_ + 1).orElse(Some(1))
yield product.finalPrice
private def productNames() =
import scala.jdk.CollectionConverters.*
IO.delay(concurrentHashMap.keys()).map(_.asScala.toList)
override def applyCoupon(code: Coupon.Code): OptionT[IO, Coupon.Discount] =
// omitted for brevity
override def deleteProductBy(name: Product.Name): OptionT[IO, Double] =
// omitted for brevity
override def printStatus(): IO[Unit] =
// omitted for brevity
object InMemory:
def impl =
for
concurrentHashMap <- IO.pure(ConcurrentHashMap[Product.Name, Int]())
mapRef = MapRef.fromConcurrentHashMap[IO, Product.Name, Int](
concurrentHashMap
)
yield InMemory(concurrentHashMap, mapRef)
Hay mucha diferencia entre las operaciones que operan sobre las claves de MapRef, y las que lo hacen sobre todos los elementos. La ergonomía que menciona la documentación se aplica solamente al primer caso, como se puede apreciar en addProductBy.
Para operar con todos los elementos no sólo hay que mantener la doble referencia (MapRef y ConcurrentHashMap), sino que además es necesario trabajar con estructuras de datos de Java, aplicando las conversiones necesarias. Por ejemplo, productNames recupera las claves y las transforma en una lista de Scala.
Cierre#
El patrón repositorio es un enfoque de diseño que proporciona una capa de abstracción para acceder a fuentes de datos, como bases de datos o APIs externas, mediando entre el modelo de dominio y la capa de datos. Su amplio uso hace indispensable contar con buenas estrategias de testing, entre las que destacan el uso de in-memory fakes para implementar una versión simplificada del código de producción.
El tipo MapRef de cats-effect tiene una ergonomía excelente para operar sobre las claves de un associative_array, siendo una herramienta muy útil para desarrollar in-memory fakes.
Sin embargo, cuando el repositorio incluye operaciones sobre todos los elementos se produce el efecto contrario: hay que mantener una referencia al ConcurrentHashMap que utiliza internamente MapRef, operar con estructuras de datos de Java y hacer trasformaciones adicionales.
Aún así, MapRef tiene otras ventajas que lo hacen muy apropiado para probar código paralelo o concurrente. MapRef proporciona operaciones atómicas sobre las claves, haciendo que sea thread-safe y utilizando técnicas como sharding para mejorar la contención.
El código completo del ejemplo está disponible en GitHub1.
-
Foto de Alexas_Fotos en Unsplash ↩︎