Ensuring Your Shiny App's Production Stability: Testing with Targets

Reading time:
time
min
By:
Jakub Sobolewski
September 27, 2023

We developed an app that visualizes data from more than 100 studies standardized to one data model, and it didn’t go as smoothly as expected… <ul><li style="font-weight: 400;" aria-level="1">“Why would I want to test the app with production data?”</li><li style="font-weight: 400;" aria-level="1">“Why should I extend my testing suite with {targets} if I already have unit-tests?”</li><li style="font-weight: 400;" aria-level="1">“Isn't {targets} only for data science pipelines?”</li><li style="font-weight: 400;" aria-level="1">“How can I save time on manual tests?”</li></ul> Those were the questions we faced during that project, read further to learn about our answers. Table of contents: <ul><li><a href="#1">The project</a></li><li><a href="#2">How we ensured the app worked correctly on new data</a></li><li><a href="#3">Why should you use targets?</a></li><li><a href="#4">Example: The test app</a></li><li><a href="#5">What about unit tests?</a></li><li><a href="#6">Setting up targets tests as a directory of a Rhino app</a></li><li><a href="#7">Reuse existing unit tests</a></li><li><a href="#8">Running a single test from targets pipeline</a></li><li><a href="#9">Expanding the pipeline to run on multiple datasets</a></li><li><a href="#10">Expanding the pipeline to run multiple tests</a></li><li><a href="#11">Maintaining the pipeline</a></li><li><a href="#12">Re-running targets when dataset is added or updated</a></li><li><a href="#13">Caveats of using targets with box</a></li><li><a href="#14">How it integrates with the software development lifecycle?</a></li><li><a href="#15">Impact</a></li></ul> <hr /> <h2 id="1">The project</h2> During the first stages of development, when most of the app architecture was implemented, we worked on one sample study. <ul><li style="font-weight: 400;" aria-level="1">The study introduced a logical data model that helped us to learn about the domain.</li><li style="font-weight: 400;" aria-level="1">It allowed us to integrate the app with constraints posed by the model.</li></ul> As more studies were loaded into the database we noticed that new edge cases were introduced, some studies were not compliant with the rules that the app was built on. Those edge cases were not present because studies didn’t comply with a schema (such errors would be caught by<a href="https://appsilon.github.io/data.validator/articles/targets_workflow.html" target="_blank" rel="noopener"> data validation pipeline</a>), but it introduced new cases which were woven into datasets themselves, making the project <b>more complex than imagined.</b> <h2 id="">How we ensured the app worked correctly on new data</h2> Our process for testing the app with new studies was as follows. <ul><li style="font-weight: 400;" aria-level="1">Run the app manually, see if or where errors happen.</li><li style="font-weight: 400;" aria-level="1">Capture a bug report or mark the study as one that’s working as expected.</li><li style="font-weight: 400;" aria-level="1">If a bug was caught, decide on a correct solution (in agreement with a business expert).</li><li style="font-weight: 400;" aria-level="1">Put the business acceptance criteria in a backlog item and plan to implement it.</li><li style="font-weight: 400;" aria-level="1">Ensure new cases in data are captured as unit-tests.</li></ul> This approach involves a lot of time spent on manual tests done on production datasets, as those new errors don’t appear in test datasets. The time spent on testing is even longer when datasets are added in cohorts, few at the same time, effectively reducing our time spent on app development. This leads us to an idea, what if we run automated tests with production data as a method for bug discovery and regression? <h2 id="2">Why should you use targets?</h2> From the<a href="https://books.ropensci.org/targets/" target="_blank" rel="noopener"> targets manual</a> we learn that it: […] coordinate the pieces of computationally demanding analysis projects. The<a href="https://docs.ropensci.org/targets/" target="_blank" rel="noopener"> targets</a> package is a<a href="https://www.gnu.org/software/make/" target="_blank" rel="noopener"> Make</a>-like pipeline tool for statistics and data science in R. The package skips costly runtime for tasks that are already up to date, orchestrates the necessary computation with implicit parallel computing, and abstracts files as R objects. If we have 100 datasets to run tests on, even if tests for one dataset takes 1 minute, it’s 100 minutes of tests runtime! But this amount of time is even longer for manual testing, so there is a lot of potential to <b>save time and money.</b> In our case, there were 3 triggers for re-running tests: <ul><li style="font-weight: 400;" aria-level="1">update of a study,</li><li style="font-weight: 400;" aria-level="1">addition of a new study,</li><li style="font-weight: 400;" aria-level="1">change in production code.</li></ul> Given those triggers, it becomes apparent that those tests would need to be run quite often. Now, let’s imagine the app hasn’t changed since the upload of the 101st dataset. If it was a simple testing script, we would need to rerun all 101 cases, but targets caches results and runs only for the tests for the 101st dataset, providing feedback much faster than a regular script. Given those triggers and potential for caching, running tests with live data sounds very close to the core purpose of targets. <h2 id="3">Example: The test app</h2> The app displays the dataset as a table, and it plots a scatterplot with Y axis variable selectable using a dropdown. It’s a very simple app to showcase the method, your app can be arbitrarily complex, but the setup of such tests will be the same. <a href="https://github.com/jakubsob/targets_shiny_app_tests" target="_blank" rel="noopener"><b>Check out this repository to see the full project.</b></a> <img class="aligncenter size-full wp-image-20965" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01a3f817460dff133582f_1.webp" alt="" width="1054" height="600" /> <pre><code> #' app/main.R #' @export ui &lt;- function(id) {  ns &lt;- NS(id)  bootstrapPage(    fluidRow(      column(6, table$ui(ns("table"))),      column(6, plot$ui(ns("plot")))    )  ) } <br>#' @export server &lt;- function(id) {  moduleServer(id, function(input, output, session) {    db &lt;- database$new()    data &lt;- db$get_data()    table$server("table", data)    plot$server("plot", data)  }) } </code></pre> Two main features of the app are split into modules. The responsibility of the <code>table</code> module is just to display the dataset, the responsibility of the <code>plot</code> module is to select a variable and display a scatter plot. Since there are no interactions between modules, we will focus on testing modules in isolation. To test how modules interact with each other, you can use shinytest2. Refer to<a href="https://rstudio.github.io/shinytest2/articles/robust.html" target="_blank" rel="noopener"> this vignette</a> for more details. <h2 id="4">What about unit tests?</h2> It’s important to have unit tests as the feedback we get from them is very fast, if set up correctly they have the potential to catch many undesired changes in behavior of the app. But since unit tests run on fixed test data, they won’t show if the code is not working as expected on new data. Let’s take a look at unit tests of <code>plot</code> module: <pre><code> #' tests/testthat/test-plot.R box::use(  testthat[...],  shiny[testServer],  jsonlite[fromJSON],  mockery[    expect_args,    mock,    stub,  ], ) <br>box::use(  module = app/view/modules/plot, ) <br>expect_plot &lt;- function(x) {  x &lt;- fromJSON(x)  expect_is(x, "list")  expect_setequal(names(x), c("x", "evals", "jsHooks", "deps")) } <br>describe("plot", {  it("should render a plot", {    # Arrange    args &lt;- list(      data = data.frame(        x = 1:10,        y = 1:10,        z = 11:20      )    ) <br>    testServer(module$server, args = args, {      # Act      session$setInputs(variable = "y")      plot &lt;- output$plot <br>      # Assert      expect_plot(plot)    })  }) <br>  it("should pass data to plotting function", {    # Arrange    args &lt;- list(      data = data.frame(        x = 1:10,        y = 1:10,        z = 11:20      )    )    mock_plot &lt;- mock()    stub(module$server, "scatterplot$plot", mock_plot) <br>    testServer(module$server, args = args, {      # Act      session$setInputs(variable = "y")      plot &lt;- output$plot <br>      # Assert      expect_args(        mock_plot,        n = 1,        data = data.frame(          x = 1:10,          value = 1:10        )      )    })  }) }) </code></pre> They focus on testing 2 behaviors of the module: <ol><li style="font-weight: 400;" aria-level="1">Asserting that it produces a plot, by checking if output is a <code>htmlwidget</code>.</li><li style="font-weight: 400;" aria-level="1">Asserting that it passes correct data to the plotting function, this includes a data preparation step – selection of a variable.</li></ol> Notice we’re not asserting how the plot will look like, with each dataset the plot will look differently. Asserting only on the type of the returned object allows us to run these tests on multiple test datasets and test the property of the code that it produces a given widget. If something goes wrong, we should expect it to throw an error, for example when input data validation fails in plot function: <pre><code> #' app/view/plotting/scatterplot.R box::use(  echarts4r[    e_charts,    e_scatter,    echarts4rOutput,    renderEcharts4r,  ],  checkmate[    assert_data_frame,    assert_numeric,    assert_subset,  ], ) <br>#' @export plot &lt;- function(data) { assert_data_frame(data) assert_subset(c("x", "value"), colnames(data)) assert_numeric(data$x) assert_numeric(data$value) data |&gt;    e_charts(x) |&gt;    e_scatter(value) } <br>#' @export output &lt;- echarts4rOutput <br>#' @export render &lt;- renderEcharts4r </code></pre> Whether the plot produces a correct output should be a responsibility of <code>scatterplot</code> module tests. In those tests, we can introduce a more controlled environment in which we can assert if it produces the correct visual given certain data. This test falls more into an<a href="http://xunitpatterns.com/acceptance%20test.html" target="_blank" rel="noopener"> acceptance test</a> category. How to implement such a test is out of scope of this article. It could be implemented with the<a href="https://github.com/r-lib/vdiffr" target="_blank" rel="noopener"> vdiffr </a>package – we expect that for given data a certain image will be produced – if the implementation of the function changes we can accept or reject the image that it produces. <h2 id="5">Step 1: Setting up targets tests as a directory of a Rhino app</h2> Targets usually serves as a project on its own, but we can leverage Rhino app flexibility to place targets in one of the project directories. Since we’re using targets for tests, let’s create a <code>tests/targets/</code> directory and add <code>tests/targets/_targets.R</code> which is the starting point of any targets' project. Let’s add a code&gt;_targets.yaml config file in project root directory: <pre><code> #' _targets.yaml main:  script: tests/targets/_targets.R  store: tests/targets/_targets </code></pre> The script entry enables running targets' pipeline with <code>targets::tar_make()</code> command from <code>tests/targets/_targets.R</code> script. The <code>store</code> entry configures a directory where targets metadata and results will be stored. <h2 id="6">Step 2: Reuse existing unit tests</h2> In case we already have test cases that we want to rerun with production data, we can extract those cases in a way that they can be used in targets and in unit tests to reduce code repetition. Let’s start with the first test case of <code>plot</code> module: <pre><code> #' tests/testthat/test-plot.R describe("plot", {  it("should render a plot", {    # Arrange    args &lt;- list(      data = data.frame(        x = 1:10,        y = 1:10,        z = 11:20      )    ) <br>    testServer(module$server, args = args, {      # Act      session$setInputs(variable = "y")      plot &lt;- output$plot <br>      # Assert      expect_plot(plot)    })  }) <br>  ... }) </code></pre> We will separate Act and Assert blocks from Arrange, as only Arrange block will be different for unit tests and target tests. <pre><code> #' tests/testthat/test-plot.R assert_render_plot &lt;- function(args) {  testServer(module$server, args = args, {    # Act    session$setInputs(variable = "y")    plot &lt;- output$plot <br>    # Assert    expect_plot(plot)  }) } <br>describe("plot", {  it("should render a plot", {    # Arrange    args &lt;- list(      data = data.frame(        x = 1:10,        y = 1:10,        z = 11:20      )    ) <br>    # Act &amp; Assert    assert_render_plot(args)  }) <br>  ... }) </code></pre> Now the <code>assert_render_plot</code> can be extracted to another module that will be used by <code>tests/testtthat/test-plot.R</code> and <code>tests/targets/_targets.R</code>. Let’s introduce <code>test_cases</code> module, <code>tests</code> directory will look like this: <pre><code> ├── tests │   ├── targets │   │   ├── _targets.R │   ├── test_cases │   │   ├── __init__.R │   │   ├── plot.R │   ├── testthat │   │   ├── test-plot.R │   │   ├── test-table.R </code></pre> We’ll use <code>__init__.R</code> file in <code>tests/test_cases/</code> to be able to<a href="https://appsilon.github.io/rhino/articles/explanation/box-modules.html#reexports" target="_blank" rel="noopener"> reexport</a> test cases for multiple modules. <code>tests/test_cases/plot.R</code> will look like this: <pre><code> #' tests/test_cases/plot.R box::use(  testthat[    expect_is,    expect_setequal,  ],  shiny[testServer],  jsonlite[fromJSON], ) <br>box::use(  module = app/view/modules/plot, ) <br>expect_plot &lt;- function(x) {  x &lt;- fromJSON(x)  expect_is(x, "list")  expect_setequal(names(x), c("x", "evals", "jsHooks", "deps")) } <br>#' @export assert_render_plot &lt;- function(args) {  testServer(module$server, args = args, {    # Act    session$setInputs(variable = "y")    plot &lt;- output$plot <br>    # Assert    expect_plot(plot)  }) } </code></pre> And <code>tests/test_cases/__init__.R</code> being: <pre><code> #' tests/test_cases/__init__.R #' @export box::use(  tests/test_cases/plot, ) </code></pre> And <code>tests/testthat/test-plot.R</code> updated to reuse imported test case: <pre><code> #' tests/testthat/test-plot.R box::use(  module = app/view/modules/plot,  test_cases = tests/test_cases/plot, ) <br>describe("plot", {  it("should render a plot", {    # Arrange    args &lt;- list(      data = data.frame(        x = 1:10,        y = 1:10,        z = 11:20      )    ) <br>    # Act &amp; Assert    test_cases$assert_render_plot(args)  })  ... }) </code></pre> <h2 id="7">Step 3: Running a single test from targets pipeline</h2> Now we are ready to run our first test case from targets. <pre><code> #' tests/targets/_targets.R box::use(  targets[    tar_target,  ] ) <br>box::use(  tests/test_cases, ) <br>dataset &lt;- data.frame(x = 1:10, y = 1:10, z = 1:10) <br>list(  tar_target(    name = test,    command = test_cases$plot$assert_render_plot(list(data = dataset))  ) ) </code></pre> This script will run an <code>assert_render_plot</code> case with the provided dataset. We’ve defined it by hand, but it could be a dataset fetched from a production database. When inspecting the pipeline with <code>targets::tar_visnetwork()</code> we see that our test case depends on the test_cases module and dataset. The test will be invalidated anytime the dataset or test_case module changes. <img class="aligncenter size-full wp-image-20967" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01a40410a00724850915f_2.webp" alt="" width="661" height="309" /> Running the pipeline with <code>targets::tar_make()</code> will produce artifacts stored in <code>tests/targets/_targets/</code> as configured in <code>_targets.yaml</code>. Let’s check if the test has the ability to fail. After all, we're interested in the conditions in which our code fails to execute. Let’s try with a given dataset: <pre><code> dataset &lt;- data.frame(x = as.character(1:10), y = as.character(1:10)) </code></pre> It will result in pipeline failing on input data validation as plot function expects numeric x and y variables, it will fail on validation of x variable, as it’s the first assertion: <pre><code> r$&gt; targets::tar_make() ▶ start target test Loading required package: shiny ✖ error target test ▶ end pipeline [0.118 seconds] Error: ! Error running targets::tar_make()  Error messages: targets::tar_meta(fields = error, complete_only = TRUE)  Debugging guide: &lt;https://books.ropensci.org/targets/debugging.html&gt;  How to ask for help: &lt;https://books.ropensci.org/targets/help.html&gt;  Last error: Assertion on 'data$x' failed: Must be of type 'numeric', not 'character'.dataset &lt;- data.frame(x = as.character(rnorm(10, 5, 3)), y = as.character(rnorm(10, 50, 12)) </code></pre> <img class="aligncenter size-full wp-image-20969" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01a42e64b1cc59a11ecca_3.webp" alt="" width="732" height="490" /> <code>targets::tar_load()</code> function can be used to load pipeline results from a selected node. Our result is stored in <code>test</code> node: <pre><code> targets::tar_load(test) </code></pre> But the loaded <code>test</code> object is <code>NULL</code> as <code>test</code> node didn’t produce any output. We need to collect errors from all test cases to be reported at the end of the pipeline. To capture them, we can use <a href="https://purrr.tidyverse.org/reference/safely.html" target="_blank" rel="noopener">purrr::safely</a> function. It modifies a function to return not only the result value, but also errors. <pre><code> #' tests/targets/_targets.R box::use(  targets[    tar_target,  ],  purrr[    safely,  ] ) <br>box::use(  tests/test_cases, ) <br>dataset &lt;- data.frame(x = as.character(1:10), y = as.character(1:10)) <br>list(  tar_target(    name = test,    command = safely(test_cases$plot$assert_render_plot)(list(data = dataset))  ) ) </code></pre> After rerunning the pipeline, we get the following: <pre><code> r$&gt; targets::tar_load(test) r$&gt; test $result NULL <br>$error <br></code></pre> Now the pipeline allows us to easily target (pun intended) the function which fails and to see the exact error message it produced. <h2 id="8">Step 4: Expanding the pipeline to run on multiple datasets</h2> Let’s increase the number of test datasets to 2, from now on it won’t matter if it’s 2 datasets or 100. Iteration in targets is done via branching. There are 2 branching options: <ul><li style="font-weight: 400;" aria-level="1"><a href="https://books.ropensci.org/targets/dynamic.html" target="_blank" rel="noopener">dynamic</a>,</li><li style="font-weight: 400;" aria-level="1">and<a href="https://books.ropensci.org/targets/static.html" target="_blank" rel="noopener">static</a>.</li></ul> In this example we’ll use static branching strategy as it<a href="https://books.ropensci.org/targets/static.html#when-to-use-static-branching" target="_blank" rel="noopener"> allows you to see all branches upfront when inspecting the pipeline with targets::tar_visnetwork()</a>. The input of static branching is either a list or a data.frame. In the case of data.frame the iteration is rowwise. Let’s create a list of datasets and the grid and use <a href="https://books.ropensci.org/targets/static.html#map" target="_blank" rel="noopener">tarchetypes::tar_map</a> to iterate over the parameters: <pre><code> #' tests/targets/_targets.R box::use(  targets[    tar_target,  ],  tarchetypes[    tar_combine,    tar_map,  ],  purrr[    safely,  ],  dplyr[    bind_rows,    tibble,  ],  rlang[`%||%`] ) <br>box::use(  tests/test_cases, ) <br>datasets &lt;- list(  "1" = data.frame(x = as.character(1:10), y = as.character(1:10)),  "2" = data.frame(x = 1:10, y = 1:10) ) <br>grid &lt;- tibble(  dataset_name = c("1", "2"),  module = c("plot", "plot"),  test_case = c("assert_render_plot", "assert_render_plot") ) <br>safe_test &lt;- function(module, test_case, dataset_name) {  # Get data from datasets list  data &lt;- datasets[[dataset_name]]  # Get test function from a module  test_function &lt;- test_cases[[module]][[test_case]]  result &lt;- safely(test_function)(list(data = data))  tibble(    module = module,    test_case = test_case,    dataset_name = dataset_name,    # Don't omit rows in which error == NULL by replacing it with NA    error = as.character(result$error %||% NA)  ) } <br>test_grid &lt;- list(  tar_map(    values = grid,    tar_target(      name = test,      command = safe_test(module, test_case, dataset_name)    )  ) ) <br>test_results &lt;- tar_combine(  name = results,  test_grid,  command = bind_rows(!!!.x, .id = "id") ) <br>list(  test_grid,  test_results ) </code></pre> <img class="aligncenter size-full wp-image-20971" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01a44299b8d6d842612a6_4.webp" alt="" width="595" height="420" /> <pre><code> r$&gt; targets::tar_make() ▶ start target test_1_plot_assert_render_plot Loading required package: shiny ● built target test_1_plot_assert_render_plot [0.167 seconds] ▶ start target test_2_plot_assert_render_plot ● built target test_2_plot_assert_render_plot [0.173 seconds] ▶ start target results ● built target results [0.001 seconds] ▶ end pipeline [0.398 seconds] </code></pre> The result of the pipeline is now a single data.frame: <pre><code> r$&gt; targets::tar_load(results) <br>r$&gt; results # A tibble: 2 × 5  id                             module test_case          dataset_name error                                                                                                                     1 test_1_plot_assert_render_plot plot   assert_render_plot 1            "Error in scatterplot$plot(select(dat… 2 test_2_plot_assert_render_plot plot   assert_render_plot 2             </code></pre> Inspecting the data.frame tells us which test cases fail, it’s easy to trace back what conditions made the code to fail. Having the result as a data.frame allows us to use it to create a custom test report, e.g., in Markdown format, which may be a better format to display results for app stakeholders. <img class="aligncenter size-full wp-image-20973" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01a450b72d063b067ac84_5.webp" alt="" width="959" height="196" /> <h2 id="9">Step 5: Expanding the pipeline to run multiple tests</h2> To add more tests, we may either expand the list of test cases for plot module or add a test case for table module. Let’s do the latter. It requires us to do the following: <ul><li style="font-weight: 400;" aria-level="1">Add tests/test_cases/table.R module</li><li style="font-weight: 400;" aria-level="1">refactor tests/testthat/test-table.R to reuse test case from tests/test_cases/table.R</li><li style="font-weight: 400;" aria-level="1">export table module from tests/test_cases/__init__.R</li><li style="font-weight: 400;" aria-level="1">expand parameters grid in tests/targets/_targets.R</li></ul> <pre><code> #' tests/targets/_targets.R box::use(  targets[    tar_target,  ],  tarchetypes[    tar_combine,    tar_map,  ],  purrr[    safely,  ],  dplyr[    bind_rows,    tibble,  ],  rlang[`%||%`] ) <br>box::use(  tests/test_cases, ) <br>datasets &lt;- list(  "1" = data.frame(x = as.character(1:10), y = as.character(1:10)),  "2" = data.frame(x = 1:10, y = 1:10) ) <br>grid &lt;- tibble(  dataset_name = c("1", "2", "1", "2"),  module = c("plot", "plot", "table", "table"),  test_case = c("assert_render_plot", "assert_render_plot", "assert_render_table", "assert_render_table") ) <br>safe_test &lt;- function(module, test_case, dataset_name) {  data &lt;- datasets[[dataset_name]]  test_function &lt;- test_cases[[module]][[test_case]]  result &lt;- safely(test_function)(list(data = data))  tibble(    module = module,    test_case = test_case,    dataset_name = dataset_name,    error = as.character(result$error %||% NA)  ) } <br>test_grid &lt;- list(  tar_map(    values = grid,    tar_target(      name = test,      command = safe_test(module, test_case, dataset_name)    )  ) ) <br>test_results &lt;- tar_combine(  name = results,  test_grid,  command = dplyr::bind_rows(!!!.x) ) <br>list(  test_grid,  test_results ) </code></pre> Now the pipeline will run 4 tests: <img class="aligncenter size-full wp-image-20975" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01a45f9568bdc7530c349_6.webp" alt="" width="683" height="455" /> <pre><code> r$&gt; targets::tar_load(results) r$&gt; results # A tibble: 4 × 4  module test_case           dataset_name error                                                                               1 plot   assert_render_plot  1            "Error in scatterplot$plot(sel… 2 plot   assert_render_plot  2             NA                             3 table  assert_render_table 1            "Error in table$table(data): A… 4 table  assert_render_table 2            "Error in table$table(data): A… </code></pre> <img class="aligncenter size-full wp-image-20977" src="https://webflow-prod-assets.s3.amazonaws.com/6525256482c9e9a06c7a9d3c%2F65b01a472a1148ae6b65fd55_7.webp" alt="" width="959" height="363" /> <h2 id="10">Step 6: Maintaining the pipeline</h2> Other than keeping a single repository for parameterized test cases, automatic creation of parameters grid for the pipeline will help with its maintenance. Knowing how to list datasets on which we want pipeline to run and using <code>ls()</code> to list objects in the <code>box</code> module, we can use <code>tidyr::expand_grid</code> to automatically create the grid and never bother to update <code>tests/targets/_targets.R</code> script. <h2 id="11">Step 7: Re-running targets when dataset is added or updated</h2> In the provided example, we have in-memory test datasets, but it’s easy to refactor the code to pull datasets from a remote database. In this case, we can provide a method that will poll the database to list the tables and check if they have been updated. It could be a pair of table names and an update timestamp. When this tuple is passed to <code>safe_test</code> function it will become its dependency, re-running only targets for new tables, or tables with changed timestamp. <h2 id="12">Caveats of using targets with {box}</h2> Functions imported with box are placed in an environment. Targets will depend on the modules from which we import test cases. That means if we change one case, the <code>tests/test_cases</code> module will change, effectively invalidating all cases. To not depend on module changing, we can take advantage of local <code>box</code> imports and refactor the <code>safe_test</code> function to be: <pre><code> #' tests/targets/_targets.R safe_test &lt;- function(module, test_case, dataset_name) {  # Don;t depend on test cases module  box::use(    tests/test_cases,  )  data &lt;- datasets[[dataset_name]]  test_function &lt;- test_cases[[module]][[test_case]]  result &lt;- safely(test_function)(list(data = data))  tibble(    module = module,    test_case = test_case,    dataset_name = dataset_name,    error = as.character(result$error %||% NA)  ) } </code></pre> Notice that this will also break dependency of target nodes on changes in tested functions as well. To invalidate pipeline nodes, we can introduce dependency on modules' production code: <pre><code> #' tests/targets/_targets.R box::use(  app/view/modules, ) <br>safe_test &lt;- function(module, test_case, dataset_name) {  # Don't depend on test cases module  box::use(    tests/test_cases,  )  # Depend on production module  modules[[module]]  data &lt;- datasets[[dataset_name]]  test_function &lt;- test_cases[[module]][[test_case]]  result &lt;- safely(test_function)(list(data = data))  tibble(    module = module,    test_case = test_case,    dataset_name = dataset_name,    error = as.character(result$error %||% NA)  ) } </code></pre> In this form, pipeline will rerun tests when the module's production code has changed. Different forms of <code>box</code> imports allow you to create custom invalidation rules of targets. <h2 id="13">How does E2E testing integrate with the Shiny software development lifecycle?</h2> This approach shouldn’t serve as a replacement of unit-testing and E2E testing methods, but as an additional safety net that can help us produce bug-free software. These tests can serve as one of last checks before the release, it can improve the confidence in our software that it integrates with production systems as expected. Feedback obtained from this test will be definitely slower than the one from unit tests. We still can use <code>rhino::test_r()</code> as the main source of feedback, with <code>targets::tar_make()</code> being run only just before committing to the trunk. The pipeline can be run: <ul><li style="font-weight: 400;" aria-level="1">locally with <code>targets::tar_make()</code></li><li style="font-weight: 400;" aria-level="1">as a<a href="https://docs.posit.co/connect/user/scheduling/" target="_blank" rel="noopener">scheduled</a><a href="https://books.ropensci.org/targets/markdown.html">Markdown</a> in Posit Connect</li><li style="font-weight: 400;" aria-level="1">as a part of<a href="https://appsilon.github.io/data.validator/articles/targets_workflow.html" target="_blank" rel="noopener">data validation workflow</a></li><li style="font-weight: 400;" aria-level="1">with<a href="https://books.ropensci.org/targets/cloud-storage.html" target="_blank" rel="noopener">cloud storage</a> to keep the same cache for all developers, reducing pipeline runtime.</li></ul> It’s up to you to find the best way to integrate the pipeline with the rest of your processes. <h2 id="14">Impact of {targets} for developing a production Shiny app</h2> The solution described helped us save tens of hours of developers time, allowing us to focus on solving issues and developing new features instead of manual validation. Tests implemented with such a pipeline were more detailed than manual testing, allowing us to catch more issues. Each time the pipeline was run we had a detailed description of which tests were executed and what was their result which was not possible with manual testing. If this is a problem you’re facing in your project, give it a shot. Let’s leverage existing solutions and <b>use targets to make our Shiny apps safer!</b> &nbsp;

Have questions or insights?

Engage with experts, share ideas and take your data journey to the next level!
Explore Possibilities

Share Your Data Goals with Us

From advanced analytics to platform development and pharma consulting, we craft solutions tailored to your needs.

Talk to our Experts
e2e
shiny
unit test
way we work
r