MUnit IV: Filtros

Finalizamos la serie de MUnit con esta última entrada, MUnit IV: Filtros, en donde describiremos cómo filtrar aquellos test que queramos ejecutar y cuál no. Para el lector interesado en la serie de entradas puede acceder a través de los siguiente enlaces:

  1. MUnit I: declaración de test.
  2. MUnit II: aserciones.
  3. MUnit III: accesorios.

Los filtrados a los test los podemos definir de la siguiente forma:

  • Ejecutar solo un test de una clase.- Para ejecutar solo un test definido en una clase se debe de utilizar la función only en el texto de la definición de un test. Un ejemplo de uso es el siguiente:
class FilteringEjem1 extends munit.FunSuite {
  test("test-issue-455") {
    assert(1 == 1)
  }
  test("consoletest-issue-456".only) {
    assert(1 == 1)
  }
  test("test issue 457") {
    assert(1 == 1)
  }
}
  • Ignorar un test.- Si se desea ignorar la ejecución de un test en una clase se debe de utilizar la función ignore en el texto de la definición de un test. Un ejemplo de uso es el siguiente:
class FilteringEjem2 extends munit.FunSuite {
  test("test-issue-455") {
    assert(1 == 1)
  }
  test("consoletest-issue-456".ignore) {
    assert(1 == 1)
  }
  test("test issue 457") {
    assert(1 == 1)
  }
}
  • Ejecución de test en función de condiciones dinámicas.- Si se desea la ejecución de test en función de valores externos, como por ejemplo: la ejecución de un test si es en una máquina Linux o Mac, se emplea la función assume la cual, si no cumple una función, lanza una excepción. Un ejemplo de uso de esta función es la siguiente clase:
class FilteringEjem3 extends munit.FunSuite {
  import scala.util.Properties
  test("paths linux") {
    try {
      print(f"Properties.isLinux=${Properties.isLinux}")
      assume(Properties.isLinux, "this test runs only on Linux")
      assume(Properties.versionNumberString.startsWith("2.13"), "this test runs only on Scala 2.13")
      assert(1 == 1)
    } catch {
      case exception: Exception => fail("error: " + exception.getMessage)
    }
  }
  test("paths mac") {
    try {
      println(f"Properties.isMac=${Properties.isMac}")
      assume(Properties.isMac, "this test runs only on Mac")
      assume(Properties.versionNumberString.startsWith("2.13"), "this test runs only on Scala 2.13")
      assert(1 == 1)
    } catch {
      case exception: Exception => fail("error: " + exception.getMessage)
    }
  }
}

Los test del ejemplo anterior fallarán o no en función de si la ejecución se realiza en una máquina Linux o Mac.

  • Ignorar una clase de test por anotación.- Un camino rápido para ignorar la ejecución de una clase de test es la utilización de la anotación munit.IgnoreSuite. Un ejemplo de uso es el siguiente:
@munit.IgnoreSuite
class FilteringIgnoreClassEjem4 extends munit.FunSuite {
  test("Ignore test 1") {
    assert(1 == 1)
  }
  test("Ignore test 2") {
    assert(1 == 1)
  }
}

La utilización de MUnit es muy intuitiva para todos aquellos que hayan trabajado con JUnit y, para quien no, no considero un aprendizaje duro y costoso por la sencillez de la definición y uso. La posibilidad de definir pruebas de tareas asíncronas es un aspecto importante para considerar su uso en ciertos casos. Por el contrario, es posible que el uso de los accesorios (fixture) sea algo más laborioso al tener que definir de forma explícita el objeto en el test.

MUnit III: accesorios

En la presente entrada, MUnit III: accesorios, definiré qué es un accesorio (fixtures) en un test con MUnit y mostraré ejemplo de uso. Para el lector interesado, el resto de entradas de la serie son las siguientes:

+ MUnit I: declaración de test.
+ MUnit II: aserciones.

Los accesorios en las pruebas son los entornos de ejecución en donde se ejecutan los test; en estos entornos, permiten la adquisición de recursos para ejecutar la prueba y, una vez finalizada, liberar todos aquellos recursos utilizados. Podemos tener dos tipos de accesorios: funcionales, son aquellos en donde se adquiere un recurso determinado ; y, los reusables, son aquellos en donde se puede definir una funcionalidad antes y después del test.

