Claude CodeでKtorサーバーを作る — testApplicationで検証する手順
Claude CodeでKtorサーバーを作るとき、testApplicationのテストを検証コマンドに据えるCLAUDE.mdとStop hookの設定、POST・JSON・cookie・外部サービスのテストの型を解説します。
Claude CodeでKtorのサーバーを作るなら、検証の足場は testApplication {} に置くのが近道です。Ktorのテストエンジンは、実サーバーを立てずソケットにも張り付かず、アプリケーションの呼び出しを内部で直接処理します。起動待ちやポート競合がないので、Claudeに「変更のたびにテストを回して」と頼みやすい構成です。
この記事では、testApplication の位置づけ、CLAUDE.mdに書く検証コマンド、Claudeに書かせるテストの型、Stop hookによる自動実行を順に扱います。
testApplicationは何を検証し、何を検証しないか
testApplication {} は、Ktorサーバーテストの入口になる関数です。分離されたテスト環境を作り、実Webサーバーを起動せずにアプリケーションを動かし、リクエスト用に設定済みのHTTPクライアントを渡してくれます。
流れは5段階です。
testApplication {}でテストを定義する- テスト用のアプリケーションを構成して起動する
- 必要ならHTTPクライアントを設定する
- クライアントでリクエストを送り、レスポンスを受け取る
kotlin.testのアサーションでステータス・ヘッダー・本文を確かめる
Claudeにとっての利点は、結果が文字で返ることです。ステータスコードや本文の食い違いがそのままテスト失敗のメッセージになるので、ブラウザで開いて目視する工程が要りません。
一方で、本物のエンジン(Nettyなど)を経由した動作までは確かめません。実サーバーを立てて HttpClient で叩くエンドツーエンドの検証は別枠で、専用の節があります。この記事の範囲は、速くて壊れにくい testApplication 側です。
| 確かめたいこと | 手段 |
|---|---|
| ルーティング・レスポンス・プラグインの配線 | 手段testApplication {} |
| 外部APIに依存する処理 | 手段externalServices {} で外部を模擬 |
| 実エンジンを通した動作 | 手段実サーバー + HttpClient(E2E) |
検証コマンドをCLAUDE.mdに固定する
CLAUDE.mdは、Claudeがセッション開始時に読む文脈です。強制される設定ではないので、「何を実行すれば正しいと言えるか」を具体的なコマンドで書いておきます。
Ktorの新規プロジェクトは、Ktorプロジェクトジェネレーター、IntelliJ IDEA Ultimateのプラグイン、Ktor CLIのいずれかで作れます。生成された本体コードは src/main/kotlin にあり、既定で Application.kt と Routing.kt の2ファイルです。テストは src/test/kotlin に置かれ、ServerTest.kt が最初から入っています。ビルドと起動はGradleなら ./gradlew build と ./gradlew run です。
これを踏まえた書き方の例です。
# Ktorサーバーの規約
## 検証コマンド(変更のたびに実行)
- ビルド: `./gradlew build`
- テスト: `./gradlew test`(実際のタスク名は `./gradlew tasks` で確認)
- 起動確認が必要なときだけ `./gradlew run`(常駐するので Ctrl+C で止める)
## テストの書き方
- ルートを足したら `src/test/kotlin` に `testApplication {}` のテストを足す
- `application { module() }` でモジュールを明示的に読み込む
- JSONを送るテストは `createClient { install(ContentNegotiation) { json() } }`
- 外部APIは本物に繋がず `externalServices { hosts(...) }` で模擬する
- アサーションは `kotlin.test` を使う./gradlew run を検証コマンドの常用に入れないのがポイントです。起動するとコマンドラインが応答しなくなり、Ctrl+Cで止めるまで返ってきません。Claudeに待たせると作業が止まるため、常用は build と test に絞ります。
CLAUDE.mdは長すぎると守られにくくなります。自動メモリーは先頭200行または25KBを読み込む扱いで、CLAUDE.mdも「再説明したくなること」を書く場所と位置づけられているので、Ktor固有の規約だけを短く入れます。
依存関係とテストクラスの最小構成
ジェネレーターで作った build.gradle.kts には、すでにテスト用の依存が入っています。チュートリアルでは testImplementation(kotlin("test")) と testImplementation(ktorLibs.server.testHost) の形です。自分で足す場合は、次の2つが必要です。
io.ktor:ktor-server-test-host: テストエンジン本体org.jetbrains.kotlin:kotlin-test: アサーション用のユーティリティ
Nativeのサーバーをテストするなら、これらを nativeTest ソースセットに追加します。Kotlin Multiplatform側の構成はClaude CodeでKotlin Multiplatformを使う手順にまとめています。
最小のテストは次の形です。
class ApplicationTest {
@Test
fun testRoot() = testApplication {
application {
module()
}
val response = client.get("/")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("Hello, world!", response.bodyAsText())
}
}ここで見落としやすいのが application { module() } です。Ktorのテストでは、モジュールを明示的に読み込むか、環境設定で読み込ませる必要があります。書き忘れると、ルートが一つも登録されていないアプリに対してリクエストを送ることになります。Claudeが新しいテストを書くたびに、この1行が入っているかを確認させる価値があります。
設定ファイルで読み込ませる場合は environment {} に ApplicationConfig("application-custom.conf") を渡します。プログラム側で値を渡すなら MapApplicationConfig("ktor.environment" to "dev") のような形です。環境ごとの挙動を変えたいときに使えます。
Claudeに書かせるテストの型
ルートの種類ごとに、テストの書き方は決まっています。以下はいずれもKtorのテスト解説に載っている型で、Claudeへの指示にそのまま使えます。
JSONを受け取るPOST
クライアントに ContentNegotiation を入れ、contentType と setBody で送ります。
@Test
fun testPostCustomer() = testApplication {
application { main() }
client = createClient {
install(ContentNegotiation) { json() }
}
val response = client.post("/customer") {
contentType(ContentType.Application.Json)
setBody(Customer(3, "Jet", "Brains"))
}
assertEquals("Customer stored correctly", response.bodyAsText())
assertEquals(HttpStatusCode.Created, response.status)
}ステータスと本文の両方を確かめている点に注意してください。ステータスだけの検証は、Claudeが書きがちな薄いテストです。CLAUDE.mdに「ステータスと本文の両方をアサートする」と1行入れておくと、書き方が揃います。
フォームとファイルアップロード
キーと値のフォームは、Content-Type を application/x-www-form-urlencoded にして、パラメーターを formUrlEncode() で符号化します。ファイルアップロードは multipart/form-data を MultiPartFormDataContent で組み立てます。アップロードのテストはファイルを実際に書き出すため、Ktorの例では @After で後始末をしています。Claudeに書かせるときは、後始末の有無まで指示に含めてください。
cookieとセッション
リクエスト間でcookieを保つには、クライアントに HttpCookies を入れます。Ktorの例は、ログイン後に /user を3回叩くと、再読み込み回数が1、2、3と増える流れを確かめるものです。セッションを使うルートは、クライアントを使い回さないと状態が引き継がれません。
外部サービスの模擬
外部APIを呼ぶルートは、externalServices {} の中で hosts() を使い、模擬サービスをアプリケーションとして定義します。Ktorの例はGoogle APIのJSON応答を再現するもので、次のような形になります。
externalServices {
hosts("https://www.googleapis.com") {
install(ContentNegotiation) { json() }
routing {
get("oauth2/v2/userinfo") {
call.respond(UserInfo("1", "JetBrains", "", ""))
}
}
}
}CIやClaudeの作業中に本物の外部APIへ繋がると、ネットワークや認証の都合でテストが不安定になります。ここを模擬に固定しておくのが、Claudeに自走させるときの最大の安全策です。
HTTPSのエンドポイントは URLProtocol.HTTPS を url {} ブロックで指定し、WebSocketはクライアントに WebSockets プラグインを入れて client.webSocket で会話を再現します。
生成プロジェクトのServerTestを足場にする
ジェネレーターで作ったプロジェクトの src/test/kotlin/ServerTest.kt には、最初から1本のテストがあります。公式のチュートリアルでは次の形です。
class ServerTest {
@Test
fun `test root endpoint`() = testApplication {
// loads default configuration
configure()
// verify server root returns 200
assertEquals(HttpStatusCode.OK, client.get("/").status)
}
}先ほどの最小例は application { module() } でモジュールを読み込んでいましたが、こちらの configure() は embeddedServer() から呼ばれるのと同じ設定を呼び出します。どちらの書き方になるかはプロジェクトの作り方で変わります。Claudeに新しいテストを足させるときは、「既存の ServerTest.kt と同じ読み込み方に揃える」と伝えるのが確実です。
このテストは、アプリが 0.0.0.0 で動いているかに依存しません。テストごとに別のKtorインスタンスが立つため、./gradlew run の起動中でも結果は変わらず、Claudeが開発サーバーを立てたまま検証しても干渉しません。
例外ハンドラーをテストで固定する
チュートリアルには StatusPages プラグインを入れ、IllegalStateException を捕まえる例があります。/error-test で例外を投げ、ハンドラーが App in illegal state as Too Busy の形で応答する構成です。この種の挙動は、ブラウザで見て済ませると次の変更で崩れても気づけません。例えば次のようなテストを足させれば、例外処理がテストで固定されます(公式の例に沿った形です)。
@Test
fun `test error handler`() = testApplication {
configure()
val response = client.get("/error-test")
assertEquals("App in illegal state as Too Busy", response.bodyAsText())
}Claudeへの指示は「ルートを足したら、成功系と例外系の両方のテストを同じ変更で足す」と書いておくと、確認の抜けが減ります。
テスト失敗をClaudeに読ませる反復
実際の運用では、1回で全部通ることは多くありません。次の手順で回します。
- ルートを実装させる
- 同じ変更で
testApplicationのテストも書かせる ./gradlew testを実行させ、失敗メッセージをそのまま読ませる- 食い違ったのがステータスか本文かを言わせてから直させる
手順3で「失敗メッセージを読ませる」のが肝心です。期待値と実際値が並んで出るので、Claudeは推測ではなく差分から直せます。
よくあるつまずき
- 起動のタイミング: テスト用のアプリは、最初のクライアント呼び出しで起動します。ライフサイクルイベントを検証したいときは
startApplication()で手動起動します - 起動前の
application参照:startApplication()かリクエストの前にapplicationプロパティへ触ると、インスタンスは取れても起動前の可能性があります - サンプルの
main()とmodule(): サンプルはファイルごとに関数名が違います。自分のプロジェクトの関数名に合わせるようClaudeに伝えます - E2Eとの混同:
testApplicationが通っても、実エンジン固有の問題は拾えません。実サーバーを立てる検証は別枠で用意します
Spring Bootで同じ型の検証ループを組んだ記録はClaude CodeでSpring Bootアプリを作る手順にあります。フレームワークが変わっても、検証コマンドをCLAUDE.mdに固定する考え方は共通です。
Stop hookでテストを自動実行する
CLAUDE.mdの指示は守られない場合があります。確実に走らせたい検証は、.claude/settings.json のhooksに寄せます。Claudeが応答を終えるタイミングでテストを走らせ、失敗したら終了コード2で止める構成です。終了コード2は、イベントによってはClaudeにフィードバックを返して作業を続けさせます。Stop hookはその一つです。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/ktor-test.sh"
}
]
}
]
}
}#!/bin/bash
INPUT=$(cat)
# 自分が起こした継続中は何もしない(無限ループ防止)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
if ! ./gradlew test >&2; then
echo "テストが失敗しています。testApplication の失敗内容を読んで直してください" >&2
exit 2
fiスクリプトに実行権限を付けるのを忘れないでください。
chmod +x .claude/hooks/ktor-test.shstop_hook_active の確認は必須です。Claude Codeは、Stop hookがClaudeのツール呼び出しを挟まず8回続けてブロックすると、そのhookを無視します。スクリプト側でも stop_hook_active を見て抜けておけば、同じ失敗の往復を増やさずに済みます。収束までに8回を超える作業なら、CLAUDE_CODE_STOP_HOOK_BLOCK_CAP で上限を上げられます。
同じ型のhooksをTypeScript側で組んだ例はClaude CodeでNestJSアプリを開発する手順にあります。テストが重いと感じるなら、Stop hookは変更に近いテストだけに絞り、全件は「機能を足したら実行」の規約に回す分け方もあります。
まとめ
Ktorサーバーの検証は、testApplication {} を軸に組むと自走させやすくなります。実サーバーを立てないので速く、結果は文字で返り、application { module() } と externalServices {} で何を載せ何を模擬するかを明示できるためです。
CLAUDE.mdには検証コマンドと「ステータスと本文の両方をアサート」「外部APIは模擬」の規約を書き、確実に走らせたい検証はStop hookに預ける。この2層が、Claudeに任せる範囲を広げる土台になります。