mcpField
A
axiom-swift-testing

axiom-swift-testing

github.com/charleswiltgenテスト・品質1.2k208 回インストール今日更新
リポジトリ

axiom-swift-testing について

axiom-swift-testingは、AppleのSwift Testingフレームワークを使う開発者向けのリファレンススキルです。テスト宣言、アサーション、パラメータ化・非同期テスト、並列実行、実行時間を短縮する方法を扱います。

主な機能

  1. 01@Test、#expect、テストスイートの解説
  2. 02パラメータ化テストと非同期テスト
  3. 03タグとテスト特性の説明
  4. 04並列実行と直列実行の案内
  5. 05テスト実行時間を短縮する方法

活用シーン

  1. 01非同期コードのテスト作成
  2. 02複数入力をパラメータ化ケースとして実行
  3. 03高速なロジックテストとUIテストの分離

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swift-testing

インストール後、Claude Code で/axiom-swift-testingを入力して読み込まれたか確認します。

詳細

Swift Testing

概要

Swift Testing は、WWDC 2024 で発表された Apple の最新テストフレームワークです。命名規則の代わりに Swift マクロ(@Test、#expect)を使用し、デフォルトでテストを並列実行し、Swift の並行処理とシームレスに統合されます。

基本原則:テストは高速で、信頼性が高く、表現力豊かであるべきです。最速のテストは、アプリやシミュレーターを起動せずに実行されます。

速度の階層

テストの実行速度は、設定によって大きく異なります。

設定目安の時間用途
swift test(パッケージ)約0.1秒純粋なロジック、モデル、アルゴリズム
ホストアプリケーション:なし約3秒UI 依存のないフレームワークコード
アプリの起動を回避約6秒アプリのターゲットだが初期化はスキップ
アプリを完全に起動20~60秒UI テスト、統合テスト

重要なポイント:テスト可能なロジックを Swift パッケージまたはフレームワークに移し、swift test またはホストアプリケーション「なし」でテストします。


基本要素

@Test 関数
import Testing

@Test func videoHasCorrectMetadata() {
    let video = Video(named: "example.mp4")
    #expect(video.duration == 120)
}

XCTest との主な違い:

  • test プレフィックスは不要です。@Test 属性で明示します
  • クラス内のメソッドだけでなく、グローバル関数にもできます
  • async、throws、アクター分離をサポートします
  • 各テストは、所属するスイートの新しいインスタンスで実行されます
#expect と #require
// Basic expectation — test continues on failure
#expect(result == expected)
#expect(array.isEmpty)
#expect(numbers.contains(42))

// Required expectation — test stops on failure
let user = try #require(await fetchUser(id: 123))
#expect(user.name == "Alice")

// Unwrap optionals safely
let first = try #require(items.first)
#expect(first.isValid)

#expect が XCTAssert より優れている理由:

  • ソースコードとサブ値を自動的にキャプチャします
  • すべての演算子(==、>、contains など)を単一のマクロで処理します
  • 専用のアサーション(XCTAssertEqual、XCTAssertNil など)は不要です
エラーのテスト
// Expect any error
#expect(throws: (any Error).self) {
    try dangerousOperation()
}

// Expect specific error type
#expect(throws: NetworkError.self) {
    try fetchData()
}

// Expect specific error value
#expect(throws: ValidationError.invalidEmail) {
    try validate(email: "not-an-email")
}

// Custom validation
#expect {
    try process(data)
} throws: { error in
    guard let networkError = error as? NetworkError else { return false }
    return networkError.statusCode == 404
}
@Suite 型
@Suite("Video Processing Tests")
struct VideoTests {
    let video = Video(named: "sample.mp4")  // Fresh instance per test

    @Test func hasCorrectDuration() {
        #expect(video.duration == 120)
    }

    @Test func hasCorrectResolution() {
        #expect(video.resolution == CGSize(width: 1920, height: 1080))
    }
}

