axiom-swift-testing について
axiom-swift-testingは、AppleのSwift Testingフレームワークを使う開発者向けのリファレンススキルです。テスト宣言、アサーション、パラメータ化・非同期テスト、並列実行、実行時間を短縮する方法を扱います。
主な機能
- 01@Test、#expect、テストスイートの解説
- 02パラメータ化テストと非同期テスト
- 03タグとテスト特性の説明
- 04並列実行と直列実行の案内
- 05テスト実行時間を短縮する方法
活用シーン
- 01非同期コードのテスト作成
- 02複数入力をパラメータ化ケースとして実行
- 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 を作成します。
- 「ファイル」→「新規」→「ワークスペース」
.xcodeprojをワークスペースにドラッグします- 「ファイル」→「パッケージ依存関係を追加」→「ローカルを追加」→
MyAppCore/を選択します 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:循環依存を解消する
パッケージのコードからアプリが所有する型を呼び出す必要がある場合:
- パッケージ内にプロトコルを定義します(抽象化はパッケージが所有します)
- 起動時に、準拠する実装をアプリターゲットから注入します
- その依存関係がすべてパッケージ内に移されたら、実装をパッケージへ移します
フェーズ 3:両方のテストターゲットを維持する
移行中は、2 つのテストターゲットを維持します。
MyAppCoreTests—swift testで実行(抽出したロジック)MyAppTests—xcodebuild testで実行(アプリレベルで残るテスト)
ソースファイルを抽出する際に、テストを MyAppTests から MyAppCoreTests へ段階的に移行します。
目標:抽出のたびに、アプリがビルドでき、すべてのテストが成功する状態を維持します。一度に複数のモジュール境界を抽出しないでください。
戦略 2:ホストアプリケーションのないフレームワーク
アプリプロジェクト内に残す必要があるコードの場合:
- フレームワークターゲットを作成します(File → New → Target → Framework)
- モデルコードをフレームワークへ移します
- 外部からアクセスする必要がある型を public にします
- フレームワークを使用するファイルに インポートを追加します
- テストターゲットの設定で、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 からの移行
比較表
| XCTest | Swift 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(期待値ごと) |
addTeardownBlock | deinit または defer |
XCTest を引き続き使用する場合
- UI テスト(XCUIApplication)
- パフォーマンステスト(XCTMetric)
- Objective-C のテスト
移行のヒント
- どちらのフレームワークも同じターゲット内で共存できます
- テストファイルを一度に 1 つずつ、段階的に移行します
- 類似した XCTest をパラメーター化した Swift テストにまとめます
- 単一テストの 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 テストはすべてを遅くします。削除してください:
- UI テストターゲットを削除します(Project Settings → ターゲットを選択 → -)
- 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 を参照してください