Las clases de test en donde se definen accesorios son clases que heredan de la clase munit.FunSuite y, en función de si es un accesorio funcional o reusable, se define la referencia a  munitFixture.

Los ejemplos de accesorios son aquellos en los que se definen test en donde es necesario utilizar un recurso, ya sea un fichero o bien una base de datos con los cuáles operar.

Ejemplo de accesorio funcional

Supongamos que necesitamos crear un fichero temporal para un test, necesitamos crear un fichero antes del test, ejecutar el test y, para finalizar, liberar dicho fichero. Un ejemplo de test es el siguiente:

import java.nio.file.{Files, Path}
class FunctionalTestLocalFixturesFilesEjem1 extends munit.FunSuite {
  val files = FunFixture[Path](
    setup = { test =>
      Files.createTempFile("tmp", test.name)
    },
    teardown = { file =>
      Files.deleteIfExists(file)
      ()
    }
  )
  files.test("Functional Test Local Fixtures") { file =>
     assert(Files.isRegularFile(file), s"Files.isRegularFile($file)")
  }
  [...]
}

En el ejemplo anterior, se define un objeto FunFixture cuyo tipo parametrizado es un objeto Path del paquete java.nio.file; la función setup, es aquella función que se ejecuta antes de la ejecución del test; y, la función teardown, es aquella función que se ejecuta después del test; en nuestro caso, se realiza la creación de un fichero temporal y su liberación respectivamente. Una vez definido el accesorio, se invoca la función test del objeto FunFixture en el cual se define las aserciones oportunas.

Si deseamos realizar una composición de accesorios, podemos emplear la función map2 o map3 del objeto FunFixture. Un ejemplo de composición de dos accesorios como el definido anteriormente es el siguiente:

import java.nio.file.{Files, Path}
class FunctionalTestLocalFixturesFilesEjem1 extends munit.FunSuite {
  [...]
  val files2 = FunFixture.map2(files, files)
  files2.test("Multiple Functional Test Local Fixtures") {
    case (file1, file2) => {
      assertNotEquals(file1, file2)
      assert(Files.isRegularFile(file1), s"Files.isRegularFile($file1)")
      assert(Files.isRegularFile(file2), s"Files.isRegularFile($file2)")
    }
  }
}

Ejemplo de accesorio reusable

En JUnit puedes definir funciones con funcionalidad antes del test y después, representadas en las funciones before y after. Para conseguir estas funciones, necesitamos definir accesorios reusables las cuáles son más poderosas que los accesorios funcionales. En el siguiente ejemplo se muestra una accesorio reusable que trata con ficheros:

import java.nio.file.{Files, Path}
class ReusableTestLocalFixturesFilesEjem1 extends munit.FunSuite {
  val file = new Fixture[Path]("files") {
    var file: Path = null
    override def apply(): Path = file
    override def beforeEach(context: BeforeEach): Unit = {
      file = Files.createTempFile("files", context.test.name)
    }
    override def afterEach(context: AfterEach): Unit = {
      Files.deleteIfExists(file)
      ()
    }
  }
  override def munitFixtures: Seq[Fixture[_]] = List(file)
  test("exists") {
    assert(Files.exists(file()))
  }
}

En el ejemplo anterior, se definie un accesorio de la clase Fixture con un tipo parametrizado de tipos Path; esta clase, define las funciones beforeEach y afterEach con la referencia al recurso de tipo file con el que se trabaja. Además, se define la lista de Fixtures que pueden ser utilizados en cada test.

Si por el contrario, deseamos trabajar con una base de datos, el ejemplo de definición de la clase de test con un Fixture con el tratamiento de una base de datos es el siguiente:

import java.sql.{Connection, DriverManager}
class ReusableTestLocalFixturesDBEjem1 extends munit.FunSuite {
  val db = new Fixture[Connection]("database") {
    private var connection: Connection = null
    override def apply(): Connection = connection
    override def beforeEach(context: BeforeEach): Unit = {
      connection = DriverManager.getConnection("jdbc:h2:mem:test", "sa", "password")
    }
    override def afterEach(context: AfterEach): Unit = {
      connection.close()
      ()
    }
  }
  override def munitFixtures: Seq[Fixture[_]] = List(db)
  test("test1") {
    db()
    assert(1 == 1)
  }
  test("test2") {
    db()
    assert(1 == 1)
  }
}