主な動作:

  • 構造体が推奨されます(値セマンティクスにより、意図しない状態共有を防げます)
  • 各 @Test に対して、スイートのインスタンスが個別に作成されます
  • セットアップには init、ティアダウンには deinit を使用します(アクター/クラスのみ)
  • 整理のために、スイートをネストできます

トレイト

トレイトでテストの動作をカスタマイズします。

// Display name
@Test("User can log in with valid credentials")
func loginWithValidCredentials() { }

// Disable with reason
@Test(.disabled("Waiting for backend fix"))
func brokenFeature() { }

// Conditional execution
@Test(.enabled(if: FeatureFlags.newUIEnabled))
func newUITest() { }

// Time limit
@Test(.timeLimit(.minutes(1)))
func longRunningTest() async { }

// Bug reference
@Test(.bug("https://github.com/org/repo/issues/123", "Flaky on CI"))
func sometimesFailingTest() { }

// OS version requirement
@available(iOS 18, *)
@Test func iOS18OnlyFeature() { }
整理用のタグ
// Define tags
extension Tag {
    @Tag static var networking: Self
    @Tag static var performance: Self
    @Tag static var slow: Self
}

// Apply to tests
@Test(.tags(.networking, .slow))
func networkIntegrationTest() async { }

// Apply to entire suite
@Suite(.tags(.performance))
struct PerformanceTests {
    @Test func benchmarkSort() { }  // Inherits .performance tag
}

タグの用途:

  • テストの一部を実行します(Test Navigator でタグを使ってフィルタリング)
  • 素早いフィードバックループから時間のかかるテストを除外します
  • 異なるファイルやスイートにまたがる関連テストをグループ化します

パラメーター化テスト

繰り返しのテストを、単一のパラメーター化テストにまとめます。

// ❌ Before: Repetitive
@Test func vanillaHasNoNuts() {
    #expect(!IceCream.vanilla.containsNuts)
}
@Test func chocolateHasNoNuts() {
    #expect(!IceCream.chocolate.containsNuts)
}
@Test func almondHasNuts() {
    #expect(IceCream.almond.containsNuts)
}

// ✅ After: Parameterized
@Test(arguments: [IceCream.vanilla, .chocolate, .strawberry])
func flavorWithoutNuts(_ flavor: IceCream) {
    #expect(!flavor.containsNuts)
}

@Test(arguments: [IceCream.almond, .pistachio])
func flavorWithNuts(_ flavor: IceCream) {
    #expect(flavor.containsNuts)
}
2つのコレクションによるパラメーター化
// Test all combinations (4 × 3 = 12 test cases)
@Test(arguments: [1, 2, 3, 4], ["a", "b", "c"])
func allCombinations(number: Int, letter: String) {
    // Tests: (1,"a"), (1,"b"), (1,"c"), (2,"a"), ...
}

// Test paired values only (3 test cases)
@Test(arguments: zip([1, 2, 3], ["one", "two", "three"]))
func pairedValues(number: Int, name: String) {
    // Tests: (1,"one"), (2,"two"), (3,"three")
}
for ループに対する利点
for ループパラメーター化
最初の失敗で停止しますすべての引数を実行します
どの値で失敗したか分かりにくい各引数が個別に表示されます
順次実行並列実行
個別のケースを再実行できません個々の引数を再実行できます

高速なテスト:テスト容易性を高めるアーキテクチャ

戦略 1:ロジックを Swift パッケージにする(最速)

アプリのロジックを Swift パッケージに切り出します。テストは xcodebuild test(約25秒)ではなく swift test(約0.4秒)で実行され、シミュレーターもアプリの起動も不要です。これは Claude Code フックで TDD を実現するための重要な手段です。

手順 1:Package.swift を作成する

.xcodeproj と同じ階層にパッケージのディレクトリを作成します。

