13  Conceptos básicos de pruebas

Las pruebas son una parte vital del desarrollo de paquetes: garantizan que su código haga lo que desea. Sin embargo, las pruebas añaden un paso adicional a su flujo de trabajo. Para hacer esta tarea más fácil y efectiva, este capítulo le mostrará cómo realizar pruebas automatizadas formales utilizando el paquete testthat.

La primera etapa de su viaje a las pruebas es convencerse de que las pruebas tienen suficientes beneficios para justificar el trabajo. Para algunos de nosotros, esto es fácil de aceptar. Otros deben aprender por las malas.

Una vez que haya decidido adoptar las pruebas automatizadas, es hora de aprender algunas mecánicas y descubrir dónde encajan las pruebas en su flujo de trabajo de desarrollo.

A medida que usted y sus paquetes R evolucionen, comenzará a encontrar situaciones de prueba en las que es fructífero utilizar técnicas que son algo específicas de las pruebas y que difieren de lo que hacemos a continuación en R/.

13.1 ¿Por qué vale la pena realizar pruebas formales?

Hasta ahora, su flujo de trabajo probablemente se vea así:

  1. Escribe una función.
  2. Cárguelo con devtools::load_all(), tal vez mediante Ctrl/Cmd + Shift + L.
  3. Experimente con ello en la consola para ver si funciona.
  4. Enjuague y repita.

Mientras estás probando tu código en este flujo de trabajo, solo lo haces de manera informal. El problema con este enfoque es que cuando vuelvas a este código dentro de 3 meses para agregar una nueva característica, probablemente hayas olvidado algunas de las pruebas informales que ejecutaste la primera vez. Esto hace que sea muy fácil descifrar el código que solía funcionar.

Muchos de nosotros adoptamos las pruebas automatizadas cuando nos damos cuenta de que estamos corrigiendo un error por segunda o quinta vez. Mientras escribimos código o corregimos errores, podemos realizar algunas pruebas interactivas para asegurarnos de que el código en el que estamos trabajando haga lo que queremos. Pero es fácil olvidar todos los diferentes casos de uso que necesita verificar si no tiene un sistema para almacenar y volver a ejecutar las pruebas. Esta es una práctica común entre los programadores de R. El problema no es que no pruebes tu código, es que no automatizas tus pruebas.

En este capítulo aprenderá cómo realizar la transición de pruebas informales ad hoc, realizadas de forma interactiva en la consola, a pruebas automatizadas (también conocidas como pruebas unitarias). Si bien convertir pruebas interactivas informales en pruebas formales requiere un poco más de trabajo inicial, vale la pena de cuatro maneras:

  • Menos errores. Debido a que es explícito acerca de cómo debe comportarse su código, tendrá menos errores. La razón es un poco parecida a la razón por la que funciona la contabilidad por partida doble: debido a que usted describe el comportamiento de su código en dos lugares, tanto en su código como en sus pruebas, puede comparar uno con el otro.

    Con las pruebas informales, resulta tentador simplemente explorar el uso típico y auténtico, similar a escribir ejemplos. Sin embargo, al escribir pruebas formales, es natural adoptar una mentalidad más conflictiva y anticipar cómo entradas inesperadas podrían romper su código.

    Si siempre introduce nuevas pruebas cuando agrega una nueva característica o función, evitará que se creen muchos errores en primer lugar, porque abordará proactivamente los molestos casos extremos. Las pruebas también evitan que (re) rompas una característica cuando estás modificando otra.

  • Mejor estructura de código. El código bien diseñado tiende a ser fácil de probar y usted puede aprovecharlo. Si tiene dificultades para escribir pruebas, considere si el problema es en realidad el diseño de su(s) función(es). El proceso de redacción de pruebas es una excelente manera de obtener comentarios gratuitos, privados y personalizados sobre qué tan bien factorizado está su código. Si integra las pruebas en su flujo de trabajo de desarrollo (en lugar de planificar realizar las pruebas “más tarde”), se verá sometido a una presión constante para dividir operaciones complicadas en funciones separadas que funcionan de forma aislada. Las funciones que son más fáciles de probar suelen ser más fáciles de entender y recombinar de nuevas formas.

  • Llamada a la acción. Cuando comenzamos a corregir un error, primero nos gusta convertirlo en una prueba (fallida). Esto es maravillosamente eficaz para hacer que su objetivo sea muy concreto: hacer que esta prueba pase. Este es básicamente un caso especial de una metodología general conocida como desarrollo impulsado por pruebas.

  • Código robusto. Si sabe que todas las funciones principales de su paquete están bien cubiertas por las pruebas, puede realizar grandes cambios con confianza sin preocuparse de romper algo accidentalmente. Esto proporciona una excelente verificación de la realidad cuando cree que ha descubierto una nueva y brillante forma de simplificar su paquete. A veces, estas “simplificaciones” no tienen en cuenta algún caso de uso importante y sus pruebas lo salvarán de usted mismo.

