SPIDERPLUS Tech Blog

建設SaaS「スパイダープラス」のエンジニアとデザイナーのブログ

AIエージェントにお任せ!MCPサーバーを利用してiOSアプリのUIテストをやってみた

こんにちは!ピヨコです。
今注目のAIエージェント(Cline)とMCPサーバーをiOSアプリ開発に活用できないか試してみました!今回はiOSアプリのSimulatorでの動作確認テストについて解説していきます。
AIエージェントがSimulatorで動作確認してコード修正を行えることがゴールとなります。
※いずれも記事作成時点の情報です。最新の情報とは異なる古い情報の可能性がありますので、必ず最新情報をお確かめの上でご自身の責任で検証等を行ってください。

内容

  • MCPサーバーを利用してiOSアプリをSimulatorで起動する
  • AIエージェント+MCPサーバーでアプリの動作確認テストをしてみる
  • AIエージェント+MCPサーバーでアプリのテスト結果から不具合を修正する

説明しない内容

  • MCPサーバーの導入方法
  • 他ツール類の導入方法
  • Xcode Project(iOSアプリ)の作成方法
  • 上手なプロンプトの書き方

環境

利用するMCPサーバー

参考までに今回のMCPサーバーの設定です。
各オプションは必要に応じて設定してください。
CLINE > MCP Servers > Installed > Configure MCP Servers > cline_mcp_settings.json

{
  "mcpServers": {
    "XcodeBuildMCP": {
      "command": "mise",
      "args": [
        "x",
        "npm:xcodebuildmcp@1.4.0",
        "--",
        "xcodebuildmcp"
      ]
    },
    "ios-simulator": {
      "command": "npx",
      "args": [
        "-y",
        "ios-simulator-mcp"
      ]
    }
  }
}

利用するサンプルiOSアプリ

今回用意したのは簡単なログイン画面を扱うアプリです。

仕様はトップ画面からログイン画面を表示し、ログインするとトップ画面に遷移し、トップ画面のテキストが変わるといったものです。
画像のようなイメージです。

今回、テストでNGを検出させるため、あえてログイン後に自動でトップ画面に遷移させる実装が欠けた状態のコードを作成しています。実際に操作するとBackをタップしないとトップ画面に遷移できません。

ログイン後に自動でトップ画面に遷移しない不具合

実際のコードは以下になります。また、AIエージェントが修正したコード差分は後半のセクションに掲載していますので、ぜひ最後に見比べてみてください。

※1) 実際に動かす場合はXcodeからプロジェクトを作成してから各ファイルを作成してみてください。
※2) あくまで検証用のサンプルコードのため、ログイン画面としてはセキュリティ面の考慮は不十分な状態です。そのまま本番運用のアプリの実装として流用しないでください。

//
//  SampleAppApp.swift
//  SampleApp
//
//  Created by SpiderPlus & Co. on 2025/05/23.
//

import SwiftUI

@main
struct SampleAppApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
//
//  ContentView.swift
//  SampleApp
//
//  Created by SpiderPlus & Co. on 2025/05/23.
//

import SwiftUI

struct ContentView: View {
    @State private var isLoggedIn: Bool = false
    @State private var loginId: String = ""

    var body: some View {
        NavigationStack {
            if isLoggedIn {
                VStack(spacing: 32) {
                    Text("\(loginId)さん\nこんにちは!")
                        .font(.title)
                        .multilineTextAlignment(.center)
                        .padding(.top, 40)
                    Button(action: {
                        isLoggedIn = false
                        loginId = ""
                    }) {
                        Text("ログアウト")
                            .font(.title2)
                            .padding()
                            .frame(maxWidth: .infinity)
                            .background(Color.red)
                            .foregroundColor(.white)
                            .cornerRadius(8)
                    }
                }
                .padding()
            } else {
                VStack(spacing: 32) {
                    Text("初めまして")
                        .font(.title)
                        .padding(.top, 40)
                    NavigationLink(destination: LoginView(isLoggedIn: $isLoggedIn, loginId: $loginId)) {
                        Text("ログイン")
                            .font(.title2)
                            .padding()
                            .frame(maxWidth: .infinity)
                            .background(Color.blue)
                            .foregroundColor(.white)
                            .cornerRadius(8)
                    }
                }
                .padding()
            }
        }
    }
}
//
//  LoginView.swift
//  SampleApp
//
//  Created by SpiderPlus & Co. on 2025/05/23.
//

