diff --git a/NAMESPACE b/NAMESPACE index d13edda0f7..f266f1f4b6 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -403,6 +403,7 @@ importFrom(rlang,get_env) importFrom(rlang,get_expr) importFrom(rlang,inject) importFrom(rlang,is_false) +importFrom(rlang,is_function) importFrom(rlang,is_missing) importFrom(rlang,is_na) importFrom(rlang,is_quosure) diff --git a/NEWS.md b/NEWS.md index e73e9ad6d5..d0265c33b6 100644 --- a/NEWS.md +++ b/NEWS.md @@ -12,6 +12,8 @@ * `Map` objects are now initialized at load time instead of build time. This avoids potential problems that could arise from storing `fastmap` objects into the built Shiny package. (#3775) +* Added methods to `mock-session` for verifying that specific calls to `session$sendInputMessage` were performed; see `verifyInputMessage`. Fully supports unit testing with the `testthat`-package, but does not require it. + ### Bug fixes * Fixed #3771: Sometimes the error `ion.rangeSlider.min.js: i.stopPropagation is not a function` would appear in the JavaScript console. (#3772) diff --git a/R/mock-session.R b/R/mock-session.R index fd4227d295..d3957acc27 100644 --- a/R/mock-session.R +++ b/R/mock-session.R @@ -149,7 +149,6 @@ makeExtraMethods <- function() { "sendBinaryMessage", "sendChangeTabVisibility", "sendCustomMessage", - "sendInputMessage", "sendInsertTab", "sendInsertUI", "sendModal", @@ -209,6 +208,7 @@ addGeneratedInstanceMethods <- function(instance, methods = makeExtraMethods()) #' of [testServer()]. #' #' @include timer.R +#' @importFrom rlang is_function #' @export MockShinySession <- R6Class( 'MockShinySession', @@ -609,6 +609,93 @@ MockShinySession <- R6Class( getCurrentOutputInfo = function() { name <- private$currentOutputName if (is.null(name)) NULL else list(name = name) + }, + + #' @description + #' Mocks a `session$sendInputMessage`-call + #' that can be later verified. + #' @param inputId,message See `sendInputMessage` in [session]. + sendInputMessage = function(inputId, message) { + stopifnot(length(inputId) == 1) ## purely guessing on internal workings of session + private$inputMessage[[as.character(inputId)]] = message + }, + + #' @description + #' Verifies that a call to `session$sendInputMessage` has been performed. + #' + #' Use either simple expectations, e.g. `expect_equal(., "some value")`, + #' or functions, `function(x) is.list(x)` or + #' `function(x) expect_equal(x, list(1))`. + #' + #' For simple expectations, the sent message is accessed with `.`. + #' + #' For functions, they are called with the sent message as first argument. + #' If any of the expressions in the function throws an error, `verifyInputMessage` + #' fails. + #' + #' For both functions and expectations, their returned value must be + #' `NULL` or pass [`isTruthy`] for the assertion to succeed. + #' + #' NB! testthat's `expect_*`-functions, when the expectations succeeds, + #' returns the tested value. I.e. if testing for any of the values on the + #' list in [`isTruthy`] (`FALSE`, `""`, `vector(0)`, etc.), `verifyInputMessage` + #' will fail if results not properly wrapped. + #' + #' @examples + #' session <- MockShinySession$new() + #' session$sendInputMessage("foo", "") + #' session$sendInputMessage("bar", list(value=2, add=TRUE)) + #' session$verifyInputMessage("foo", . == "") + #'\dontrun{ + #' # This should be wrapped in an if (requireNamespace("testthat)), + #' # but expect_equal was still now found?! + #' session$verifyInputMessage("bar", expect_equal(., list(value=2, add=TRUE))) + #' + #' # Will fail, as `expect_equal` returns the value, which + #' # in this case is not truthy. + #' session$verifyInputMessage("foo", expect_equal(., "")) + #' } + #' + #' @param inputId Expected inputId and message of the + #' last call to `session$sendInputMessage`. + #' @param ... Assertions to test against. + #' @param env (advanced use only) the environment in which to evaluate + #' `...` assertions. + verifyInputMessage = function(inputId, ..., env = rlang::caller_env()) { + asserts <- eval(substitute(alist(...))) + test.env <- new.env(parent = env) + msg <- private$inputMessage[[as.character(inputId)]] + if (length(msg) == 0) { + stop(errorCondition( + sprintf("session$sendInputMessage(inputId=\"%s\") has not been called.", inputId), + class = c("failure","expectation") + )) + } + + delayedAssign(".", msg, assign.env = test.env) + for (assertion in asserts) { + res <- tryCatch({ + val <- eval(assertion, test.env) + if (is_function(val)) { + val <- val(msg) + } + outcome <- isTruthy(val %||% TRUE) + attr(outcome, "msg") <- attr(val, "msg") + outcome + }, assertError = function(e) { + structure(FALSE, msg = e$message) + }, error = function(e) { + stop(e) + }) + if (!res) { + msg <- attr(res, "msg") %||% paste0(deparse(assertion), " is not TRUE") + stop(errorCondition(msg, class = c("failure", "expectation"))) + } + } + + # signal a (expectation?) condition, so testthat accepts this as a test. + signalCondition(simpleCondition(TRUE)) + invisible() } ), private = list( @@ -696,7 +783,9 @@ MockShinySession <- R6Class( createVarPromiseDomain(private, "currentOutputName", name), expr ) - } + }, + + inputMessage = list() ), active = list( #' @field files For internal use only. diff --git a/man/MockShinySession.Rd b/man/MockShinySession.Rd index 68f0911469..4b49e0223c 100644 --- a/man/MockShinySession.Rd +++ b/man/MockShinySession.Rd @@ -28,6 +28,25 @@ of \code{\link[=testServer]{testServer()}}. \dontrun{ session$setInputs(x=1, y=2) } + +## ------------------------------------------------ +## Method `MockShinySession$verifyInputMessage` +## ------------------------------------------------ + +session <- MockShinySession$new() +session$sendInputMessage("foo", "") +session$sendInputMessage("bar", list(value=2, add=TRUE)) +session$verifyInputMessage("foo", . == "") +\dontrun{ + # This should be wrapped in an if (requireNamespace("testthat)), + # but expect_equal was still now found?! + session$verifyInputMessage("bar", expect_equal(., list(value=2, add=TRUE))) + + # Will fail, as `expect_equal` returns the value, which + # in this case is not truthy. + session$verifyInputMessage("foo", expect_equal(., "")) +} + } \section{Public fields}{ \if{html}{\out{
}} @@ -113,6 +132,8 @@ user. Always \code{NULL} for a \code{MockShinySesion}.} \item \href{#method-MockShinySession-onSessionEnded}{\code{MockShinySession$onSessionEnded()}} \item \href{#method-MockShinySession-registerDownload}{\code{MockShinySession$registerDownload()}} \item \href{#method-MockShinySession-getCurrentOutputInfo}{\code{MockShinySession$getCurrentOutputInfo()}} +\item \href{#method-MockShinySession-sendInputMessage}{\code{MockShinySession$sendInputMessage()}} +\item \href{#method-MockShinySession-verifyInputMessage}{\code{MockShinySession$verifyInputMessage()}} \item \href{#method-MockShinySession-clone}{\code{MockShinySession$clone()}} } } @@ -610,8 +631,88 @@ executed. \subsection{Returns}{ A list with with the \code{name} of the output. If no output is currently being executed, this will return \code{NULL}. -output, or \code{NULL} if no output is currently executing. } +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-MockShinySession-sendInputMessage}{}}} +\subsection{Method \code{sendInputMessage()}}{ +Mocks a \code{session$sendInputMessage}-call +that can be later verified. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{MockShinySession$sendInputMessage(inputId, message)}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{inputId, message}}{See \code{sendInputMessage} in \link{session}.} +} +\if{html}{\out{
}} +} +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-MockShinySession-verifyInputMessage}{}}} +\subsection{Method \code{verifyInputMessage()}}{ +Verifies that a call to \code{session$sendInputMessage} has been performed. + +Use either simple expectations, e.g. \code{expect_equal(., "some value")}, +or functions, \code{function(x) is.list(x)} or +\code{function(x) expect_equal(x, list(1))}. + +For simple expectations, the sent message is accessed with \code{.}. + +For functions, they are called with the sent message as first argument. +If any of the expressions in the function throws an error, \code{verifyInputMessage} +fails. + +For both functions and expectations, their returned value must be +\code{NULL} or pass \code{\link{isTruthy}} for the assertion to succeed. + +NB! testthat's \verb{expect_*}-functions, when the expectations succeeds, +returns the tested value. I.e. if testing for any of the values on the +list in \code{\link{isTruthy}} (\code{FALSE}, \code{""}, \code{vector(0)}, etc.), \code{verifyInputMessage} +will fail if results not properly wrapped. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{MockShinySession$verifyInputMessage(inputId, ..., env = rlang::caller_env())}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{inputId}}{Expected inputId and message of the +last call to \code{session$sendInputMessage}.} + +\item{\code{...}}{Assertions to test against.} + +\item{\code{env}}{(advanced use only) the environment in which to evaluate +\code{...} assertions. +output, or \code{NULL} if no output is currently executing.} +} +\if{html}{\out{
}} +} +\subsection{Examples}{ +\if{html}{\out{
}} +\preformatted{session <- MockShinySession$new() +session$sendInputMessage("foo", "") +session$sendInputMessage("bar", list(value=2, add=TRUE)) +session$verifyInputMessage("foo", . == "") +\dontrun{ + # This should be wrapped in an if (requireNamespace("testthat)), + # but expect_equal was still now found?! + session$verifyInputMessage("bar", expect_equal(., list(value=2, add=TRUE))) + + # Will fail, as `expect_equal` returns the value, which + # in this case is not truthy. + session$verifyInputMessage("foo", expect_equal(., "")) +} + +} +\if{html}{\out{
}} + +} + } \if{html}{\out{
}} \if{html}{\out{}} diff --git a/tests/testthat/test-mock-session.R b/tests/testthat/test-mock-session.R index 8a34696b58..c83c24150b 100644 --- a/tests/testthat/test-mock-session.R +++ b/tests/testthat/test-mock-session.R @@ -245,7 +245,45 @@ test_that("session supports sendBinaryMessage", { test_that("session supports sendInputMessage", { session <- MockShinySession$new() session$sendInputMessage(inputId=1, message=2) - expect_true(TRUE) # testthat insists that every test must have an expectation + session$sendInputMessage(inputId="foo", message=list(bar=1, add=TRUE)) + session$verifyInputMessage(1, expect_equal(., 2)) + session$verifyInputMessage(1, function(x) { + expect_type(x, "double") + expect_equal(x, 2) + }) + session$verifyInputMessage("foo", expect_true(.$add), expect_equal(.$bar, 1)) +}) + +test_that("session supports failing verifyInputMessage", { + session <- MockShinySession$new() + expect_failure( + session$verifyInputMessage(1, expect_equal(., 1)), + message = "session$sendInputMessage(inputId=\"1\") has not been called.", + fixed = TRUE + ) + session$sendInputMessage(inputId=1, message=2) + expect_success(session$verifyInputMessage(1, expect_equal(., 2))) + expect_failure( + session$verifyInputMessage(1, expect_equal(., 1)), + message = "`.` (`actual`) not equal to 1 (`expected`)", + fixed = TRUE + ) + expect_failure( + session$verifyInputMessage(1, function(x) expect_equal(x, 1)), + message = "`x` (`actual`) not equal to 1 (`expected`)", + fixed = TRUE + ) + expect_failure( + session$verifyInputMessage(1, . == 1), + message = ". == 1 is not TRUE", + fixed = TRUE + ) + expect_failure( + session$verifyInputMessage(1, function(x) x == 1), + message = "function(x) x == 1 is not TRUE", + fixed = TRUE + ) + }) test_that("session supports setBookmarkExclude", { diff --git a/tests/testthat/test-update-input.R b/tests/testthat/test-update-input.R index 369db4efc1..b236f88558 100644 --- a/tests/testthat/test-update-input.R +++ b/tests/testthat/test-update-input.R @@ -1,36 +1,19 @@ test_that("Radio buttons and checkboxes work with modules", { - createModuleSession <- function(moduleId) { - session <- as.environment(list( - ns = NS(moduleId), - sendInputMessage = function(inputId, message) { - session$lastInputMessage = list(id = inputId, message = message) - } - )) - class(session) <- "ShinySession" - session - } - - sessA <- createModuleSession("modA") - - updateRadioButtons(sessA, "test1", label = "Label", choices = letters[1:5]) - resultA <- sessA$lastInputMessage - - expect_equal("test1", resultA$id) - expect_equal("Label", resultA$message$label) - expect_equal("a", resultA$message$value) - expect_true(grepl('"modA-test1"', resultA$message$options)) - expect_false(grepl('"test1"', resultA$message$options)) - - - sessB <- createModuleSession("modB") - - updateCheckboxGroupInput(sessB, "test2", label = "Label", choices = LETTERS[1:5]) - resultB <- sessB$lastInputMessage - - expect_equal("test2", resultB$id) - expect_equal("Label", resultB$message$label) - expect_null(resultB$message$value) - expect_true(grepl('"modB-test2"', resultB$message$options)) - expect_false(grepl('"test2"', resultB$message$options)) - + session <- MockShinySession$new() + + updateRadioButtons(session, "test1", label = "Label", choices = letters[1:5]) + session$verifyInputMessage("test1", + expect_equal(.$label, "Label"), + expect_equal(.$value, "a"), + expect_true(grepl('"mock-session-test1"', .$options)), + !expect_false(grepl('"test1"', .$options)) ## negate returned FALSE from expect_false + ) + + updateCheckboxGroupInput(session, "test2", label = "Label", choices = LETTERS[1:5]) + session$verifyInputMessage("test2", + expect_equal(.$label, "Label"), + expect_null(.$value), + expect_true(grepl('"mock-session-test2"', .$options)), + !expect_false(grepl('"test2"', .$options)) + ) })