13.2 Presentamos testthat

Este capítulo describe cómo probar su paquete R usando el paquete testthat: https://testthat.r-lib.org

Si está familiarizado con los marcos para pruebas unitarias en otros lenguajes, debe tener en cuenta que existen algunas diferencias fundamentales con testthat. Esto se debe a que R es, en esencia, más un lenguaje de programación funcional que un lenguaje de programación orientado a objetos. Por ejemplo, debido a que los principales sistemas orientados a objetos de R (S3 y S4) se basan en funciones genéricas (es decir, un método implementa una función genérica para una clase específica), los enfoques de prueba creados alrededor de objetos y métodos no tienen mucho sentido.

testthat 3.0.0 (lanzado el 31 de octubre de 2020) introdujo la idea de una edición de testthat, específicamente la tercera edición de testthat, a la que nos referimos como testthat 3e. Una edición es un conjunto de comportamientos que usted debe elegir explícitamente utilizar, lo que nos permite realizar cambios que de otro modo serían incompatibles con versiones anteriores. Esto es particularmente importante para testthat, ya que tiene una gran cantidad de paquetes que lo utilizan (casi 5000 según el último recuento). Para utilizar testthat 3e, debe tener una versión de testthat >= 3.0.0 y aceptar explícitamente los comportamientos de la tercera edición. Esto permite que testthat continúe evolucionando y mejorando sin romper los paquetes históricos que se encuentran en una fase de mantenimiento bastante pasiva. Puede obtener más información en el artículo testthat 3e y la publicación del blog Actualización a testthat edición 3.

Recomendamos testthat 3e para todos los paquetes nuevos y recomendamos actualizar los paquetes existentes que se mantienen activamente para usar testthat 3e. A menos que digamos lo contrario, este capítulo describe la prueba que 3e.

13.3 Mecánica de prueba y flujo de trabajo

13.3.1 Configuración inicial

Para configurar su paquete para usar testthat, ejecute:

usethis::use_testthat(3)

Esto va a:

  1. Crear un directorio tests/testthat/.

  2. Agregar testthat al campo Suggests en DESCRIPTION y especificar testthat 3e en el campo Config/testthat/edition. Los campos DESCRIPTION afectados podrían verse así:

    Suggests: testthat (>= 3.0.0)
    Config/testthat/edition: 3
  3. Crear un archivo tests/testthat.R que ejecuta todas sus pruebas cuando se ejecute R CMD check (Sección 4.5). Para un paquete llamado “pkg”, el contenido de este archivo será algo como:

Esta configuración inicial suele ser algo que se hace una vez por paquete. Sin embargo, incluso en un paquete que ya usa testthat, es seguro ejecutar use_testthat(3), cuando esté listo para optar por testthat 3e.

¡No edite tests/testthat.R! Se ejecuta durante R CMD check (y, por lo tanto, devtools::check()), pero no se usa en la mayoría de los otros escenarios de ejecución de pruebas (como devtools::test() o devtools: :test_active_file()). Si desea hacer algo que afecte todas sus pruebas, casi siempre hay una mejor manera que modificar el script estándar tests/testthat.R. Este capítulo detalla muchas formas diferentes de hacer que los objetos y la lógica estén disponibles durante las pruebas.

13.3.2 Crear una prueba

A medida que define funciones en su paquete, en los archivos debajo de R/, agrega las pruebas correspondientes a los archivos .R en tests/testthat/. Recomendamos encarecidamente que la organización de los archivos de prueba coincida con la organización de los archivos R/, discutidos en Sección 6.1: La función foofy() (y sus amigos y ayudantes) debe definirse en R/foofy.R y sus pruebas deben vivir en tests/testthat/test-foofy.R.

R                                     tests/testthat
└── foofy.R                           └── test-foofy.R
    foofy <- function(...) {...}          test_that("foofy does this", {...})
                                          test_that("foofy does that", {...})