// MyAppCore/Package.swift
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "MyAppCore",
    platforms: [.iOS(.v18), .macOS(.v15)],
    products: [
        .library(name: "MyAppCore", targets: ["MyAppCore"]),
    ],
    targets: [
        .target(name: "MyAppCore"),
        .testTarget(name: "MyAppCoreTests", dependencies: ["MyAppCore"]),
    ]
)
手順 2:パッケージをアプリにリンクする

アプリプロジェクトとパッケージの両方を含む .xcworkspace を作成します。

  1. 「ファイル」→「新規」→「ワークスペース」
  2. .xcodeproj をワークスペースにドラッグします
  3. 「ファイル」→「パッケージ依存関係を追加」→「ローカルを追加」→ MyAppCore/ を選択します
  4. MyAppCore フレームワークを、アプリのターゲットの「フレームワーク、ライブラリ、埋め込みコンテンツ」に追加します
手順 3:ロジックを移動し、ルートビューを公開する

モデル、サービス、ビューモデルを MyAppCore/Sources/MyAppCore/ に移動します。アプリで使用する型は public にする必要があります。依存関係をインジェクションで受け取る、公開されたルートビューを作成します。

// In MyAppCore
public struct MyAppRootView: View {
    @State private var appState: AppStateController

    public init(modelContainer: ModelContainer) {
        _appState = State(initialValue: AppStateController(container: modelContainer))
    }

    public var body: some View { /* ... */ }
}
手順 4:薄いシェルにする App.swift

アプリのターゲットは、パッケージをインポートして処理を委譲する薄いシェルになります(薄いシェルの原則の詳細は axiom-app-composition を参照してください)。

import SwiftUI
import MyAppCore

@main
struct MyApp: App {
    let container = try! ModelContainer(for: /* schemas */)

    var body: some Scene {
        WindowGroup {
            MyAppRootView(modelContainer: container)
        }
    }
}
アプリターゲットに残すものとパッケージへ移すもの
アプリターゲットに残すものパッケージへ移すもの
@main App.swift(薄いシェル)モデル、ビューモデル、サービス
アセットカタログ、リソースビジネスロジック、アルゴリズム
Info.plist、エンタイトルメントナビゲーション、状態管理
起動画面ユーティリティ、拡張機能

テストでは、内部にアクセスするために @testable import MyAppCore を使用します。

テストの実行
cd MyAppCore
swift test                              # All tests (~0.4s)
swift test --filter MyAppCoreTests.UserTests  # Single suite

ユニットテストと UI テストを分けるプロジェクトレベルのスクリプトについては、次を参照してください。

# script/test
#!/bin/bash
case "${1:-unit}" in
    unit) cd MyAppCore && swift test ;;
    ui)   xcodebuild test -workspace MyApp.xcworkspace \
            -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16' ;;
esac
既存プロジェクトで段階的に抽出する

一度にすべてを抽出できないアプリでは、モジュールを段階的に移動します。

フェーズ 1:依存関係のないモジュールから始める

アプリターゲットに依存しないコードから始めます。

  • データモデルと DTO
  • ネットワーク層(API クライアント、リクエストビルダー)
  • ビジネスロジックと検証ルール
  • ユーティリティ拡張機能
フェーズ 2:循環依存を解消する

パッケージのコードからアプリが所有する型を呼び出す必要がある場合:

  1. パッケージ内にプロトコルを定義します(抽象化はパッケージが所有します)
  2. 起動時に、準拠する実装をアプリターゲットから注入します
  3. その依存関係がすべてパッケージ内に移されたら、実装をパッケージへ移します
フェーズ 3:両方のテストターゲットを維持する

移行中は、2 つのテストターゲットを維持します。

  • MyAppCoreTests — swift test で実行(抽出したロジック)
  • MyAppTests — xcodebuild test で実行(アプリレベルで残るテスト)

ソースファイルを抽出する際に、テストを MyAppTests から MyAppCoreTests へ段階的に移行します。

