Do you want to share your content on R-bloggers? Click here if you have a blog, or here If you don’t.
Your shiny app has business logic everywhere.
Testing it feels impossible. Every time you touch one piece, three others break. Dependencies beat together as headphone cables in your pocket.
There is a cleaner way.
Interfaces determine what, not how
With R6 interfaces you can bundle concepts together without locking the implementation data. Your company logic depends on contracts, no concrete classes.
Consider a shiny app that must analyze customer data. Sometimes you want real database questions. During testing you want fast fake. In Acceptance tests you need controlled scenarios.
The interface remains the same. The implementation is changing.
Level of your test game! Take your copy of the R -Routekaart van de R.
Implement the interface
Define the behavior of your interface. What methods should it have? Which parameters? Which return values?
Let all methods give a mistake. In the event that a class that implements this interface, forget to ignore one, it must fail loudly.
CustomerAnalyzerInterface <- R6::R6Class(
classname = "CustomerAnalyzerInterface",
public = list(
initialize = function() {
rlang::abort("This class cannot be instantiated.")
},
get_customer_metrics = function(customer_id) {
rlang::abort("Not implemented.")
},
calculate_risk_score = function(metrics) {
rlang::abort("Not implemented.")
},
generate_recommendations = function(customer_id, risk_score) {
rlang::abort("Not implemented.")
}
)
)Depending on what your workflow is, you make concrete implementations, either real or fake.
Choose your workflow
Inside out
If you want to take the company logic, you write tests for the real implementation and implement it. It may be worth checking whether what you should calculate is even possible.
- Can you get the data you need?
- Can you perform the calculations in a reasonable time?
This approach can lead to a more complex real implementation, but you will know that it works. You can always write an adapter to operate the data in the app later.
Out in
If you want the app to work, write the fake implementation to serve fake data in the app. Focus on the experience of the user and developer.
- Can users get what they need in the app?
- What should the user interface look like?
- Which code interface makes serving the interface easy?
What you learn in this phase can inform the real implementation later.
The real implementation
Your production class does the heavy work. It makes a connection with databases, calls on APIs, performs expensive calculations.
CustomerAnalyzer <- R6::R6Class(
classname = "CustomerAnalyzer",
inherit = CustomerAnalyzerInterface,
public = list(
initialize = function(...) {
# Set up whatever is needed to work in production
},
get_customer_metrics = function(customer_id) {
# Query production database
# Call analytics service
# Return complex calculations
},
calculate_risk_score = function(metrics) {
# Run ML model prediction
# Factor in market conditions
# Return weighted score
},
generate_recommendations = function(customer_id, risk_score) {
# Query recommendation engine
# Apply business rules
# Return personalized actions
}
)
)The fake implementation for testing
Testing needs control. Your fake returns known values every time.
CustomerAnalyzerFake <- R6::R6Class(
classname = "CustomerAnalyzerFake",
inherit = CustomerAnalyzerInterface,
public = list(
initialize = function() {},
get_customer_metrics = function(customer_id) {
list(
revenue = 5000,
retention = 0.85,
satisfaction = 4.2
)
},
calculate_risk_score = function(metrics) {
0.3
},
generate_recommendations = function(customer_id, risk_score) {
c("Schedule follow-up call", "Send satisfaction survey")
}
)
)Make a factory for instantiation
Implement a make_/create_“build_“get_/new_ Function that chooses the right implementation and initializes. Environmental variables or configuration files regulate the choice.
make_customer_analyzer <- function(
type = c("real", "fake"),
... # additional parameters for initialization
) {
type <- match.arg(type)
switch(
type,
real = CustomerAnalyzerReal$new(),
fake = CustomerAnalyzerFake$new()
)
}Shiny app remains clean and testable
Company logic depends on the interface. Implementation data hide behind the factory. External dependencies become interchangeable. You can still develop the app if the production -DB is no longer.
server <- function(input, output, session) {
analyzer_type <- Sys.getenv("ANALYZER_TYPE", "fake")
analyzer <- make_customer_analyzer(analyzer_type)
output$metrics <- renderPlot({
req(input$customer_id)
metrics <- analyzer$get_customer_metrics(input$customer_id)
chart(metrics)
})
# ... other server logic
}Testing becomes simple
Use the same thing to make the copy in your tests such as in production. This is the public interface of the company logic, not the classes itself.
Test that logic is correct in the real implementation:
test_that("high risk customers get urgent recommendations", {
# Arrange
analyzer <- make_customer_analyzer("real")
# Act
recommendations <- analyzer$generate_recommendations("customer-123", 0.9)
# Assert
expect_set_equal(recommendations, c("Immediate outreach"))
})Test that the fake fulfills the interface contract:
test_that("fake customer analyzer returns a recommendation", {
# Arrange
analyzer <- make_customer_analyzer("fake")
# Act
recommendations <- analyzer$generate_recommendations("123", 0.3)
# Assert
expect_s3_class(recommendations, "character")
})To test the app itself, use the fake implementation to serve predictable data. Especially if it depends on fragile external dependencies. Whether it is APIs, databases or file systems.
test_that("app shows customer metrics", {
# Arrange
withr::with_envvar(c(ANALYZER_TYPE = "fake"), {
app <- AppDriver$new("path/to/app")
})
# Act
app$set_inputs(customer_id = "123")
# Assert
output <- app$get_value("metrics")
expect_true(is.character(output$src))
})Do not test the same logic twice. Use fakes for acceptance tests. You get a better separation of worries, faster and more reliable tests.
Acceptance tests arrange full scenarios
The same code that is carried out in production in your tests. Only the data source changes.
This pattern transforms fragile apps into testable systems. Your company logic will remain isolated. Dependencies become interchangeable. Tests become quickly and reliable.
Stop fighting confused code. Start designing with interfaces.
Related
#interfaces #backend #Define #RBloggers


