KotlinFest 2026

LLM任せにしない
UIテストワークフローを、
Koogの型付き戦略グラフで設計する

resolve

attempt

report

自己紹介

andousan の写真

andousan

ピクシブ株式会社 
Android 横断テックリード

OpenTelemetry Kotlin SDKのコントリビューター
Kotlinを愛でる人

X:@inetandev
Linkedin: @inetand

このセッションのゴール

LLMに任せる範囲と、Kotlinで制御する処理の境界を、
Koogでどう設計するかを理解する

アジェンダ

01

なぜ、LLM に任せきらない仕組みが必要だったか

02

作ったハーネスを動かしてみる

03

Koog で、テスト一本の流れを組み立てる

04

Coroutines で、複数テストを並行に動かす

05

ハーネスの動きを追い、正しさを確かめる

06

何が改善し、何が残ったか

01

なぜ、LLM に任せきらない
仕組みが必要だったか

このトークで e2e テストと呼ぶもの

ユーザーの操作

アプリ

サーバー

画面で確かめる

e2e テスト

結合テスト

ユニットテスト

アプリを実際に動かし、ユーザーと同じ操作をして、結果は画面の状態から検証する

遅くて壊れやすいので、守りたいユーザーフローのみを対象にする

変更が増えるにつれて、リリース前の確認が増えた

image.png

リリース前のリグレッションテスト項目の平均件数

LLMにデバイスを触らせるテストは、どこまで頼れるか

AndroidWorld

Google Researchの
AI エイジェント評価用のベンチマーク

20

アプリ

116

タスク

AndroidWorld の構成図。Android エミュレーター上のアプリと OS の状態、TaskEval の goal・initialize_task・is_successful などの関数、タスクの目的と画面と UI ツリーを受け取って操作するエージェント、116 の Android タスクと 92 の MiniWob++ タスクの例

google-research.github.io/android_world

AndroidWorld のサイトへの QR コード

LLM に渡したプロンプト

手順

1

検証アプリを起動する

2

shopping_list という名前で Markdown ファイルを作る

3

本文に milk, eggs, bread と入力する

4

保存して、ファイル一覧に戻る

確かめてほしいこと

A

ファイル一覧に shopping_list.md が出ている

B

shopping_list.md を開くと、本文が milk, eggs, bread になっている

結果は PASS でも、中では3つの問題が起きていた

mobile-mcp に Markor のテストを任せた3回目の録画。左が端末の画面、右が渡した手順と確かめること。到達した手順にチェックが付き、手順4までと確認 B を終えたあと、指示にない強制終了と再起動、タップ前の画面のスクショ、画面を見ないままの報告が赤で追記される

指示にない操作

「保存して一覧に戻る」までなのに、最後にアプリを強制終了して再起動した

証拠のずれ

「タップ後」として撮った画像に、タップ前の画面が写っていた

確かめない報告

最後の画面を見ないまま「一覧に戻した」と報告した

この経験から決めた、3つの要件

1

シナリオを使い捨てにせず、差分を見ながら育てる

2

テスト手順にない操作をさせない

3

根拠のない合格は「合格」として扱わない

3つの要件を、フレームワークに求める条件に置き換える

この条件で Koog を選んだ

シナリオを使い捨てにせず、差分を見ながら育てる

テスト手順にない操作をさせない

根拠のない合格は「合格」として扱わない

流れをグラフで組み、作る・直す・再生するを分けられる

保存した手順を、LLM を挟まず機械的に実行できる

LLM の判定を型で受け、根拠をコードで確かめられる

Koogとは

2025

KotlinConf'25

Koogを発表

2026年5月

KotlinConf'26

安定版の Koog v1.0
公式ドキュメントも拡充

2026年11月

Kotlin Fest'26

Koog 1.3.0 +
Spring Boot のCLI

02

作ったハーネスを動かしてみる

仕様から結果までの完成形を見る

Koogで作ったものを動かしてみる

対象プラットフォーム

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

Koog で、テスト一本の
流れを組み立てる