目標:抽出のたびに、アプリがビルドでき、すべてのテストが成功する状態を維持します。一度に複数のモジュール境界を抽出しないでください。

戦略 2:ホストアプリケーションのないフレームワーク

アプリプロジェクト内に残す必要があるコードの場合:

  1. フレームワークターゲットを作成します(File → New → Target → Framework)
  2. モデルコードをフレームワークへ移します
  3. 外部からアクセスする必要がある型を public にします
  4. フレームワークを使用するファイルに インポートを追加します
  5. テストターゲットの設定で、Host Application を 「None」に設定します
Project Settings → Test Target → Testing
  Host Application: None  ← Key setting
  ☐ Allow testing Host Application APIs

ビルドとテストの時間:約 3 秒。アプリを起動する場合の 20~60 秒と比べて短縮できます。

戦略 3:SwiftUI アプリの起動を回避する

フレームワークを使用できない場合は、アプリの起動を回避します。

// Simple solution (no custom startup code)
@main
struct ProductionApp: App {
    var body: some Scene {
        WindowGroup {
            if !isRunningTests {
                ContentView()
            }
        }
    }

    private var isRunningTests: Bool {
        NSClassFromString("XCTestCase") != nil
    }
}
// Thorough solution (custom startup code)
@main
struct MainEntryPoint {
    static func main() {
        if NSClassFromString("XCTestCase") != nil {
            TestApp.main()  // Empty app for tests
        } else {
            ProductionApp.main()
        }
    }
}

struct TestApp: App {
    var body: some Scene {
        WindowGroup { }  // Empty
    }
}

非同期テスト

基本的な非同期テスト
@Test func fetchUserReturnsData() async throws {
    let user = try await userService.fetch(id: 123)
    #expect(user.name == "Alice")
}
継続を使用したコールバックのテスト
// Convert completion handler to async
@Test func legacyAPIWorks() async throws {
    let result = try await withCheckedThrowingContinuation { continuation in
        legacyService.fetchData { result in
            continuation.resume(with: result)
        }
    }
    #expect(result.count > 0)
}
複数のイベントに対する確認
@Test func cookiesAreEaten() async {
    await confirmation("cookie eaten", expectedCount: 10) { confirm in
        let jar = CookieJar(count: 10)
        jar.onCookieEaten = { confirm() }
        await jar.eatAll()
    }
}

// Confirm something never happens
await confirmation(expectedCount: 0) { confirm in
    let cache = Cache()
    cache.onEviction = { confirm() }
    cache.store("small-item")  // Should not trigger eviction
}
Concurrency Extras による信頼性の高い非同期テスト

問題:スケジューリングが予測できないため、非同期テストは不安定になることがあります。

// ❌ Flaky: Task scheduling is unpredictable
@Test func loadingStateChanges() async {
    let model = ViewModel()
    let task = Task { await model.loadData() }
    #expect(model.isLoading == true)  // Often fails!
    await task.value
}

解決策:Point-Free の swift-concurrency-extras を使用します。

import ConcurrencyExtras

@Test func loadingStateChanges() async {
    await withMainSerialExecutor {
        let model = ViewModel()
        let task = Task { await model.loadData() }
        await Task.yield()
        #expect(model.isLoading == true)  // Deterministic!
        await task.value
        #expect(model.isLoading == false)
    }
}

仕組み:非同期処理をメインスレッド上で直列化し、中断ポイントを決定的にします。

TestClock による決定的な時間制御

Point-Free の swift-clocks を使用すると、テスト内の時間を制御できます。

import Clocks

@MainActor
class FeatureModel: ObservableObject {
    @Published var count = 0
    let clock: any Clock<Duration>
    var timerTask: Task<Void, Error>?

    init(clock: any Clock<Duration>) {
        self.clock = clock
    }

    func startTimer() {
        timerTask = Task {
            while true {
                try await clock.sleep(for: .seconds(1))
                count += 1
            }
        }
    }
}

