Declarative Testing
Hay una cosa que me viene pasando desde que trabajo con aplicaciones más complejas, y es que los tests de aceptación se complican mucho también. Al principio pensé que era lo normal: si la aplicación es muy compleja, lo normal es que el test también lo sea. Luego, repasando un código del año pasado me di cuenta de que introducir cambios en un test es casi tan difícil (y a veces más) que cambiar el código de producción.
Para los tests de aceptación uso escenarios sintéticos, generados a partir de las reglas de negocio. Esta estrategia me proporciona el contexto necesario para probar las aplicaciones usando datos y condiciones similares a las que encontraré luego en producción. Sin embargo, el contexto que necesita este tipo de test crece con la complejidad de la aplicación que se prueba.
Una mejora relativamente rápida de introducir consiste en dividir las aplicaciones en módulos más pequeños, haciendo que el contexto necesario para probar cada módulo sea también más pequeño. Aún así, hay un momento en el que es necesario orquestar todos esos módulos para que trabajen coordinadamente en conseguir el resultado esperado.
Me hacía falta entonces encontrar una nueva estrategia para probar la orquestación, sin volver a incurrir en la creación de un contexto global y complejo. Fue ahí que descubrí la serie Purify Your Tests de Daniel Beskin. Sin entrar en detalles, la idea consiste en aprovechar el sistema de tipos de Scala junto con las capacidades del lenguaje para componer funcionalidades más grandes a partir de fragmentos de código más pequeños. Con este enfoque se consigue una estrategia de testing más declarativa que se apoya en parámetros de tipo.
Siempre que pruebo algo nuevo me gusta implementar un ejemplo para entender sus ventajas y limitaciones. En este caso, release-pager es un pet project de Pavels Sisojevs con suficientes componentes y una complejidad adecuada para intentar aplicar la nueva estrategia de testing. Entonces vamos a ello.
Hay una nueva versión disponible#
Release-pager es una aplicación que revisa periódicamente una lista de repositorios de código buscando nuevas versiones. Cuando la aplicación encuentra una actualización de alguno de los repositorios envía una notificación a cada uno de los suscriptores interesados. Así de simple.
Esta aplicación tiene tres dominios bien diferenciados:
- Los repositorios de los cuales se quiere recibir actualizaciones.
- Los subscriptores que están interesados en conocer sobre las actualizaciones.
- Las notificaciones que se envían a los suscriptores.
Un posible algoritmo sería como sigue a continuación:
def checkAndNotify: Unit =
val repositories = repositoryService.listAll()
repositories.foreach: repository =>
val maybeNewVersion = releaseFinder.newVersionOf(repository)
maybeNewVersion match
case Some(version) =>
val subscribers = subscriptionService.subscribersOf(
repository
)
val notification = notificationBuilder.make(
subscribers,
repository,
version
)
notificationSender.send(notification)
case None => () // Do nothing
Probando el flujo de la aplicación#
Si implementamos el algoritmo anterior dentro de la clase ReleaseChecker e inyectamos las dependencias como colaboradores en el constructor de la clase:
class ReleaseChecker(
repositoryService: RepositoryService,
releaseFinder: ReleaseFinder,
subscriptionService: SubscriptionService,
notificationBuilder: NotificationBuilder,
notificationSender: NotificationSender,
):
def checkAndNotify: Unit = ???
Nuestro objetivo ahora será probar la funcionalidad de esta clase aislada de sus dependencias. Hay muchas maneras de conseguirlo y posiblemente la más extendida sea reemplazando las dependencias por mocks. En Scala existen varias librerías de mocking que permiten conectar expectativas con una respuesta específica. Aún así, también hay buenas razones para preferir fakes livianos. A diferencia de los mocks, los fakes son pequeñas implementaciones que están limitadas para usarlas durante el desarrollo. Un ejemplo de fake es una implementación en memoria de un repositorio que en producción estaría respaldado por una base de datos. La estrategia de testing que voy a demostrar en este artículo utiliza fakes para reemplazar las dependencias de la clase que se está probando.
Dentro de esta estrategia, cada uno de los colaboradores se define con la ayuda de parámetros de tipo “no fungibles” que permiten hacer un seguimiento del flujo de la aplicación. Este patrón de diseño conocido como phantom type parameters se define como:
Información adicional a nivel de tipo disponible durante el tiempo de compilación mediante la introducción de variables de tipo adicionales.
Por ejemplo, RepositoryService es el primer colaborador que recibe el constructor de la clase ReleaseChecker y se puede definir de la siguiente forma:
trait RepositoryService:
def findEarliestUpdates(): List[Repository]
// Don't do this or (╯°□°)╯︵ ┻━┻
def update(repository: Repository, version: Version): Unit
La función findEarliestUpdates() de RepositoryService permite obtener un listado con los repositorios que deben ser visitados para encontrar actualizaciones. Además, RepositoryService define la función update(...) que permite actualizar la versión de un repositorio.
La definición anterior utiliza un enfoque imperativo en el que update(...) es tratado como un modificador que no devuelve nada, o lo que es lo mismo que tiene tipo Unit.
Como ya había dicho antes, una característica de la estrategia de testing declarativa es que todas las entradas y salidas van a estar tipadas. Siguiendo esta idea, la función update(...) se puede redefinir como una función completa que realiza una acción sobre los argumentos de tipo Repository y Version, devolviendo un valor de tipo Updated:
trait RepositoryService[Repository, Version, Updated]:
def findEarliestUpdates(): List[Repository]
def update(repository: Repository, version: Version): Updated
Esto no es más que una generalización del principio parse, don’t validate definido por Alexis King.
Una de las principales diferencias entre la versión imperativa y la declarativa es que los tipos Repository, Version y Unit de la primera representan tipos concretos. Por ejemplo, un repositorio puede estar definido como:
case class Repository(
id: Id,
groupId: GroupId,
artifactId: ArtifactId,
version: Version,
)
Mientras que la versión podría ser:
opaque type Version <: String = String
En cambio, en la versión declarativa, Repository, Version y Updated son parámetros de tipo que dan pistas o indicios sobre el flujo de la aplicación, pero que no imponen ninguna restricción sobre el tipo concreto. Dicho de otra forma, Repository y Version son entradas de la función update(...), mientras que Updated es la salida, y todos ellos pueden ser de cualquier tipo.
Teniendo en cuenta lo anterior, podemos implementar el siguiente fake de RepositoryService donde Repository es de tipo Int, mientras que Version y Updated son de tipo String:
class FakeRepositoryService(repositories: List[Int])
extends RepositoryService[Int, String, String]:
override def findEarliestUpdates(): List[Int] = repositories
override def update(repository: Int, version: String): String =
s"updated->{repository:$repository,version:$version}"
Si probamos la clase ReleaseChecker con el fake anterior deberíamos poder comprobar que se producen líneas como las siguientes al actualizar las versiones de los repositorios:
updated->{repository:1,version:0.1}
updated->{repository:2,version:1.0}
updated->{repository:3,version:1.8}
Luego, la implementación de RepositoryService que usemos en producción puede usar los tipos concretos de Repository y Version que fueron definidos más arriba. Por ejemplo, usando doobie para conectarnos a una base de datos PostgreSQL:
object RepositoryServiceImpl:
final class Postgres(
transactor: Transactor[IO],
) extends RepositoryService[Repository, Version, Unit]:
override def findEarliestUpdates(): IO[List[Repository]] =
val sql = sql"""SELECT id, group_id, artifact_id, version
FROM repositories"""
sql.query[Repository].to[List].transact(transactor)
override def update(repository: Repository, version: Version): IO[Unit] =
val sql = sql"""UPDATE repositories SET version = $version
WHERE id = ${repository.id}"""
sql.update.run.transact(transactor).void
Esta implementación estaría probada de manera independiente con un test de integración, por ejemplo, en un entorno local virtualizado:
class RepositoryServiceImplSuite extends PostgresSuite:
test("should update the version of a repository"):
forAllF(testCaseGen):
case TestCase(rows, (repository, version), expected) =>
testTransactor.resource.use: transactor =>
val testRepositoryService = TestRepositoryServiceImpl(transactor)
val repositoryService = RepositoryServiceImpl.Postgres(transactor)
(for
_ <- testRepositoryService.addAll(rows)
_ <- repositoryService.update(repository, version)
obtained <- testRepositoryService.findBy(repository.id).value
yield obtained).assertEquals(expected)
Finalmente, si definimos parámetros de tipo para todas las entradas y salidas del resto de colaboradores de la clase ReleaseChecker la definición queda como sigue:
class ReleaseChecker[
Repository,
Updated,
Version,
Subscriber,
Notification,
Notified: Semigroup
](
repositoryService: RepositoryService[
Repository, Version, Updated
],
releaseFinder: ReleaseFinder[
Repository, Version
],
subscriptionService: SubscriptionService[
Repository, Subscriber, ?, ?
],
notificationBuilder: NotificationBuilder[
Subscriber, Repository, Version, Notification
],
notificationSender: NotificationSender[
Notification, Notified, Updated
]
):
def checkAndNotify: Notified = ???
Hay dos detalles de esta definición que necesitan ser explicados. Primero, no todos los parámetros de tipo son necesarios para la definición. Tal es el caso del colaborador SubscriptionService que toma cuatro parámetros, pero sólo los dos primeros son necesarios en la clase ReleaseChecker:
trait SubscriptionService[
Repository,
Subscriber,
SubscriberId,
Subscription
]:
def subscribe(
repository: Repository, subscriberId: SubscriberId
): Subscription
def unsubscribe(
repository: Repository, subscriberId: SubscriberId
): Subscription
def subscribersOf(repository: Repository): List[Subscriber]
Los parámetros innecesarios se pueden representar con un signo de interrogación ?, indicándole así al compilador que no son importantes para la definición.
El segundo detalle es el context bound Semigroup aplicado al parámetro de tipo Notified. Este context bound asegura que siempre que se utilice la clase ReleaseChecker haya un parámetro de contexto de tipo Semigroup[Notified], asegurando que se pueda usar la operación combine(...) de Semigroup para reducir un conjunto de Notified. Como veremos más adelante, esta operación nos será de utilidad para reducir la salida de la función checkAndNotify(...).
Implementación final#
Después de ajustar el algoritmo inicial para ser usado en producción, la función checkAndNotify(...) queda como sigue:
def checkAndNotify: OptionT[IO, Notified] = OptionT:
for
repositories <- repositoryService.findEarliestUpdates()
notifiedList <- repositories.parFlatTraverse: repository =>
(for
version <- releaseFinder.findNewVersionOf(repository)
notified <- OptionT.liftF:
for
subscribers <- subscriptionService.subscribersOf(
repository
)
notification <- notificationBuilder.make(
subscribers, repository, version
)
updated <- repositoryService.update(
repository, version
)
notified <- notificationSender.send(
updated, notification
)
yield notified
yield notified).value.map(_.toList)
maybeNotified =
NonEmptyList.fromList(notifiedList).map(_.reduce)
yield maybeNotified
He introducido los siguientes cambios en el código:
- He utilizado la mónada
IOpara ejecutar los efectos. Por ejemplo,IOgarantiza que las operaciones de acceso a base de datos sucederán en contexto aislado del programa principal. - He utilizado
parFlatTraversepara recorrer el listado de repositorios en paralelo. - He utilizado la monad transformer
OptionTcomo un envoltorio ligero deIO[Option[Notified]]porque creo que facilita el trabajo conOption[Notified]dentro del contexto deIO.
El test de aceptación#
Hemos visto que el código de producción queda muy parecido al algoritmo inicial que nos habíamos planteado, con algunas optimizaciones que fueron introducidas para mejorar el performance y la experiencia del desarrollador. Aún así, no deja de tener cierta complejidad. Es hora entonces de probar si la estrategia de testing declarativa puede o no simplificar el test de aceptación.
Usando los componentes que he ido introduciendo a lo largo de este texto, el test de aceptación puede ser definido como sigue:
class ReleaseCheckerSuite extends CatsEffectSuite:
test("should check for updates and notify subscribers"):
val repositories = (1 to 4).toList
val subscriptions = repositories.map(repository =>
repository -> (5 to 6).toList.map(
chatId => s"chatId->$chatId"
)).toMap
val releaseChecker = ReleaseCheckerImpl.Pure(
repositories, subscriptions
)
val expected = Some(expectedFrom(subscriptions).fold("")(_ + _))
releaseChecker.checkAndNotify.value.assertEquals(expected)
Aprovechando que todos mis fakes utilizan Int como materialización del parámetro de tipo Repository, he definido una lista del 1 al 4 que serán nuestros repositorios en el test. De forma similar he definido una lista de chatId5 hasta chatId6 como los suscriptores que he ido asignando a cada uno de los repositorios.
Con estos datos he podido probar la función checkAndNotify(...) de la clase ReleaseChecker utilizando los fakes definidos para cada colaborador:
object ReleaseCheckerImpl:
class Pure(
repositories: List[Int],
subscriptions: Map[Int, List[String]],
filter: Int => Boolean = isEven,
) extends ReleaseChecker(
FakeRepositoryService(repositories),
FakeReleaseFinder(filter),
FakeSubscriptionService(subscriptions),
FakeNotificationBuilder,
FakeNotificationSender,
)
Por otra parte, las expectativas de test también se han simplificado y además reflejan perfectamente el flujo de los datos dentro del programa:
def expectedFrom(subscriptions: Map[Int, List[String]]): List[String] =
subscriptions
.filter { case (x, _) => isEven(x) }
.toList
.map:
case (x, xs) =>
val ys = xs.sorted.mkString("[", ",", "]")
s"""notified->{
updated->{
repository:$x, version:released->{repository:$x}
},
notification->{
subscribers:$ys, repository:$x, version:released->{repository:$x}
}
}"""
Hay un aspecto en el que la estrategia de testing declarativa supera a los mocks por bastante margen, y es cuando hace falta comprobar el orden en el que se ejecutan las operaciones dentro del programa. Por ejemplo, digamos que hay un requisito del negocio que dice que no se puede enviar ninguna notificación a los suscriptores hasta estar seguros de que la versión del repositorio ha quedado actualizada en la base de datos.
Este requisito se traduce en la implementación en una precondición de la función send(...), que necesita un argumento updated que a su vez es generado en la salida de la función update(...):
for
updated <- repositoryService.update(
repository, version
)
notified <- notificationSender.send(
updated, notification
)
yield ???
Probar esto ha sido tan sencillo como componer un String con el orden esperado de las operaciones.
Cierre#
He quedado convencido del valor que tiene seguir estrategia de testing más declarativa para simplificar los tests en los que sólo se quiera probar el flujo de operaciones del programa. Aunque el ejemplo usa Scala, la estrategia debería ser aplicable en otros lenguajes con tipado estático y polimorfismo paramétrico, como Kotlin o Java.
El razonamiento detrás de la estrategia de testing declarativa se puede encontrar en la serie Purify Your Tests de Daniel Beskin. Este artículo presenta un ejemplo en el que he usado esta estrategia para probar el flujo de una aplicación con varios componentes de tres dominios diferentes.
El código completo del ejemplo está disponible en GitHub.
Para la organización del proyecto, así como algunos aspectos de la nomenclatura de los componentes he tomado como inspiración la serie Modeling in Scala de Mateusz Kubuszok, de la cual me gustaría escribir en otra ocasión.