Context
Recently, we worked on testing a {shiny}
app that relies on values stored within the session$request
object. This object is an environment that captures the details of the HTTP exchange between R and the browser. Without diving too deeply into the technicalities (as much as I’d love to 😅), it’s important to understand that session$request
contains information provided by both the browser and any proxy redirecting the requests.
Our app is deployed behind a proxy in a Microsoft Azure environment. Here, the authentication service attaches several headers to validate user identity (see documentation for details). Headers like X-MS-CLIENT-PRINCIPAL
and X-MS-CLIENT-PRINCIPAL-ID
are critical for identifying users, and the {shiny}
app depends on these to manage authentication.
Testing headers
When a user connects to the app, their identifiers are retrieved from a header and stored for use throughout the app. Here’s a simplified example of how this might work:
library(shiny)
ui <- fluidPage(
textOutput("user_id")
)
server <- function(input, output, session) {
r <- reactiveValues(
email = NULL
)
observe({
r$email <- session$request$HTTP_X_MS_CLIENT_PRINCIPAL_NAME
})
output$user_id <- renderText({
req(r$email)
sprintf("Hello %s", r$email)
})
}
shinyApp(ui, server)
Testing this functionality, particularly in Continuous Integration (CI) environments, can be challenging.
In our use case, we’d love to have something like this:
test_that("app server", {
# Tweaking the session here
testServer(app_server, {
# Waiting for the session to be fired up
session$elapse(1)
expect_equal(
r$email,
"[email protected]"
)
})
})
But Authentication headers like HTTP_X_MS_CLIENT_PRINCIPAL_NAME
are absent during automated tests, so we need a way to simulate their presence. {shiny}
provides the MockShinySession
class for testing, but it doesn’t natively simulate a realistic session$request
object. Let’s explore how to work around this limitation.
Overriding session$request
We first attempt to directly modify session$request
, but it doesn’t work:
> session <- MockShinySession$new()
> session$request
<environment: 0x13a032600>
Warning message:
In (function (value) :
session$request doesn't currently simulate a realistic request on MockShinySession
Ok, maybe we can assign a new entry here?
> session$request$HTTP_X_MS_CLIENT_PRINCIPAL_NAME <- "test"
Error in (function (value) : session$request can't be assigned to
In addition: Warning message:
In (function (value) :
session$request doesn't currently simulate a realistic request on MockShinySession
Ouch, it doesn’t work, it can’t be assigned to. But let’s continue our exploration. What is session
?
> class(session)
[1] "MockShinySession" "R6"
> class(session$request)
[1] "environment"
As we can see, it’s an R6 object, an instance of the MockShinySession
class, and session$request
an env. What we want is being able to access, in our app, to session$request$HTTP_X_MS_CLIENT_PRINCIPAL_NAME
. Maybe we could override request
?
request
is contained in the active
field of the R6 class:
> MockShinySession$active
# [...]
$request
function (value)
{
if (!missing(value)) {
stop("session$request can't be assigned to")
}
warning("session$request doesn't currently simulate a realistic request on MockShinySession")
new.env(parent = emptyenv())
}
<bytecode: 0x11f25d8a8>
<environment: namespace:shiny
To override the request
object, we can use the set()
method of the R6 class. Here’s how we redefine the behavior:
MockShinySession$set(
"active",
"request",
function(value) {
return(
list(
"HTTP_X_MS_CLIENT_PRINCIPAL_NAME" = "[email protected]"
)
)
},
overwrite = TRUE
)
Now, the session behaves as expected:
> session <- MockShinySession$new()
> session$request
$HTTP_X_MS_CLIENT_PRINCIPAL_NAME
[1] "[email protected]
Writing the Test
With the overridden request
, we can now write a functional test:
test_that("app server", {
MockShinySession$set(
"active",
"request",
function(value) {
return(
list(
"HTTP_X_MS_CLIENT_PRINCIPAL_NAME" = "[email protected]"
)
)
},
overwrite = TRUE
)
testServer(app_server, {
# Waiting for the session to be fired up
session$elapse(1)
expect_equal(
r$email,
"[email protected]"
)
})
})
Cleaning Up After Tests
But, just one more thing: we need to clean our test so that the session object stays the same after our test. For this, we’ll use on.exit
to restore the old behavior:
test_that("app server", {
old_request <- MockShinySession$active$request
on.exit({
MockShinySession$set(
"active",
"request",
old_request,
overwrite = TRUE
)
})
MockShinySession$set(
"active",
"request",
function(value) {
return(
list(
"HTTP_X_MS_CLIENT_PRINCIPAL_NAME" = "[email protected]"
)
)
},
overwrite = TRUE
)
testServer(app_server, {
# Waiting for the session to be fired up
session$elapse(1)
expect_equal(
r$email,
"[email protected]"
)
})
})
This setup ensures that our tests remain isolated and reliable, even in CI environments. By leveraging R6’s flexibility, we can fully control and mock session$request
to test authentication-dependent logic.
If you want to dig more into the details, you can visit this repo, where you’ll find a reproducible example!
Do you need help with testing your apps?
Still unsure how to implement a good testing strategy for your app? Let’s chat!