En el ejemplo anterior, para cada test definido, se inicializa una base de datos de tipo m2 en memoria.

En la siguiente entrada, MUnit IV: filtrado, describiré como realizar filtros de test en ejecución.

MUnit II: aserciones

En la entrada anterior, Munit I: declaración de test, realizaré una descripción de cómo se definen test; en la presenta entrada, Munit II: aserciones, realizaré una descripción de cómo utilizar las aserciones en los test.

Las aserciones son aquellas comprobaciones lógicas que determinan si el resultado del test es correcto. Las aserciones se definen, entre otras, con las siguientes funciones : assert, assertNotEquals, assertEquals,… Las aserciones están compuestas por una comprobación lógica, o bien, por una comprobación lógica y un mensaje informativo. Unos ejemplos básicos de test son los siguientes:

class AssertEjem1 extends munit.FunSuite {
  test("Basic assert() 1") {
    val obtained = 42
    val expected = 43
    assert(obtained < expected)
  }
  test("Basic assert() 2") {
    val obtained = 42
    val expected = 43
    assert(obtained < expected, "obtained was smaller than expected")
  }
  [...]
}

MUnit ofrece la posibilidad de mostrar información sobre aquellos test cuyo resultado no es válido. Para mostrar información adicional, es necesario utilizar la función clue; para su mayor comprehensión, mostramos los siguientes ejemplos de test con sus respectivas salidas por consola:

  • Test sin función clue.
class AssertEjem1 extends munit.FunSuite {
  test("Basic assert() 1") {
    val obtained = 42
    val expected = 43
    assert(obtained == expected)
  }
}

La salida por consola es la que se muestra en la siguiente imagen:

  • Test con función clue.
class AssertEjem1 extends munit.FunSuite {
  test("Basic assert() clue - error") {
    val obtained = 42
    val expected = 43
    assert(clue(obtained) == clue(expected))
  }
}

La salida por consola es la que se muestra en la siguiente imagen:

Realizando la comparativa de la información que se muestra en los resultados de los test, al utilizar la función clue, MUnit muestra los datos que se emplean para poder realizar un análisis de los datos que se han tratado en el test y, así, poder realizar las correcciones oportunas en el momento de la definición del test.

La función clue no se usa exclusivamente con valores básicos, podemos utilizar con datos más estructurados como se muestra a continuación:

  • Clue con case class.

Supongamos que definimos un test que realiza la comparación entre dos objetos definidos en una case class, el test sería el siguiente:

test("Basic assertEquals() case class".flaky) {
  case class Library(name: String, awesome: Boolean, versions: Range = 0.to(1))
  val munitLibrary = Library("MUnit", true)
  val mdocLibrary = Library("MDoc", true)
  assertEquals(clue(munitLibrary), clue(mdocLibrary))
}

La salida por consola es la que se muestra en la siguiente imagen:

Como se observa en la imagen anterior, MUnit muestra la diferencia en los valores de los objetos que se utilizan en la aserción.

  • Clue con Map.

Supongamos que queremos definir una aserción de igualdad con dos elementos de tipo Map cuyos valores son diferentes. La definición del test es el siguiente:

test("Basic assertEquals Map".flaky) {
  assertEquals(
   clue(Map(1 -> List(1.to(3)))),
   clue(Map(1 -> List(1.to(4))))
  )
}

El resultado no es satisfactorio al no ser iguales los objetos Map y, el resultado del test, muestra las diferencias entre los dos objetos. La salida se muestra en la siguiente imagen:

  • Clue con String.

Supongamos que tenemos dos string y queremos verificar que no existen diferencias entre ellos; pero, los strings tienen una longitud grande. La definición del test es el siguiente:

test("Basic assertNoDiff") {
  val obtainedString = "val x = 41\nval y = 43\nval z = 43"
  val expectedString = "val x = 41\nval y = 42\nval z = 43"
  assertNoDiff(obtainedString, expectedString)
}

El resultado no es satisfactorio al existir diferencias. La salida se muestra en la siguiente imagen:

  • Interceptación de una excepción.

