Translate

martes, 21 de febrero de 2023

Probar código asíncrono con cats

Cómo simplificar las pruebas unitarias para código asincrónico haciéndolas sincrónicas con Cats? Esta es la pregunta que vamos a intantar responder. 

Supongamos que estamos midiendo el tiempo de actividad en un conjunto de servidores.  Habrá dos componentes. El primero es un UptimeClient que sondea servidores remotos para conocer su tiempo de actividad:


import scala.concurrent.Future

trait UptimeClient {

    def getUptime(hostname: String): Future[Int]

}


También tendremos un UptimeService que mantiene una lista de servidores y permite al usuario sondearlos por su tiempo de actividad total:


import cats.instances.future._ // for Applicative

import cats.instances.list._// for Traverse

import cats.syntax.traverse._// for traverse

import scala.concurrent.ExecutionContext.Implicits.global


class UptimeService(client: UptimeClient) {

    def getTotalUptime(hostnames: List[String]): Future[Int] =

        hostnames.traverse(client.getUptime).map(_.sum)

}

Hemos modelado UptimeClient como un trait porque vamos a querer probarlo en pruebas unitarias. Por ejemplo, podemos escribir un cliente de prueba que nos permita proporcionar datos ficticios en lugar de llamar a servidores reales:


class TestUptimeClient(hosts: Map[String, Int]) extends UptimeClient {

    def getUptime(hostname: String): Future[Int] =

        Future.successful(hosts.getOrElse(hostname, 0))

}


Ahora, supongamos que estamos escribiendo pruebas unitarias para UptimeService. Queremos probar su capacidad para sumar valores, independientemente de dónde los obtenga. Aquí hay un ejemplo:


def testTotalUptime() = {

   val hosts= Map("host1" -> 10, "host2" -> 6)

   val client= new TestUptimeClient(hosts)

   val service= new UptimeService(client)

   val actual= service.getTotalUptime(hosts.keys.toList)

   val expected = hosts.values.sum

   assert(actual == expected)

}


El código no compila porque hemos cometido un error clásico. Olvidamos que nuestro código de aplicación es asíncrono. Nuestro resultado real es de tipo Future[Int] y nuestro resultado esperado es de tipo Int. ¡No podemos compararlos directamente!

Hay un par de maneras de resolver este problema. Podríamos modificar nuestro código de prueba para acomodar la asincronía. Sin embargo, existe otra alternativa. ¡Hagamos que nuestro código de servicio sea sincrónico para que nuestra prueba funcione sin modificaciones!

Necesitamos implementar dos versiones de UptimeClient: una asíncrona para usar en producción y una síncrona para usar en nuestras pruebas unitarias:


trait RealUptimeClient extends UptimeClient {

    def getUptime(hostname: String): Future[Int]

}

trait TestUptimeClient extends UptimeClient {

    def getUptime(hostname: String): Int

}


La pregunta es: ¿qué tipo de resultado debemos dar al método abstracto en UptimeClient? Future[Int] o Int:


trait UptimeClient {

    def getUptime(hostname: String): ???

}


Al principio esto puede parecer difícil. Pero afortunadamente, Cats brinda una solución en términos del tipo de identidad, Id. Id nos permite "envolver" tipos en un constructor de tipos sin cambiar su significado:


package cats

type Id[A] = A


Id nos permite abstraernos sobre los tipos de devolución en UptimeClient. Implementa esto ahora:


import cats.Id

trait UptimeClient[F[_]] {

    def getUptime(hostname: String): F[Int]

}

trait RealUptimeClient extends UptimeClient[Future] {

   def getUptime(hostname: String): Future[Int]

}

trait TestUptimeClient extends UptimeClient[Id] {

   def getUptime(hostname: String): Id[Int]

}


Ahora deberíamos poder desarrollar una definición de TestUptimeClient en una clase completa basada en Map[String, Int].


Dirijamos nuestra atención a UptimeService. Necesitamos reescribirlo para abstraer los dos tipos de UptimeClient:

class UptimeService[F[_]](client: UptimeClient[F]) {

    def getTotalUptime(hostnames: List[String]): F[Int] =

        hostnames.traverse(client.getUptime).map(_.sum)

}

Ahora descomente el cuerpo de getTotalUptime. Debería obtener un error de compilación similar al siguiente:


// <console>:28: error: could not find implicit value for

//evidence parameter of type cats.Applicative[F]

//hostnames.traverse(client.getUptime).map(_.sum)

//                              ^


El problema aquí es que traverse solo funciona en secuencias de valores que tienen un Aplicativo. En nuestro código original estábamos atravesando una Lista[Futuro[Int]]. Hay un aplicativo para Future, así que estuvo bien.

En esta versión estamos atravesando una Lista[F[Int]]. Necesitamos demostrarle al compilador que F tiene un Aplicativo. Haga esto agregando un parámetro de constructor implícito a UptimeService.


import cats.Applicative

import cats.syntax.functor._ // for map


class UptimeService[F[_]](client: UptimeClient[F]) (implicit a: Applicative[F]) {

    def getTotalUptime(hostnames: List[String]): F[Int] =

        hostnames.traverse(client.getUptime).map(_.sum)

}


Finalmente, dirijamos nuestra atención a nuestras pruebas unitarias. Nuestro código de prueba ahora funciona según lo previsto sin ninguna modificación. Creamos una instancia de TestUptimeClient y la envolvemos en un UptimeService. Esto une efectivamente F a Id, lo que permite que el resto del código funcione sincrónicamente sin preocuparse por las mónadas o los aplicativos:


def testTotalUptime() = {

    val hosts= Map("host1" -> 10, "host2" -> 6)

    val client= new TestUptimeClient(hosts)

    val service= new UptimeService(client)

    val actual= service.getTotalUptime(hosts.keys.toList)

    val expected = hosts.values.sum

    assert(actual == expected)

}

testTotalUptime()


Este estudio de caso proporciona un ejemplo de cómo Cats puede ayudarnos a abstraernos en diferentes escenarios computacionales. Usamos la clase de tipo Applicative para abstraer sobre código asíncrono y síncrono. Apoyarnos en una abstracción funcional nos permite especificar la secuencia de cálculos que queremos realizar sin preocuparnos por los detalles de la implementación.

Las clases de tipo como Functor, Applicative, Monad y Traverse proporcionan implementaciones abstractas de patrones como mapeo, compresión, secuenciación e iteración. Las leyes matemáticas de esos tipos aseguran que funcionen junto con un conjunto consistente de semántica.

Usamos Applicative en este caso de estudio porque era la clase de tipos menos poderosa que hacía lo que necesitábamos. Si hubiéramos requerido flatMap, podríamos haber cambiado Applicative por Monad. Si hubiéramos necesitado abstraernos sobre diferentes tipos de secuencias, podríamos haber usado Traverse. También hay clases de tipos como ApplicativeError y MonadError que ayudan a modelar fallas y cálculos exitosos.