Java
Selenium
Kotlin
テスト自動化
再帰関数

テストを一部自動化するのは当たり前のように聞くようになりました。
時々Thread.sleep()を使ってブラウザの動作を待っている方がいるようです。ちょっと待ってください。そのsleep時間、実行環境に応じて変化しませんか?固定時間待機する処理をテストする場合はThread.sleep()でいいんです。でも「どれくらいかかるかネットワーク次第だけど、なんとなく2秒くらい待ってからじゃないとうまく動かない」的な場当たり的にsleep時間調整しているのではないでしょうか。

以前Seleniumで画面描画を待つ方法というのを書きました。この時はScalaTestにあったeventually{}をJavaでも使いたかったのでJavaで実装しました。今回はkotlinで使いたい、いやkotlinからJavaのクラスやメソッドは呼べるんだけど、kotlinで書いたらもっと綺麗にかけるんじゃないかなあ。ついでにvavrも使います。前回と違って、基本的な部分を書いてから肉付けする様を見ていきましょう。

インターフェースを考える

ここではJava/Kotlinのinterfaceではなく、eventuallyがどんな形だったら使いやすいかを考えます。使う時はこんな風に気軽に使いたいですよね。

driver.get(url) 
eventually {
    // url開くまで時間がかかるので直後だとエラーになる
    assert(driver.findElementById("id"))
}

期限や待機時間を指定する時にカッコが増えるのはまあ許容しましょうかね。デフォルトタイムアウトを用意しておいて、普段はカッコなし、どうしても指定が必要なときだけ指定すればいいのですから。

broswer.login()
eventually ({
    assert(browser.contents().contains(data))
}, Duration.ofSeconds(2), Duration.ofSeconds(1))

eventually実装

kotilinにはデフォルト引数があるので、それを使えばJavaみたいに似たようなメソッドを引数違いで生やさなくてもいいです。

object Eventually {
    val DEFAULT_TIMEOUT = Duration.ofSeconds(10)
    val DEFAULT_INTERVAL = Duration.ofSeconds(3)