Para capturar una excepción en un test, se utiliza la función intercept. Un ejemplo de uso en un test se muestra en el siguiente código de ejemplo:

test("Basic intercept") {
  intercept[java.lang.IllegalArgumentException] {
    def throwException(): Unit = throw new IllegalArgumentException()
    throwException()
  }
}
  • Interceptación de un mensaje de una excepción.

Para capturar un mensaje de una excepción en un test, se utiliza la función interceptMessage. Un ejemplo de uso en un test se muestra en el siguiente código de ejemplo:

test("Basic interceptMessage") {
  interceptMessage[java.lang.IllegalArgumentException]("My Message Exception") {
    def throwException(): Unit = throw new IllegalArgumentException("My Message Exception")
    throwException()
  }
}

MUnit permite definir test para verificar que un snippet de código no tenga errores de compilación. Un ejemplo de uso de este tipo de test cuyo resultado es correcto es el siguiente código:

test("Basic compileError") {
  compileErrors("val x: String = 2")
}

La verificación del resultado de los test es muy intuitiva ya que el concepto de aserciones es utilizado por muchas soluciones; de la misma manera, la captura de excepciones o mensajes. Esta situación supone que la curva de aprendizaje de MUnit no sea grande.

En la siguiente entrada, MUnit III: accesorios, describiré cómo trabajar con los entornos de ejecución.

MUnit I: declaración de test

Inicio una serie de entradas relacionadas MUnit. MUnit es una librería de testing para Scala. En la presente entrada, MUnit 1: declaración de test, realizaré una introducción y una descripción de cómo se declaran test con esta librería.

MUnit tiene una parecido a otra librería de testing como es JUnit. JUnit es una librería del contexto del lenguaje de programación Java. La filosofía de MUnit es seguir la misma línea de JUnit pero en el contenxto del lengua Scala. Las características de MUnit son las siguientes:

  1. MUnit se implementa como un ejecutor de JUnit e intenta construir sobre JUnit siempre que sea posible.
  2. No tiene dependencias externas con otras librerías del mundo Scala.
  3. Es una librería multiplataforma que complica MUnit a JVM a través de JavaScript Scala.js.
  4. Informes de pruebas entendibles para analizar los problemas.

Definición de dependencias

La definición de dependencias de MUnit en un proyecto Scala en donde se utiliza sbt se realiza de la siguiente manera:

val MunitVersion = "0.7.20"
val MunitCatsEffectVersion = "0.12.0"
[...]
lazy val root = (project in file("."))
  .settings(
     scalacOptions += "-Yrangepos",
     libraryDependencies ++= Seq(
        "org.scalameta" %% "munit" % MunitVersion % Test,
        "org.typelevel" %% "munit-cats-effect-2" % MunitCatsEffectVersion % Test
     ),
  testFrameworks += new TestFramework("munit.Framework")
)

Ejemplo básico de test con MUnit

La definición de un test en MUnit y la forma de trabajar es parecida a JUnit. Los test se definen heredando de la clase munit.FunSuite, se declara un test con la sentencia test y, dentro de esta, se define la prueba funcional verificando el resultado con assertEquals. Un ejemplo básico de test es el descrito en el siguiente snippet:

class BasicEjem1 extends munit.FunSuite {
  test("Basic") {
    val obtained = 43
    val expected = 43
    assertEquals(obtained, expected)
  }
}

Declaración de test en MUnit

Declaración de test asíncronos.

MUnit tiene la capacidad de realizar test de tareas asíncronas. Esta tarea es posible gracias a los transformadores de MUnit los cuáles permiten controlar la ejecución de este tipo de tarea con un tiempo límite de espera de 30 segundos.

En el siguiente ejemplo, se muestra la definición de un test asíncrono cuyo tiempo de espera es el valor por defecto.

import scala.concurrent.Future
class BasicAsyncEjem1 extends munit.FunSuite {
  implicit val ec = scala.concurrent.ExecutionContext.global
  test("async") {
    Future {
      println("Hello World")
    }
  }
}

Si deseamos modificar el timeout, debemos de modificar el valor del atributo munitTimeout. Un ejemplo de modificación del campo munitTimeout es el siguiente:

