KotlinFest 2026
resolve
attempt
report
ピクシブ株式会社
Android 横断テックリード
OpenTelemetry Kotlin SDKのコントリビューター
Kotlinを愛でる人
X:@inetandev
Linkedin: @inetand
LLMに任せる範囲と、Kotlinで制御する処理の境界を、
Koogでどう設計するかを理解する
01
なぜ、LLM に任せきらない仕組みが必要だったか
02
作ったハーネスを動かしてみる
03
Koog で、テスト一本の流れを組み立てる
04
Coroutines で、複数テストを並行に動かす
05
ハーネスの動きを追い、正しさを確かめる
06
何が改善し、何が残ったか
01
ユーザーの操作
アプリ
サーバー
画面で確かめる
e2e テスト
結合テスト
ユニットテスト
アプリを実際に動かし、ユーザーと同じ操作をして、結果は画面の状態から検証する
遅くて壊れやすいので、守りたいユーザーフローのみを対象にする
リリース前のリグレッションテスト項目の平均件数
AndroidWorld
Google Researchの
AI エイジェント評価用のベンチマーク
20
アプリ
116
タスク
google-research.github.io/android_world
手順
1
検証アプリを起動する
2
shopping_list という名前で Markdown ファイルを作る
3
本文に milk, eggs, bread と入力する
4
保存して、ファイル一覧に戻る
確かめてほしいこと
A
ファイル一覧に shopping_list.md が出ている
B
shopping_list.md を開くと、本文が milk, eggs, bread になっている
「保存して一覧に戻る」までなのに、最後にアプリを強制終了して再起動した
「タップ後」として撮った画像に、タップ前の画面が写っていた
最後の画面を見ないまま「一覧に戻した」と報告した
1
シナリオを使い捨てにせず、差分を見ながら育てる
2
テスト手順にない操作をさせない
3
根拠のない合格は「合格」として扱わない
この条件で Koog を選んだ
シナリオを使い捨てにせず、差分を見ながら育てる
テスト手順にない操作をさせない
根拠のない合格は「合格」として扱わない
流れをグラフで組み、作る・直す・再生するを分けられる
保存した手順を、LLM を挟まず機械的に実行できる
LLM の判定を型で受け、根拠をコードで確かめられる
2025
Koogを発表
2026年5月
安定版の Koog v1.0
公式ドキュメントも拡充
2026年11月
Koog 1.3.0 +
Spring Boot のCLI
02
仕様から結果までの完成形を見る
対象プラットフォーム
Android / iOS
iOS は実験的
インターフェース
CLI / MCP
出力
CLI logs / Web Dashboard
$ e2e generate markor_create_note # LLM が実機を探索して、シナリオを作る
$ e2e run-release 1.0.0 # 保存したシナリオを、LLM なしで再生する
$ e2e dashboard # 証拠付きの結果と、Langfuse のトレース
03
仕様を書く
1
人間
前提条件の準備
2
Kotlin
シナリオの生成
3
LLM
Kotlin
シナリオの実行
4
Kotlin
外部ツール
結果の判定
5
LLM
Kotlin
レポート
6
Kotlin
LLM
ループで組む
LLM
ツール
次に何をするかを、毎回LLMが決める
中で何が起きたのかを、あとから追いにくい
グラフで組む
準備
実行
判定
報告
流れは人が決め、LLMは決まった場所で判断する
どこを通ったかが追えて、テストも書ける
LLMを呼ぶノード
コードだけのノード
準備
実行
判定
報告
やり直す
合格・不合格
Node
val judge by node<Evidence, Verdict> {
llm.judge(it)
}
Edge
edge(
judge forwardTo execute
onCondition { it is Verdict.Retry }
transformed { (it as …).scenario }
)
Graph
strategy<Scenario, Report>("graph") {
edge(nodeStart forwardTo execute)
…
edge(report forwardTo nodeFinish)
}
試行サブグラフ
やり直す
strategy<RunRequest, Reported>("koog-e2e-run") {
val resolve by resolveNode.asNode()
val attempt by attemptSubgraph.delegate()
val report by reportNode.asNode()
edge(nodeStart forwardTo resolve)
edge(
resolve forwardTo attempt
onCondition { it is Resolved.Ready }
transformed { (it as Resolved.Ready).attempt }
)
edge(
resolve forwardTo report
onCondition { it is Resolved.Blocked }
transformed { (it as Resolved.Blocked).outcome }
)
edge(attempt forwardTo report)
edge(report forwardTo nodeFinish)
}
試行のサブグラフ
シナリオ解決
実行
判定
リトライ
手順修正
要素の再探索
報告
試行のサブグラフ
シナリオ解決
実行
判定
リトライ
手順修正
要素の再探索
報告
仕様とシナリオ
記録と報告書
stories/markor_create_note.md
---
kind: story
platforms: [android]
clearState: true
locale: en-US
prerequisites: ["!premium", "files >= 31"]
---
## User goal
Markor で新しい Markdown ファイル「shopping_list」を作り、本文に「milk, eggs, bread」と入力して保存し、ファイル一覧に戻る。
## Acceptance
- [ ] {#file-listed} 保存してファイル一覧に戻ると、一覧に shopping_list.md が表示されている
- [ ] {#body-saved} shopping_list.md を開き直すと、本文が「milk, eggs, bread」になっている
テストの設定
ユーザーの目的
受け入れ条件
platforms
対象プラットフォーム(複数可)
clearState
初期化してからテストするかどうか
prerequisites
テストの実行に必要なテスト条件
locale
端末・アプリの言語
アカウントの定義
// accounts/inventory.json
{ "id": "free-user",
"capabilities": {
"signed_in": "true",
"premium": "false",
"files": "31"
}
}
アカウント
プレミアムかどうかやファイルの数を、アカウントの事実として照合する
| id | signed_in | premium | files |
|---|---|---|---|
| free-user | true | false | 31 |
| premium-user | true | true | 5 |
| new-user | true | false | 0 |
--- … ---
## User goal
Markor で新しい Markdown ファイル「shopping_list」を作り、本文に「milk, eggs, bread」と入力して保存し、ファイル一覧に戻る。
## Acceptance
- [ ] {#file-listed} 保存してファイル一覧に戻ると、一覧に shopping_list.md が表示されている
- [ ] {#body-saved} shopping_list.md を開き直すと、本文が「milk, eggs, bread」になっている
テストの設定
ユーザーの目的
受け入れ条件
書くのはユーザーが達成したいことだけ
ユーザー目的はテスト実行手順ではない
--- … ---
## User goal
Markor で新しい Markdown ファイル「shopping_list」を作り、本文に「milk, eggs, bread」と入力して保存し、ファイル一覧に戻る。
## Acceptance
- [ ] {#file-listed} 保存してファイル一覧に戻ると、一覧に shopping_list.md が表示されている
- [ ] {#body-saved} shopping_list.md を開き直すと、本文が「milk, eggs, bread」になっている
テストの設定
ユーザーの目的
受け入れ条件
テスト基準が結果の合否を決める根拠
全項目を満たす必要がある
Kotlin
LLM
Kotlin
Kotlin と LLM
Kotlin
仕様を読む
実機を探索する
操作を記録する
再生して判定する
保存する
記録するのは端末で実行できた操作だけで、判定を通った手順だけが flows/ に残る
検証には、このあと説明する試行サブグラフをそのまま使う
{
"specId": "open_first_item",
"specHash": "9f2c…e81a",
"appId": "com.example.app",
"backend": "appium",
"steps": [
{ "type": "launch", "clearState": false },
{ "type": "tap", "target": { "id": "list_item", "index": 0 } },
{ "type": "screenshot", "name": "02_detail" }
],
"checkpoints": [
{ "name": "02_detail", "criterionIds": ["detail-opens"] }
],
"provenance": { "runId": "20261003-…", "verifiedAt": "…" }
}
steps
要素は id かテキストで指し、座標は置かない
checkpoints
証拠を撮る場所と、確かめる受け入れ条件
specHash
仕様が変わると一致しなくなり、古いと分かる
interface DeviceDriver {
val backend: Backend
val operations: Set<String> // モデルに提示してよい操作
suspend fun observe(): Readiness
suspend fun useLocale(locale: DeviceLocale): LocaleCheck
suspend fun perform(steps: List<FlowStep>): StepOutcome
suspend fun execute(scenario: Scenario, attemptDir: Path, listener: ExecutionListener): ExecutionResult
suspend fun capture(name: String, directory: Path): CheckpointEvidence?
}
MaestroDriver
AppiumDriver
FakeDeviceDriver
中間表現なし:モデルが YAML を直接書く
- tapOn: "保存する" # 画面にない文言
- tapOn:
point: "50%,88%" # 座標で押す
- runScript: cleanup.js # 何でも実行できる
中間表現あり:モデルは番号を選ぶだけ
// モデルの答え
{ "action": "tap", "element": 12 }
// 番号付きの UI 一覧
[12] id=save_button text="保存"
// app/build.gradle.kts
runtimeOnly(project(":driver-maestro"))
// MaestroStepWriter は YAML の命令に書き出す
fun command(step: FlowStep): Any =
when (step) {
is FlowStep.Launch -> mapOf("launchApp" to …)
is FlowStep.Tap -> mapOf("tapOn" to …)
is FlowStep.Swipe -> mapOf("swipe" to …)
FlowStep.Back -> "back"
// ほか10種類
}
// app/build.gradle.kts
runtimeOnly(project(":driver-appium"))
// AppiumStepExecutor はその場で実行する
suspend fun execute(step: FlowStep) {
when (step) {
is FlowStep.Launch -> launch(step.clearState)
is FlowStep.Tap -> tap(step)
is FlowStep.Swipe -> gesture { … }
FlowStep.Back -> session.post("/back")
// ほか10種類
}
}
interface GraphNode<In, Out> {
suspend fun run(input: In): Out
}
class ExecuteNode(…) : GraphNode<AttemptState, AttemptState>
class ValidateNode(…) : GraphNode<AttemptState, Validated>
class RetryNode(…) : GraphNode<Validated, AttemptDecision>
edge(execute forwardTo validate) // 戻り値が、そのまま次の入力になる
edge(validate forwardTo retry)
edge(retry forwardTo execute
onCondition { it is AttemptDecision.Retry }
transformed { (it as AttemptDecision.Retry).next }) // 次の試行の状態だけを渡す
// サブグラフも、状態は入力と出力の型だけで持つ
subgraph<AttemptState, AttemptOutcome>("attempt")
subgraph<LocateRequest, LocateResult>("locate")
data class AttemptState(
val scenario: Scenario,
val attemptNumber: Int,
val retriesRemaining: Int,
val recoveriesRemaining: Int,
val previousAttempts: List<AttemptRecord>,
…
)
data class Validated(
val state: AttemptState,
val record: AttemptRecord,
)
sealed interface AttemptDecision {
data class Retry(val next: AttemptState)
data class Recover(val request: LocateRequest)
data class Finish(val outcome: AttemptOutcome)
}
// 判定の順序を示す簡略版
fun decide(record: AttemptRecord, … ): NextAttempt {
val verdict = record.verdict
if (verdict.overall == PASS || verdict.overall == FAIL)
return Finish(rules.outcomeOf(verdict, warnings))
val failure = record.execution.failure
if (failure is ElementNotFound)
return if (recoveries > 0)
Recover(failure)
else
Finish(ReviewRequired(…))
if (retries > 0 && timeLeft) {
return Retry(reason)
}
return Finish(Inconclusive(reason))
}
subgraph<GraphState, GraphOutput>("graph") {
edge(nodeStart forwardTo execute)
edge(execute forwardTo validate)
edge(validate forwardTo retry)
edge(retry forwardTo execute onCondition { it is Retry })
edge(retry forwardTo locate onCondition { it is Recover })
edge(retry forwardTo nodeFinish onCondition { it is Finish })
edge(locate forwardTo patch)
edge(patch forwardTo execute onCondition { it is Retry })
edge(patch forwardTo nodeFinish onCondition { it is Finish })
}
リトライ
実行
is Retry
要素の再探索
is Recover
終了
is Finish
初手では見つからず
step 5: tap id=com.arduia.expense:id/btn_more_logs
element not found:
id=com.arduia.expense:id/btn_more_logs
スクロール後にある
<Button text="MORE"
resource-id="com.arduia.expense:id/btn_more_logs"
bounds="[84,2479][1356,2476]" displayed="false">
支出
view_bg
食費
view_bg
交通費
view_bg
日用品
view_bg
外食
view_bg
光熱費
view_bg
通信費
view_bg
書籍
view_bg
ジム
view_bg
医療費
画面に見えている:1ページ目
画面の外:2ページ目
2ページ目の2つ目を
選びたい
UIツリーを丸ごと読み込んだ場合にかかる累計トークン数
0
20万
40万
60万
1
4
8
12ターン
12ターンで約32万
約11万字
1画面の中央値
20画面に1つ
1ターンで10万トークン超
約123万トークン
いちばん重い1画面
元々のUIツリー
<android.widget.LinearLayout index="2" package="jp.pxv.android" class="android.widget.LinearLayout"
text="" resource-id="jp.pxv.android:id/user_follow_button" checkable="false" checked="false"
clickable="true" enabled="true" focusable="true" focused="false" long-clickable="true" password="false"
scrollable="false" selected="false" bounds="[993,1534][1356,1642]" displayed="true" …
圧縮したUIツリー
[4] id=recycler_view scrollable
… 15 more elements here are not listed: expand [4] to list them
省いた場所と、展開する番号を書く
[65] id=top_container clickable
[66] text="Followed. Find more users to follow."
[68] id=recycler_view scrollable
[69] id=user_name text="…" clickable
[70] id=user_follow_button clickable
目的に近い要素は残る
×3, each: id=user_preview_thumbnail_view clickable > …
似た行は、形を1回だけ書く
[72] [73] [74] "Series" [75]
… 4 more elements here are not listed: expand [68] to list them
[96] id=image_view clickable
番号は、全体のリストのまま
試行のサブグラフ
シナリオ解決
実行
判定
リトライ
手順修正
要素の再探索
報告
仕様とシナリオ
記録と報告書
Kotlin で制御する
前提条件の確認
シナリオの再生
形式と証拠の検証
回復の上限
手順の修正と再実行
LLM に判断を求める
証拠から合否を判断する
見つからない要素を探す
04
4時間...
fun results(testIds: List<String>): Flow<ResultLine> = channelFlow {
val waiting = produce { testIds.forEach { send(it) } } // キュー
devices.slots.forEach { slot ->
launch { for (id in waiting) send(runOne(id, slot)) } // 端末ごとのワーカーに送る
}
}
runBlocking // CLI
└─ channelFlow // 結果を1本の Flow に
├─ produce // 待ち行列
├─ launch // Pixel 8 のワーカー
│ └─ withTimeoutOrNull → runInterruptible
└─ launch // Pixel 9 のワーカー
親をキャンセルすると、子にも届く
ワーカーも再生もキャンセルされる
タイムアウトも同じツリーで届く
外部プロセスの待ちに割り込む
class ModelPermits(limit: Int) {
private val permits = Semaphore(limit) // 同時に limit 本のリクエストまで
// Semaphoreが空くまで suspend して待つ
suspend fun <T> holding(call: suspend () -> T): T = permits.withPermit { call() }
}
// LLM を呼ぶ所は、すべてここを通る
permits.holding { executor.executeStructured<JudgedRun>(prompt, model, fixingParser = fixingParser) }
1.4時間
4時間から
※4台端末の場合
05
open-telemetry / opentelemetry-kotlin
KMP対応のOpenTelemetry SDK
プロンプト・応答・トークン・ログなど
Langfuse でも Datadog でも任意の
バックエンドでも見られる
MR ごとに
分岐や停止が正しいかを確かめる
リリース前に
LLM の判定が正しいかを測る
features.testGraph<RunRequest, Reported>("koog-e2e-test-graph") {
val attempt = assertSubgraphByName<AttemptState, AttemptOutcome>("attempt")
verifySubgraph(attempt) {
val execute = assertNodeByName<AttemptState, AttemptState>("execute")
ノードと、入出力の型
val validate = assertNodeByName<AttemptState, Validated>("validate")
val retry = assertNodeByName<Validated, AttemptDecision>("retry")
val locate = assertSubgraphByName<LocateRequest, LocateResult>("locate")
assertReachable(retry, execute)
やり直しの道があるか
assertEdges {
execute alwaysGoesTo validate
retry withOutput Retry(…) goesTo execute
出力の型ごとの行き先
retry withOutput Recover(…) goesTo locate
retry withOutput Finish(…) goesTo finishNode()
}
}
}
06
| 指標(各3回) | このハーネス Sonnet 5.5 | このハーネス Opus 5.5 | mobile-mcp Opus 5.5 |
|---|---|---|---|
| 3回とも同じ操作手順だった割合 | 100% | 100% | 0% |
| 3回とも同じ判定だった割合 | 100% | 100% | 96% |
| 判定不能にした件数 | 0 / 75件 | 0 / 75件 | 1 / 81件 |
| 判定までの時間(1回) | 平均50秒 | 平均60秒 | 平均75秒 |
| LLM の呼び出し(1回) | 2回 | 2回 | 平均15回 |
| 費用(1回) | $0.102 | $0.358 | $0.438 |
モデルを賢くしたのではなく、手順を先に固めた効果
このハーネスと mobile-mcp(Opus)
約1.3倍
判定までが速い
約18%
1回あたりの費用が低い
7.5分の1
モデルの呼び出し回数
var strategy = Planners.goap("my-strategy", MyState::new)
.goal("solve the task", builder -> builder.condition(
state -> state.assessment != null && state.assessment.correct))
.action("solve", builder -> builder
.precondition(state -> true)
.belief(state -> state.copy("solution", null))
.execute((context, state) -> …)) // LLM に解かせる
.action("verify", builder -> builder
.precondition(state -> state.solution != null && state.assessment == null)
.belief(state -> state.copy(state.solution,
new SolutionAssessment(true, "solved")))
.execute((context, state) -> …)) // LLM に確かめさせる
.build();
Koog の GOAP の例
GoapStrategyExample.java
– アプリ仕様の整備、テストアカウントの準備、Google Play との接続、テスト項目そのものの整理
良かったこと
1
大抵の機能が揃っている
素の Kotlin なら、グラフも出力の修復もトレースなども自作になる
2
抽象度がちょうどよく、規模を広げやすい
3つのグラフの定義は、合わせて約160行に収まった
3
Kotlinでハーネスを快適に書ける
LLMとのやり取りはdata classでできて、全てが型安全
4
トークン消費の最適化を、いくらでも詰められる
ノードごとに、渡す情報とモデルをコードで選べる
気をつけること
想定から離れるほど、自作が増える
ノードから LLM を直接呼ぶと、トレースに載せるのに内部 API が要った
Koogはチャット系Agentに最適化されている
履歴を積む既定の動きは、該当しないAgentは自分で繋いだり外したりする必要がある(bind と release)
1
LLM に任せる範囲は、Koog のグラフで制御する
2
グラフの設計は、テストの形式と用途で変わる
3
AI 時代でも E2E テストは高いので、工夫が効く
テスト仕様を育てる
UIツリーの扱いに注意する
テストを並行に実行する
サンプルプロジェクト
github.com/inetand/
kotlinfest2026-koog-e2e
KotlinFest 2026
BONUS
課題
文言やABテストで
セレクタが壊れる
解決
Kotlin Compiler PluginでtestTag を生成し、Composeのノードにつける
// 書いたコード
Button(onClick = onFollow) { Text(label) }
// プラグインが変換
Button(
onClick = onFollow,
modifier = Modifier.testTag("Detail.Follow"),
) { Text(label) }
// ルートで一度だけ
Modifier.semantics { testTagsAsResourceId = true }
install(Persistence) {
storage = JVMFilePersistenceStorageProvider(runDirectory.resolve("checkpoints"))
enableAutomaticPersistence = true
rollbackToolRegistry = RollbackToolRegistry {
registerRollback(createItem, deleteItem) // 副作用のあるツールと、それを取り消すツールの組
}
}
保存されるもの
現在のノード・入力・履歴
条件
ペイロードが直列化できる
端末は DeviceSlot の値で持つ
ただし
デバイスの状態は戻らない
取り消すツールを登録した操作だけは、巻き戻すときに逆順で取り消される
戻らないもの
対処
data class AttemptState(…, val createdResources: List<ResourceRef> = emptyList())
// 再開時:作ったリソースを、その状態のまま直接開く
FlowStep.OpenLink("myapp://items/${ref.id}")
8
モジュール
約1.2万
LoC
約6千
行のテスト
それでも保守しやすいのは、Koog のおかげ
Appium
約0.1秒
Maestro
約17秒
1テストで10秒縮めるだけでも、テストの数と回数を掛ければ大きな差になる
1
シナリオ実行にLLMを使わない
– 保存したシナリオの操作をそのまま再生し、LLM は判定と回復にだけ使う
2
画面は Appium で読む
– UIツリーを直接取って、1画面を約0.1秒で読む
3
UIツリーは圧縮した状態で渡す
– 番号付きのリストにして、LLM に渡す量を約30分の1にする
4
8割はコードで解決する
– 圧縮したUIツリーを展開し、画面外はスクロールで探し、見えていればモデルを呼ばない
5
実行シナリオを再利用する
– 記録した画面遷移で目的の画面に近づいてから、LLM に聞く
6
シナリオ生成・テスト並列実行
– 端末ごとのワーカーが、1つの待ちキューを分け合う
準備
探索のサブグラフ
LLM とツールのループ。次の一手は LLM が決める
ターン数と時間の上限はコードで持つ
判定
報告
決まった流れを、何度も同じように
今日のハーネス:ループは生成と回復の中だけ
その場の状況に合わせて動いてほしい
探索的なテスト、調査:ループを大きくする
決定論か LLM 任せかの二択ではなく、ループを置く場所と上限をグラフで決める
未実装の構成案
今の状態
残りの条件と回数
プランナー
A* で計画する
計画
準備
起動
操作
証拠
判定
報告
先頭の1手だけ実行して、計画し直す
f = g + h
g:スタートからのコスト
h:ゴールまでの見積もり
スタート
ゴール
調べた所
次の候補
見つかった道
| → ↓ Space PgDn | 次へ(アニメーション操作モードでは次の状態へ) |
| ← ↑ Shift+Space PgUp | 前へ(同じく前の状態へ) |
| Shift+→ / Shift+← | 状態を飛ばして次 / 前のスライドへ |
| Home / End | 最初 / 最後のスライド |
| A | アニメーション操作モードの切り替え |
| S | 発表者モード(全画面にして発表者ビューを開く) |
| O | スライド一覧 |
| F | 全画面 |
| ? | このヘルプ |
| Esc | 閉じる |
クリック / タップ:右側で次へ、左側で前へ。横スワイプでも移動できます。URL の #slide-N で特定のスライドを開けます。アニメーション操作モードでは、動くスライド(グラフの流れ・タイムライン・録画)が自動再生せず、→ で1状態ずつ進みます。