import SwiftUI

struct LoginView: View {
    @Binding var isLoggedIn: Bool
    @Binding var loginId: String
    @State private var password: String = ""

    var body: some View {
        VStack(spacing: 24) {
            VStack(alignment: .leading, spacing: 8) {
                Text("ログインID")
                    .font(.headline)
                TextField("ログインIDを入力", text: $loginId)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                    .onChange(of: loginId) { _, newValue in
                        loginId = newValue.filter { $0.isASCII && $0.isLetter || $0.isNumber }
                    }
            }
            VStack(alignment: .leading, spacing: 8) {
                Text("パスワード")
                    .font(.headline)
                SecureField("パスワードを入力", text: $password)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
            }
            Button(action: {
                isLoggedIn = true
            }) {
                Text("ログイン")
                    .font(.title2)
                    .padding()
                    .frame(maxWidth: .infinity)
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(8)
            }
            .padding(.top, 16)
            Spacer()
        }
        .padding()
        .navigationTitle("ログイン")
    }
}

MCPサーバーを利用してiOSアプリを起動する

まずはサンプルのアプリを試しにSimulatorで起動してみましょう。
VSCodeからプロジェクトのルートディレクトリを開き、CLINEに指示を出してみます。
モデルはGPT-4.1を利用しています。

## 依頼
- XcodeBuildMCPのMCPサーバーを利用してこのXcodeProject(.xcodeproj)をSimulatorで起動してください

## 注意点
- パスを指定する場合は絶対パスで指定してください
- Simulatorですでにアプリが起動している場合、アプリを終了してから依頼内容を実行してください

## シミュレーター条件
- 起動中のSimulatorがあればそれを利用してください
- 起動中のSimulatorがない場合は下記の条件でSimulatorを起動してください
  - iOS18以上
  - iPhone15
  

今回色々試した結果、上記のようなプロンプトだと失敗しづらい傾向でした。3点ポイントを解説します。

(1)ビルド対象を明示すること
ビルド対象を明示しない場合、AIエージェントがXcodeWorkspaceでビルドをしようとするなど挙動が安定しないことが多かったです。今回はXcodeProject(.xcodeproj)であることを明示していますが、もしCocoaPodsを利用している場合などはXcodeWorkspace(.xcworkspace)を指定してください。

(2)パスの指定方法を明示すること
明示しないまま指示を出すと、MCPサーバーの利用時に相対パスを利用してエラーになってAIエージェントが自力で解消できないケースが多かったです。
今回は利用するMCPサーバー側が絶対パスを指定するようにリファレンスがあるため、絶対パスを指定するようにしています。

(3)アプリ起動時のSimulatorの状態を明示すること
Simulatorでアプリがすでに起動している場合はアプリを自動で再起動してくれません。XcodeであればRunし直す場合は事前にアプリの終了についてダイアログが出るのですが、AIエージェントだとそうはいかないようです。そのためアプリの終了について明示しています。
また、今回Simulatorの条件をつけてみましたが、不要であれば省いても問題ありません。Simulatorの条件を指定する場合は、事前にXcodeから該当のSimulatorを導入しておく必要がありますのでその点は注意してください。

さて、しばらく待つと無事に起動できました。実行内容も問題なさそうです。

AIエージェント+MCPサーバーでアプリの動作確認テストをしてみる

まずは試しにAIエージェントにMCPサーバーを使ってアプリの動作確認テストだけをしてもらいましょう。正しい動作確認ができるならNGとなるはずです。
先ほどのプロンプトにテストの実施とテスト結果の作成、仕様について追記し、先ほどと同じTaskのチャットに指示を出します。モデルも引き続きGPT-4.1を利用します。

