15  Técnicas de prueba avanzadas

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.

15.1 Accesorios de prueba

Cuando no sea práctico hacer que su prueba sea completamente autosuficiente, prefiera hacer que el objeto, la lógica o las condiciones necesarios estén disponibles de una manera estructurada y explícita. Hay un término preexistente para esto en ingeniería de software: un dispositivo de prueba.

Un dispositivo de prueba es algo que se utiliza para probar consistentemente algún elemento, dispositivo o software. — Wikipedia

La idea principal es que debemos hacer que sea lo más fácil y obvio posible organizar el mundo en un estado propicio para las pruebas. Describimos varias soluciones específicas a este problema:

  • Poner código repetido en una función auxiliar de tipo constructor. Téngalo en cuenta si se demuestra que la construcción es lenta.
  • Si el código repetido tiene efectos secundarios, escriba una función local_*() personalizada para hacer lo que sea necesario y limpiar después.
  • Si los métodos anteriores son demasiado lentos o incómodos y lo que necesita es bastante estable, guárdelo como un archivo estático y cárguelo.

15.1.1 Crear cosas_útiles con una función auxiliar

¿Es complicado crear una useful_thing? ¿Se necesitan varias líneas de código, pero no mucho tiempo ni memoria? En ese caso, escriba una función auxiliar para crear una useful_thing a pedido:

new_useful_thing <- function() {
  # tu complicado código para crear algo útil va aquí
}

y llamar a ese ayudante en las pruebas afectadas:

test_that("foofy() does this", {
  useful_thing1 <- new_useful_thing()
  expect_equal(foofy(useful_thing1, x = "this"), EXPECTED_FOOFY_OUTPUT)
})

test_that("foofy() does that", {
  useful_thing2 <- new_useful_thing()
  expect_equal(foofy(useful_thing2, x = "that"), EXPECTED_FOOFY_OUTPUT)
})

¿Dónde debería definirse el asistente new_useful_thing()? Esto vuelve a lo que describimos en Sección 14.3. Los ayudantes de prueba se pueden definir debajo de R/, como cualquier otra utilidad interna de su paquete. Otra ubicación popular es en un archivo auxiliar de prueba, por ejemplo, tests/testthat/helper.R. Una característica clave de ambas opciones es que los asistentes están disponibles durante el mantenimiento interactivo a través de devtools::load_all().

Si es complicado Y costoso crear una useful_thing, su función auxiliar podría incluso usar la memorización para evitar un nuevo cálculo innecesario. Una vez que tienes una ayuda como new_useful_thing(), a menudo descubres que tiene usos más allá de las pruebas, por ejemplo, detrás de escena en una viñeta. A veces incluso te das cuenta de que debes definirlo debajo de R/ y exportarlo y documentarlo, para que puedas usarlo libremente en documentación y pruebas.

15.1.2 Crear (y destruir) una cosa_útil “local”

SHasta ahora, nuestro ejemplo de useful_thing era un objeto R normal, que se limpia automáticamente al final de cada prueba. ¿Qué pasa si la creación de algo útil tiene un efecto secundario en el sistema de archivos local, en un recurso remoto, en las opciones de sesión de R, en las variables de entorno o similares? Entonces su función auxiliar debería crear una useful_thing y limpiarla después. En lugar de un simple constructor new_useful_thing(), escribirás una función personalizada al estilo de las funciones local_*() de withr:

local_useful_thing <- function(..., env = parent.frame()) {
  # tu complicado código para crear algo útil va aquí
  withr::defer(
    # tu complicado código para limpiar después de algo útil va aquí
    envir = env
  )
}

Úselo en sus pruebas de esta manera:

test_that("foofy() does this", {
  useful_thing1 <- local_useful_thing()
  expect_equal(foofy(useful_thing1, x = "this"), EXPECTED_FOOFY_OUTPUT)
})

test_that("foofy() does that", {
  useful_thing2 <- local_useful_thing()
  expect_equal(foofy(useful_thing2, x = "that"), EXPECTED_FOOFY_OUTPUT)
})