// Test with controlled time
@Test func timerIncrements() async {
    let clock = TestClock()
    let model = FeatureModel(clock: clock)

    model.startTimer()

    await clock.advance(by: .seconds(1))
    #expect(model.count == 1)

    await clock.advance(by: .seconds(4))
    #expect(model.count == 5)

    model.timerTask?.cancel()
}

クロックの種類:

  • TestClock — 時間を手動で進め、決定的な動作を実現します
  • ImmediateClock — すべてのスリープが即座に終了します(プレビューに最適です)
  • UnimplementedClock — 使用すると失敗します(予期しない時間依存を検出します)

並列テスト

Swift Testing はデフォルトでテストを並列実行します。

直列化する場合
// Serialize tests in a suite that share external state
@Suite(.serialized)
struct DatabaseTests {
    @Test func createUser() { }
    @Test func deleteUser() { }  // Runs after createUser
}

// Serialize parameterized test cases
@Test(.serialized, arguments: [1, 2, 3])
func sequentialProcessing(value: Int) { }
隠れた依存関係
// ❌ Bug: Tests depend on execution order
@Suite struct CookieTests {
    static var cookie: Cookie?

    @Test func bakeCookie() {
        Self.cookie = Cookie()  // Sets shared state
    }

    @Test func eatCookie() {
        #expect(Self.cookie != nil)  // Fails if runs first!
    }
}

// ✅ Fixed: Each test is independent
@Suite struct CookieTests {
    @Test func bakeCookie() {
        let cookie = Cookie()
        #expect(cookie.isBaked)
    }

    @Test func eatCookie() {
        let cookie = Cookie()
        cookie.eat()
        #expect(cookie.isEaten)
    }
}

ランダムな順序は、こうしたバグの発見に役立ちます。直列化するのではなく、バグを修正してください。


既知の問題

想定される失敗を、余計なノイズを出さずに処理します。

@Test func featureUnderDevelopment() {
    withKnownIssue("Backend not ready yet") {
        try callUnfinishedAPI()
    }
}

// Conditional known issue
@Test func platformSpecificBug() {
    withKnownIssue("Fails on iOS 17.0") {
        try reproduceEdgeCaseBug()
    } when: {
        ProcessInfo().operatingSystemVersion.majorVersion == 17
    }
}

.disabled より優れている理由:

  • テストは引き続きコンパイルされます(構文エラーを検出できます)
  • 問題が修正されたときに通知されます
  • 結果には「スキップ」ではなく「想定された失敗」と表示されます

XCTest からの移行

比較表
XCTestSwift Testing
func testFoo()@Test func foo()
XCTAssertEqual(a, b)#expect(a == b)
XCTAssertNil(x)#expect(x == nil)
XCTAssertThrowsError#expect(throws:)
XCTUnwrap(x)try #require(x)
class FooTests: XCTestCase@Suite struct FooTests
setUp() / tearDown()init / deinit
continueAfterFailure = false#require(期待値ごと)
addTeardownBlockdeinit または defer
XCTest を引き続き使用する場合
  • UI テスト(XCUIApplication)
  • パフォーマンステスト(XCTMetric)
  • Objective-C のテスト
移行のヒント
  1. どちらのフレームワークも同じターゲット内で共存できます
  2. テストファイルを一度に 1 つずつ、段階的に移行します
  3. 類似した XCTest をパラメーター化した Swift テストにまとめます
  4. 単一テストの XCTestCase → グローバルな @Test 関数

よくある間違い

❌ アサーションを混在させる
// Don't mix XCTest and Swift Testing
@Test func badExample() {
    XCTAssertEqual(1, 1)  // ❌ Wrong framework
    #expect(1 == 1)       // ✅ Use this
}
❌ スイートにクラスを使用する
// ❌ Avoid: Reference semantics can cause shared state bugs
@Suite class VideoTests { }