Incluso si tiene diferentes convenciones para la organización y el nombre de archivos, tenga en cuenta que las pruebas testthat deben residir en archivos debajo de tests/testthat/ y estos nombres de archivos deben comenzar con test. El nombre del archivo de prueba se muestra en la salida de testthat, lo que proporciona un contexto útil1.

usethis ofrece un par de funciones útiles para crear o alternar entre archivos:

Cualquiera de los dos puede ser llamado con un nombre de archivo (base), para crear un archivo de novo y abrirlo para editarlo:

use_r("foofy")    # creates and opens R/foofy.R
use_test("blarg") # creates and opens tests/testthat/test-blarg.R

El dúo use_r() / use_test() tiene algunas características convenientes que los hacen “simplemente funcionar” en muchas situaciones comunes:

  • Al determinar el archivo de destino, pueden tener en cuenta la presencia o ausencia de la extensión .R y el prefijo test-.
    • Equivalente: use_r("foofy.R"), use_r("foofy")
    • Equivalente: use_test("test-blarg.R"), use_test("blarg.R"), use_test("blarg")
  • Si el archivo de destino ya existe, se abre para editarlo. De lo contrario, el objetivo se crea y luego se abre para editarlo.
RStudio

Si R/foofy.R es el archivo activo en su editor de código fuente, ¡incluso puede llamar a use_test() sin argumentos! El archivo de prueba de destino se puede inferir: si está editando R/foofy.R, probablemente desee trabajar en el archivo de prueba complementario, tests/testthat/test-foofy.R. Si aún no existe, se crea y, de cualquier manera, el archivo de prueba se abre para editarlo. Todo esto también funciona al revés. Si está editando tests/testthat/test-foofy.R, una llamada a use_r() (opcionalmente, crea y) abre R/foofy.R.

En pocas palabras: use_r() / use_test() son útiles para crear inicialmente estos pares de archivos y, más tarde, para desviar su atención de uno a otro.

Cuando use_test() crea un nuevo archivo de prueba, inserta una prueba de ejemplo:

test_that("multiplication works", {
  expect_equal(2 * 2, 4)
})

Reemplazarás esto con tu propia descripción y lógica, pero es un buen recordatorio de la forma básica:

  • Un archivo de prueba contiene una o más pruebas test_that().
  • Cada prueba describe lo que está probando: por ejemplo, “la multiplicación funciona”.
  • Cada prueba tiene una o más expectativas: por ejemplo, expect_equal(2 * 2, 4).

A continuación, entramos en muchos más detalles sobre cómo probar sus propias funciones.

13.3.3 Ejecutar pruebas

Dependiendo de dónde se encuentre en el ciclo de desarrollo, ejecutará sus pruebas en varias escalas. Cuando itera rápidamente una función, puede trabajar a nivel de pruebas individuales. A medida que el código se asiente, ejecutará archivos de prueba completos y, finalmente, todo el conjunto de pruebas.

Microiteración: esta es la fase interactiva en la que inicia y perfecciona una función y sus pruebas en conjunto. Aquí ejecutará devtools::load_all() con frecuencia y luego ejecutará expectativas individuales o pruebas completas de forma interactiva en la consola. Tenga en cuenta que load_all() adjunta testthat, por lo que lo coloca en la posición perfecta para probar sus funciones y ejecutar pruebas y expectativas individuales.

# modifica la función foofy() y vuelve a cargarla
devtools::load_all()

# explorar y perfeccionar interactivamente expectativas y pruebas
expect_equal(foofy(...), EXPECTED_FOOFY_OUTPUT)

test_that("foofy does good things", {...})

Mezzo-iteración: A medida que las funciones de un archivo y sus pruebas asociadas comienzan a tomar forma, querrás ejecutar el archivo completo de pruebas asociadas, tal vez con testthat::test_file():

testthat::test_file("tests/testthat/test-foofy.R")
RStudio

En RStudio, tiene un par de atajos para ejecutar un único archivo de prueba.

Si el archivo de prueba de destino es el archivo activo, puede usar el botón “Ejecutar pruebas” en la esquina superior derecha del editor de origen.

