はじめに
Shiny Codeを書いていて、
- Shiny Appもunit testを行いたい
- testが通ったら本番環境にdeployしたい
となったときに、現状のベストプラクティスはなにかと探していて、方法が固まってきたので紹介します
この記事では、まずshinytest
とtestthat
を使ってtestコードを書く方法をまとめます
shinytest
とは?
RStudioが提供してるR packageであり、Shinyをtestするためのツールです。公式ドキュメントのProject Pageを見るのが一番わかりやすいです
日本語ですと、shinytestでShinyのテストを行う方法 で詳しく使い方を解説してくれています
testコードを作るには、shinytest
から、Shiny Appを立ち上げ、一定の操作をして、snapshotを撮影するのを繰り返すことで、testコードを生成することができます。具体的には、以下のコードを実行してみるとShinyAppが立ち上がり、操作をファイルに保存してくれます
# install.packages("shinytest")
library(shinytest)
recordTest("path/to/app")
testthat
とは?
testthatはRのコードの単体テストを行うためのR packageです。これについては、すでに多くのドキュメントがあるので、そちらを参考にしたほうがよいかと思います
実装例
A/B testで使用するSample Sizeを計算するShiny Appのコードをもとに説明します。 Githubはこちら : okiyuki99/ShinyAB
STEP 1. Shiny Appのtestを実行するスクリプトの準備
CIでスクリプトをKickすればTestが実行するようにしたいので、まずTestを実行する(Runする)コードrun_tests.R
を用意します
このときのディレクトリ構成は以下で準備します
$ tree -L 2
.
├── run_tests.R
├── server.R
├── tests
│ ├── test-expected
│ └── test.R
└── ui.R
tests/
ディレクトリ配下に、実際のtestの内容(test.R
など)を用意します。test-expected/
は空のディレクトリでOKです
run_tests.R
はこのように書きます
library(testthat)
library(shinytest)
dir.create("tests/test-current", showWarnings = F)
testthat::test_that("Application works", {
# Use compareImages = FALSE because the expected image screenshots were created
shinytest::expect_pass(shinytest::testApp(".", testnames = NULL, quiet = F, compareImages = F))
})
3行目は、一時的にtestの結果を格納するディレクトクリtests/test-current/
を準備します(testが終わると同時に無くなります)。shinytest::testApp(.)
でtestを実行するapp.R
またはui.R/server.R
が配置されているパスを指定します
ここでのポイントとして、compareImage=FALSE
にしておくことです。デフォルトはTRUE
なのですが、FALSE
にすることで、ScreenshotのImageの比較を行わずに、生成するImageのメタ情報(jsonファイル)で比較することができるので、CIと相性が良いです
STEP 2. test codeを書く
実際のテストの中身を書いているtest.R
を用意します。
多いシチュエーションとしては、ボタンをClickしたときに正しい結果が返ってくるかのテストと、なんらかのinputIdの値を変更した場合のテストがあると思います
ここでは2つのtestを行います
- 1つ目のtestが、デフォルトパラメータで"Go"ボタンを得られる結果が正しい結果かどうかを判定します
- 2つ目のtestが、パラメータを変更し、Shiny UIが正しくアップデートされたかを判定します
library(testthat)
library(shinytest)
testthat::context("Shiny AB Test")
# open Shiny app and PhantomJS
app <- shinytest::ShinyDriver$new("../", loadTimeout = 100000)
testthat::test_that("Click go", {
# btn click
app$setInputs(btn_go = "click")
# Wait
Sys.sleep(1)
# Get data
vals <- app$getAllValues()
# Test
testthat::expect_identical(vals$output$kable_proportion[[1]],
'<table class=\"table table-striped table-bordered\" style=\"margin-left: auto; margin-right: auto;\">\n <thead>\n <tr>\n <th style=\"text-align:right;\"> UU </th>\n <th style=\"text-align:right;\"> number_of_samples </th>\n <th style=\"text-align:right;\"> alpha </th>\n <th style=\"text-align:right;\"> power </th>\n <th style=\"text-align:right;\"> test_method </th>\n <th style=\"text-align:right;\"> as_is </th>\n <th style=\"text-align:right;\"> to_be </th>\n <th style=\"text-align:right;\"> sample_size_per_group </th>\n <th style=\"text-align:right;\"> required_sample_size </th>\n <th style=\"text-align:right;\"> sampling_rate </th>\n </tr>\n </thead>\n<tbody>\n <tr>\n <td style=\"text-align:right;\"> 10,000 </td>\n <td style=\"text-align:right;\"> 2 </td>\n <td style=\"text-align:right;\"> 0.05 </td>\n <td style=\"text-align:right;\"> 0.8 </td>\n <td style=\"text-align:right;\"> prop.test </td>\n <td style=\"text-align:right;\"> 0.3 </td>\n <td style=\"text-align:right;\"> 0.4 </td>\n <td style=\"text-align:right;\"> <span style=\" color: blue !important;\">356</span> </td>\n <td style=\"text-align:right;\"> <span style=\" color: blue !important;\">712</span> </td>\n <td style=\"text-align:right;\"> <span style=\" color: blue !important;\">7.12%</span> </td>\n </tr>\n</tbody>\n</table>'
)
# remove click
app$setInputs(btn_remove = "click")
# Get data
vals <- app$getAllValues()
# Test
testthat::expect_null(vals$output$kable_proportion[[1]])
})
testthat::test_that("Change expected value and Update Lift", {
# input
app$setInputs(expected_value = 0.5)
# Wait
Sys.sleep(1)
# Get data
vals <- app$getAllValues()
# Test
testthat::expect_identical(vals$output$ui_lift$html[[1]],
'<div style="font-style: italic;">Lift from As-Is ratio to To-Be ratio :<strong> 66.67% </strong></div>')
})
# stop the Shiny app
app$stop()
shinytest
のコードのポイントとなる箇所は以下の3点です
1. Shiny Appの立ち上げ
app <- shinytest::ShinyDriver$new("../", loadTimeout = 100000)
でShinyDriver(Phantom JSで実装されているらしい) を使って、shiny appを立ち上げます
2. InputIdの操作・変更
app$setInputs(btn_go = "click")
で shinyのinputIdがbtn_go
の箇所をclickします。他にもapp$setInputs(expected_value = 0.5)
でinputIdがexpected_value
のものをupdateします
3. 出力結果の比較
vals <- app$getAllValues()
で現時点でのappが保有するRオブジェクトのvalueを取得(inputとoutputの内容など)し、testthat
どおりに事前に用意した正解とtestthat::expect_identical
で比較します
(この例では、kableのhtml出力コードをベタッと比較しています。reactiveなdata.frameを用意して、比較することもできると思います)
STEP 3. testを実行する
ここまででコードは用意できたので、R -f run_tests.R
でtestを実行してみましょう。Errorがなければ、そのまま完了するかと思います
おわりに
shinytest
とtestthat
を使ったShinyのunit testの方法を紹介しました。次は、Travis CIからtestを実行して、testが成功したら、shinyapps.ioに自動でデプロイする方法を紹介します