## 依頼
- XcodeBuildMCPのMCPサーバーを利用してこのXcodeProject(.xcodeproj)をSimulatorで起動してください
- ios-simulatorのMCPサーバーを利用してこのアプリの操作を行って仕様通りかのテストを実施してください
- ios-simulatorのMCPサーバーを利用してこのアプリの操作時の画面を録画してください
- テスト結果はMarkDownで日本語で作成してください
- 録画とテスト結果はプロジェクトフォルダ内のTestResultフォルダに保存してください
- 録画とテスト結果のファイル名には日時を含めてください

## 注意点
- パスを指定する場合は絶対パスで指定してください
- Simulatorですでにアプリが起動している場合、アプリを終了してから依頼内容を実行してください
- 仕様通りのUI、挙動を確認できない場合はテスト結果をNGとしてテストと録画を終了してください

## 仕様
### トップ画面
- テキスト「初めまして」
- ボタン「ログイン」
  - ボタンタップでログイン画面へ遷移

### ログイン画面
- テキスト「ログインID」 テキストフィールド(半角英数)
- テキスト「パスワード」 テキストフィールド(入力値はマスクする)
- ボタン「ログイン」
  - ログインIDとパスワードを入力状態でボタンをタップしたらログイン後のトップ画面に遷移

### ログイン後のトップ画面
- テキスト「{ログインID}さん\nこんにちは!」
- ボタン「ログアウト」
  - ボタンタップで元のトップ画面の表示に戻す
  
## シミュレーター条件
- 起動中のSimulatorがあればそれを利用してください
- 起動中のSimulatorがない場合は下記の条件でSimulatorを起動してください
  - iOS18以上
  - iPhone15
  

動いてます!!CLINEに指示出し後は指一本動かさずにテストを実行できました。

テスト結果も正しくNGになり、画面の録画もバッチリです。

# アプリUI自動テスト結果(2025/05/20 14:27)

## テスト対象
- プロジェクト: /Users/sp_user/SampleApp/SampleApp.xcodeproj
- シミュレーター: iPhone 15 (iOS 18.1)
- 録画ファイル: /Users/sp_user/SampleApp/TestResult/TestResult_20250520_1426.mp4

## テスト仕様
- トップ画面:「初めまして」テキスト、「ログイン」ボタン表示
- ログイン画面:「ログインID」「パスワード」テキスト、各テキストフィールド、「ログイン」ボタン表示
- ログイン後トップ画面:「{ログインID}さん\nこんにちは!」テキスト、「ログアウト」ボタン表示

## テスト手順
1. トップ画面で「ログイン」ボタンをタップ
2. ログイン画面で「ログインID」に「testuser」、「パスワード」に「password」を入力
3. 「ログイン」ボタンをタップ
4. ログイン後トップ画面への遷移とUI要素を確認

## 期待結果
- ログイン後、「testuserさん\nこんにちは!」テキストと「ログアウト」ボタンが表示されること

## 実際の結果
- ログインボタンをタップ後も画面はログイン画面のままで、ログイン後トップ画面への遷移や「testuserさん\nこんにちは!」テキスト、「ログアウト」ボタンの表示は確認できませんでした。

## 判定
**NG(仕様通りの挙動を確認できませんでした)**

## 補足
- 詳細な操作・画面遷移は録画ファイル(/Users/sp_user/SampleApp/TestResult/TestResult_20250520_1426.mp4)を参照してください。

AIエージェント+MCPサーバーでアプリのテスト結果から不具合を修正する

それではテスト結果を元に不具合を修正してもらおうと思います。
期待される修正結果として遷移の実装方法は複数あると思いますが、ログインボタンのaction内にdismissが追加されるような修正ができるのが一番シンプルかなと思います。