    fun <R> eventually(f: () -> R, timeout: Duration = DEFAULT_TIMEOUT, interval: Duration = DEFAULT_INTERVAL): R {
...

しかしこのインターフェースでは、タイムアウトを指定しない場合でも次のように丸括弧と波括弧の両方が必要でした。

eventually ({
    // ...
})

仕方ないのでもう一つ用意します。

    fun <R> eventually(f: () -> R) = Eventually.eventually(f, DEFAULT_TIMEOUT, DEFAULT_INTERVAL)
    fun <R> eventually(f: () -> R, timeout: Duration = DEFAULT_TIMEOUT, interval: Duration = DEFAULT_INTERVAL): R {
...

これで次のどちらでも呼べるようになりました。

eventually { ... }
eventually ({ ... }, timeout, interval)
...

もちろんkotlinなので引数名指定も可能です。

// intervalだけ指定したい
eventually ({ ... }, interval=Duration.ofSeconds(1))
...

ロジック実装

まず「処理f()をして、例外が出たらリトライする」という関数を考えます。

fun <R> tryExecute(f: () -> R):R {
    return try {
        f()
    } catch (t: Throwable) {
        tryExecute(f)
    }
}

これでエラーが出続ける限り永久ループする関数ができました。永久に止まらないのと困るので期限を設けましょう。ここではjava.time.Instantを使います。タイムアウトはDurationで指定したいのですが、ここは後述します。再帰なんでついでにtailrecつけときましょうかね

tailrec fun <R> tryExecute(f: () -> R, until: Instant):R {
    if(now()>until) throw RuntimeException("もう無理")
    return try {
        f()
    } catch (t: Throwable) {
        Thread.sleep(interval)
        tryExecute(f, until)
    }
}

f()がすぐ例外を吐く場合、期限まではCPU1個(f()の実装次第では全部)使い切っちゃうんで、エラーが出たら一定時間待機します。今度は期間を表すのでjava.time.Durationを使います。

tailrec fun <R> tryExecute(f: () -> R, until: Instant, interval: Duration):R {
    if(now()>until) throw RuntimeException("もう無理")
    return try {
        f()
    } catch (t: Throwable) {
        Thread.sleep(interval)
        tryExecute(f, until, interval)
    }
}

さて、f()が吐いた例外が欲しいですよね。デバッグに必要です。発生した例外を再起する時に渡して、最終的にタイムアウトになった時の例外の原因として返してあげましょう。そろそろ引数定義が長くなって来たんで一文字にします。

tailrec fun <R> tryExecute(f: () -> R, u: Instant, i: Duration, t: Throwable):R {
    if(now() > u) throw RuntimeException("もう無理",t)
    return try {
        f()
    } catch (t: Throwable) {
        Thread.sleep(interval)
        tryExecute(f, u, i, t)
    }
}

これでタイムアウトになった時、最後にキャッチした例外がcause()で拾えるようになりました。ロジックはこれで完成です。

eventually()実装

じゃあさっき作ったメソッドから今のロジックを呼びましょうか。タイムアウトをDurationでもらっているのを現在時刻と足して有効期限を計算します。

val start = Instant.now()
val until = start.plusMillis(timeout.toMillis())

tryEventは最後にキャッチした例外を引数にとるのですが、最初は例外なんて発生してないし、かといってnullを渡すのも気持ち悪いので、Optionを取るようにしてOption.none()を渡しましょう。あ、このOptionはvavr(旧javaslang)を使ってます。これについての解説は今回はしません。

    tailrec fun <R> tryExecute(f: () -> R, u: Instant, i: Duration, t: Option<Throwable>): R {
        if (Instant.now() > u) throw t.getOrElse(TimeoutException())
        return try {
            f()
        } catch (t: Throwable) {
            Thread.sleep(i.toMillis())
            tryExecute(f, u, i, Option.some(t))
        }
    }

tryExecute(f, until, interval, Option.none())

一度も例外が起きる前にタイムアウトを起こした時はTimeoutExceptionを投げるようにしました。で、この例外をキャッチした時に経過時間と合わせてエラーメッセージを作成します。この辺から本題じゃないのでがっつり割愛して、冷蔵庫で30分寝かせたものがこちらになります。

Eventually.kt
import io.vavr.Tuple2
import io.vavr.collection.HashMap
import io.vavr.collection.Map
import io.vavr.control.Option
import java.time.Duration
import java.time.Instant
import java.util.concurrent.TimeoutException

object Eventually {
    val DEFAULT_TIMEOUT = Duration.ofSeconds(10)
    val DEFAULT_INTERVAL = Duration.ofSeconds(3)


    val MESSAGE_TEMPLATE = "Eventually failed over :time. Last message is:\n:message";
    fun replace(s: String, m: Map<String, String>): String =
        if (m.isEmpty) s else replace(s.replace(":" + m.head()._1, m.head()._2), m.tail())

    private tailrec fun <R> tryExecute(f: () -> R, u: Instant, i: Duration, t: Option<Throwable>): R {
        if (Instant.now() > u) throw t.getOrElse(TimeoutException())
        return try {
            f()
        } catch (t: Throwable) {
            Thread.sleep(i.toMillis())
            tryExecute(f, u, i, Option.some(t))
        }
    }

    private fun createException(start: Instant, t: Throwable): Throwable {
        val messageMap = HashMap.ofEntries<String, String>(
                Tuple2("time", Duration.between(start, Instant.now()).toString()),
                Tuple2("message", t.message)
        )
        return RuntimeException(replace(MESSAGE_TEMPLATE, messageMap), t)
    }

    fun <R> eventually(f: () -> R) = Eventually.eventually(f, DEFAULT_TIMEOUT, DEFAULT_INTERVAL)
    fun <R> eventually(f: () -> R, timeout: Duration = DEFAULT_TIMEOUT, interval: Duration = DEFAULT_INTERVAL): R {
        val start = Instant.now()
        val until = start.plusMillis(timeout.toMillis())
        return try {
            tryExecute(f, until, interval, Option.none())
        } catch (t: Throwable) {
            throw createException(start, t)
        }
    }

2回もtry-catchしてるのが嫌ですね。最初はtry{}とか使わずにio.vavr.kotlin.Tryを使おうとして試行錯誤してみたり、tryEvent()はEitherを返すようにしてたりしたのですが、こっちの方がスッキリしたので変えました。といってもJava版と比べて大して行数も減ってないな。もっと良い方法あったら教えてください。

テスト

実際は実装しながら各機能をテストしていくんですが、疲れたんで省略してテストだけ。

EventuallyTest.kt
import io.vavr.Tuple2
import io.vavr.collection.HashMap
import jp.co.sbicb.fime.testautomator.aws.common.Eventually
import jp.co.sbicb.fime.testautomator.aws.common.Eventually.eventually
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Test
import org.slf4j.LoggerFactory
import java.lang.IllegalArgumentException
import java.time.Duration

typealias e = Tuple2<String, String>
//    private fun e(k:String, v:String): Tuple2<String, String> = Tuple2(k, v)

class EventuallyTest {
    private val log = LoggerFactory.getLogger(this.javaClass)

    @Test
    fun testReplace() {
        val r = Eventually.replace("this is :1, :2, :x", HashMap.ofEntries(
                e("1", "changed"),
                e("2", "zzzzz"),
                e("x", "yyyyy")
        ))
        log.info(r)
        assertEquals("this is changed, zzzzz, yyyyy", r)
    }

    @Test
    fun testEventually() {
        val r = eventually {
            log.info("aaa")
            "a"
        }
        assertEquals("a", r)
    }

    @Test
    fun testEventually2Sec() {
        try {
            eventually({
                log.info("aaa")
                throw IllegalArgumentException("x")
            }, timeout = Duration.ofSeconds(2))
        }catch (e: Exception){
            assertEquals("x", e.cause!!.message)
        }
    }
}

KotlinTest

どうやらKotlinTesteventuallyがあるようです。FunSpecなどもありScalaTestのように使えて便利そうですね。KotlinTest自体のパッケージ関係が複雑でハードルが高そうですが導入できる場合はこれもいいのではないでしょうか。arrowに対する拡張メソッドなんかもあるんですね