¿Dónde debería definirse el asistente local_useful_thing()? Se aplican todos los consejos dados anteriormente para new_useful_thing(): defínalo debajo de R/ o en un archivo auxiliar de prueba.

Para obtener más información sobre cómo escribir ayudas personalizadas como local_useful_thing(), consulte la viñeta de testthat en dispositivos de prueba.

15.1.3 Almacenar una cosa_útil concreta de forma persistente

Si crear una useful_thing es costosa, en términos de tiempo o memoria, tal vez no necesites volver a crearla para cada ejecución de prueba. Puede crear useful_thing una vez, almacenarlo como un dispositivo de prueba estático y cargarlo en las pruebas que lo necesiten. Aquí hay un boceto de cómo podría verse esto:

test_that("foofy() does this", {
  useful_thing1 <- readRDS(test_path("fixtures", "useful_thing1.rds"))
  expect_equal(foofy(useful_thing1, x = "this"), EXPECTED_FOOFY_OUTPUT)
})

test_that("foofy() does that", {
  useful_thing2 <- readRDS(test_path("fixtures", "useful_thing2.rds"))
  expect_equal(foofy(useful_thing2, x = "that"), EXPECTED_FOOFY_OUTPUT)
})

Ahora podemos revisar una lista de archivos anterior, que abordaba exactamente este escenario:

.
├── ...
└── tests
    ├── testthat
    │   ├── fixtures
    │   │   ├── make-useful-things.R
    │   │   ├── useful_thing1.rds
    │   │   └── useful_thing2.rds
    │   ├── helper.R
    │   ├── setup.R
    │   └── (all the test files)
    └── testthat.R

Esto muestra archivos de prueba estáticos almacenados en tests/testthat/fixtures/, pero también observe el script R complementario, make-useful-things.R. A partir del análisis de datos, todos sabemos que no existe un script que se ejecute solo una vez. El refinamiento y la iteración son inevitables. Esto también es válido para objetos de prueba como useful_thing1.rds. Recomendamos encarecidamente guardar el código R utilizado para crear los objetos de prueba, para que puedan volver a crearse según sea necesario.

15.2 Construyendo sus propias herramientas de prueba

Volvamos al tema de la duplicación en su código de prueba. Le recomendamos que tenga una mayor tolerancia a la repetición en el código de prueba, con el fin de hacer que sus pruebas sean obvias. Pero todavía hay un límite en cuanto a la cantidad de repetición que se puede tolerar. Hemos cubierto técnicas como cargar objetos estáticos con test_path(), escribir un constructor como new_useful_thing() o implementar un dispositivo de prueba como local_useful_thing(). Hay incluso más tipos de ayudas de prueba que pueden resultar útiles en determinadas situaciones.

15.2.1 Ayudante definido dentro de una prueba

Considere esta prueba para la función str_trunc() en stringr:

# de stringr (hipotéticamente)
test_that("truncations work for all sides", {
  expect_equal(
    str_trunc("This string is moderately long", width = 20, side = "right"),
    "This string is mo..."
  )
  expect_equal(
    str_trunc("This string is moderately long", width = 20, side = "left"),
    "...s moderately long"
  )
  expect_equal(
    str_trunc("This string is moderately long", width = 20, side = "center"),
    "This stri...ely long"
  )
})

Hay mucha repetición aquí, lo que aumenta la posibilidad de errores de copiar y pegar y, en general, hace que los ojos se pongan vidriosos. A veces es bueno crear un asistente hiperlocal, dentro de la prueba. Así es como se ve realmente la prueba en stringr

# de stringr (en realidad)
test_that("truncations work for all sides", {

  trunc <- function(direction) str_trunc(
    "This string is moderately long",
    direction,
    width = 20
  )

  expect_equal(trunc("right"),   "This string is mo...")
  expect_equal(trunc("left"),    "...s moderately long")
  expect_equal(trunc("center"),  "This stri...ely long")
})