import scala.concurrent.Future
import scala.concurrent.duration.Duration
class BasicAsyncEjem2 extends munit.FunSuite {
  implicit val ec = scala.concurrent.ExecutionContext.global
  override val munitTimeout = Duration(1, "s")
  test("slow-async") {
    Future {
      Thread.sleep(5000)
      println("Hello world, slow-async")
    }
  }
}

La salida por consola es la excepción de Timeout cuya traza es la siguiente:

java.util.concurrent.TimeoutException: Future timed out after [1 second]

MUnit tiene una tratamiento especial para manejar Future, pudiendo definir tareas asíncronas sin necesidad de ser ejecutas con un resultado de éxito del test. En el siguiente ejemplo, se muestra una tarea asíncrona definida en un case class que nunca se ejecuta y el resultado de ejecución es correcto.

class BasicAsyncEjem3 extends munit.FunSuite {
  implicit val ec = scala.concurrent.ExecutionContext.global
  case class LazyFuture[+T](run: () => Future[T])
  object LazyFuture {
    def apply[T](thunk: => T)(implicit ec: ExecutionContext): LazyFuture[T] =
      LazyFuture(() => Future(thunk))
  }
  test("buggy-task") {
    LazyFuture {
      Thread.sleep(10)
      println("Hello world BasicAsyncEjem3")
    }
  }
}

Para poder ejecutar la anterior tarea asíncrona, necesitamos definir un ejecutor que sea capaz de ejecutar la referencia a la función run. En el siguiente ejemplo, definimos dicho ejecutor identificado como munitValueTransform. El código del ejemplo es el siguiente:

import scala.concurrent.{ExecutionContext, Future}
class BasicAsyncEjem4 extends munit.FunSuite {
  case class LazyFuture[+T](run: () => Future[T])
  object LazyFuture {
    def apply[T](thunk: => T)(implicit ec: ExecutionContext): LazyFuture[T] =
      LazyFuture(() => Future(thunk))
  }
  override def munitValueTransforms = super.munitValueTransforms ++ List(
    new ValueTransform(
      "LazyFuture",
      { 
        case LazyFuture(run) => run()
      }
    )
  )
  implicit val ec = ExecutionContext.global
    test("tarea OK") {
      LazyFuture {
         Thread.sleep(5000)
         println("Hello world BasicAsyncEjem4")
      }
    }
}

La salida por consola del código anterior es la siguiente:

Hello world BasicAsyncEjem4

Definir test con una función auxiliar.

Podemos definir una función que defina un test cuyos parámetros pueden ser por valor o por nombre. Un código ejemplo de estos test es el siguiente:

class AuxiliarFunctionEjem1 extends munit.FunSuite {
   def check[T](name: String, original: List[T], expected: Option[T])(implicit loc: munit.Location): Unit = {
      test(name) {
         val obtained = original.headOption
         assertEquals(obtained, expected)
      }
   }
   check("basic", List(1, 2), Some(1))
   def checkByName(name: String, bytes: => Array[Byte]): Unit =
       test(name) {
          assertEquals(bytes.length > 0, true)
       }
   import java.nio.file.{Files, Paths}
   checkByName("file", Files.readAllBytes(Paths.get("build.sbt")))
}

Definir test que siempre falle.

En ocasiones necesitamos que un test falle. Para definir este comportamiento, empleamos la función fail. Un código ejemplo es el siguiente:

class AlwaysFailEjem1 extends munit.FunSuite {
  test("issue-456".fail) {
     assertEquals(1, 1)
  }
}

Modificar el comportamiento de ejecutor munitTest

Si necesitamos modificar el comportamiento de un test empleamos un tag. Un tag es una entidad de Munit la cual se representa con un case class. El comportamiento del test lo definiremos en una clase TestTransform del ejecutor munitTestTransforms, referenciando al tag identificador del comportamiento. En la identificación del test, se define la referencia al tag a ejecutar. Un código ejemplo para ejecutar un test cuatro veces es el siguiente:

