flowchart TD A[Shiny Testing Challenges] --> B[Reactive Dependencies] A --> C[Asynchronous Operations] A --> D[UI State Management] A --> E[Browser Interactions] B --> B1[Input Validation] B --> B2[Output Updates] B --> B3[Observer Execution] C --> C1[File Uploads] C --> C2[Database Queries] C --> C3[API Calls] D --> D1[Dynamic UI Elements] D --> D2[Conditional Panels] D --> D3[Modal Dialogs] E --> E1[Click Events] E --> E2[Form Submissions] E --> E3[Navigation] style A fill:#e1f5fe style B fill:#f3e5f5 style C fill:#e8f5e8 style D fill:#fff3e0 style E fill:#fce4ec
Key Takeaways
- Comprehensive Testing Strategy: Implement unit testing, integration testing, and manual testing workflows that catch issues before they reach production
- Advanced Debugging Techniques: Master reactive debugging, browser developer tools, and systematic troubleshooting approaches for complex Shiny applications
- Automated Quality Assurance: Set up continuous testing pipelines with testthat, shinytest2, and GitHub Actions for reliable deployment processes
- Production Error Handling: Implement robust error handling, logging, and monitoring systems that provide meaningful feedback to users and developers
- Performance Testing: Use profiling tools and load testing techniques to ensure your applications perform well under real-world conditions
Introduction
Building reliable Shiny applications requires more than just functional code - it demands a systematic approach to testing and debugging that ensures your applications work consistently across different environments, user scenarios, and edge cases. Professional Shiny development distinguishes itself through rigorous quality assurance practices that catch issues early and provide confidence in production deployments.
This comprehensive guide covers the essential testing and debugging strategies used by experienced Shiny developers. You’ll learn to implement automated testing workflows, master debugging techniques for reactive programming, and establish robust error handling that makes your applications reliable and maintainable. Whether you’re building internal dashboards or client-facing applications, these practices ensure your Shiny apps meet professional standards and provide excellent user experiences.
Understanding Shiny Testing Challenges
The Unique Testing Landscape
Shiny applications present distinct testing challenges compared to traditional R packages or web applications. The reactive programming model, client-server architecture, and dynamic UI generation create complexity that requires specialized testing approaches.
Testing Strategy Framework
A comprehensive Shiny testing strategy operates at multiple levels, each addressing different aspects of application reliability:
Unit Testing Level:
- Individual reactive expressions
- Data processing functions
- Utility functions and helpers
- Business logic components
Integration Testing Level:
- UI-Server interactions
- Module communication
- Database connections
- External API integrations
End-to-End Testing Level:
- Complete user workflows
- Cross-browser compatibility
- Performance under load
- Security and authentication
Debugging & Troubleshooting Cheatsheet - Essential debugging patterns, error solutions, and performance fixes with copy-paste code.
Quick Diagnostics • Performance Solutions • Error Handling Patterns
Setting Up Your Testing Environment
Essential Testing Packages
# Install essential testing packages
install.packages(c(
"testthat", # Unit testing framework
"shinytest2", # End-to-end testing for Shiny
"mockery", # Mocking for isolated testing
"withr", # Temporary state management
"httptest", # API testing and mocking
"DBI", # Database testing utilities
"pool" # Connection pool testing
))
# Development and debugging packages
install.packages(c(
"profvis", # Performance profiling
"reactlog", # Reactive dependency visualization
"shiny.telemetry", # Application monitoring
"logger" # Structured logging
))
# Specialized testing and monitoring
install.packages(c(
"shinyloadtest", # Load testing for Shiny apps
"promises", # Asynchronous programming testing
"future", # Parallel processing testing
"memoise", # Caching and memoization testing
"config", # Configuration management testing
"golem" # Package-based development testing
))
# CI/CD and automation
install.packages(c(
"covr", # Code coverage analysis
"lintr", # Code style and static analysis
"styler", # Automated code formatting
"pkgdown", # Documentation testing
"devtools" # Development workflow tools
))
Project Structure for Testing
Organize your Shiny project to support comprehensive testing workflows:
my-shiny-project/
├── R/
│ ├── app.R # Main application
│ ├── ui.R # UI components
│ ├── server.R # Server logic
│ ├── modules/ # Shiny modules
│ ├── functions/ # Business logic
│ └── utils/ # Utility functions
├── tests/
│ ├── testthat/
│ │ ├── test-modules.R # Module testing
│ │ ├── test-functions.R # Function testing
│ │ ├── test-ui.R # UI testing
│ │ └── test-server.R # Server logic testing
│ ├── shinytest2/
│ │ ├── test-workflows.R # End-to-end testing
│ │ └── test-integration.R # Integration testing
│ └── manual/
│ ├── performance.R # Performance testing
│ └── load-testing.R # Load testing scripts
├── logs/ # Application logs
├── config/
│ ├── config.yml # Environment configuration
│ └── testing.yml # Testing configuration
└── .github/
└── workflows/
└── test.yml # CI/CD testing pipeline
Unit Testing Shiny Components
Testing Business Logic Functions
Start with testing the core business logic that powers your Shiny application:
# Example business logic function
<- function(revenue, costs, periods = 12) {
calculate_financial_metrics if (!is.numeric(revenue) || !is.numeric(costs)) {
stop("Revenue and costs must be numeric")
}
if (length(revenue) != length(costs)) {
stop("Revenue and costs must have the same length")
}
<- revenue - costs
profit <- profit / revenue * 100
margin
list(
profit = profit,
margin = margin,
total_profit = sum(profit),
avg_margin = mean(margin, na.rm = TRUE)
)
}
# Comprehensive unit tests
test_that("calculate_financial_metrics works correctly", {
# Test normal operation
<- calculate_financial_metrics(
result revenue = c(100, 150, 200),
costs = c(60, 90, 120)
)
expect_equal(result$profit, c(40, 60, 80))
expect_equal(result$margin, c(40, 40, 40))
expect_equal(result$total_profit, 180)
expect_equal(result$avg_margin, 40)
# Test edge cases
expect_error(
calculate_financial_metrics("100", 60),
"Revenue and costs must be numeric"
)
expect_error(
calculate_financial_metrics(c(100, 150), c(60)),
"Revenue and costs must have the same length"
)
# Test with zeros and negatives
<- calculate_financial_metrics(
result_zero revenue = c(0, 100),
costs = c(0, 50)
)expect_true(is.na(result_zero$margin[1])) # Division by zero
expect_equal(result_zero$margin[2], 50)
})
# Data processing function
<- function(data, filters = list()) {
process_user_data # Validate input
if (!is.data.frame(data)) {
stop("Input must be a data frame")
}
# Apply filters
<- data
processed_data
if (!is.null(filters$date_range)) {
<- processed_data[
processed_data $date >= filters$date_range[1] &
processed_data$date <= filters$date_range[2],
processed_data
]
}
if (!is.null(filters$category)) {
<- processed_data[
processed_data $category %in% filters$category,
processed_data
]
}
# Add computed columns
$processed_at <- Sys.time()
processed_data$row_id <- seq_len(nrow(processed_data))
processed_data
processed_data
}
# Comprehensive data processing tests
test_that("process_user_data handles filtering correctly", {
# Setup test data
<- data.frame(
test_data date = as.Date(c("2023-01-01", "2023-06-01", "2023-12-01")),
category = c("A", "B", "A"),
value = c(10, 20, 30),
stringsAsFactors = FALSE
)
# Test no filters
<- process_user_data(test_data)
result expect_equal(nrow(result), 3)
expect_true("processed_at" %in% names(result))
expect_true("row_id" %in% names(result))
# Test date filtering
<- process_user_data(
date_filtered
test_data,filters = list(date_range = as.Date(c("2023-05-01", "2023-12-31")))
)expect_equal(nrow(date_filtered), 2)
expect_equal(date_filtered$date, as.Date(c("2023-06-01", "2023-12-01")))
# Test category filtering
<- process_user_data(
category_filtered
test_data,filters = list(category = "A")
)expect_equal(nrow(category_filtered), 2)
expect_equal(category_filtered$category, c("A", "A"))
# Test combined filtering
<- process_user_data(
combined_filtered
test_data,filters = list(
date_range = as.Date(c("2023-05-01", "2023-12-31")),
category = "A"
)
)expect_equal(nrow(combined_filtered), 1)
expect_equal(combined_filtered$date, as.Date("2023-12-01"))
# Test error handling
expect_error(
process_user_data("not a data frame"),
"Input must be a data frame"
) })
Testing Reactive Expressions
Testing reactive expressions requires careful setup to simulate the Shiny reactive context:
# Testing reactive expressions
test_that("reactive expressions work correctly", {
# Create a mock Shiny session
<- MockShinySession$new()
session
# Setup reactive values
<- reactiveValues(
values data = mtcars,
filter_cyl = NULL,
filter_am = NULL
)
# Create reactive expression to test
<- reactive({
filtered_data <- values$data
data
if (!is.null(values$filter_cyl)) {
<- data[data$cyl == values$filter_cyl, ]
data
}
if (!is.null(values$filter_am)) {
<- data[data$am == values$filter_am, ]
data
}
data
})
# Test initial state
expect_equal(nrow(filtered_data()), nrow(mtcars))
# Test cylinder filtering
$filter_cyl <- 4
valuesexpect_equal(nrow(filtered_data()), sum(mtcars$cyl == 4))
# Test combined filtering
$filter_am <- 1
values<- sum(mtcars$cyl == 4 & mtcars$am == 1)
expected_rows expect_equal(nrow(filtered_data()), expected_rows)
# Test clearing filters
$filter_cyl <- NULL
values$filter_am <- NULL
valuesexpect_equal(nrow(filtered_data()), nrow(mtcars))
})
Understand what’s happening under the hood when debugging reactive issues:
Many Shiny debugging challenges stem from misunderstanding reactive execution flow. Visualizing how reactive expressions trigger and cascade helps identify where logic breaks down.
Debug with the Reactive Visualizer →
Use the live execution tracker to understand normal reactive behavior, then compare it to your problematic application to identify where the reactive chain breaks.
Testing Shiny Modules
Module Testing Strategy
Shiny modules require specialized testing approaches that account for namespacing and module communication:
# Example Shiny module
<- function(id) {
data_filter_ui <- NS(id)
ns
tagList(
selectInput(
ns("dataset"),
"Choose Dataset:",
choices = c("mtcars", "iris", "faithful"),
selected = "mtcars"
),conditionalPanel(
condition = "input.dataset == 'mtcars'",
ns = ns,
selectInput(
ns("cyl_filter"),
"Filter by Cylinders:",
choices = c("All", "4", "6", "8"),
selected = "All"
)
),verbatimTextOutput(ns("summary"))
)
}
<- function(id) {
data_filter_server moduleServer(id, function(input, output, session) {
# Get selected dataset
<- reactive({
selected_data switch(input$dataset,
"mtcars" = mtcars,
"iris" = iris,
"faithful" = faithful
)
})
# Apply filters
<- reactive({
filtered_data <- selected_data()
data
if (input$dataset == "mtcars" && input$cyl_filter != "All") {
<- data[data$cyl == as.numeric(input$cyl_filter), ]
data
}
data
})
# Generate summary output
$summary <- renderText({
output<- filtered_data()
data paste(
"Dataset:", input$dataset,
"\nRows:", nrow(data),
"\nColumns:", ncol(data)
)
})
# Return filtered data for parent app
return(filtered_data)
})
}
# Module unit tests
test_that("data_filter_module works correctly", {
# Test UI generation
<- data_filter_ui("test")
ui_output expect_s3_class(ui_output, "shiny.tag.list")
# Test server logic with mock session
<- MockShinySession$new()
session
# Mock input values
$setInputs(
sessiondataset = "mtcars",
cyl_filter = "4"
)
# Test module server
testServer(data_filter_server, args = list(id = "test"), {
# Test initial dataset selection
expect_equal(nrow(selected_data()), nrow(mtcars))
# Test filtering
$setInputs(cyl_filter = "4")
sessionexpect_equal(nrow(filtered_data()), sum(mtcars$cyl == 4))
# Test output generation
<- output$summary
output_text expect_true(grepl("Dataset: mtcars", output_text))
expect_true(grepl("Rows: 11", output_text)) # mtcars with 4 cylinders
}) })
# Test module integration with parent app
test_that("module integration works correctly", {
# Complete app for integration testing
<- function() {
test_app <- fluidPage(
ui titlePanel("Module Integration Test"),
fluidRow(
column(6, data_filter_ui("filter1")),
column(6, data_filter_ui("filter2"))
),
fluidRow(
column(12,
h3("Comparison Results"),
verbatimTextOutput("comparison")
)
)
)
<- function(input, output, session) {
server # Initialize modules
<- data_filter_server("filter1")
data1 <- data_filter_server("filter2")
data2
# Compare module outputs
$comparison <- renderText({
output<- data1()
d1 <- data2()
d2
paste(
"Module 1 - Rows:", nrow(d1), "Columns:", ncol(d1),
"\nModule 2 - Rows:", nrow(d2), "Columns:", ncol(d2),
"\nData Types Match:", identical(class(d1), class(d2))
)
})
}
shinyApp(ui, server)
}
# Test with shinytest2
<- AppDriver$new(test_app())
app
# Test initial state
expect_true(app$get_text("#comparison") != "")
# Test module interaction
$set_inputs(
app`filter1-dataset` = "iris",
`filter2-dataset` = "faithful"
)
<- app$get_text("#comparison")
comparison_text expect_true(grepl("Module 1.*Rows: 150", comparison_text)) # iris
expect_true(grepl("Module 2.*Rows: 272", comparison_text)) # faithful
$stop()
app })
Integration Testing with shinytest2
End-to-End Testing Workflows
Integration testing ensures that all components of your Shiny application work together correctly:
# Comprehensive integration test
test_that("complete application workflow", {
# Initialize the app
<- AppDriver$new(
app app_dir = "path/to/your/app",
name = "workflow-test",
height = 800,
width = 1200
)
# Test initial application state
expect_true(app$get_text("title") == "Your App Title")
expect_true(length(app$get_html("#main-content")) > 0)
# Test data loading workflow
$set_inputs(file_input = "test-data.csv")
app$wait_for_idle(2000) # Wait for file processing
app
# Verify data was loaded
expect_true(grepl("Data loaded successfully", app$get_text("#status")))
expect_true(app$get_value("nrows") > 0)
# Test filtering workflow
$set_inputs(
appdate_from = "2023-01-01",
date_to = "2023-12-31",
category_filter = c("A", "B")
)
# Wait for reactive updates
$wait_for_idle(1000)
app
# Verify filtering worked
<- app$get_value("filtered_rows")
filtered_count expect_true(filtered_count > 0)
expect_true(filtered_count <= app$get_value("total_rows"))
# Test visualization generation
$click("generate_plot")
app$wait_for_idle(3000) # Wait for plot generation
app
# Verify plot was created
expect_true(length(app$get_html("#main_plot")) > 0)
expect_false(grepl("error", app$get_text("#plot_status"), ignore.case = TRUE))
# Test download functionality
$click("download_data")
app# Note: Actual file download testing may require additional setup
# Test error handling
$set_inputs(invalid_input = "invalid_value")
app$wait_for_idle(1000)
app
<- app$get_text("#error_display")
error_message expect_true(nchar(error_message) > 0)
expect_true(grepl("error|invalid", error_message, ignore.case = TRUE))
# Clean up
$stop()
app })
Testing Asynchronous Operations
Many Shiny applications involve asynchronous operations that require special testing considerations:
# Testing async operations with promises
test_that("async data loading works correctly", {
<- AppDriver$new(test_app_with_async())
app
# Trigger async operation
$click("load_remote_data")
app
# Check loading state
expect_true(grepl("Loading", app$get_text("#status")))
expect_true(app$get_html("#spinner") != "")
# Wait for async operation to complete
$wait_for_idle(5000) # Adjust timeout as needed
app
# Verify completion
expect_true(grepl("Data loaded", app$get_text("#status")))
expect_false(app$get_html("#spinner") != "")
expect_true(app$get_value("data_ready") == TRUE)
$stop()
app
})
# Mock async operations for consistent testing
<- function() {
mock_async_data_loader promise(function(resolve, reject) {
# Simulate network delay
::later(function() {
later# Simulate potential failure
if (runif(1) < 0.1) { # 10% failure rate
reject("Network error")
else {
} resolve(data.frame(
id = 1:100,
value = rnorm(100),
timestamp = Sys.time()
))
}delay = 1)
},
}) }
# Testing database operations
<- function() {
setup_test_database # Create temporary database for testing
<- DBI::dbConnect(RSQLite::SQLite(), ":memory:")
test_db
# Setup test tables
::dbExecute(test_db, "
DBI CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
")
# Insert test data
::dbExecute(test_db, "
DBI INSERT INTO users (name, email) VALUES
('John Doe', 'john@example.com'),
('Jane Smith', 'jane@example.com'),
('Bob Johnson', 'bob@example.com')
")
test_db
}
test_that("database operations work correctly", {
# Setup test database
<- setup_test_database()
test_db
# Test within Shiny context
<- AppDriver$new(test_app_with_database(test_db))
app
# Test data loading
<- app$get_value("user_count")
user_count expect_equal(user_count, 3)
# Test user creation
$set_inputs(
appnew_user_name = "Test User",
new_user_email = "test@example.com"
)$click("create_user")
app$wait_for_idle(1000)
app
# Verify user was created
<- app$get_value("user_count")
updated_count expect_equal(updated_count, 4)
# Test duplicate email handling
$set_inputs(
appnew_user_name = "Another User",
new_user_email = "test@example.com" # Duplicate email
)$click("create_user")
app$wait_for_idle(1000)
app
# Should show error for duplicate email
<- app$get_text("#error_message")
error_msg expect_true(grepl("email.*already exists", error_msg, ignore.case = TRUE))
# Count should remain the same
<- app$get_value("user_count")
final_count expect_equal(final_count, 4)
# Cleanup
::dbDisconnect(test_db)
DBI$stop()
app })
Advanced Debugging Techniques
Reactive Programming Cheatsheet - Section 6 provides reactive debugging techniques, reactlog visualization, and common pitfall solutions.
Reactive Flow • Performance Debug • Common Pitfalls
Reactive Debugging
Understanding and debugging reactive dependencies is crucial for maintaining complex Shiny applications:
# Enable reactive logging for debugging
options(shiny.reactlog = TRUE)
# In your Shiny app, add debugging helpers
<- function() {
debug_reactive_app <- fluidPage(
ui titlePanel("Reactive Debugging Demo"),
sidebarLayout(
sidebarPanel(
numericInput("n", "Number of observations:", 100, min = 1, max = 1000),
selectInput("dist", "Distribution:",
choices = c("Normal" = "norm",
"Uniform" = "unif",
"Log-normal" = "lnorm")),
# Add debugging button
actionButton("debug_reactives", "Debug Reactives", class = "btn-warning")
),
mainPanel(
plotOutput("plot"),
verbatimTextOutput("summary")
)
)
)
<- function(input, output, session) {
server # Reactive expression with debugging
<- reactive({
data cat("DEBUG: Generating data with n =", input$n, "dist =", input$dist, "\n")
switch(input$dist,
norm = rnorm(input$n),
unif = runif(input$n),
lnorm = rlnorm(input$n)
)%>%
}) bindCache(input$n, input$dist) # Add caching for performance
# Observer for debugging reactive graph
observeEvent(input$debug_reactives, {
cat("=== REACTIVE DEPENDENCY GRAPH ===\n")
# Show reactive log
if (getOption("shiny.reactlog")) {
cat("Reactive log is enabled. Use reactlogShow() after stopping the app.\n")
}
# Manual dependency tracking
cat("Current reactive context:\n")
cat("- Input n:", input$n, "\n")
cat("- Input dist:", input$dist, "\n")
cat("- Data length:", length(data()), "\n")
})
$plot <- renderPlot({
outputcat("DEBUG: Rendering plot\n")
hist(data(), main = paste("Distribution:", input$dist))
})
$summary <- renderText({
outputcat("DEBUG: Generating summary\n")
<- data()
d paste(
"Summary Statistics:\n",
"Mean:", round(mean(d), 3), "\n",
"SD:", round(sd(d), 3), "\n",
"Min:", round(min(d), 3), "\n",
"Max:", round(max(d), 3)
)
})
}
shinyApp(ui, server)
}
# After running the app, use:
# reactlogShow() # Opens reactive dependency graph in browser
# Performance profiling for Shiny apps
<- function() {
profile_shiny_app # Use profvis to profile your app
library(profvis)
# Profile a specific operation
profvis({
# Simulate expensive operation
<- data.frame(
large_dataset x = rnorm(100000),
y = rnorm(100000),
category = sample(letters[1:10], 100000, replace = TRUE)
)
# Expensive aggregation
<- large_dataset %>%
summary_stats group_by(category) %>%
summarise(
mean_x = mean(x),
mean_y = mean(y),
count = n(),
.groups = 'drop'
)
# Expensive plotting operation
<- ggplot(large_dataset, aes(x = x, y = y, color = category)) +
p geom_point(alpha = 0.5) +
theme_minimal()
})
}
# Memory usage monitoring
<- function() {
monitor_memory_usage <- fluidPage(
ui titlePanel("Memory Usage Monitor"),
sidebarLayout(
sidebarPanel(
numericInput("data_size", "Data Size:", 1000, min = 100, max = 100000),
actionButton("generate_data", "Generate Data"),
actionButton("clear_data", "Clear Data"),
h4("Memory Usage:"),
verbatimTextOutput("memory_info")
),
mainPanel(
plotOutput("data_plot")
)
)
)
<- function(input, output, session) {
server <- reactiveValues(
values data = NULL,
memory_log = data.frame(
timestamp = Sys.time(),
memory_mb = as.numeric(pryr::mem_used()) / 1024^2
)
)
# Memory monitoring observer
observe({
invalidateLater(2000, session) # Update every 2 seconds
<- as.numeric(pryr::mem_used()) / 1024^2
current_memory
$memory_log <- rbind(
values$memory_log,
valuesdata.frame(
timestamp = Sys.time(),
memory_mb = current_memory
)
)
# Keep only last 50 records
if (nrow(values$memory_log) > 50) {
$memory_log <- tail(values$memory_log, 50)
values
}
})
observeEvent(input$generate_data, {
$data <- data.frame(
valuesx = rnorm(input$data_size),
y = rnorm(input$data_size),
category = sample(LETTERS[1:5], input$data_size, replace = TRUE)
)
})
observeEvent(input$clear_data, {
$data <- NULL
valuesgc() # Force garbage collection
})
$memory_info <- renderText({
outputif (nrow(values$memory_log) > 0) {
<- tail(values$memory_log$memory_mb, 1)
current_mem <- max(values$memory_log$memory_mb)
max_mem <- min(values$memory_log$memory_mb)
min_mem
paste(
"Current:", round(current_mem, 2), "MB\n",
"Peak:", round(max_mem, 2), "MB\n",
"Minimum:", round(min_mem, 2), "MB\n",
"Data Objects:", length(ls(values))
)
}
})
$data_plot <- renderPlot({
outputif (!is.null(values$data)) {
ggplot(values$data, aes(x = x, y = y, color = category)) +
geom_point(alpha = 0.6) +
theme_minimal() +
labs(title = paste("Data Size:", nrow(values$data), "points"))
}
})
}
shinyApp(ui, server)
}
# Logging system for comprehensive error tracking
<- function() {
setup_logging library(logger)
# Setup different log levels
log_threshold(DEBUG)
# File appender for persistent logging
log_appender(appender_file("logs/shiny-app.log"))
# Console appender for development
log_appender(appender_console)
# Custom formatter
log_formatter(formatter_glue_or_sprintf)
}
# Error handling wrapper for Shiny functions
<- function(expr, fallback = NULL, error_message = "An error occurred") {
safe_render tryCatch({
exprerror = function(e) {
}, # Log the error
log_error("Render error: {e$message}")
log_debug("Error details: {capture.output(traceback())}")
# Return fallback or error message
if (!is.null(fallback)) {
return(fallback)
else {
} return(div(
class = "alert alert-danger",
strong("Error: "), error_message,
br(),
small("Please check your inputs and try again.")
))
}
})
}
# Example usage in server
<- function(input, output, session) {
server setup_logging()
$safe_plot <- renderPlot({
outputsafe_render({
# Potentially error-prone plotting code
if (is.null(input$data_file)) {
stop("No data file uploaded")
}
<- read.csv(input$data_file$datapath)
data
if (nrow(data) == 0) {
stop("Data file is empty")
}
ggplot(data, aes(x = x, y = y)) + geom_point()
error_message = "Could not generate plot. Please check your data file.")
},
})
# Global error handler
$onSessionEnded(function() {
sessionlog_info("User session ended")
})
}
# User input validation
<- function(input, required_fields = character()) {
validate_inputs <- character()
errors
for (field in required_fields) {
if (is.null(input[[field]]) ||
is.character(input[[field]]) && input[[field]] == "") ||
(is.numeric(input[[field]]) && is.na(input[[field]]))) {
(<- c(errors, paste("Field", field, "is required"))
errors
}
}
if (length(errors) > 0) {
return(list(valid = FALSE, errors = errors))
else {
} return(list(valid = TRUE, errors = character()))
} }
Automated Testing with CI/CD
GitHub Actions Testing Pipeline
Set up automated testing that runs on every commit and pull request:
# .github/workflows/test.yml
name: Test Shiny Application
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
r-version: ['4.1', '4.2', '4.3']
steps:
- uses: actions/checkout@v3
- name: Set up R
uses: r-lib/actions/setup-r@v2
with:
r-version: ${{ matrix.r-version }}
use-public-rspm: true
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libcurl4-openssl-dev \
libssl-dev \
libxml2-dev \
libpng-dev
- name: Install R dependencies
uses: r-lib/actions/setup-r-dependencies@v2
with:
cache-version: 2
extra-packages: |
any::rcmdcheck
any::testthat
any::shinytest2
any::covr
- name: Run unit tests
run: |
R -e "testthat::test_dir('tests/testthat')"
- name: Run integration tests
run: |
R -e "source('tests/shinytest2/test-integration.R')"
- name: Test coverage
run: |
R -e "covr::codecov()" env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- name: Lint code
run: |
R -e "lintr::lint_dir('R')" R -e "lintr::lint_dir('tests')"
Continuous Integration Best Practices
Testing Configuration:
- Use matrix builds to test across multiple R versions
- Cache dependencies to speed up builds
- Separate unit tests from integration tests
- Include code coverage reporting
- Implement code linting and style checks
Test Data Management:
# Setup test fixtures
<- function() {
setup_test_fixtures # Create reproducible test data
set.seed(12345)
<- list(
test_data simple_data = data.frame(
x = 1:10,
y = rnorm(10),
category = rep(c("A", "B"), 5)
),
time_series = data.frame(
date = seq(as.Date("2023-01-01"), by = "day", length.out = 365),
value = cumsum(rnorm(365)),
trend = 1:365 * 0.1 + rnorm(365, 0, 0.5)
),
large_dataset = data.frame(
id = 1:10000,
value = rnorm(10000),
category = sample(letters[1:5], 10000, replace = TRUE),
timestamp = seq(
as.POSIXct("2023-01-01 00:00:00"),
by = "min",
length.out = 10000
)
)
)
# Save test fixtures
if (!dir.exists("tests/fixtures")) {
dir.create("tests/fixtures", recursive = TRUE)
}
saveRDS(test_data, "tests/fixtures/test_data.rds")
return(test_data)
}
# Load test fixtures in tests
<- function() {
load_test_fixtures <- "tests/fixtures/test_data.rds"
fixture_path
if (file.exists(fixture_path)) {
return(readRDS(fixture_path))
else {
} return(setup_test_fixtures())
} }
Performance Testing and Load Testing
Load Testing with shinyloadtest
Test how your application performs under realistic user loads:
# Record user session for load testing
library(shinyloadtest)
# 1. Record a typical user session
record_session("http://localhost:3838", "loadtest-recording.log")
# 2. Run load test with multiple concurrent users
shinycannon("loadtest-recording.log",
"http://localhost:3838",
workers = 5, # Number of concurrent users
loaded_duration_minutes = 10,
output_dir = "loadtest-results")
# 3. Generate load test report
shinyloadtest_report("loadtest-results")
# Comprehensive load testing setup
<- function() {
setup_load_testing # Test configuration
<- list(
config base_url = "http://localhost:3838",
test_scenarios = list(
light_load = list(workers = 2, duration = 5),
normal_load = list(workers = 10, duration = 10),
heavy_load = list(workers = 25, duration = 15),
stress_test = list(workers = 50, duration = 5)
)
)
# Run multiple test scenarios
<- list()
results
for (scenario_name in names(config$test_scenarios)) {
<- config$test_scenarios[[scenario_name]]
scenario
cat("Running", scenario_name, "scenario...\n")
# Create output directory
<- paste0("loadtest-results-", scenario_name)
output_dir
# Run load test
shinycannon(
"loadtest-recording.log",
$base_url,
configworkers = scenario$workers,
loaded_duration_minutes = scenario$duration,
output_dir = output_dir
)
# Parse results
<- parse_loadtest_results(output_dir)
results[[scenario_name]]
}
return(results)
}
# Analyze load test results
<- function(loadtest_results) {
analyze_performance <- list()
analysis
for (scenario in names(loadtest_results)) {
<- loadtest_results[[scenario]]
data
<- list(
analysis[[scenario]] avg_response_time = mean(data$response_time),
p95_response_time = quantile(data$response_time, 0.95),
max_response_time = max(data$response_time),
success_rate = mean(data$success) * 100,
throughput = nrow(data) / max(data$elapsed_time)
)
}
return(analysis)
}
# Resource monitoring during load tests
<- function(duration_minutes = 10) {
monitor_resources library(ps)
library(pryr)
<- data.frame(
monitoring_data timestamp = POSIXct(),
cpu_percent = numeric(),
memory_mb = numeric(),
disk_io_read = numeric(),
disk_io_write = numeric(),
network_bytes = numeric()
)
<- Sys.time()
start_time <- start_time + duration_minutes * 60
end_time
while (Sys.time() < end_time) {
# Get system metrics
<- ps_system_cpu_percent()
sys_info <- ps_system_memory()
mem_info
<- rbind(monitoring_data, data.frame(
monitoring_data timestamp = Sys.time(),
cpu_percent = sys_info,
memory_mb = mem_info$available / 1024^2,
disk_io_read = 0, # Would need system-specific implementation
disk_io_write = 0,
network_bytes = 0
))
Sys.sleep(5) # Monitor every 5 seconds
}
return(monitoring_data)
}
# Performance benchmarking
<- function() {
benchmark_operations library(microbenchmark)
# Test data
<- data.frame(
large_data x = rnorm(100000),
y = rnorm(100000),
category = sample(letters[1:10], 100000, replace = TRUE)
)
# Benchmark different operations
<- microbenchmark(
results data_filtering = {
<- large_data[large_data$category %in% c("a", "b", "c"), ]
filtered
},
aggregation = {
<- aggregate(x ~ category, large_data, mean)
agg
},
plotting_prep = {
<- large_data[sample(nrow(large_data), 1000), ]
sample_data
},
times = 10
)
return(results)
}
Common Issues and Solutions
Issue 1: Tests Failing in CI but Passing Locally
Problem: Tests work on your local machine but fail in the CI environment.
Solution:
# Environment-specific test configuration
<- function() {
configure_test_environment # Detect CI environment
<- Sys.getenv("CI") != "" || Sys.getenv("GITHUB_ACTIONS") != ""
is_ci
if (is_ci) {
# CI-specific settings
options(
shiny.testmode = TRUE,
shiny.sanitize.errors = FALSE,
timeout = 30 # Longer timeout for slower CI systems
)
# Set display for headless testing
if (Sys.getenv("DISPLAY") == "") {
Sys.setenv(DISPLAY = ":99")
}else {
} # Local development settings
options(
shiny.testmode = FALSE,
timeout = 10
)
}
}
# Use in test setup
::setup({
testthatconfigure_test_environment()
})
Issue 2: Flaky Integration Tests
Problem: Integration tests sometimes pass and sometimes fail without code changes.
Solution:
# Robust waiting strategies
<- function(condition_fn, timeout = 10, interval = 0.5) {
wait_for_condition <- Sys.time()
start_time
while (Sys.time() - start_time < timeout) {
if (condition_fn()) {
return(TRUE)
}Sys.sleep(interval)
}
FALSE
}
# Retry mechanism for flaky tests
<- function(test_fn, max_attempts = 3) {
retry_test for (attempt in 1:max_attempts) {
<- tryCatch({
result test_fn()
TRUE
error = function(e) {
}, if (attempt == max_attempts) {
stop(e)
}FALSE
})
if (result) break
# Wait before retry
Sys.sleep(1)
}
}
# Use in tests
test_that("flaky operation works reliably", {
retry_test(function() {
<- AppDriver$new(test_app())
app
# Wait for app to be fully loaded
expect_true(wait_for_condition(function() {
length(app$get_html("#main-content")) > 0
}))
$set_inputs(value = 100)
app
# Wait for reactive update
expect_true(wait_for_condition(function() {
$get_value("result") == 100
app
}))
$stop()
app
}) })
Issue 3: Memory Leaks in Long-Running Tests
Problem: Tests consume increasing memory over time, eventually causing failures.
Solution:
# Memory cleanup after tests
<- function() {
cleanup_memory # Clear large objects
rm(list = ls(envir = .GlobalEnv)[sapply(ls(envir = .GlobalEnv), function(x) {
object.size(get(x, envir = .GlobalEnv)) > 1024^2 # Objects > 1MB
envir = .GlobalEnv)
})],
# Force garbage collection
gc(verbose = FALSE, full = TRUE)
# Clear Shiny cache if using caching
if (exists("shiny_cache")) {
$clear()
shiny_cache
}
}
# Use in test teardown
::teardown({
testthatcleanup_memory()
})
# Monitor memory in tests
test_that("operation doesn't leak memory", {
<- pryr::mem_used()
initial_memory
# Perform operation multiple times
for (i in 1:100) {
<- expensive_operation()
result rm(result)
}
gc()
<- pryr::mem_used()
final_memory
# Memory increase should be minimal
<- as.numeric(final_memory - initial_memory)
memory_increase expect_lt(memory_increase, 10 * 1024^2) # Less than 10MB increase
})
- Start with unit tests for individual functions before integration testing
- Use meaningful test data that represents real-world scenarios
- Test edge cases including empty data, invalid inputs, and error conditions
- Implement retry mechanisms for tests that interact with external systems
- Monitor resource usage during tests to catch performance regressions
- Separate test environments from development to avoid interference
Common Questions About Shiny Testing and Debugging
Use testServer()
to create isolated testing environments where you can simulate user inputs and verify reactive behavior. Mock complex interactions by setting multiple input values in sequence and testing intermediate states. For complex workflows, break them into smaller, testable reactive components. Use reactiveValues()
to create controllable test scenarios and isolate()
to test specific reactive dependencies without triggering the entire reactive chain. Consider creating helper functions that encapsulate complex reactive logic, making them easier to unit test independently of the Shiny context.
Use a multi-layered testing approach: (1) Unit tests with mocked dependencies - create fake database connections and API responses for fast, reliable tests, (2) Integration tests with test databases - use lightweight databases like SQLite or Docker containers with test data, (3) Contract tests to verify API compatibility without depending on external services, (4) End-to-end tests with staging environments that mirror production. Use dependency injection patterns to make external dependencies easily replaceable during testing. The mockery
package helps create realistic mocks, while httptest
is excellent for API testing.
Enable reactive logging with options(shiny.reactlog = TRUE)
and use reactlogShow()
after running your app to visualize the reactive dependency graph. Look for: (1) Reactive loops where expressions invalidate each other cyclically, (2) Missing dependencies where reactives don’t update when they should, (3) Excessive dependencies where reactives update too often. Use isolate()
to break unnecessary dependencies and req()
to handle missing inputs gracefully. Add cat()
or browser()
statements to trace execution flow, and consider using debounce()
for user inputs that change rapidly.
Focus testing efforts on: (1) Business logic functions - these should have comprehensive unit tests, (2) Critical user workflows - test the most important user paths end-to-end, (3) Data processing pipelines - ensure data transformations work correctly, (4) Security-sensitive operations - authentication, authorization, and data validation. You can often skip testing: (1) Simple UI layouts without logic, (2) Third-party package functionality (unless you’re using it in unusual ways), (3) Trivial getter/setter functions, (4) Static content rendering. Use the 80/20 rule: focus on the 20% of functionality that represents 80% of the risk or user value.
Flaky tests usually indicate timing issues, external dependencies, or test environment problems. Common solutions: (1) Add explicit waits in integration tests using app$wait_for_idle()
or custom wait conditions, (2) Use test fixtures with known, controlled data instead of random or time-dependent data, (3) Mock external dependencies to eliminate network timeouts and service availability issues, (4) Reset application state between tests to prevent test interdependencies, (5) Run tests multiple times locally to identify patterns in failures. For Shiny-specific flakiness, ensure proper cleanup in session$onSessionEnded()
and use isolate()
to control reactive dependencies.
Local debugging allows interactive exploration with browser()
, debug()
, and IDE breakpoints, while production debugging relies primarily on logs and monitoring data. For production issues: (1) Implement comprehensive logging with different log levels (DEBUG, INFO, WARN, ERROR), (2) Use structured logging with JSON format for easier parsing, (3) Set up monitoring dashboards to identify issues proactively, (4) Create error tracking systems that aggregate and alert on errors, (5) Implement feature flags to quickly disable problematic features. Always test fixes in staging environments that mirror production before deploying. Never debug directly in production unless it’s a critical incident requiring immediate investigation.
Create test fixtures that represent different user roles and permission levels. Use helper functions to simulate login with different user types: login_as_admin()
, login_as_viewer()
, etc. Test both positive cases (users can access what they should) and negative cases (users cannot access what they shouldn’t). Mock authentication systems during testing to avoid dependencies on external identity providers. For role-based access control, create comprehensive test matrices that verify each permission combination. Use testServer()
to test server-side permission checks and shinytest2
to verify that UI elements are properly hidden/shown based on user roles.
Use profvis
to profile R code and identify slow functions - wrap your server logic with profvis({ ... })
to see detailed performance breakdowns. Monitor reactive execution patterns with reactive logging to identify inefficient reactive chains. Use browser developer tools to analyze network requests and JavaScript performance. Implement custom timing logs around expensive operations: start_time <- Sys.time(); ...; logger::log_info("Operation took {difftime(Sys.time(), start_time, units='secs')} seconds")
. For database operations, enable query logging to identify slow queries. Use shinyloadtest
for load testing to understand how performance degrades under concurrent user load. Memory profiling with pryr::mem_used()
helps identify memory leaks.
Test Your Understanding
You’re building a Shiny application that loads data from a database, applies user-selected filters, and generates interactive visualizations. What testing strategy would provide the most comprehensive coverage?
- Only unit tests for individual functions
- Only end-to-end tests with shinytest2
- Unit tests + Integration tests + End-to-end tests
- Manual testing only
- Consider the different components of the application
- Think about what each testing level can catch
- Remember the testing pyramid concept
C) Unit tests + Integration tests + End-to-end tests
A comprehensive testing strategy for this complex application requires multiple levels:
- Unit tests: Test individual functions like data processing, filtering logic, and validation functions
- Integration tests: Test database connections, module interactions, and reactive dependencies
- End-to-end tests: Test complete user workflows from data loading through visualization
This multi-layered approach catches different types of issues: - Unit tests catch logic errors quickly and cheaply - Integration tests catch component interaction issues - End-to-end tests catch user experience issues and complete workflow problems
Your Shiny app has a complex reactive chain where changes to one input should trigger updates in multiple outputs, but some outputs aren’t updating as expected. What’s the best debugging approach?
- Add
print()
statements throughout the reactive code
- Use
reactlog
to visualize the reactive dependency graph
- Rewrite all reactive expressions as observers
- Test each reactive expression in isolation
- Consider tools specifically designed for reactive debugging
- Think about visualizing dependencies vs. just printing values
- Remember that Shiny has built-in debugging capabilities
B) Use reactlog
to visualize the reactive dependency graph
reactlog
is specifically designed for debugging reactive dependencies in Shiny:
# Enable reactive logging
options(shiny.reactlog = TRUE)
# Run your app, then after stopping it:
reactlogShow() # Opens interactive dependency graph
This approach is superior because: - Visual representation shows the complete reactive dependency chain - Interactive exploration lets you see which reactives are invalidated - Timing information shows when each reactive fires - Dependency tracking reveals missing or unexpected connections
While print statements can help with individual values, they don’t show the relationship between reactive components, which is usually the source of complex reactive bugs.
Your Shiny app will be deployed to production where users might upload invalid data files. How should you implement error handling to provide the best user experience while maintaining debugging capabilities?
- Let errors crash the app so users know something went wrong
- Catch all errors silently and continue without showing anything
- Implement user-friendly error messages with detailed logging for developers
- Show the full R error message to users for transparency
- Consider the different audiences: users vs. developers
- Think about information that helps users vs. information that helps debugging
- Remember that error messages should be actionable
C) Implement user-friendly error messages with detailed logging for developers
This approach balances user experience with debugging needs:
# User-friendly error handling with developer logging
<- function(file_path) {
safe_file_processing tryCatch({
# Attempt file processing
<- read.csv(file_path)
data validate_data(data)
return(data)
error = function(e) {
}, # Log detailed error for developers
::log_error("File processing failed: {e$message}")
logger::log_debug("File path: {file_path}")
logger::log_debug("Stack trace: {paste(traceback(), collapse = '\n')}")
logger
# Return user-friendly error
stop("Unable to process the uploaded file. Please ensure it's a valid CSV with the required columns.")
}) }
Benefits: - Users get actionable error messages they can understand - Developers get detailed logs for debugging and monitoring - Application remains stable and doesn’t crash - Security is maintained by not exposing internal details to users
Conclusion
Comprehensive testing and debugging practices transform Shiny applications from functional prototypes into reliable, production-ready software. The strategies covered in this guide - from unit testing individual functions to implementing sophisticated error handling and monitoring systems - provide the foundation for building applications that users trust and developers can maintain confidently.
The investment in testing infrastructure pays dividends throughout the application lifecycle. Automated tests catch regressions before they reach users, debugging tools accelerate problem resolution, and robust error handling ensures graceful failure modes. These practices become particularly valuable as applications grow in complexity and user base.
Professional Shiny development requires this systematic approach to quality assurance. The testing strategies, debugging techniques, and error handling patterns you’ve learned represent industry best practices that scale from simple dashboards to enterprise applications serving thousands of users.
Next Steps
Based on what you’ve learned in this comprehensive testing and debugging guide, here are the recommended paths for applying these practices:
Immediate Next Steps (Complete These First)
- Code Organization and Structure - Implement proper project structure that supports comprehensive testing workflows
- Version Control with Git - Set up version control practices that integrate with your testing pipeline
- Practice Exercise: Add comprehensive testing to an existing Shiny application, starting with unit tests for business logic functions
Building on Your Foundation (Choose Your Path)
For Production Deployment Focus:
- Security Best Practices and Guidelines
- Production Deployment Overview
- Docker Containerization for Shiny
For Advanced Development Focus:
For Package Development:
Long-term Goals (2-4 Weeks)
- Implement a complete CI/CD pipeline with automated testing for your Shiny projects
- Create a standardized testing framework that can be reused across multiple applications
- Build monitoring and alerting systems for production Shiny applications
- Contribute testing utilities or debugging tools to the Shiny community
Explore More Articles
Here are more articles from the same category to help you dive deeper into the topic.
Reuse
Citation
@online{kassambara2025,
author = {Kassambara, Alboukadel},
title = {Shiny {Testing} and {Debugging:} {Build} {Reliable}
{Applications}},
date = {2025-05-23},
url = {https://www.datanovia.com/learn/tools/shiny-apps/best-practices/testing-debugging.html},
langid = {en}
}