こんにちは!デバイスソフトウエア開発部の山内です。
私は現在建設DXを担うiPadアプリの開発を担当しているのですが、その中で、最近Swift Testingフレームワークでテストの自動化に取り組んでいます。 しかしこれがなかなか大変というか面倒というか…。
そこで思いました。GitHub Copilot の /tests コマンド でテストを自動生成すれば、工数を大幅に減らせるのでは…?
今回はそれを実際に試してみました。
まずは結論(3行で要約)
- GitHub Copilot の
/testsはプロジェクトのコンテキストや付随プロンプト次第で出力が変わる - どう書いてもおおむね品質は良さそうだが、コスパ的に
.github/copilot-instructions.mdで指示を固める手法が一番おすすめ - 複雑なコードに対する品質や、人間の手直しを極力減らす仕組みの模索が今後の課題
ユニットテストの基本的な考え方
本題に入る前に、ユニットテストの考え方とSwift Testingに関する説明を軽くしておきましょう。 すでに知っている方は「検証プロジェクトの紹介」セクションまで読み飛ばして構いません。
テストを書くと何が嬉しいのか
バグを早期発見できる、ということはよく言われますが、個人的には、「ソースコードの変更が怖くなくなる」という点が大きいと思います。
ある処理を書き直したとき、テストをパスすれば「既存の処理を壊していない」=デグレードが発生していないことを確認できます。逆にテストに失敗が出れば「どこかが変わった」とすぐに気づけます。 この安心感があることで、機能追加やリファクタリングを進めやすくなります。
Arrange-Act-Assert(AAA)
テストコードの書き方として、AAAパターンというArrange-Act-Assertの3段構成がよく使われます。 (3段構成のパターンとして Given-When-Then もあります。両者は単にステップの意味付け・解釈の違いと言って差し支えありませんが、個人的には動詞で揃っているAAAのほうが好きです。)
- Arrange(準備):テスト対象のオブジェクトや入力値を用意する
- Act(実行) :テスト対象の処理を呼ぶ
- Assert(検証) :結果が期待通りかを確認する
慣れると自然にこの構造で書けるようになりますが、Copilot にテストを生成させるときのプロンプトにも活用できる ので、頭の片隅に入れておくと後で役立ちます。
何をテストすべきか
「全部のコードにテストを書かないといけないの?」という疑問をよく見聞きします。現実的には ビジネスロジックのある箇所に集中 するのが効率的でしょう。
テストの対象として優先度が高いのは、
- 分岐のある処理(if / switch / guard)
- エラーが起き得る処理(try-catch / optional型)
- 入力に対して出力が決まる純粋な関数
Swift Testing とは・導入方法
Swift Testingは、Apple が WWDC 2024 で発表した比較的新しいテストフレームワークです。同年 9 月にリリースされた Xcode 16 から正式統合 され、新規テストターゲット作成時のデフォルトフレームワークになっています。XCTest と何が違うのか
Swift Testing は XCTest と比べて記述量が減り、Swift の言語機能との整合性が高いのが特徴です。大きな違いを表にまとめると以下のようになります。
| 項目 | XCTest | Swift Testing |
|---|---|---|
| テストの書き方 | XCTestCase を継承したクラス |
struct / actor / グローバル関数 |
| テスト関数の識別 | メソッド名を test から始める |
@Test マクロを付ける |
| アサーション | XCTAssertEqual 等、多数 |
#expect / #require に統一 |
| セットアップ | setUp() / tearDown() |
init() / deinit |
| 非同期処理 | XCTestExpectation で待機 |
async/await がそのまま使える |
| 並列実行 | 複数プロセス | 単一プロセス内の軽量な並列化 |
クラス継承が不要になった点と、アサーションが #expect に統合された点が大きいでしょう。
導入方法
Xcode 16 以降なら追加パッケージ不要です。
新規テストターゲットを追加すると、Swift Testing がデフォルトで選択された状態になります。 テストファイルの先頭に import Testing と書くだけで使い始められます。
基本構文
@Test マクロ
|
1 2 3 4 5 |
import Testing @Test func 足し算が正しいこと() { #expect(1 + 1 == 2) } |
関数名に test プレフィックスは不要です。@Test を付けた関数がテストとして認識されます。
displayName を指定することもできます。
|
1 2 3 4 |
@Test("マイナスの入力を弾くこと") func rejectNegativeInput() { // ... } |
@Suite マクロ
関連するテストをまとめるには @Suite を使います。XCTest の XCTestCase に近いイメージですが、struct でも書くことができます。
|
1 2 3 4 5 6 7 8 9 10 |
@Suite("PriceCalculator のテスト") struct PriceCalculatorTests { let calc = PriceCalculator() @Test func 税込み価格が正しいこと() throws { let result = try calc.priceWithTax(1000) #expect(result == 1100) } } |
#expect と #require
#expect は失敗してもテストを続行します。#require は失敗したら即テストを中断します。
|
1 2 3 4 5 6 |
// #expect:失敗してもその後も実行 #expect(result.count == 3) // #require:失敗したらここで止まる(Optional のアンラップにも使える) let user = try #require(findUser(id: 1)) #expect(user.name == "山内") |
#expect の強力な点は、失敗時に式の値を自動でレポートしてくれることです。どの値がどう違ったか一目でわかります。
|
1 2 |
// 失敗時のログ例 Expectation failed: (result → 1050) == 1100 |
パラメタライズド(パラメータ化)テスト
複数の入力パターンを効率よく検証するのに使います。
|
1 2 3 4 5 6 7 8 9 |
@Test("割引率の検証", arguments: [ (price: 1000.0, rate: 0.1, expected: 900.0), (price: 1000.0, rate: 0.5, expected: 500.0), (price: 500.0, rate: 0.0, expected: 500.0), ]) func 割引後の価格が正しいこと(testCase: (price: Double, rate: Double, expected: Double)) throws { let result = try calc.applyDiscount(testCase.price, rate: testCase.rate) #expect(result == testCase.expected) } |
各引数が 独立したテストケースとして Xcode 上に表示される ので、どのパターンで失敗したかが一目でわかるつくりとなっています。
検証プロジェクトの紹介
今回の検証では、以下のシンプルな PriceCalculator を題材にしてみました。
- 複数の
guard分岐(ネガティブケース)がある throwsでエラーを返す- パラメータ化テストの題材として扱いやすい
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
// PriceCalculator.swift enum PriceError: Error, Equatable { case negativePrice case invalidDiscount // 割引率が 0〜1 の範囲外 } struct PriceCalculator { let taxRate: Double init(taxRate: Double = 0.10) { self.taxRate = taxRate } /// 税込み価格を返す func priceWithTax(_ basePrice: Double) throws -> Double { guard basePrice >= 0 else { throw PriceError.negativePrice } return basePrice * (1 + taxRate) } /// 割引後の価格を返す func applyDiscount(_ price: Double, rate: Double) throws -> Double { guard price >= 0 else { throw PriceError.negativePrice } guard (0...1).contains(rate) else { throw PriceError.invalidDiscount } return price * (1 - rate) } /// 消費税額のみを返す func taxAmount(_ basePrice: Double) throws -> Double { guard basePrice >= 0 else { throw PriceError.negativePrice } return basePrice * taxRate } } |
実際のプロジェクトでいえば、ViewModelのロジック部分やバリデーション処理に近いイメージです。
GitHub Copilot /tests コマンドを使ってみる
さて、本題です。GitHub Copilot Extension for Xcode の /tests コマンドで、上記の PriceCalculator のテストを生成してみます。今回は4パターン試しました。
なお、検証した環境は以下の通りです:
Xcodeバージョン:26.2
モデル:GPT-5.2-Codex
事前準備:Copilot for Xcode のセットアップ
インストールしていない場合は以下の手順で準備します。
- github/CopilotForXcode の Releases から最新の
.dmgをダウンロードしてインストール or Homebrewでインストール - macOS の「システム設定 → プライバシーとセキュリティ → アクセシビリティ」で GitHub Copilot を許可
- Xcode 再起動後、メニューバーのCopilotアイコン → Open Chat でチャットを起動

パターン① /tests だけ入力(コンテキストなし)
PriceCalculator.swift のみが存在するプロジェクトで、何も指示せず /tests を実行。

結果:XCTest 形式で出てきた。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import XCTest @testable import BlogSample final class PriceCalculatorTests: XCTestCase { func testPriceWithTaxUsesDefaultRate() throws { let calculator = PriceCalculator() let result = try calculator.priceWithTax(100) XCTAssertEqual(result, 110, accuracy: 0.0001) } func testPriceWithTaxThrowsForNegativePrice() { let calculator = PriceCalculator() XCTAssertThrowsError(try calculator.priceWithTax(-1)) { error in XCTAssertEqual(error as? PriceError, .negativePrice) } } // ... } |
テストの内容自体は悪くないです。境界値(ネガティブケース、割引率 0 / 1 の境界)まで考慮してくれています。ただし、Copilot の学習データには XCTest のコードが圧倒的に多く、Swift Testing への参照が少ないためか、何も指示しないと XCTest を選んできます。
パターン② プロジェクトに Swift Testing ファイルがある状態で /tests
新規テストターゲット作成時に Xcode が自動生成した空の Swift Testing ファイル(import Testing だけ書いてある)がプロジェクトに存在する状態で同じく /tests を実行。
結果:Swift Testing 形式で出てきた。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import Testing @testable import BlogSample struct BlogSampleTests { @Test func priceWithTaxUsesDefaultRate() async throws { let calculator = PriceCalculator() let result = try calculator.priceWithTax(100) #expect(abs(result - 110) < 0.000_1) } @Test func priceWithTaxRejectsNegativeBasePrice() async throws { let calculator = PriceCalculator() #expect(throws: PriceError.negativePrice) { try calculator.priceWithTax(-1) } } // ... } |

何も特別な指示をしていないのに、プロジェクト内に Swift Testing のファイルがあるというだけで正しくフレームワークを選んでくれました。 Copilot はプロジェクト内の既存コードをコンテキストとして参照しているということがよくわかる結果です。
ただし、よく見ると気になる点が一か所あります。async throws がすべてのテスト関数についていますね。 実際には PriceCalculator の処理はどれも非同期ではないので async は不要です。動くのですが、不要な記述が混入しているのは把握しておきたいところです。
パターン③ /tests にプロンプトを追加する
「Swift Testing で作って」という(雑)指示を添えて実行。
/tests XCTestじゃなくてSwift Testingで作って
結果:Swift Testing 形式、ただし @Suite なし(グローバル関数)。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import Testing @testable import BlogSample @Test func priceWithTaxReturnsCalculatedValue() throws { let calculator = PriceCalculator(taxRate: 0.10) let result = try calculator.priceWithTax(100) #expect(abs(result - 110) < 1e-9) } // ... @Test func applyDiscountRejectsInvalidRate() { let calculator = PriceCalculator(taxRate: 0.10) #expect(throws: PriceError.invalidDiscount) { try calculator.applyDiscount(100, rate: -0.01) } #expect(throws: PriceError.invalidDiscount) { try calculator.applyDiscount(100, rate: 1.01) } } // ... |

import Testing は正しく使えていて、エラーの型も PriceError.invalidDiscount と具体的に指定できています。 ただし、グローバル関数の並びになっていて @Suite ではまとめられていません。
パターン④ .github/copilot-instructions.md を置いてから /tests
プロジェクトのルートに .github/copilot-instructions.md を配置してから同じく /tests を実行。
|
1 2 3 4 5 |
. ├── .github/ │ └── copilot-instructions.md ← ここ ├── BlogSample/ └── BlogSample.xcodeproj |
ファイルの中身は以下のように書きました。
|
1 2 3 4 5 6 7 8 9 |
# GitHub Copilot Instructions ## テストコードについて - ユニットテストは XCTest ではなく **Swift Testing フレームワーク**(`import Testing`)を使うこと - テストスイートは `XCTestCase` を継承したクラスではなく、`@Suite` アノテーション付きの struct にすること - テスト関数には `@Test` マクロを付けること - セットアップは `setUp()` ではなく `init()` を使うこと - AAAパターンを意識してテストコードを書くこと |
結果:@Suite 付きの struct 形式。指示がほぼほぼ遵守されている。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import Testing @testable import BlogSample @Suite struct PriceCalculatorTests { let calculator: PriceCalculator init() { self.calculator = PriceCalculator(taxRate: 0.10) } @Test func priceWithTaxReturnsExpectedTotal() throws { let result = try calculator.priceWithTax(100) #expect(abs(result - 110) < 0.0001) } @Test func priceWithTaxThrowsForNegativePrice() { #expect(throws: PriceError.negativePrice) { try calculator.priceWithTax(-1) } } // ... } |

比較的構造の整ったテストコードが出てきました。copilot-instructions.md の内容を守ってくれています。 テスト内容が単純なためAAA(Arrange-Act-Assert)の3段構成は断片的にしか見られませんが、1テストにつき1つの検証という形にはなっています。
生成されたコードは信用して良いか
4パターンとも、そのままコピーして使えるかというと、若干不安な部分は残ります。 以下、生成されたコードの中で見つけた注意点をいくつか見ていきましょう。
注意点①:エラーの型が曖昧
|
1 2 3 4 |
// Copilot が生成したコード #expect(throws: (any Error).self) { _ = try calculator.priceWithTax(-1) } |
(any Error).self はどんなエラーが投げられても通過します。意図していない種類のエラーが出ていても気づけません。 具体的なエラー型を指定したほうが適切です。
|
1 2 3 4 5 |
// ✗ 何のエラーでも通る #expect(throws: (any Error).self) { try calculator.priceWithTax(-1) } // ✓ 意図したエラーが出ているか確認できる #expect(throws: PriceError.negativePrice) { try calculator.priceWithTax(-1) } |
注意点②:Double の等値比較
|
1 |
#expect(result == 110) // Double を == で比較 |
PriceCalculator の計算が 100 * 1.10 という単純な掛け算なのでこのケースは実際にはパスしますが、SwiftのDouble型の精度は約15〜16桁のため、桁が多かったり計算が複雑だとたまに丸め誤差で失敗する可能性があります。 abs(result - 110) < 1e-9 のようにして単純なイコールの使用を避けたほうが安全です。
注意点③:不要な async
|
1 |
@Test func priceWithTaxUsesDefaultRate() async throws { |
PriceCalculator の処理は同期処理なので async は不要です。動作には影響しませんが、他人が読んだ時に「なぜ async がついているのか」という疑問を与えかねませんので、こういった点は取り除いておきたいところです。
実務での使い方を考える
4パターンを比較した感想として、著しくクオリティに差がつくというよりかは、XCTestを出してくる点さえ防止できれば、多少の差はあっても、どれもある程度品質の良いテストコードを出してくれる印象です。
しかしその上で、カスタム指示を一度書けば使いまわせる点から .github/copilot-instructions.md を使う方法が最も良いと感じます。
ただし、先に挙げた細かな改善点はどうしても出てきますので、Copilot の /tests は「そのまま完成品」ではなく「良い感じの叩き台」という位置付けで使うのが良いでしょう。ただそれだけでも、人間の仕事はテストコードのレビュー・修正作業くらいになりますから、テスト作成のハードルは相当下がるはずです。
今後に向けて
…とは言ったものの。 今回の検証はどちらかというと基礎的なものにとどまっています。 次はこんなことを知りたくなりますよね:
- もっと複雑な処理でも精度保てるの?
- そもそもテストの網羅性どうやって保証するの?
copilot-instructions.mdを工夫して「そのまま完成品」の状態まで持っていけないの?- etc.
ここまでお読みくださりありがとうございます。
エコモットでは一緒にモノづくりをしていく仲間を随時募集しています。弊社に少しでも興味がある方はぜひ下記の採用ページをご覧ください!