case class Rerun(count: Int) extends munit.Tag("Rerun")
class CustomizeEvaluationTestEjem1 extends munit.FunSuite {
   override def munitTestTransforms = super.munitTestTransforms ++ List(
     new TestTransform(
       "Rerun",
       { test =>
           val rerunCount = test.tags.collectFirst { case Rerun(n) => n }.getOrElse(1)
           if (rerunCount == 1) test
           else {
             test.withBody(() => {
                Future.sequence(1.to(rerunCount).map(_ => test.body()).toList)
             })
           }
       }
     )
   )
   test("files".tag(Rerun(4))) {
       println(s"Hello world...")
   }
}

El resultado del anterior código es la escritura en consola del texto «Hello world…» cuatro veces.

Modificar el nombre de un test de forma dinámica.

Si necesitamos ejecutar test cuyo nombre dependa de circunstancias externas, es necesario emplear un TestTransform. Un código ejemplo que modifica el nombre de los test de forma dinámica con la versión de scala es el siguiente:

class CustomizeTestNameEjem1 extends munit.FunSuite {
  val scalaVersion = scala.util.Properties.versionNumberString
  override def munitTestTransforms: List[TestTransform] = super.munitTestTransforms ++ List(
     new TestTransform(
        "append Scala version",
        { test =>
            test.withName(test.name + "-" + scalaVersion)
        }
      )
  )
  test("test-Foo") {
     assert(scalaVersion.startsWith("2.13.4"))
     assertEquals(scalaVersion, "2.13.4")
  }
}

El resultado de la ejecución es el que se muestra en la siguiente imagen:

Saltar un test.

Si necesitamos saltar un test, emplearemos la función flaky para saltar la ejecución. Un código ejemplo es el siguiente:

class FlakyTestEjem1 extends munit.FunSuite {
  test("requests".flaky) {
    val obtained = 42
    val expected = 43
    assertEquals(obtained, expected)
  }
}

Para saltar la ejecución en los test, es necesario ejecutar sbt definiendo la variable MUNIT_FLASK_OK con valor a true. Si no se emplea la variable, los test se ejecutan normalmente. Un ejemplo de ejecución es el siguiente:

MUNIT_FLAKY_OK=true sbt test

Compartir configuración de test.

Si necesitamos definir clases de test con componentes comunes, se define una herencia de clases cuya clase base defina todo aquello que sea común. Si necesitamos modificar el nombre de los test de dos clases diferentes, definimos una clase base con el transformador del nombre de test y, sus clases hijas, heredan de dicha clase base. Un código ejemplo es el siguiente:

class BaseSuite extends munit.FunSuite {
   val scalaVersion = scala.util.Properties.versionNumberString
   override def munitTestTransforms: List[TestTransform] = super.munitTestTransforms ++ List(
      new TestTransform(
        "append Scala version",
        { test =>
          test.withName(test.name + "-" + scalaVersion)
        }
      )
   )
}
class MyFirstTestSuite extends BaseSuite {
   test("FirstTestSuite.test1") {
      println("Hello world ...FirstTestSuite.test1")
   }
   test("FirstTestSuite.test2") {
      println("Hello world FirstTestSuite.test2")
   }
}
class MySecondTestSuite extends BaseSuite {
   test("SecondTestSuite.test1") {
      println("Hello world ...SecondTestSuite.test1")
   }
   test("SecondTestSuite.test2") {
      println("Hello world SecondTestSuite.test2")
   }
}

La salida por consola es la que se muestra en la siguiente imagen:

Definición de una batería de pruebas.

Si necesitamos definir una batería de pruebas, definimos una clase de test que herede de munit.Suite, definimos el tipo de de valor de las pruebas y, para finalizar, definimos la lista de test con la creación de tantas clases Test como queremos. Un código de ejemplo es el siguiente:

import munit.Assertions.assertEquals
import munit.{Location, Tag}
class TestLibrarySuite2 extends munit.Suite {
   override type TestValue = Unit
   override def munitTests(): Seq[Test] = List(
     new Test(
       "example1",
       body = () => {
          val obtained = 43
          val expected = 43
          assertEquals(obtained, expected)
       },
       tags = Set.empty[Tag],
       location = implicitly[Location]
     ),
     new Test(
        "example2",
        body = () => {
           val obtained = "43"
           val expected = "43"
           assertEquals(obtained, expected)
        },
        tags = Set.empty[Tag],
        location = implicitly[Location]
     )
   )
}

En la siguiente entrada, MUnit II: aserciones, describiré en cómo definir aserciones en los test.