Un asistente hiperlocal como trunc() es particularmente útil cuando le permite encajar todos los negocios importantes para cada expectativa en una línea. Luego, sus expectativas se pueden leer casi como una tabla entre lo real y lo esperado, para un conjunto de casos de uso relacionados. Arriba, es muy fácil ver cómo cambia el resultado a medida que truncamos la entrada desde la derecha, la izquierda y el centro.

Tenga en cuenta que esta técnica debe utilizarse con extrema moderación. Un asistente como trunc() es otro lugar donde puedes introducir un error, por lo que es mejor mantener dichos asistentes extremadamente breves y simples.

15.2.2 Expectativas personalizadas

Si se considera necesario un ayudante más complicado, es un buen momento para reflexionar sobre por qué es así. Si es complicado ponerse en posición para probar una función, eso podría ser una señal de que también es complicado usar esa función. ¿Necesitas refactorizarlo? Si la función parece sólida, entonces probablemente necesite utilizar un asistente más formal, definido fuera de cualquier prueba individual, como se describió anteriormente.

Un tipo específico de ayuda que quizás quieras crear es una expectativa personalizada. Aquí hay dos muy simples de usethis:

expect_usethis_error <- function(...) {
  expect_error(..., class = "usethis_error")
}

expect_proj_file <- function(...) {
  expect_true(file_exists(proj_path(...)))
}

expect_usethis_error() comprueba que un error tenga la clase "usethis_error". expect_proj_file() es un contenedor simple alrededor de file_exists() que busca el archivo en el proyecto actual. Son funciones muy simples, pero la gran cantidad de repeticiones y la expresividad de sus nombres las hacen sentir justificadas.

Es algo complicado crear una expectativa personalizada adecuada, es decir, una que se comporte como las expectativas integradas en testthat. Lo remitimos a la viñeta Expectativas personalizadas si desea obtener más información al respecto.

Por último, puede resultar útil saber qué prueba pone a disposición información específica cuando se está ejecutando:

En algunas situaciones, es posible que desee explotar esta información sin depender del tiempo de ejecución de testthat. En ese caso, simplemente inserte la fuente de estas funciones directamente en su paquete.

15.3 Cuando las pruebas se vuelven difíciles

A pesar de todas las técnicas que hemos cubierto hasta ahora, siguen existiendo situaciones en las que todavía resulta muy difícil escribir pruebas. En esta sección, revisamos más formas de lidiar con situaciones desafiantes:

  • Saltarse una prueba en determinadas situaciones.
  • Burlarse de un servicio externo.
  • Lidiar con los secretos

15.3.1 Saltarse una prueba

A veces es imposible realizar una prueba: es posible que no tenga conexión a Internet o que no tenga acceso a las credenciales necesarias. Desafortunadamente, otra razón probable se desprende de esta simple regla: cuantas más plataformas utilice para probar su código, más probable será que no pueda ejecutar todas sus pruebas, todo el tiempo. En resumen, hay ocasiones en las que, en lugar de reprobar, simplemente quieres saltarte una prueba.

15.3.1.1 testthat::skip()

Aquí usamos testthat::skip() para escribir un skipper personalizado hipotético, skip_if_no_api():

skip_if_no_api() <- function() {
  if (api_unavailable()) {
    skip("API not available")
  }
}

test_that("foo api returns bar when given baz", {
  skip_if_no_api()
  ...
})

skip_if_no_api() es otro ejemplo más de ayuda de prueba y los consejos ya dados sobre dónde definirlo se aplican aquí también.

Los skip() y los motivos asociados se informan en línea a medida que se ejecutan las pruebas y también se indican claramente en el resumen:

devtools::test()
#> ℹ Loading abcde
#> ℹ Testing abcde
#> ✔ | F W S  OK | Context
#> ✔ |         2 | blarg
#> ✔ |     1   2 | foofy
#> ────────────────────────────────────────────────────────────────────────────────
#> Skip (test-foofy.R:6:3): foo api returns bar when given baz
#> Reason: API not available
#> ────────────────────────────────────────────────────────────────────────────────
#> ✔ |         0 | yo                                                              
#> ══ Results ═════════════════════════════════════════════════════════════════════
#> ── Skipped tests  ──────────────────────────────────────────────────────────────
#> • API not available (1)
#> 
#> [ FAIL 0 | WARN 0 | SKIP 1 | PASS 4 ]
#> 
#> 🥳

Es probable que aparezca algo como skip_if_no_api() muchas veces en su conjunto de pruebas. Esta es otra ocasión en la que resulta tentador SECAR las cosas, elevando skip() al nivel superior del archivo. Sin embargo, todavía nos inclinamos por llamar a skip_if_no_api() en cada prueba donde sea necesario.

# we prefer this:
test_that("foo api returns bar when given baz", {
  skip_if_no_api()
  ...
})

test_that("foo api returns an errors when given qux", {
  skip_if_no_api()
  ...
})

# Más allá de esto:
skip_if_no_api()

test_that("foo api returns bar when given baz", {...})

test_that("foo api returns an errors when given qux", {...})

Dentro del ámbito del código de nivel superior en archivos de prueba, tener un skip() al principio de un archivo de prueba es una de las situaciones más benignas. Pero una vez que un archivo de prueba no cabe completamente en su pantalla, crea una conexión implícita pero fácil de pasar por alto entre skip() y las pruebas individuales.

15.3.1.2 Funciones skip() incorporadas

De manera similar a las expectativas integradas de test, existe una familia de funciones skip() que anticipan algunas situaciones comunes. Estas funciones a menudo le liberan de la necesidad de escribir un patrón personalizado. A continuación se muestran algunos ejemplos de las funciones skip() más útiles:

test_that("foo api returns bar when given baz", {
  skip_if(api_unavailable(), "API not available")
  ...
})
test_that("foo api returns bar when given baz", {
  skip_if_not(api_available(), "API not available")
  ...
})

skip_if_not_installed("sp")
skip_if_not_installed("stringi", "1.2.2")

skip_if_offline()
skip_on_cran()
skip_on_os("windows")

15.3.1.3 Peligros de saltar

Un desafío con los saltos es que actualmente son completamente invisibles en CI: si automáticamente omite demasiadas pruebas, es fácil engañarse pensando que todas sus pruebas están pasando cuando en realidad simplemente se están omitiendo. En un mundo ideal, su CI/CD facilitaría ver cuántas pruebas se omiten y cómo eso cambia con el tiempo.

Es una buena práctica profundizar periódicamente en los resultados de la “R CMD check”, especialmente en CI, y asegurarse de que los saltos sean los esperados. Pero esto tiende a ser algo que hay que aprender a través de la experiencia.

15.3.2 Mocking

La práctica conocida como mocking ocurre cuando reemplazamos algo que es complicado, poco confiable o fuera de nuestro control por algo más simple, que está totalmente bajo nuestro control. Por lo general, se realiza mocking de un servicio externo, como una API REST, o una función que informa algo sobre el estado de la sesión, como si la sesión es interactiva.

La aplicación clásica de mocking se encuentra en el contexto de un paquete que incluye una API externa. Para probar sus funciones, técnicamente necesita realizar una llamada en vivo a esa API para obtener una respuesta, que luego procesa. Pero, ¿qué pasa si esa API requiere autenticación o si es algo inestable y tiene un tiempo de inactividad ocasional? Puede ser más productivo simplemente fingir llamar a la API pero, en cambio, probar el código bajo su control procesando una respuesta pregrabada de la API real.

Nuestro principal consejo sobre realizar mocking es evitarlo si puedes. Esto no es una acusación de mocking, sino simplemente una evaluación realista de que realizar mocking introduce una nueva complejidad que no siempre está justificada por los beneficios.