// ✅ Prefer: Value semantics isolate each test
@Suite struct VideoTests { }
❌ @MainActor を指定し忘れる
// ❌ May fail with Swift 6 strict concurrency
@Test func updateUI() async {
    viewModel.updateTitle("New")  // Data race warning
}

// ✅ Isolate to main actor
@Test @MainActor func updateUI() async {
    viewModel.updateTitle("New")
}
❌ 過剰に直列化する
// ❌ Don't serialize just because tests use async
@Suite(.serialized) struct APITests { }  // Defeats parallelism

// ✅ Only serialize when tests truly share mutable state
❌ Swift 6.2 の MainActor デフォルト設定で XCTestCase を使用する

Swift 6.2 の default-actor-isolation = MainActor は XCTestCase を破壊します:

// ❌ Error: Main actor-isolated initializer 'init()' has different
// actor isolation from nonisolated overridden declaration
final class PlaygroundTests: XCTestCase {
    override func setUp() async throws {
        try await super.setUp()
    }
}

解決策:XCTestCase のサブクラスに nonisolated を指定します:

// ✅ Works with MainActor default isolation
nonisolated final class PlaygroundTests: XCTestCase {
    @MainActor
    override func setUp() async throws {
        try await super.setUp()
    }

    @Test @MainActor
    func testSomething() async {
        // Individual tests can be @MainActor
    }
}

理由:XCTestCase は Objective-C であり、Swift の並行処理用の注釈が付いていません。イニシャライザーは nonisolated であるため、MainActor で分離されたサブクラスとの競合が発生します。

より良い解決策:分離を適切に処理する Swift Testing(@Suite struct)に移行します。


高速なフィードバックのための Xcode 最適化

XCTest の並列実行をオフにする

Swift Testing はデフォルトで並列実行されます。XCTest の並列化はオーバーヘッドを増やします:

Test Plan → Options → Parallelization → "Swift Testing Only"
テストデバッガーをオフにする

デバッガーのアタッチには、実行ごとに約 1 秒かかります:

Scheme → Edit Scheme → Test → Info → ☐ Debugger
UI テストのテンプレートを削除する

Xcode のデフォルトの UI テストはすべてを遅くします。削除してください:

  1. UI テストターゲットを削除します(Project Settings → ターゲットを選択 → -)
  2. UI テストのソースフォルダーを削除します
デバッグビルドで dSYM を無効にする
Build Settings → Debug Information Format
  Debug: DWARF
  Release: DWARF with dSYM File
ビルドスクリプトを確認する

入力や出力が定義されていない実行スクリプトフェーズは、全体の再ビルドを引き起こします。必ず次を指定してください:

  • 入力ファイル/入力ファイルリスト
  • 出力ファイル/出力ファイルリスト

チェックリスト

テストを書く前
  • Swift Package に移せるもの(純粋なロジック)を特定する
  • パッケージが適さない場合は、フレームワークターゲットを設定する
  • ユニットテストでは Host Application を None に設定する
テストを書くとき
  • わかりやすい表示名を付けて @Test を使用する
  • すべてのアサーションに #expect を使用する
  • 事前条件で速やかに失敗させるには #require を使用する
  • 類似したテストケースにはパラメーター化を使用する
  • 整理のために .tags() を追加する
非同期テスト
  • テスト関数に async を付けて await を使用する
  • コールバックベースのコードには confirmation() を使用する
  • 不安定なテストでは withMainSerialExecutor を検討する
並列実行の安全性
  • テスト間で共有の可変状態を避ける
  • 各テストで新しいインスタンスを使用する
  • 絶対に必要な場合にのみ .serialized を使用する

リソース

WWDC:2024-10179、2024-10195

ドキュメント:/testing、/testing/migratingfromxctest、/testing/testing-asynchronous-code、/testing/parallelization

GitHub:pointfreeco/swift-concurrency-extras、pointfreeco/swift-clocks


**履歴:**変更については git log を参照してください

コメント