テスト1本の6つの段階と、LLM とコードの担当

仕様を書く

1

人間

前提条件の準備

2

Kotlin

シナリオの生成

3

LLM

Kotlin

シナリオの実行

4

Kotlin

外部ツール

結果の判定

5

LLM

Kotlin

レポート

6

Kotlin

LLM

Graph Engineering

ループで組む

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)

}

試行サブグラフ

やり直す

テストハーネスのグラフ設計をKoog の DSLで書く

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"

  }

}

アカウント

プレミアムかどうかやファイルの数を、アカウントの事実として照合する

idsigned_inpremiumfiles
free-usertruefalse31
premium-usertruetrue5
new-usertruefalse0

ユーザー目的

--- … ---

## 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/ に残る

検証には、このあと説明する試行サブグラフをそのまま使う

Git でレビューできる中間表現

{

  "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

仕様が変わると一致しなくなり、古いと分かる

命令を抽象化する DeviceDriver

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

LLM は番号を選び、コードがステップに変える

中間表現なし:モデルが 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)

}

要素が見つからない時だけ、LLM で回復する

// 判定の順序を示す簡略版

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))

}

結論が出たら終わる

見つからない時だけ回復

それ以外はやり直す

分岐は、すべて sealed な型で書く

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ツリーが膨大でトークンが積み上がる

UIツリーを丸ごと読み込んだ場合にかかる累計トークン数

0

20万

40万

60万

1

4

8

12ターン

12ターンで約32万

約11万字

1画面の中央値

20画面に1つ

1ターンで10万トークン超

約123万トークン

いちばん重い1画面

UIツリーを圧縮し、押せるものに番号を振って渡す

元々の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

番号は、全体のリストのまま

圧縮したリストを渡し、必要な所だけ LLM に展開させる

テスト1本がグラフを流れる様子

LLM を使うのは、判定と再探索と報告の3か所だけ

試行のサブグラフ

シナリオ解決

実行

判定

リトライ

手順修正

要素の再探索

報告

仕様とシナリオ

記録と報告書

Kotlin で制御する

前提条件の確認

シナリオの再生

形式と証拠の検証

回復の上限

手順の修正と再実行

LLM に判断を求める

証拠から合否を判断する

見つからない要素を探す

04

Coroutines でテストをスケールする

4時間...

e2eテストは重い

端末ごとにワーカーを立て、LLM の同時実行だけを絞る

キューは Channel、結果は channelFlow

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 のワーカー

親をキャンセルすると、子にも届く
ワーカーも再生もキャンセルされる

タイムアウトも同じツリーで届く
外部プロセスの待ちに割り込む

共有している LLM をSemaphore で制御する

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台端末の場合

Kotlinでe2eテストは早い!

05

本番運用に必要な要素

ログを整える

OpenTelemetry Kotlin SDK

open-telemetry / opentelemetry-kotlin

KMP対応のOpenTelemetry SDK

opentelemetry-kotlin の Issue「Create a MetricStorage API #952」の画面

OpenTelemetry で、テスト1本を1本のトレースに

ノードごとの記録

プロンプト・応答・トークン・ログなど

OTLP で送るだけ

Langfuse でも Datadog でも任意の
バックエンドでも見られる

image.png
トレースの見出しを拡大したもの:54.22s、$0.506、105k tokens

テストハーネスそのものを検証する

MR ごとに

グラフの単体テスト

分岐や停止が正しいかを確かめる

リリース前に

AndroidWorld で検証

LLM の判定が正しいかを測る

構成のテストは Koog の testGraph で担保する

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

モデルの呼び出し回数

GOAP で手順を柔軟に計画させる

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 の GoapStrategyExample.java の QR コード

Koog の GOAP の例

GoapStrategyExample.java

手動確認の時間は、まだ劇的には減っていない

  • リリース前の動作確認でハーネスで自動化できたのは約4割程度
  • まだ、テストの環境やテスト条件の整備が必要