Dado que la mayoría de los paquetes de R no necesitan la realización de mocking, no lo cubrimos aquí. En su lugar, le indicaremos los paquetes que representan lo último sobre esto en R hoy en día:

Tenga en cuenta también que, en el momento de escribir este artículo, parece probable que el paquete testthat reintroduzca algunas capacidades de mocking (después de haber salido previamente del negocio de mocking una vez). La versión v3.1.7 tiene dos nuevas funciones experimentales, testthat::with_mocked_bindings() y testthat::local_mocked_bindings().

15.3.3 Secretos

Otro desafío común para los paquetes que incluyen un servicio externo es la necesidad de administrar las credenciales. Específicamente, es probable que necesite proporcionar un conjunto de credenciales de prueba para probar completamente su paquete.

Nuestro principal consejo aquí es diseñar su paquete de modo que gran parte del mismo pueda probarse sin acceso en vivo y autenticado al servicio externo.

Por supuesto, aún querrá poder probar su paquete con el servicio real que incluye, en entornos que admitan variables de entorno seguras. Dado que este también es un tema muy especializado, no entraremos en más detalles aquí. En su lugar, lo remitimos a la viñeta API de ajuste en el paquete httr2, que ofrece soporte sustancial para la gestión de secretos.

15.4 Consideraciones especiales para paquetes CRAN

CRAN ejecuta R CMD check en todos los paquetes aportados, tanto al momento del envío como de forma regular después de la aceptación. Esta verificación incluye, entre otras, la prueba que realiza las pruebas. Discutimos el desafío general de preparar su paquete para enfrentar todos los “sabores” de cheques de CRAN en Sección 22.4.1. Aquí nos centramos en consideraciones específicas de CRAN para su conjunto de pruebas.