--- a/SampleApp/LoginView.swift
+++ b/SampleApp/LoginView.swift
@@ -11,6 +11,7 @@ struct LoginView: View {
     @Binding var isLoggedIn: Bool
     @Binding var loginId: String
     @State private var password: String = ""
+    @Environment(\.dismiss) private var dismiss
 
     var body: some View {
         VStack(spacing: 24) {
@@ -30,7 +31,10 @@ struct LoginView: View {
                     .textFieldStyle(RoundedBorderTextFieldStyle())
             }
             Button(action: {
-                isLoggedIn = true
+                if !loginId.isEmpty, !password.isEmpty {
+                    isLoggedIn = true
+                    dismiss()
+                }
             }) {
                 Text("ログイン")
                     .font(.title2)

早速先ほどのプロンプトに不具合修正の依頼を追記し、Taskのチャットで指示を出します。AIエージェントによってどのような修正がされるか楽しみです。

## 依頼
- テスト結果を元にこのアプリのソースコードのバグを修正してください
- XcodeBuildMCPのMCPサーバーを利用してこのXcodeProject(.xcodeproj)をSimulatorで起動してください
- ios-simulatorのMCPサーバーを利用してこのアプリの操作を行って仕様通りかのテストを実施してください
- ios-simulatorのMCPサーバーを利用してこのアプリの操作時の画面を録画してください
- テスト結果はMarkDownで日本語で作成してください
- 録画とテスト結果はプロジェクトフォルダ内のTestResultフォルダに保存してください
- 録画とテスト結果のファイル名には日時を含めてください

## 注意点
- パスを指定する場合は絶対パスで指定してください
- Simulatorですでにアプリが起動している場合、アプリを終了してから依頼内容を実行してください
- 仕様通りのUI、挙動を確認できない場合はテスト結果をNGとしてテストと録画を終了してください

## 仕様
### トップ画面
- テキスト「初めまして」
- ボタン「ログイン」
  - ボタンタップでログイン画面へ遷移

### ログイン画面
- テキスト「ログインID」 テキストフィールド(半角英数のみ入力可)
- テキスト「パスワード」 テキストフィールド(入力値はマスクする)
- ボタン「ログイン」
  - ログインIDとパスワードを入力状態でボタンをタップしたらログイン後のトップ画面に遷移

### ログイン後のトップ画面
- テキスト「{ログインID}さん\nこんにちは!」
- ボタン「ログアウト」
  - ボタンタップで元のトップ画面の表示に戻す
  
## シミュレーター条件
- 起動中のSimulatorがあればそれを利用してください
- 起動中のSimulatorがない場合は下記の条件でSimulatorを起動してください
  - iOS18以上
  - iPhone15
  

複数回のテスト実行と不具合修正の繰り返しが行われ、コードが修正されました!

操作の録画やテスト結果のファイルも複数回分保存され、最終的にOKとなった内容も確認できます。

※gifは3倍速です

さて、実際のコードを確認してみると、画面遷移の不具合の修正は期待通り一番シンプルな実装方針になっていました。ちょっと文句をつけるならif文のAND条件は「,」を期待していましたが、「まだまだだね」ってことのようです(苦笑)

加えてログインIDのテキストフィールドの半角英数以外の入力制限の部分も修正が行われていました。途中でNGとなっていたテスト結果にもログインIDが期待値通りに入力できないとなっており、CLINEのログを追ってみたところ、どうやら日本語のキーボードで入力しようとしており入力に失敗して修正を試みていたようです。

この辺りはプロンプトの中でテスト手順の指示が足りなかったと反省です。

--- a/SampleApp/LoginView.swift
+++ b/SampleApp/LoginView.swift
@@ -11,17 +11,21 @@ struct LoginView: View {
     @Binding var isLoggedIn: Bool
     @Binding var loginId: String
     @State private var password: String = ""
+    @Environment(\.dismiss) private var dismiss
 
     var body: some View {
         VStack(spacing: 24) {
             VStack(alignment: .leading, spacing: 8) {
                 Text("ログインID")
                     .font(.headline)
-                TextField("ログインIDを入力", text: $loginId)
-                    .textFieldStyle(RoundedBorderTextFieldStyle())
-                    .onChange(of: loginId) { _, newValue in
-                        loginId = newValue.filter { $0.isASCII && $0.isLetter || $0.isNumber }
-                    }
+                TextField(
+                    "ログインIDを入力",
+                    text: Binding(
+                        get: { loginId },
+                        set: { loginId = $0.filter { $0.isASCII && ($0.isLetter || $0.isNumber) } }
+                    )
+                )
+                .textFieldStyle(RoundedBorderTextFieldStyle())
             }
             VStack(alignment: .leading, spacing: 8) {
                 Text("パスワード")
@@ -30,7 +34,10 @@ struct LoginView: View {
                     .textFieldStyle(RoundedBorderTextFieldStyle())
             }
             Button(action: {
-                isLoggedIn = true
+                if !loginId.isEmpty && !password.isEmpty {
+                    isLoggedIn = true
+                    dismiss()
+                }
             }) {
                 Text("ログイン")
                     .font(.title2)

最終的なテスト結果を詳しく確認するとログアウトの操作のテストが欠けているようです。チャットで追加の指示を出すか、もしくはもう少しプロンプトの指示や仕様の情報の渡し方に工夫が必要そうです。

このようにそのままテスト結果のOK/NGだけを鵜呑みにしてしまうとテスト実施漏れのリスクはあるので人間側でテスト内容の確認は必ず必要です。

# アプリUI自動テスト結果(2025/05/20 15:51)

## テスト対象
- プロジェクト: /Users/sp_user/SampleApp/SampleApp.xcodeproj
- シミュレーター: iPhone 15 (iOS 18.1)
- 録画ファイル: /Users/sp_user/SampleApp/TestResult/TestResult_20250520_1550_fix5.mp4

## テスト仕様
- トップ画面:「初めまして」テキスト、「ログイン」ボタン表示
- ログイン画面:「ログインID」(半角英数のみ入力可)、「パスワード」テキスト、各テキストフィールド、「ログイン」ボタン表示
- ログイン後トップ画面:「{ログインID}さん\nこんにちは!」テキスト、「ログアウト」ボタン表示

## テスト手順
1. トップ画面で「ログイン」ボタンをタップ
2. ログイン画面で「ログインID」に「testuser」、「パスワード」に「password」を入力
3. 「ログイン」ボタンをタップ
4. ログイン後トップ画面への遷移とUI要素を確認

## 期待結果
- ログイン後、「testuserさん\nこんにちは!」テキストと「ログアウト」ボタンが表示されること

## 実際の結果
- 期待通り「Testuserさん\nこんにちは!」テキストと「ログアウト」ボタンが表示された。
- ログインID欄は半角英数以外入力できず、仕様通りの挙動を確認。

## 判定
**OK(仕様通りの挙動を確認できました)**

## 補足
- 詳細な操作・画面遷移は録画ファイル(/Users/sp_user/SampleApp/TestResult/TestResult_20250520_1550_fix5.mp4)を参照してください。

今回のプロンプトだと繰り返しでテストと不具合修正を自動で実行しましたが、あまり無制限に繰り返し行っているとAPIの利用制限に達したり、利用金額的な部分の懸念があります。プロンプトに「3回テストがNGの場合はタスクの実行の継続を確認してください」といった制限を付け加えると良さそうです。

まとめ

AIエージェントからMCPサーバーを利用して動作確認のテストと不具合修正を自動実行してみました。AIエージェントとMCPサーバーを組み合わせることで不具合を検知して修正まで行うことができそうです。

そしてよくよくClineのログを確認すると、MCPサーバーを通して画面の要素を取得して動作確認のテストを実行しているため、今後の展望としてはXCUITestなどのUIテストも応用的に自動で書くこともできるのでは?と考えています。

一方で、AIエージェントは再現性という点では課題が残ります。プロダクトのテストとして活用する場合は、今回のような方法でAIエージェントを活用しながらテスト項目書を作成し、それを利用してAIエージェントに動作確認を実行させるか、もしくは先述のようにAIエージェントにXCUITestのコードを生成するフローが良さそうです。

また、今回は同じTaskのチャットで指示を出しましたが、継続して同じTaskの中で指示を与え続けてしまうと情報が発散してしまい、逆にAIエージェントの精度が落ちてしまいます。指示に対して意図と異なる結果をループしてしまう場合はTaskを新しくしてみてください。

もしそれでも上手くいかない場合でも、プロンプトの中に条件や前提となる情報を増やしてみるなど調整をしてみてください。それでも何度やっても上手くいかない場合は、そもそもモデルが作業内容に適していない可能性もあります。GitHub Copilotで採用しているモデルに関してはタスクに適したAIモデルが公式サイトで公開されていますので参考にしてみてください。

docs.github.com

最後に

スパイダープラスでは仲間を募集中です。 スパイダープラスにちょっと興味が出てきたなという方がいらっしゃったらお気軽にご連絡ください。