なぜ runn に loop.until + test の警告機能を追加したのか - 実行フローから理解する
runn は、シナリオテストを実行するためのソリューションとしてとても便利なもので、さくらインターネットでもいくつかのプロダクトで便利に利用されている。
runn を使っているときに、loop:
を使うとテストの中でリトライ処理ができて便利なのだけど、loop:
の中で test:
を使うと意図しない挙動になることがある。
この挙動の改善すべく、runn に pull-request を出してマージされたので、なぜこの改善を行ったのかを説明する。
loop の実行フロー
runn の便利な機能の一つに loop:
がある。テストシナリオの中には、サーバー側の処理が終わるまで待つ必要があり、リトライをしたいケースなどがある。
このような場合には loop;
が便利だ。以下のように書くことができる。
steps:
waitingroom:
loop:
count: 10
until: 'steps.waitingroom.res.status == "201"' # Store values of latest loop
minInterval: 500ms
maxInterval: 10 # sec
# jitter: 0.0
# interval: 5
# multiplier: 1.5
req:
/cart/in:
post:
body:
しかしながら、このような retry step を runn がどのように解釈しているかを分からずに書くとうっかりハマって時間を無駄にする事がある。
runn ではこの実行処理は operator.go に実装されている。
擬似コードで表すと以下のようになる。
function stepFn() {
// メインの Runner (排他的に実行 - 1つだけ選択される)
if (hasHttpRunner()) {
httpRunner() // HTTP リクエスト実行
} else if (hasDbRunner()) {
dbRunner() // DB クエリ実行
} else if (hasGrpcRunner()) {
grpcRunner() // gRPC リクエスト実行
} else if (hasCdpRunner()) {
cdpRunner() // CDP (Chrome DevTools Protocol) アクション実行
} else if (hasSshRunner()) {
sshRunner() // SSH コマンド実行
} else if (hasExecRunner()) {
execRunner() // ローカルコマンド実行
} else if (hasIncludeRunner()) {
includeRunner() // 別のrunbookをインクルード
} else if (hasRunnerRunner()) {
runnerRunner() // カスタムRunner定義
}
// 追加の Runner (メインRunner実行後に順番に実行される)
if (hasDumpRunner()) {
dumpRunner() // リクエスト/レスポンスをダンプ
}
if (hasBindRunner()) {
bindRunner() // 変数をバインド
}
if (hasTestRunner()) {
testRunner() // アサーション実行
}
}
if (loop) {
c = loop.count // ループ回数を取得
retrySuccess = !loop.until // until がなければ成功扱い
for (j=0; j<c; j++) {
stepFn() // ステップを実行
if (loop.until) { // until条件が指定されている場合
if (eval(loop.until())) { // 条件を評価
retrySuccess = true // 条件が満たされた
break; // ループを抜ける
}
}
}
if (!retrySuccess) {
throw "retry loop failed" // 全回数実行しても条件が満たされなかった
}
} else {
stepFn() // ループなしの場合は1回だけ実行
}
loop と test は併用するとどうなるか
loop 句に until をつけている場合、until でテストが実行されるので、test:
を使う必要はない。
のだけど、なぜかついつけてしまう人がいる。僕も最初そうしてハマったし、うちのチームの他の人も同様にハマっていた。。
loop:
と test:
を両方つけるとは、例えば以下のように書くということだ。
desc: loop and test
runners:
req: http://127.0.0.1:5000/
steps:
login:
req:
/login:
get:
body: ~
loop:
count: 10
until: int(current.res.rawBody)%10 == 0
dump: current.res.rawBody
test: int(current.res.rawBody)%10 == 0
このように書いてしまうと、loop の中での初回の stepFn の中で test が実行されてエラーで終了することになる。
このコードを書く心理としては、以下のような挙動を期待していると言うことだ。
retrySuccess = false
for (j=0; j<loop.count; j++) {
if (loop.until() {
retrySuccess = true
break;
}
}
if (!retrySuccess) {
throw "retry loop failed";
}
test()
しかしながら、実際の実行フローは以下のようになる。
retrySuccess = false
for (j=0; j<loop.count; j++) {
test(); // ここで test 句が実行されてエラーで終了する。
if (loop.until() {
retrySuccess = true
break;
}
}
if (!retrySuccess) {
throw "retry loop failed";
}
この挙動をドキュメント眺めて理解するのはちょっと難しいなぁと思いつつ、自分が一回ハマったけど自分はもう理解したのでまぁいいか、と思っていたのだけど、チームの人が同じ挙動にハマっていたので runn に pull-request を出すことにした。
pull-request の内容
pull-request の内容は以下の二点。
-
loop:
の中にtest:
がある場合には警告を出す - README に
loop:
とtest:
の併用は避けるように注意書きを追加する
以下のように loop: until:
と test:
を併用した場合、警告が出るようになる。
desc: Test case for loop.until + test warning
runners:
req: http://127.0.0.1:8080
steps:
- desc: This step should trigger a warning
req:
/status:
get:
body: ~
loop:
count: 5
until: 'current.res.status == 200'
interval: 1s
test: 'current.res.status == 200' # This will cause warning
実際の警告は以下。
これらの変更は https://github.com/k1LoW/runn/pull/1294 でマージされ、v0.136.1 リリースに含まれている。
Discussion