–  アプリ仕様の整備、テストアカウントの準備、Google Play との接続、テスト項目そのものの整理

Koog を使って良かったことと、気をつけること

良かったこと

1

大抵の機能が揃っている

素の Kotlin なら、グラフも出力の修復もトレースなども自作になる

2

抽象度がちょうどよく、規模を広げやすい

3つのグラフの定義は、合わせて約160行に収まった

3

Kotlinでハーネスを快適に書ける

LLMとのやり取りはdata classでできて、全てが型安全

4

トークン消費の最適化を、いくらでも詰められる

ノードごとに、渡す情報とモデルをコードで選べる

気をつけること

想定から離れるほど、自作が増える

ノードから LLM を直接呼ぶと、トレースに載せるのに内部 API が要った

Koogはチャット系Agentに最適化されている

履歴を積む既定の動きは、該当しないAgentは自分で繋いだり外したりする必要がある(bind と release)

持ち帰ってほしい3つのこと

1

LLM に任せる範囲は、Koog のグラフで制御する

2

グラフの設計は、テストの形式と用途で変わる

3

AI 時代でも E2E テストは高いので、工夫が効く

テスト仕様を育てる

UIツリーの扱いに注意する

テストを並行に実行する

Koog のサンプルプロジェクト github.com/inetand/kotlinfest2026-koog-e2e の QR コード

サンプルプロジェクト

github.com/inetand/
kotlinfest2026-koog-e2e

KotlinFest 2026

ご清聴ありがとうございました

BONUS

本編で省いた内容

安定したセレクタを作る、testTag の自動生成

課題

文言や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 }

Koog の Persistence は数行で入る

install(Persistence) {

    storage = JVMFilePersistenceStorageProvider(runDirectory.resolve("checkpoints"))

    enableAutomaticPersistence = true

    rollbackToolRegistry = RollbackToolRegistry {

        registerRollback(createItem, deleteItem)    // 副作用のあるツールと、それを取り消すツールの組

    }

}

保存されるもの

現在のノード・入力・履歴

条件

ペイロードが直列化できる

端末は DeviceSlot の値で持つ

ただし

デバイスの状態は戻らない

取り消すツールを登録した操作だけは、巻き戻すときに逆順で取り消される

端末の状態は巻き戻らない:再開方針の候補(未検証)

戻らないもの

  • いまの画面と遷移の履歴
  • テスト中に作ったデータ
  • アカウントの状態

対処

  1. 作ったリソースのIDを状態に持ち、そのまま開く
  2. 再開は試行の開始から。アプリは launch で戻せるが、アカウントやサーバーのデータは別途戻す
  3. 判定からの再開なら、証拠ファイルだけで足りる

data class AttemptState(…, val createdResources: List<ResourceRef> = emptyList())

// 再開時:作ったリソースを、その状態のまま直接開く

FlowStep.OpenLink("myapp://items/${ref.id}")

e2eのハーネスは肥大化されやすい

8

モジュール

約1.2万

LoC

約6千

行のテスト

それでも保守しやすいのは、Koog のおかげ

  • Kotlin らしい DSL のおかげで、3つのグラフの定義は合わせて約160行
  • 流れはグラフに、中身はノードのクラスに分かれるので、直す場所がすぐ分かる
  • カスタム実装も少なめに済んだ

学び:e2e は重いので、1つ1つの処理の速さが効く

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 が決める

ターン数と時間の上限はコードで持つ

判定

報告

決まった流れを、何度も同じように

今日のハーネス:ループは生成と回復の中だけ

その場の状況に合わせて動いてほしい

探索的なテスト、調査:ループを大きくする

決定論か LLM 任せかの二択ではなく、ループを置く場所と上限をグラフで決める

未実装の構成案

その場で判断させるなら、GOAP で手順を計画する

今の状態

残りの条件と回数

プランナー

A* で計画する

計画

準備

起動

操作

証拠

判定

報告

先頭の1手だけ実行して、計画し直す

Koog の GOAP では、状態の上を A* で探す

–