En todos los posts anteriores (menos en trabajar en remoto) he incluido algún ejemplo de código, pero muy poco de lo que he escrito tiene que ver con los tests. Por eso hoy quiero contar más detalles sobre cómo hice los tests del caso de uso para añadir promociones al carro de la compra.

El escenario que me interesa probar parte de un estado inicial en el que hay:

  • Un usuario del que sólo conozco su identificador.
  • Un carro de la compra con artículos.
  • Campañas promocionales que se aplican solamente a algunos artículos del carro.
  • Incentivos que definen qué promociones son elegibles para el usuario -y por consiguiente, cuáles no lo son.

El caso de uso produce una transición de estado, y el estado final al que se llega se diferencia del estado inicial en que:

  • Los artículos del carro ahora están anotados con las promociones que forman parte de los incentivos del usuario.

Por ejemplo, imaginemos que el usuario ha puesto dos camisetas de Acme Corporation en el carro de la compra. Imaginamos también que estas camisetas tienen una promoción de 2 por 1, que está disponible para todos los usuarios. Como la promoción es un incentivo elegible para el usuario, entonces ésta debería ser aplicada al carro de la compra.

Eligiendo una Estrategia de Testing

Una de las posibles estrategias que podríamos usar para probar el funcionamiento de este programa consiste en seleccionar ejemplos que cubran tanto los edge cases (casos que prueban las condiciones de contorno, y que -por tanto- requieren un manejo especial) como los average cases (casos que prueban el happy path, y que están libres de condiciones excepcionales o de error). La Coffee Machine kata es un ejemplo de aplicación de este tipo de estrategias. El propio enunciado de la kata nos proporciona los casos de prueba que necesitamos, y casi que nos condiciona a escribir los tests con esta estrategia -hacer lo contrario sería complicar las cosas.

En cambio, cuando definí el caso de uso para añadir promociones al carro de la compra no di muchos detalles sobre los casos de prueba -quedaría muy bien decir que fue intencional, pero no fue el caso-, así que ahora puedo elegir otra estrategia.

En lugar de definir manualmente los ejemplos, voy a introducir ScalaCheck: una librería de Scala para desarrollar pruebas automatizadas basadas en propiedades -o simplemente property-based tests. Además, como he utilizado cats-effect en mi código -y en particular la monad cats.effect.IO-, voy a hacer uso de Weaver Test: un framework de testing para cats-effect y fs2.

No voy a dar muchos detalles sobre el uso de estas dos librerías porque su documentación lo explica mucho mejor de lo que yo podría llegar a hacerlo. Solamente diré que ScalaCheck proporciona generadores que permiten crear dinámicamente datos de prueba a partir de patrones, mientras que Weaver Test proporciona el boilerplate necesario para probar código con la IO monad.

Generando Casos de Prueba

Utilizando estas dos librerías, el código de los tests me ha quedado como sigue:

object AddCampaignsToShoppingCartSuite extends SimpleIOSuite with IOCheckers {
  simpleTest("Add eligible campaigns to shopping cart") {
    val gen = (for {
      userId <- userIdGen
      shoppingCartId <- shoppingCartIdGen
      shoppingCartItems <- Gen
        .containerOfN[List, ShoppingCartItem](
          10, shoppingCartItemGen(itemIdGen, quantityGen)
        )
      allPromotionOffers <- promotionOffersGen
      (eligiblePromotionOffers, notEligiblePromotionOffers) = Random
        .shuffle(allPromotionOffers)
        .splitAt(3)
      (eligibleItems, notEligibleItems) = shoppingCartItems.splitAt(3)
      eligibleItemsWithPromotions <- eligibleItems.traverse {
        case (itemId, quantity) => 
          itemWithPromotionGen(itemId, quantity, eligiblePromotionOffers)
      }
      notEligibleItemsMaybeWithPromotions <- notEligibleItems.traverse {
        case (itemId, quantity) =>
          itemMaybeWithPromotionGen(itemId, quantity, notEligiblePromotionOffers)
      }
    } yield TestCase(
      userId,
      shoppingCartId,
      eligibleItemsWithPromotions,
      notEligibleItemsMaybeWithPromotions,
      eligibleItemsWithPromotions ++ notEligibleItemsMaybeWithPromotions
    )).sampleWithSeed("AddCampaignsToShoppingCartSuite")

    forall(gen) { testCase =>
      val testee = AddCampaignsToShoppingCart
        .impl[IO](
          eligibilityEngineFrom(testCase),
          rewardCatalogFrom(testCase),
          shoppingCartRepositoryFrom(testCase)
        )

      testee.shoppingCartWithPromotionsFor(testCase.userId).map {
        shoppingCartWithPromotions =>
          expect(
            shoppingCartWithPromotions == ShoppingCartWithPromotions(
              testCase.shoppingCartId,
              testCase.userId.some,
              testCase.eligibleShoppingCartItems.toMap ++ testCase.notEligibleShoppingCartItems
                .map {
                  case (itemId, (quantity, _)) => (itemId, (quantity, List.empty))
                }
            ).some
          )
      }
    }
  }
}