También hay una función útil, devtools::test_active_file(). Infiere el archivo de prueba de destino a partir del archivo activo y, de manera similar a cómo funcionan use_r() y use_test(), funciona independientemente de si el archivo activo es un archivo de prueba o un R/*.R complementario. archivo. Puede invocar esto a través de “Ejecutar un archivo de prueba” en el menú Complementos. Sin embargo, para usuarios habituales (¡como nosotros!), recomendamos vincular esto a un método abreviado de teclado; Usamos Ctrl/Cmd + T.

Macroiteración: a medida que se acerque a la finalización de una nueva función o corrección de errores, querrá ejecutar todo el conjunto de pruebas.

Lo más frecuente es que hagas esto con devtools::test():

devtools::test()

Luego, eventualmente, como parte de R CMD check con devtools::check():

devtools::check()
RStudio

devtools::test() está asignado a Ctrl/Cmd + Shift + T. devtools::check() está asignado a Ctrl/Cmd + Shift + E.

La salida de devtools::test() se ve así:

devtools::test()
ℹ Loading usethis
ℹ Testing usethis
✓ | F W S  OK | Context
✓ |         1 | addin [0.1s]
✓ |         6 | badge [0.5s]
   ...
✓ |        27 | github-actions [4.9s]
   ...
✓ |        44 | write [0.6s]

══ Results ═════════════════════════════════════════════════════════════════
Duration: 31.3 s

── Skipped tests  ──────────────────────────────────────────────────────────
• Not on GitHub Actions, Travis, or Appveyor (3)

[ FAIL 1 | WARN 0 | SKIP 3 | PASS 728 ]

El error de la prueba se informa así:

Failure (test-release.R:108:3): get_release_data() works if no file found
res$Version (`actual`) not equal to "0.0.0.9000" (`expected`).

`actual`:   "0.0.0.1234"
`expected`: "0.0.0.9000"

Cada error proporciona una descripción de la prueba (p. ej., “get_release_data() funciona si no se encuentra ningún archivo”), su ubicación (p. ej., “test-release.R:108:3”) y el motivo del error (p. ej., “res$Versión (actual) no es igual a”0.0.0.9000” (esperado)“).

La idea es que modifiques tu código (ya sea las funciones definidas debajo de R/ o las pruebas en tests/testthat/) hasta que todas las pruebas pasen.

13.4 Organización de pruebas

Un archivo de prueba se encuentra en tests/testthat/. Su nombre debe comenzar con “prueba”. Inspeccionaremos y ejecutaremos un archivo de prueba del paquete stringr.

Pero primero, a los efectos de reproducir este libro, debemos adjuntar stringr y probar eso. Tenga en cuenta que en situaciones de ejecución de pruebas de la vida real, las herramientas de desarrollo de paquetes se encargan de esto:

Importante

Sus archivos de prueba no deben incluir estas llamadas library(). También solicitamos explícitamente testthat edición 3, pero en un paquete real esto se declarará en DESCRIPTION.

Aquí está el contenido de tests/testthat/test-dup.r de stringr:

test_that("basic duplication works", {
  expect_equal(str_dup("a", 3), "aaa")
  expect_equal(str_dup("abc", 2), "abcabc")
  expect_equal(str_dup(c("a", "b"), 2), c("aa", "bb"))
  expect_equal(str_dup(c("a", "b"), c(2, 3)), c("aa", "bbb"))
})
#> Test passed

test_that("0 duplicates equals empty string", {
  expect_equal(str_dup("a", 0), "")
  expect_equal(str_dup(c("a", "b"), 0), rep("", 2))
})
#> Test passed

test_that("uses tidyverse recycling rules", {
  expect_error(str_dup(1:2, 1:3), class = "vctrs_error_incompatible_size")
})
#> Test passed

Este archivo muestra una combinación típica de pruebas:

  • “La duplicación básica funciona” prueba el uso típico de str_dup().
  • “0 duplicados equivalen a una cadena vacía” investiga un caso extremo específico.
  • “usa reglas de reciclaje de tidyverse” comprueba que la entrada con formato incorrecto produzca un tipo específico de error.

Las pruebas están organizadas jerárquicamente: las expectativas se agrupan en pruebas que se organizan en archivos:

  • Un archivo contiene múltiples pruebas relacionadas. En este ejemplo, el archivo tests/testthat/test-dup.r tiene todas las pruebas para el código en R/dup.r.

  • Una prueba agrupa múltiples expectativas para probar el resultado de una función simple, una variedad de posibilidades para un solo parámetro de una función más complicada o una funcionalidad estrechamente relacionada de múltiples funciones. Es por eso que a veces se les llama pruebas unitarias. Cada prueba debe cubrir una única unidad de funcionalidad. Se crea una prueba con test_that(desc, code).

    Es común escribir la descripción (desc) para crear algo que se lea naturalmente, por ejemplo, test_that("la duplicación básica funciona", {... }). Un informe de falla de prueba incluye esta descripción, razón por la cual desea una declaración concisa del propósito de la prueba, por ejemplo, un comportamiento específico.

  • Una expectativa es el átomo de la prueba. Describe el resultado esperado de un cálculo: ¿Tiene el valor correcto y la clase correcta? ¿Produce un error cuando debería? Una expectativa automatiza la verificación visual de los resultados en la consola. Las expectativas son funciones que comienzan con expect_.

Desea organizar las cosas de manera que, cuando una prueba falle, sepa qué está mal y en qué parte de su código buscar el problema. Esto motiva todas nuestras recomendaciones con respecto a la organización de archivos, el nombre de los archivos y la descripción de la prueba. Finalmente, trate de evitar poner demasiadas expectativas en una prueba; es mejor tener más pruebas más pequeñas que menos pruebas más grandes.

13.5 Expectativas

Una expectativa es el mejor nivel de prueba. Hace una afirmación binaria sobre si un objeto tiene o no las propiedades esperadas. Este objeto suele ser el valor de retorno de una función en su paquete.

Todas las expectativas tienen una estructura similar:

  • Comienzan con expect_.

  • Tienen dos argumentos principales: el primero es el resultado real, el segundo es lo que se espera.

  • Si los resultados reales y esperados no coinciden, la prueba arroja un error.

  • Algunas expectativas tienen argumentos adicionales que controlan los puntos más finos de comparar un resultado real y esperado.

Si bien normalmente colocará las expectativas dentro de las pruebas dentro de los archivos, también puede ejecutarlas directamente. Esto facilita la exploración de las expectativas de forma interactiva. Hay más de 40 expectativas en el paquete testthat, que se pueden explorar en el índice de referencia de testthat. Aquí sólo cubriremos las expectativas más importantes.

13.5.1 Pruebas de igualdad

expect_equal() comprueba la igualdad, con una cantidad razonable de tolerancia numérica:

expect_equal(10, 10)
expect_equal(10, 10L)
expect_equal(10, 10 + 1e-7)
expect_equal(10, 11)
#> Error: 10 (`actual`) not equal to 11 (`expected`).
#> 
#>   `actual`: 10
#> `expected`: 11

Si desea probar la equivalencia exacta, utilice expect_identical().

expect_equal(10, 10 + 1e-7)
expect_identical(10, 10 + 1e-7)
#> Error: 10 (`actual`) not identical to 10 + 1e-07 (`expected`).
#> 
#>   `actual`: 10.0000000
#> `expected`: 10.0000001

expect_equal(2, 2L)
expect_identical(2, 2L)
#> Error: 2 (`actual`) not identical to 2L (`expected`).
#> 
#> `actual` is a double vector (2)
#> `expected` is an integer vector (2)

13.5.2 Prueba de errores

Utilice expect_error() para comprobar si una expresión arroja un error. Es la expectativa más importante en un trío que también incluye expect_warning() y expect_message(). Aquí vamos a enfatizar los errores, pero la mayor parte de esto también se aplica a las advertencias y mensajes.

Por lo general, le importan dos cosas cuando prueba un error:

  • ¿El código falla? Específicamente, ¿falla por el motivo correcto?
  • ¿Tiene sentido el mensaje que lo acompaña para el ser humano que necesita lidiar con el error?

La solución básica es esperar un tipo específico de condición:

1 / "a"
#> Error in 1/"a": non-numeric argument to binary operator
expect_error(1 / "a") 

log(-1)
#> Warning in log(-1): NaNs produced
#> [1] NaN
expect_warning(log(-1))

Sin embargo, esto es un poco peligroso, especialmente cuando se prueba un error. ¡Hay muchas maneras en que el código falla! Considere la siguiente prueba:

expect_error(str_duq(1:2, 1:3))

Esta expectativa tiene como objetivo probar el comportamiento de reciclaje de str_dup(). Pero, debido a un error tipográfico, prueba el comportamiento de una función inexistente, str_duq(). El código arroja un error y, por lo tanto, la prueba anterior pasa, pero por el motivo equivocado. Debido al error tipográfico, el error real arrojado se debe a que no se puede encontrar la función str_duq():

str_duq(1:2, 1:3)
#> Error in str_duq(1:2, 1:3): could not find function "str_duq"

Históricamente, la mejor defensa contra esto era afirmar que el mensaje de condición coincide con una determinada expresión regular, mediante el segundo argumento, regexp.

expect_error(1 / "a", "non-numeric argument")
expect_warning(log(-1), "NaNs produced")

De hecho, esto hace que nuestro problema de error tipográfico salga a la superficie:

expect_error(str_duq(1:2, 1:3), "recycle")
#> Error in str_duq(1:2, 1:3): could not find function "str_duq"

Los desarrollos recientes tanto en base R como en rlang hacen que sea cada vez más probable que las condiciones se señalen con una clase, lo que proporciona una mejor base para crear expectativas precisas. Eso es exactamente lo que ya has visto en este ejemplo de cadena. Para esto sirve el argumento class:

# falla, el error tiene una clase incorrecta
expect_error(str_duq(1:2, 1:3), class = "vctrs_error_incompatible_size")
#> Error in str_duq(1:2, 1:3): could not find function "str_duq"

# pasa, el error tiene la clase esperada
expect_error(str_dup(1:2, 1:3), class = "vctrs_error_incompatible_size")

Si tiene la opción, exprese sus expectativas en términos de la clase de la condición, en lugar de su mensaje. A menudo esto está bajo su control, es decir, si su paquete indica la condición. Si la condición se origina en la base R u otro paquete, proceda con precaución. Esto suele ser un buen recordatorio para reconsiderar la conveniencia de probar una condición que, en primer lugar, no está completamente bajo su control.

Para verificar la ausencia de un error, advertencia o mensaje, use expect_no_error():

Por supuesto, esto es funcionalmente equivalente a simplemente ejecutar 1/2 dentro de una prueba, pero algunos desarrolladores encuentran expresiva la expectativa explícita.

Si realmente le importa el mensaje de la condición, las pruebas instantáneas de testthat 3e son el mejor enfoque, que describimos a continuación.

13.5.3 Pruebas de instantáneas

A veces resulta difícil o incómodo describir un resultado esperado con código. Las pruebas instantáneas son una gran solución a este problema y esta es una de las principales innovaciones en testthat 3e. La idea básica es registrar el resultado esperado en un archivo separado y legible por humanos. En el futuro, prueba que le avisa cuando un resultado recién calculado difiere de la instantánea registrada anteriormente. Las pruebas de instantáneas son particularmente adecuadas para monitorear la interfaz de usuario de su paquete, como sus mensajes informativos y errores. Otros casos de uso incluyen probar imágenes u otros objetos complicados.

Ilustraremos las pruebas de instantáneas utilizando el paquete waldo. Debajo del capó, testthat 3e usa waldo para hacer el trabajo pesado de comparaciones “reales versus esperadas”, por lo que es bueno que sepas un poco sobre waldo de todos modos. Uno de los principales objetivos de diseño de Waldo es presentar las diferencias de una manera clara y práctica, en lugar de una frustrante declaración de que “esto difiere de aquello y sé exactamente cómo, pero no te lo diré”. Por lo tanto, el formato de la salida de waldo::compare() es muy intencional y se adapta bien a una prueba de instantáneas. El resultado binario de TRUE (real == esperado) versus FALSE (real! = esperado) es bastante fácil de verificar y podría obtener su propia prueba. Aquí nos preocupa escribir una prueba para garantizar que las diferencias se informen al usuario de la forma prevista.

Waldo utiliza algunos diseños diferentes para mostrar diferencias, dependiendo de diversas condiciones. Aquí restringimos deliberadamente el ancho para activar un diseño de lado a lado.2 (Hablaremos más sobre el paquete withr a continuación).

withr::with_options(
  list(width = 20),
  waldo::compare(c("X", letters), c(letters, "X"))
)
#>     old | new    
#> [1] "X" -        
#> [2] "a" | "a" [1]
#> [3] "b" | "b" [2]
#> [4] "c" | "c" [3]
#> 
#>      old | new     
#> [25] "x" | "x" [24]
#> [26] "y" | "y" [25]
#> [27] "z" | "z" [26]
#>          - "X" [27]

Las dos entradas principales difieren en dos ubicaciones: una al principio y otra al final. Este diseño presenta ambos, con algún contexto circundante, que ayuda al lector a orientarse.

Así es como se vería esto como una prueba instantánea:

test_that("side-by-side diffs work", {
  withr::local_options(width = 20)
  expect_snapshot(
    waldo::compare(c("X", letters), c(letters, "X"))
  )
})

Si ejecuta expect_snapshot() o una prueba que contiene expect_snapshot() de forma interactiva, verá esto:

Can't compare snapshot to reference when testing interactively
ℹ Run `devtools::test()` or `testthat::test_file()` to see changes

seguido de una vista previa de la salida de la instantánea.

Esto le recuerda que las pruebas instantáneas solo funcionan cuando se ejecutan de forma no interactiva, es decir, mientras se ejecuta un archivo de prueba completo o todo el conjunto de pruebas. Esto se aplica tanto a la grabación de instantáneas como a su comprobación.

La primera vez que se ejecuta esta prueba a través de devtools::test() o similar, verá algo como esto (suponga que la prueba está en tests/testthat/test-diff.R):

── Warning (test-diff.R:63:3): side-by-side diffs work ─────────────────────
Adding new snapshot:
Code
  waldo::compare(c(
    "X", letters), c(
    letters, "X"))
Output
      old | new    
  [1] "X" -        
  [2] "a" | "a" [1]
  [3] "b" | "b" [2]
  [4] "c" | "c" [3]
  
       old | new     
  [25] "x" | "x" [24]
  [26] "y" | "y" [25]
  [27] "z" | "z" [26]
           - "X" [27]

Siempre hay una advertencia al crear la instantánea inicial. La instantánea se agrega a tests/testthat/_snaps/diff.md, bajo el título “side-by-side diffs work”, que proviene de la descripción de la prueba. La instantánea se ve exactamente como lo que un usuario ve de forma interactiva en la consola, que es la experiencia que queremos comprobar. El archivo de instantánea también es muy legible, lo cual resulta agradable para el desarrollador del paquete. Esta legibilidad se extiende a los cambios de instantáneas, es decir, al examinar las diferencias de Git y revisar las solicitudes de extracción en GitHub, lo que le ayuda a controlar su interfaz de usuario. En el futuro, siempre que su paquete continúe recapitulando la instantánea esperada, esta prueba pasará.

Si ha escrito muchas pruebas unitarias convencionales, podrá apreciar lo adecuadas que son las pruebas instantáneas para este caso de uso. Si nos viésemos obligados a incluir el resultado esperado en el archivo de prueba, habría una gran cantidad de citas, escapes y administración de nuevas líneas. Irónicamente, con las expectativas convencionales, el resultado que espera que vea su usuario tiende a quedar oscurecido por una pesada capa de ruido sintáctico.

¿Qué pasa cuando falla una prueba instantánea? Imaginemos un cambio interno hipotético en el que las etiquetas predeterminadas cambian de “old” y “new” a “OLD” y “NEW”. Así es como reaccionaría esta prueba instantánea:

── Failure (test-diff.R:63:3): side-by-side diffs work──────────────────────────
Snapshot of code has changed:
old[3:15] vs new[3:15]
  "    \"X\", letters), c("
  "    letters, \"X\"))"
  "Output"
- "      old | new    "
+ "      OLD | NEW    "
  "  [1] \"X\" -        "
  "  [2] \"a\" | \"a\" [1]"
  "  [3] \"b\" | \"b\" [2]"
  "  [4] \"c\" | \"c\" [3]"
  "  "
- "       old | new     "
+ "       OLD | NEW     "
and 3 more ...

* Run `snapshot_accept('diff')` to accept the change
* Run `snapshot_review('diff')` to interactively review the change

Esta diferencia se presenta de manera más efectiva en la mayoría de los usos del mundo real, por ejemplo, en la consola, mediante un cliente Git o mediante una aplicación Shiny (ver más abajo). Pero incluso esta versión en texto plano resalta los cambios con bastante claridad. Cada uno de los dos lugares de cambio se indica con un par de líneas marcadas con - y +, que muestran cómo ha cambiado la instantánea.

Puedes llamar a testthat::snapshot_review('diff') para revisar los cambios localmente en una aplicación Shiny, lo que te permite omitir o aceptar instantáneas individuales. O, si todos los cambios son intencionales y esperados, puede ir directamente a testthat::snapshot_accept('diff'). Una vez que haya resincronizado su salida real y las instantáneas archivadas, sus pruebas pasarán una vez más. En la vida real, las pruebas instantáneas son una excelente manera de mantenerse informado sobre los cambios en la interfaz de usuario de su paquete, debido a sus propios cambios internos o a cambios en sus dependencias o incluso al propio R.

expect_snapshot() tiene algunos argumentos que vale la pena conocer:

  • cran = FALSE: De forma predeterminada, las pruebas instantáneas se omiten si parece que se están ejecutando en los servidores de CRAN. Esto refleja la intención típica de las pruebas instantáneas, que es monitorear proactivamente la interfaz de usuario, pero no verificar su corrección, lo que presumiblemente es el trabajo de otras pruebas unitarias que no se omiten. En el uso típico, un cambio de instantánea es algo que el desarrollador querrá saber, pero no indica un defecto real.

  • error = FALSE: De forma predeterminada, el código de instantánea no puede generar un error. Consulte expect_error(), descrito anteriormente, para conocer un enfoque para probar errores. Pero a veces quieres evaluar “¿Tiene sentido este mensaje de error para un humano?” y tenerlo presentado en contexto en una instantánea es una excelente manera de verlo con nuevos ojos. Especifique error = TRUE en este caso:

    expect_snapshot(error = TRUE,
      str_dup(1:2, 1:3)
    )
  • transform: a veces una instantánea contiene elementos volátiles e insignificantes, como una ruta de archivo temporal o una marca de tiempo. El argumento transform acepta una función, presumiblemente escrita por usted, para eliminar o reemplazar dicho texto modificable. Otro uso de “transformar” es eliminar información confidencial de la instantánea.

  • variante: A veces las instantáneas reflejan las condiciones ambientales, como el sistema operativo o la versión de R o una de tus dependencias, y necesitas una instantánea diferente para cada variante. Esta es una característica experimental y algo avanzada, por lo que si puedes organizar las cosas para usar una sola instantánea, probablemente deberías hacerlo.

En un uso típico, testthat se encargará de administrar los archivos de instantáneas debajo de tests/testthat/_snaps/. Esto sucede en el curso normal de la ejecución de las pruebas y, tal vez, al llamar a testthat::snapshot_accept().

13.5.4 Atajos para otros patrones comunes

Concluimos esta sección con algunas expectativas más que surgen con frecuencia. Pero recuerde que la prueba tiene muchas más expectativas prediseñadas de las que podemos demostrar aquí.

Varias expectativas pueden describirse como “atajos”, es decir, simplifican un patrón que aparece con suficiente frecuencia como para merecer su propio envoltorio.

  • expect_match(object, regexp, ...) es un atajo que envuelve grepl(pattern = regexp, x = object, ...). Compara una entrada de vector de caracteres con una expresión regular regexp. El argumento opcional “todos” controla si todos los elementos o solo uno deben coincidir. Lea la documentación expect_match() para ver cómo argumentos adicionales, como ignore.case = FALSE o fixed = TRUE, se pueden pasar a grepl().

    string <- "Testing is fun!"
    
    expect_match(string, "Testing") 
    
    #  Falla, la coincidencia distingue entre mayúsculas y minúsculas
    expect_match(string, "testing")
    #> Error: `string` does not match "testing".
    #> Actual value: "Testing is fun!"
    
    #  Pasa porque se pasan argumentos adicionales a grepl():
    expect_match(string, "testing", ignore.case = TRUE)
  • expect_length(object, n) es un atajo para expect_equal(length(object), n).

  • expect_setequal(x, y) prueba que cada elemento de x ocurre en y, y que cada elemento de y ocurre en x. Pero no fallará si “x” e “y” tienen sus elementos en un orden diferente.

  • expect_s3_class() y expect_s4_class() comprueban que un objeto hereda() de una clase especificada. expect_type()comprueba el typeof() de un objeto.

    model <- lm(mpg ~ wt, data = mtcars)
    expect_s3_class(model, "lm")
    expect_s3_class(model, "glm")
    #> Error: `model` inherits from 'lm' not 'glm'.

expect_true() y expect_false() son complementos útiles si ninguna de las otras expectativas cumple con lo que necesitas.


  1. La función heredada testthat::context() ahora se reemplaza y se desaconseja su uso en código nuevo o mantenido activamente. En la prueba 3e, context() está formalmente obsoleto; simplemente deberías eliminarlo. Una vez que adopta un enfoque intencional y sincronizado para la organización de los archivos debajo de R/ y tests/testthat/, la información contextual necesaria está ahí mismo en el nombre del archivo, lo que hace que el context() heredado sea superfluo.↩︎

  2. La prueba de Waldo real que inspira este ejemplo apunta a una función auxiliar no exportada que produce el diseño deseado. Pero este ejemplo utiliza una función waldo exportada por simplicidad.↩︎