kotlinでもeventuallyしたい

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

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


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

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

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

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

### eventually実装

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

```kotlin
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 {
...
```

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

```kotlin
eventually ({
    // ...
})

```

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

```kotlin
    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 {
...
```

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

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

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

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


### ロジック実装

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

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

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

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

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

```kotlin
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でもらっているのを現在時刻と足して有効期限を計算します。

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

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


```kotlin

    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を投げるようにしました。で、この例外をキャッチした時に経過時間と合わせてエラーメッセージを作成します。

```kotlin
        // 呼び出し側
        try {
            tryExecute(f, until, interval, Option.none())
        } catch (t: Throwable) {
            throw createException(start, t)
        }
```

メッセージ作成関数です。replaceは文字列sに対してmapに入っているkeyがあればvalueに変換していきます。再帰にしてますがmap.foldで畳み込めますね。

```kotlin
    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 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)
    }
```

これで完成です。全体をまとめるとこうなります。

```kotlin: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";
    internal tailrec 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)
        }
    }
```


最初はtry{}とか使わずにio.vavr.kotlin.Tryを使おうとして試行錯誤してみたり、tryEvent()はEither<R,Throwable>を返すようにしてたりしたのですが、こっちの方がスッキリしたので変えました。といっても[Java版](https://qiita.com/hakaicode/items/d44751f49d1215dcbdee)と比べて大して行数も減ってないな。もっと良い方法あったら教えてください。


### テスト

実際は実装しながら各機能をテストしていきます。例えばreplace()を書いた時にはその部分が意図したように動くかチェックします。replaceはprivateで良いので、eventuallyのテストでreplace()まで呼ばれるところまで確認したらtestReplace()は消してしまってもいいと思います。その場合はちゃんと`internal fun replace()`を`private fun replace()`にしましょう。ここでは残しています。

```kotlin:EventuallyTest.kt
import <src>.Eventually.eventually
import <src>.Eventually

import io.vavr.Tuple2
import io.vavr.collection.HashMap
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>

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

どうやら[KotlinTest](https://github.com/kotlintest)に[eventually](https://github.com/kotlintest/kotlintest/blob/master/kotlintest-assertions/src/jvmMain/kotlin/io/kotlintest/Eventually.kt)があるようです。FunSpecなどもありScalaTestのように使えて便利そうですね。KotlinTest自体のパッケージ関係が複雑でハードルが高そうですが導入できる場合はこれもいいのではないでしょうか。[arrow](https://github.com/arrow-kt/arrow)に対する拡張メソッドなんかもあるんですね