Hay un único test para el escenario que me interesa, definido dentro del bloque simpleTest("Add ...). El test comienza por definir el generador gen que sirve para generar casos de prueba que están definidos por el tipo:

final case class TestCase(
  userId: UserId,
  shoppingCartId: ShoppingCartId,
  eligibleShoppingCartItems: List[(ItemId, (SomeQuantity, List[PromotionalOffer]))],
  notEligibleShoppingCartItems: List[(ItemId, (SomeQuantity, List[PromotionalOffer]))],
  allShoppingCartItems: List[(ItemId, (SomeQuantity, List[PromotionalOffer]))]
)

Cada caso de prueba va a definir:

  • Un identificador de usuario, generado mediante el generador userIdGen que tiene en cuenta las restricciones del tipo UserId.
  • Un identificador de carro de la compra, generado mediante el generador shoppingCartIdGen que tiene en cuenta las restricciones del tipo ShoppingCartId.
  • Los artículos del carro de la compra con sus promociones. Además, el caso de prueba va a distinguir los que son elegibles como incentivo para el usuario: eligibleShoppingCartItems, y los que no lo son: notEligibleShoppingCartItems.
  • Finalmente, el caso de prueba va a proporcionar una lista con todos los artículos del carro: allShoppingCartItems. Esta lista se podría calcular mediante la suma de eligibleShoppingCartItems con notEligibleShoppingCartItems. La única razón para incluirla es que hace más fácil razonar con el test.

Uno de los detalles que me parecen más interesantes es la generación de artículos del carro de la compra. Utilizando el siguiente fragmento del código anterior consigo obtener 10 artículos diferentes, con su correspondiente cantidad (como ocurre con todos los generadores anteriones, el generador quantityGen asegura que las cantidades generadas cumplan con las restricciones del dominio):

// ...
shoppingCartItems <- Gen.containerOfN[List, ShoppingCartItem](
  10, shoppingCartItemGen(itemIdGen, quantityGen)
)
// ...

El siguiente paso consiste en dividir los artículos para quedarme con 3 que serán los incentivos elegibles, y el resto de los artículos que no podrán ser elegidos para ninguna de las promociones:

// ...
(eligibleItems, notEligibleItems) = shoppingCartItems.splitAt(3)
// ...

(Yo he elegido arbitráriamente el número de artículos del carro de la compra, así como la cantidad que van a ser elegibles y los que no. La razón para hacerlo de esta forma es que ninguna de las dos cosas son relevantes para probar el caso de uso, y por eso tampoco se comprueban en el assertion del test).

El siguiente fragmento me sirve para generar las promociones de los artículos elegibles. Hay un fragmento equivalente para los artículos que no son elegibles, con la diferencia de que éstos últimos podrían no tener promociones asociadas y de ahí a que se use el generador itemMaybeWithPromotionGen en lugar de itemWithPromotionGen:

// ...
eligibleItemsWithPromotions <- eligibleItems.traverse {
  case (itemId, quantity) => 
    itemWithPromotionGen(itemId, quantity, eligiblePromotionOffers)
}
// ...

La lógica que he seguido para generar las promociones es semejante.

Ejecutando los Tests

ScalaCheck va a generar 100 casos de prueba, aunque esta cantidad se puede configurar. Además, ScalaCheck identifica y comprueba todos los edge cases a partir del patrón que usan los generadores. Cuanto mejor sea el patrón, más completos serán los casos de prueba.

Uno de los retos de esta estrategia es: en caso de que falle el test, reconstruir los casos de prueba. Para tener tests reproducibles he incluido la función sampleWithSeed que imprime por pantalla la semilla del generador codificada en Base64. Al ejecutar el test se debería ver algo como esto:

Seed for AddCampaignsToShoppingCartSuite: d8irH3pjLRzMNNj7XFPj06KOrBfhtUJ1Z8bjayAFj5K=

Esta idea de Dani permite volver a generar el mismo conjunto de casos de prueba para estudiar las causas del fallo. Incluso en un entorno automatizado -como puede ser un pipeline de integración contínua-, los tests podrían imprimir la semilla a los logs para que luego pueda ser recuperada en caso de ser necesario.

Para completar la explicación de esta parte, quiero contar cómo se relacionan los casos de prueba con el caso de uso. El álgebra que define el caso de uso AddCampaignsToShoppingCart combina otras tres álgebras para obtener el carro de la compra del usuario: ShoppingCartRepository, consultar cuáles campañas promocionales están activas para cada artículo: RewardCatalog, y conocer cuáles de las promociones son elegibles para el usuario: EligibilityEngine.

El intérprete del álgebra AddCampaignsToShoppingCart obtiene intérpretes de las otras álgebras que han sido creados específicamente para los tests (dobles de prueba):

// ...
val testee = AddCampaignsToShoppingCart.impl[IO](
  eligibilityEngineFrom(testCase),
  rewardCatalogFrom(testCase),
  shoppingCartRepositoryFrom(testCase)
)
// ...

Por ejemplo, FakeEligibilityEngine es un intérprete del álgebra EligibilityEngine que utiliza una referencia mutable -implementada con cats.effect.concurrent.Ref- para mantener una lista de incentivos elegibles para cada usuario. Esta referencia se inicializa con las promociones elegibles -que son proporcionadas por el caso de prueba:

private[this] def eligibilityEngineFrom(testCase: TestCase) =
  new FakeEligibilityEngine(
    Ref.unsafe[IO, Map[UserId, List[PromotionalOffer]]](
      Map(testCase.userId -> testCase.eligibleShoppingCartItems.flatMap {
        case (_, (_, promotionalOffers)) => promotionalOffers
      })
    )
  )

Internamente, FakeEligibilityEngine utiliza una lógica muy simple para comprobar si una promoción es elegible para un usuario:

final class FakeEligibilityEngine(
  val ref: Ref[IO, Map[UserId, List[PromotionalOffer]]]
) extends EligibilityEngine[IO] {
  override def isEligible(
    userId: UserId, 
    promotionalOffer: PromotionalOffer
  ): IO[Boolean] =
    for {
      promotionalOffers <- ref.get
      isEligible = promotionalOffers
        .getOrElse(userId, List.empty)
        .contains_(promotionalOffer)
    } yield isEligible
}

Finalmente Weaver Test va a ejecutar el test para todos los casos de prueba. En el assertion del test he comprobado que el carro de la compra contiene los artículos junto con las promociones que son elegibles para el usuario.

Conclusiones

Property-based testing es una de esas cosas sobre las que se ha escrito mucho en Internet. Sin embargo, mi sensación siempre había sido que necesita una inversión demasiado grande para el valor que aporta.

Después de darle una oportunidad, y sobre todo después de tener el acompañamiento de Agilogy en el equipo -que nos han ayudado a superar la barrera de adopción-, mi opinión es completamente diferente. Es cierto que property-based testing necesita más preparación que buscar un conjunto de ejemplos para probar, pero lo que sí ha cambiado es mi forma de percibir el valor.

Comparativamente, me resulta mucho más simple razonar con unos generadores de casos de prueba que tienen unos patrones que están relacionados con las restricciones del dominio, que con ejemplos concretos. Por ejemplo, viendo este fragmento de código me es muy fácil reconocer que un artículo tiene que tener al menos una promoción y que estas no pueden repetirse:

Gen.nonEmptyContainerOf[Set, PromotionalOffer](
  Gen.oneOf(promotionalOffers)
).map { promotionalOffers =>
  (itemId, (quantity, promotionalOffers.toList))
}

Si hago inventario de lo que he aprendido en el último año, yo diría que property-based testing es de las cosas que han hecho una diferencia.

Agradecimientos

A Dani (de Agilogy), por ayudarme a descubrir el potencial del property-based testing, y por todo su empeño en hacerlo fácil y usable.

ScalaCheck