Cuando un paquete entra en conflicto con la Política de repositorio de CRAN (https://cran.r-project.org/web/packages/policies.html), el conjunto de pruebas suele ser el culpable (aunque no siempre). Si su paquete está destinado a CRAN, esto debería influir en cómo escribe sus pruebas y cómo (o si) se ejecutarán en CRAN.

15.4.1 Saltar una prueba

Si una prueba específica simplemente no es apropiada para ser ejecutada por CRAN, incluya skip_on_cran() desde el principio.

test_that("some long-running thing works", {
  skip_on_cran()
  # código de prueba que potencialmente puede tardar "un tiempo" en ejecutarse  
})

Debajo del capó, skip_on_cran() consulta la variable de entorno NOT_CRAN. Dicha prueba solo se ejecutará cuando NOT_CRAN se haya definido explícitamente como "true". Esta variable la establecen devtools y testthat, lo que permite que esas pruebas se ejecuten en entornos donde espera tener éxito (y donde puede tolerar y solucionar fallas ocasionales).

En particular, los flujos de trabajo de GitHub Actions que recomendamos en Sección 20.2.1 ejecutarán pruebas con NOT_CRAN = "true". Para ciertos tipos de funcionalidad, no existe una forma práctica de probarlas en CRAN y sus propias comprobaciones, en GitHub Actions o un servicio de integración continua equivalente, son su mejor método de control de calidad.

Incluso hay casos raros en los que tiene sentido mantener las pruebas fuera de su paquete por completo. El equipo de tidymodels utiliza esta estrategia para pruebas de tipo integración de todo su ecosistema que serían imposibles de alojar dentro de un paquete CRAN individual.

15.4.2 Velocidad

Sus pruebas deben ejecutarse relativamente rápido; idealmente, menos de un minuto en total. Utilice skip_on_cran() en una prueba que inevitablemente sea de larga duración.

15.4.3 Reproducibilidad

Tenga cuidado al probar cosas que probablemente sean variables en las máquinas CRAN. Es arriesgado probar cuánto tiempo lleva algo (porque las máquinas CRAN a menudo están muy cargadas) o probar código paralelo (debido a que CRAN ejecuta múltiples pruebas de paquetes en paralelo, no siempre habrá múltiples núcleos disponibles). La precisión numérica también puede variar entre plataformas, así que use expect_equal() a menos que tenga una razón específica para usar expect_identical().

15.4.4 Pruebas inestables

Debido a la escala a la que CRAN verifica los paquetes, básicamente no hay margen para una prueba que es “simplemente inestable”, es decir, que a veces falla por razones incidentales. CRAN no procesa los resultados de las pruebas de su paquete como usted lo hace, donde puede inspeccionar cada falla y ejercer un juicio humano sobre qué tan preocupante es.

Probablemente sea una buena idea eliminar las pruebas inestables, ¡sólo por tu propio bien! Pero si tiene pruebas valiosas y bien escritas que son propensas a fallas molestas ocasionales, definitivamente coloque skip_on_cran() al principio.

El ejemplo clásico es cualquier prueba que acceda a un sitio web o API web. Dado que cualquier recurso web en el mundo experimentará un tiempo de inactividad ocasional, es mejor no permitir que dichas pruebas se ejecuten en CRAN. La Política de repositorio de CRAN dice:

Los paquetes que utilizan recursos de Internet deberían fallar elegantemente con un mensaje informativo si el recurso no está disponible o ha cambiado (y no dar aviso de verificación ni error).

A menudo, hacer que tal falla sea “elegante” iría en contra del comportamiento que realmente desea en la práctica, es decir, querría que su usuario recibiera un error si su solicitud falla. Por eso suele ser más práctico probar dicha funcionalidad en otro lugar.

Recuerde que las pruebas instantáneas (Capítulo 13), de forma predeterminada, también se omiten en CRAN. Normalmente se utilizan estas pruebas para controlar, por ejemplo, cómo se ven varios mensajes informativos. Pequeños cambios en el formato de los mensajes son algo sobre lo que desea recibir una alerta, pero no indican un defecto importante en su paquete. Esta es la motivación para el comportamiento predeterminado skip_on_cran() de las pruebas instantáneas.

Finalmente, las pruebas inestables causan problemas a quienes mantienen sus dependencias. Cuando se actualizan los paquetes de los que depende, CRAN ejecuta R CMD check en todas las dependencias inversas, incluido su paquete. Si su paquete tiene pruebas inestables, su paquete puede ser la razón por la que otro paquete no pasa las comprobaciones entrantes de CRAN y puede retrasar su liberación.

15.4.5 Higiene del sistema de archivos y procesos

En Sección 14.3.7, le instamos a que escriba únicamente en el directorio temporal de la sesión y que limpie lo que haya dejado usted mismo. Esta práctica hace que su conjunto de pruebas sea mucho más fácil de mantener y predecible. Para los paquetes que están (o aspiran a estar) en CRAN, esto es absolutamente necesario según la política del repositorio de CRAN:

Los paquetes no deben escribirse en el espacio de archivos de inicio del usuario (incluidos los portapapeles), ni en ningún otro lugar del sistema de archivos aparte del directorio temporal de la sesión R (o durante la instalación en la ubicación señalada por TMPDIR: y dicho uso debe limpiarse). … Se pueden permitir excepciones limitadas en sesiones interactivas si el paquete obtiene la confirmación del usuario.

Del mismo modo, debes esforzarte en ser higiénico con respecto a cualquier proceso que inicies:

Los paquetes no deben iniciar software externo (como visores o navegadores de PDF) durante los ejemplos o pruebas a menos que esa instancia específica del software se cierre explícitamente después.

Acceder al portapapeles es la tormenta perfecta que potencialmente entra en conflicto con ambas pautas, ya que el portapapeles se considera parte del espacio de archivos de inicio del usuario y, en Linux, puede iniciar un proceso externo (por ejemplo, xsel o xclip). Por lo tanto, es mejor desactivar cualquier funcionalidad del portapapeles en sus pruebas (y asegurarse de que, durante el uso auténtico, su usuario claramente opte por ello).