はじめに
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に自動でデプロイする方法を紹介します