はじめに
こんにちは。スタディサプリのiOSアプリの開発に携わっているk-koheyです。突然ですがみなさんは、「なにもしとらんのに壊れた」という言葉を聞いた事はありますか??ソフトウェア開発の現場では、ついさっきまで正常に動いていたプログラムが急に動かなくなる事があります。奇妙な事にキーボードやマウスに全く触っていないのに、突然プログラムの動作が変わってしまうのです*1。その結果納期に遅れが出たり、最悪な事に開発が進まなくなってしまう事もあるとかないとか....。「なにもしとらんのに壊れた」はこのような不思議な現象に遭ったときによく使われます。
私は今、この現象に取って代わる新しい現象に遭遇しています。それは「なにもしとらんのに出来た」です。これはプログラミングをしていないのに、目の前でプログラムが完成していくという「なにもしとらんのに壊れた」と真逆の不思議な現象です。聞くところによるとこの現象は生成AIと関わりが深いらしい。この記事は、そんな「なにもしとらんのに出来た」の謎に迫っていく記事です。
ところでxcresultを知ってますか?
xcresultはiOS開発にて用いられるテストフレームワークXCTestのテスト結果や実行ログを保存しているファイルです。同じくiOS開発にて用いられる統合開発環境のXcodeを使ってテストを実行すると、DerivedData配下の <アプリ識別子>/Logs/Test/
というパスに*.xcresultという拡張子で生成されます。xcresultをクリックすると、Xcodeが開きテスト結果が以下のように表示されます。
また、xcresultが持つテスト結果はコマンドラインから取得する事もできます。取得した情報はもちろん自由に使えるので、テスト結果を独自で実装したツール内で利用することができます。xcresultから情報を取得するコマンドは以下のようになります。出力のフォーマットには、--format
を使って後から扱いやすいようにjsonを指定しています。
$ xcrun xcresulttool get --path <xcresultのパス> --format json
なお実行すると以下のようなjsonが得られます。
{ "_type" : { "_name" : "ActionsInvocationRecord" }, "actions" : { "_type" : { "_name" : "Array" }, "_values" : [ { "_type" : { "_name" : "ActionRecord" }, "actionResult" : { "_type" : { "_name" : "ActionResult" }, "coverage" : { "_type" : { "_name" : "CodeCoverageInfo" }, "archiveRef" : { "_type" : { "_name" : "Reference" }, "id" : { "_type" : { "_name" : "String" 以下略
しかし出力してみたものの、jsonの構造や各フィールドの意味もサッパリわかりません。肝心なテスト結果すらどこにあるのかすら不明です。文字通り何もわからん状態なので、ここで「なにもしとらんのに出来た」を支える不思議な現象を起こしていきます。
不思議を起こしていく
本題の不思議現象を起こしていきます。今回の不思議現象によって完成させたいプログラムはxcresultから失敗したテストケースを抽出してSlackに通知するGitHub Actionsのカスタムアクションです。想定しているカスタムアクションの処理は以下の通りです。
- xcresultから失敗したテスト結果を取得する
- テスト結果をもとにSlackへの通知に使うPayloadを作る
- PayloadをSlackへ送信する
なお、不思議を起こすにはChatGPTのような生成AIサービスを使っていきます。
不思議1:日本語を使ってjsonから欲しい情報をクエリできた
まずは、xcresultから失敗したテスト結果を取得します。そのためにxcrunコマンドの使い方を生成AIサービスに聞いてみます。
自分:
xcresult
から各テストケースのテスト結果を抽出するxcrunコマンドを教えてください
生成AI:
xcresult
ファイルから各テストケースのテスト結果を抽出するために、xcrun
コマンドラインツールを使用することができます。このプロセスはいくつかのステップに分けられます。まず、xcresult
ファイルを解析するために必要な情報を取得し、次にその情報を使って具体的なテストケースの結果を取得します。
以下は、このプロセスを実行するための基本的なステップです:
xcresultファイルの概要を取得する
最初に、xcrun xcresulttool
を使ってxcresult
ファイルの概要を取得します。この概要には、テストの実行に関する情報が含まれています。
xcrun xcresulttool get --path <path_to_xcresult> --format json
ここで、<path_to_xcresult>
はxcresultファイルのパスです。
テストケースの結果を抽出する
xcresulttool get
コマンドを再度使用して、特定のテストケースの結果を抽出します。このステップでは、最初のコマンドの出力から得られたIDやその他の必要な情報を使用して、具体的なテスト結果を取得します。
例えば、特定のテストケースの詳細を取得するには、以下のようにコマンドを実行します:
xcrun xcresulttool get --path <path_to_xcresult> --format json --id <test_summary_id>
ここで、<test_summary_id>
は、最初のコマンドの出力から取得したテストケースやテストスイートのIDです。
これらのコマンドは、Xcodeのバージョンによっては若干異なる場合があります。また、--idパラメータに渡すべき正確なIDを見つけるためには、最初に得られたJSON出力を慎重に解析する必要があります。
質問の結果$ xcrun xcresulttool get --path <path_to_xcresult> --format json --id <test_summary_id>
というコマンドを使えばいい事がわかりました。しかし、test_summary_idが何を指しているかや、どのように取得できるかがわからないので追加で質問します。
自分: idはどれ??
{ "_type" : { "_name" : "ActionsInvocationRecord" }, "actions" : { "_type" : { "_name" : "Array" }, "_values" : [ { "_type" : { "_name" : "ActionRecord" }, "actionResult" : { "_type" : { "_name" : "ActionResult" }, "coverage" : { "_type" : { "_name" : "CodeCoverageInfo" } }, "issues" : { "_type" : { "_name" : "ResultIssueSummaries" } }, "metrics" : { "_type" : { "_name" : "ResultMetrics" } }, <以下略>
生成AI:
このJSON出力に基づいて、テストケースの結果を抽出するために参照すべきIDは、testsRefのidフィールドにあります。具体的には、"testsRef": { "id": "0~h9B1i-P2GOZHAIXdopYaLoKO3Kcc4cz6NZCE6xx8i1yRYxaR25KSY9ZtUb9Nw" }
の部分です。
テスト結果を取得するには、このIDを使用してxcresulttool get
コマンドを実行します。コマンドは以下のようになります:
xcrun xcresulttool get --path <path_to_xcresult> --format json --id 0~h9B1i-P2GOZHAIXdopYaLoKO3Kcc4cz6NZCE6xx8i1yRYxaR25KSY9ZtUb9Nw
このコマンドは、指定されたxcresultファイル内のテストケースの結果を含むJSONデータを返します。そのデータを解析することで、個々のテストケースのステータスや結果に関する詳細な情報を抽出できます。
自分: jqを使ってtestRefからidを抽出するコマンドを書いて
生成AI:
jq
はコマンドラインJSONプロセッサで、JSON形式のデータを処理するために使用されます。xcresult
のJSON出力からtestsRef
のid
を抽出するには、次のようなjq
コマンドを使用します:
echo '<json_data>' | jq '.actions._values[] | .actionResult.testsRef.id._value'
質問の結果、テスト結果の取得方法がわかりました。結果を受けて作成したコマンドを実行すると、以下のようにテスト結果っぽいjsonが無事出力されました。
$xcrun xcresulttool get --path .github/TestData/single_module.xcresult/ --format json --id "0~rCjKNSFWPFyfFp-vFGww9w0dhWWhSU_SrC9j_Q_Sfvsu12MJXi-1kfw=="
{ "_type" : { "_name" : "ActionTestPlanRunSummaries" }, "summaries" : { "_type" : { "_name" : "Array" }, "_values" : [ { "_type" : { "_name" : "ActionTestPlanRunSummary", "_supertype" : { "_name" : "ActionAbstractTestSummary" } }, "name" : { "_type" : { "_name" : "String" }, "_value" : "Test Scheme Action" }, "testableSummaries" : { "_type" : { "_name" : "Array" }, "_values" : [ { "_type" : { "_name" : "ActionTestableSummary", "_supertype" : { "_name" : "ActionAbstractTestSummary" } }, 以下略
不要な情報が多いのでクエリに関して何回か質問を繰り返した結果、以下のようなゴツいjqが完成しました。なお、私はxcrun xcresulttool
の出力をコピペして質問しているだけで、コマンドの出力内容については熱心に読んでいないためjqのクエリが適切かは判断できていません*2。わからないのに完成してくのは不思議ですね。
$ xcrun xcresulttool get --path <xcresultPath> --format json --id <id> | \ jq -s '. | map(.summaries._values[].testableSummaries._values[] | \ {module: .name._value, identifierURL: .identifierURL._value, projectRelativePath: .projectRelativePath._value, targetName: .targetName._value, tests: [.tests._values[]? | \ {name: .name._value, subtests: [.subtests._values[]? | \ {name: .name._value, subtests: [.subtests._values[]? | \ {name: .name._value, subtests: [.subtests._values[]? | \ {name: .name._value, testStatus: .testStatus._value, identifier: .identifier._value, duration: .duration._value} | \ select(.testStatus == "Failure")]}]}]}]})'
道中、何度もエラーとなるjqコマンドが出力されましたが、以下のようにエラー内容を生成AIサービスに投げると修正してくれました。何回か試行したのですがjqのクエリを修正する技術は高いです。
自分:
xcrun xcresulttool get --path TestResults.xcresult/ --format json --id 0~h9B1i-P2GOZHAIXdopYaLoKO3Kcc4c | jq '.summaries._values[].testableSummaries._values[].tests._values[].subtests._values[].subtests._values[].subtests._values[] | {name: .name._value, testStatus: .subtests._values[].testStatus._value}' jq: error (at <stdin>:16813): Cannot iterate over null (null)
生成AI:
エラーメッセージjq: error (at <stdin>:16813): Cannot iterate over null (null)
は、jq
コマンドが期待するパスに沿ったデータ構造がJSONファイル内に存在しないことを示しています。これは、一部のテストがsubtests
を持たない、または異なる構造を持つ可能性があるため発生します。
問題を解決するために、jq
フィルターを修正して、null
である可能性のある配列に対して安全にアクセスし、テスト名とテストステータスを抽出できるようにします。以下のコマンドは、subtests
配列が存在しない場合でもエラーにならず、存在する場合のみテスト名とテストステータスを抽出します。
xcrun xcresulttool get --path TestResults.xcresult/ --format json --id 0~h9B1i-P2GOZHAIXdopYaLoKO3Kcc4c | jq '.summaries._values[].testableSummaries._values[].tests._values[] | .subtests._values[]? | .subtests._values[]? | .subtests._values[]? | {name: .name._value, testStatus: .testStatus._value}'
不思議2:面倒なパース処理を実装できた
続いて、Slackに通知する際に送信するPayloadを作成する処理を作っていきます。そのためにまずSlackで表示する内容を決めます。ここは自分の好みを反映させたいので自分で考えました。今回は以下のように失敗したテストメソッド名、テストケース名、モジュール名、およびテストの実行時間を表示しようと思います。Block Kit Builderを使うとPayloadと対応するSlackのUIを確認できるので便利です。
続いて、不思議1で取得したjsonを上述したPayloadに変換する処理を書きます。このような処理は何をどう実装するか分かり切っている分、単調で退屈です。退屈な処理は書いてもらいましょう。結果は以下の通りです。
自分: 失敗しているテストケースのみを抽出して以下のようなjsonに変換したいです. このようなjsonの変換を行うrubyのコードを生成してください.
{ "blocks": [ { "type": "section", "text": { "type": "plain_text", "text": "Test Failed", "emoji": true } }, { "type": "divider" }, { "type": "section", "text": { "type": "mrkdwn", "text": "*testHoge*" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Duration:*\n0.001" }, { "type": "mrkdwn", "text": "*Module:*\nHogeTests" }, { "type": "mrkdwn", "text": "*Test Case:*\nHogeHogeTests" } ] }, { "type": "divider" }, { "type": "section", "text": { "type": "mrkdwn", "text": "*testFuga*" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Duration:*\n0.001" }, { "type": "mrkdwn", "text": "*Module:*\nFugaTests" }, { "type": "mrkdwn", "text": "*Test Case:*\nFugaFugaTests" } ] }, { "type": "divider" } ] }
生成AI: 以下に、指定されたJSONフォーマットへの変換を行うRubyのコード例を示します。このコードは、失敗したテストケースを抽出し、指定の形式に変換しています。コードは、与えられたJSONデータをパースし、必要な情報を抽出して新しいJSON構造を構築します。
require 'json' # 与えられたJSONデータ json_data = 'ここに元のJSONデータをペースト' # JSONをパース data = JSON.parse(json_data) # 新しいJSON構造を構築 output = { "blocks" => [] } # ヘッダーを追加 output["blocks"] << { "type" => "section", "text" => { "type" => "plain_text", "text" => "Test Failed", "emoji" => true } } output["blocks"] << { "type" => "divider" } data["tests"].each do |test| test["subtests"].each do |subtest| subtest["subtests"].each do |test_case| test_case["subtests"].each do |failed_test| if failed_test["testStatus"] == "Failure" output["blocks"] << { "type" => "section", "text" => { "type" => "mrkdwn", "text" => "*#{failed_test['name']}*" } } output["blocks"] << { "type" => "section", "fields" => [ { "type" => "mrkdwn", "text" => "*Duration:*\n#{failed_test['duration']}" }, { "type" => "mrkdwn", "text" => "*Module:*\n#{data['module']}" }, { "type" => "mrkdwn", "text" => "*Test Case:*\n#{test_case['name']}" } ] } output["blocks"] << { "type" => "divider" } end end end end end # 結果をJSON形式で出力 puts JSON.pretty_generate(output)
不思議3:以上までの諸々を行うRubyコードとカスタムアクションを生成できた
主要なロジックは構築できました。最後の仕上げに、細かいスクリプトの修正とカスタムアクションの生成を依頼します。生成結果は以下のとおりです。利用しているcheckoutステップのバージョンがやや古いですが、それ以外は欲しかったカスタムアクションが概ね実装されていそうです。
(経緯略)
自分: 標準出力にはloggerを使ってloggerのレベルの制御にverboseを使ってください
生成AI:
RubyのLogger
クラスを使用して、ログのレベルを制御し、verbose
オプションに基づいて出力を管理するようにスクリプトを修正しました。以下の修正版では、Logger
を使って詳細なログを出力し、そのログレベルはverbose
フラグによって制御されます。
require 'json' require 'open3' require 'logger' def setup_logger(verbose) logger = Logger.new($stdout) logger.level = verbose ? Logger::DEBUG : Logger::INFO logger end def find_arg_value(arg_name) ARGV.each_with_index do |arg, index| return ARGV[index + 1] if arg == arg_name && index + 1 < ARGV.length end nil end def execute_command(command, logger) logger.debug("Run #{command}") stdout, stderr, status = Open3.capture3(command) unless status.success? logger.error("Command Error: #{stderr}") raise "Command Error: #{stderr}" end logger.debug("Get #{stdout}") stdout end def fetch_test_details(xcresult_path, id, logger) command = """ xcrun xcresulttool get --path #{xcresult_path} --format json --id #{id} | \ jq '.summaries._values[].testableSummaries._values[] | \ {module: .name._value, identifierURL: .identifierURL._value, projectRelativePath: .projectRelativePath._value, targetName: .targetName._value, tests: [.tests._values[]? | {name: .name._value, subtests: [.subtests._values[]? | {name: .name._value, subtests: [.subtests._values[]? | {name: .name._value, subtests: [.subtests._values[]? | {name: .name._value, testStatus: .testStatus._value, identifier: .identifier._value, duration: .duration._value} | select(.testStatus == \"Failure\")]}]}]}]}' """ JSON.parse(execute_command(command, logger)) end xcresult_path = find_arg_value('--xcresult_path') raise ArgumentError, 'xcresult_path is required' unless xcresult_path verbose = find_arg_value('--verbose') == 'true' logger = setup_logger(verbose) id_command = "xcrun xcresulttool get --path #{xcresult_path} --format json | jq -r '.actions._values[] | select(.actionResult.testsRef != null) | .actionResult.testsRef.id._value'" output_blocks = [] ids = execute_command(id_command, logger).strip.split(/\n/) ids.each do |id| details = fetch_test_details(xcresult_path, id, logger) details["tests"].each do |test| test["subtests"].each do |subtest| subtest["subtests"].each do |test_case| test_case["subtests"].each do |failed_test| if failed_test["testStatus"] == "Failure" duration_rounded = failed_test["duration"].to_f.round(3) content_blocks = [ { "type" => "section", "text" => { "type" => "mrkdwn", "text" => "*#{failed_test['name']}*" } }, { "type" => "section", "fields" => [ { "type" => "mrkdwn", "text" => "*Duration:*\n#{duration_rounded}" }, { "type" => "mrkdwn", "text" => "*Module:*\n#{details['module']}" }, { "type" => "mrkdwn", "text" => "*Test Case:*\n#{test_case['name']}" } ]}, { "type" => "divider" } ] output_blocks.concat(content_blocks) end end end end end end output = { "blocks" => [{ "type" => "section", "text" => { "type" => "plain_text", "text" => "Test Failed", "emoji" => true } }, { "type" => "divider" }] + output_blocks } logger.info(JSON.pretty_generate(output))
不思議4:RubyのコードがJavaScriptになった
今更ですがカスタムアクションはNode.js環境で実行できるらしく、RubyではなくJavaScriptを使った方が利便性が高い事がわかりました。JavaScriptは書き慣れていないので、代わりに書いてもらおうと思います。
(経緯略)
生成AI:
理解しました。以下は、@actions/core
を使用してJavaScriptで実装されたGitHub Action
のaction.yml
ファイルです。
name: "XCTest alert slack" description: "GitHub Action that sends XCTest failure notifications to Slack" author: "k-kohey" inputs: xcresult-path: description: "The path to the xcresult file" required: true verbose: description: "Enable verbose output" required: false default: "false" slack-bot-token: description: "Slack bot token" required: false channel-id: description: "Slack channel ID to send the message to" required: true runs: using: "node16" main: "index.js"
そして、以下がindex.jsファイルの中身になります。
const core = require('@actions/core'); const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); try { // Inputs const xcresultPath = core.getInput('xcresult-path', { required: true }); const verbose = core.getInput('verbose') === 'true'; const slackBotToken = core.getInput('slack-bot-token') || process.env.SLACK_BOT_TOKEN; const channelId = core.getInput('channel-id', { required: true }); // Functions function runCommand(command) { core.debug(`Running command: ${command}`); try { const result = execSync(command, { encoding: 'utf-8' }); core.debug(`Command output: ${result}`); return result; } catch (error) { core.error(`Command execution failed: ${error.stderr}`); throw new Error(`Command execution failed: ${error.stderr}`); } } function getFailedTestIds(xcresultPath) { const command = `xcrun xcresulttool get --path ${xcresultPath} --format json | jq -r '.actions._values[] | select(.actionResult.testsRef != null) | .actionResult.testsRef.id._value'`; const ids = runCommand(command).trim().split(/\n/); return ids.length === 0 ? null : ids; } function getTestDetails(xcresultPath, id) { const command = `xcrun xcresulttool get --path ${xcresultPath} --format json --id ${id}`; const result = runCommand(command); return JSON.parse(result); } function formatTestDetails(details) { return details.tests.flatMap(test => { return test.subtests.flatMap(subtest => { return subtest.subtests.flatMap(testCase => { return testCase.subtests.filter(failedTest => failedTest.testStatus === 'Failure').map(failedTest => { const durationRounded = parseFloat(failedTest.duration).toFixed(3); return { name: failedTest.name, duration: durationRounded, module: details.module, testCaseName: testCase.name }; }); }); }); }); } function generatePayload(runUrl, failedTests) { const blocks = [ { type: 'section', text: { type: 'plain_text', text: `:red_circle: XCTest is failed at ${runUrl}`, emoji: true } }, { type: 'divider' } ].concat(failedTests.map(test => ({ type: 'section', fields: [ { type: 'mrkdwn', text: `*Test Name:*\n${test.name}` }, { type: 'mrkdwn', text: `*Duration:*\n${test.duration}` }, { type: 'mrkdwn', text: `*Module:*\n${test.module}` }, { type: 'mrkdwn', text: `*Test Case:*\n${test.testCaseName}` } ] }))); return JSON.stringify({ blocks }, null, 2); } // Main const failedTestIds = getFailedTestIds(xcresultPath); if (!failedTestIds) { core.info('No failed tests found'); return; } const failedTests = failedTestIds.flatMap(id => { const details = getTestDetails(xcresultPath, id); return formatTestDetails(details); }); const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; const outputPath = 'payload.json'; const payload = generatePayload(runUrl, failedTests); fs.writeFileSync(path.resolve(outputPath), payload, 'utf-8'); core.info(`Payload written to ${outputPath}`); if (!fs.existsSync(outputPath)) { core.setFailed('Payload file does not exist'); } // Send message to Slack const slackAction = require('@slack/web-api'); const { WebClient } = slackAction; const web = new WebClient(slackBotToken); web.chat.postMessage({ channel: channelId, text: ':red_circle: XCTest is failed', blocks: JSON.parse(payload).blocks }).catch(error => { core.setFailed(`Slack message sending failed: ${error}`); }); } catch (error) { core.setFailed(`Action failed: ${error}`); }
言語を変えるだけなので簡単に済むかと思ったんですが、コンテキストがうまく伝わっていないのか、以下の点に苦労しました。
- JavaScriptのアクションにすると(Node.jsを使うと)index.jsが呼ばれるため明示的にJavaScriptのコードを呼び出す必要はないが、action.ymlからJavaScriptを呼び出すコードを生成してしまう
@actions/core
を使ってカスタムアクションの入力からxcresultのパスやSlackのAPIトークンを取得して欲しいのに、スクリプトの引数から取得しようとしてしまう
これらの問題点を生成AIサービスに指摘して生成されたものを、自分がデバッグして細かいバグを潰すと正しく動作するカスタムアクションを作れました!こちらが完成したカスタムアクションです。
実際に弊社のワークフローに組み込んで使ってみると以下のような投稿がSlackにされました。バッチリ動いてそうですね。
「なにもしとらんのに出来た」と言うにはたくさんの事をしてしまった気がしますが、自分で1から作るよりは圧倒的に何もしていないので誤差です。
「なにもしとらんのに出来た」の制約
自分でプログラムを(殆ど)書かずにカスタムアクションを完成させました。カスタムアクションを作るにあたって使用した技術は普段の業務では使わない不慣れなものでした。そのため、自分で一から調べて作るより効率気的に開発を行う事ができた実感があります。例えば、Slackと通信する処理は生成されたプログラムをそのまま使っているのでSlackのドキュメントを読んで実装する作業を一切していません。通常、ドキュメントを読まずに初見のサードパーティ製のツールを扱うのは不可能です。しかし、このように一見簡単そうに見える開発方法ですが、実際にやってみて制約があると感じた事があるので紹介します。
それは、プログラムを書かなくてもプログラムの方針を決めるのは自分という制約です。技術選定や設計などのマクロな部分は、プログラムを生成するまでに自分で決めてディレクションをする必要があります。さらに、このマクロな部分は自分の能力に大きく依存するため、自分でプログラムを書いても書かなくても完成物の品質は自分の能力に依存すると感じました。 今回の例だと、JavaScriptではなくTypeScriptを使うような技術選定や、jqではなくJavaScriptを使ってjsonのパースを行う設計など改善ポイントが見えます。しかしJavaScriptやjqを使うようにこちらが指示している以上そのような改善の提案も得る事は難しいです。試しに、「シニアエンジニアがこのコードを書き直すとどうなると思う?」や「シニアエンジニアならどうレビューする?」などと質問してみましたが、細かい処理は変わってもアウトラインは殆ど変わりませんでした*3。
まとめ
この記事では不思議な現象である「なにもしとらんのに出来た」について紹介しました。具体的には、テスト結果をSlackに通知するカスタムアクションを実装しました。その過程において、ChatGPTに挙げられる生成AIサービスを利用する事により思考せずコードを生成できるため、ミクロな作業で楽できる事がわかりました。しかし、技術選定や設計などのマクロな部分に関しては利用者側が思考して品質を保証する必要があると推察しました。
弊社ではGitHub Copilotも導入されており、生成AIを利用して効率的に開発できる環境が整っています。細かいスクリプトをとても簡単に生成できる事がわかったので、スクリプトをたくさん書いて業務の自動化をできないか引き続き検討してこうと思います。