✍🏻

Swift Testingと仲良くなる

2025/02/08に公開

C++、KotlinやRustは割とすんなりテストコードが書けるものの、なぜかSwift(XCode)だけ敷居が高いと感じていたXCTest。気づいたら新しくSwift Testingがリリースされていたので、今回はSwift Testingと仲良くなろうと思い、やったことなどをまとめてみました。

公式サイト

https://developer.apple.com/documentation/xcode/testing

XCTestからSwift Testにマイグレーションするときは以下のドキュメントを参考にしてください。
https://developer.apple.com/documentation/testing/migratingfromxctest

単体テストの基本形

XCTestからSwift Testに変わり、評価関数が#expect#requireのみとかなりシンプルになりました。テストのショートカットキーが⌘Uなので ⌘Command + u でテストが実行できます。

テストコード

import Testing

@Suite("Swift Test Demo")  struct SwiftTestDemo {
    @Test("Simple test") func example() {
        #expect(1 == 1)
    }
}

成功パターン

􀟈 Test run started.
􀄵 Testing Library Version: 102 (x86_64-apple-macos13.0)
􀟈 Suite "Swift Test Demo" started.
􀟈 Test "Simple test" started.
􁁛 Test "Simple test" passed after 0.001 seconds.
􁁛 Suite "Swift Test Demo" passed after 0.001 seconds.
􁁛 Test run with 1 test passed after 0.001 seconds.

失敗パターン

XCodeの画面でビルドエラーと同じように#expectで失敗した箇所が赤く表示されます。

テストコードを詳しくみてみる

基本形のコードを1行ずつ詳しくみてみましょう。

Swift Testingのロード

XCTestからTestingに変わっただけです。

import Testing

@Suiteマクロ

テストスイートとしてまとめるマクロです。

@Suite("Swift Test Demo")  struct SwiftTestDemo {
    // ...
}

@Suiteマクロは、引数を2つ以上設定することができます。@Teststruct,actor,class内に含まれる場合は、明示する必要はありません。引数にdisplayNameを設定したり、SuiteTraitを指定したいときはマクロを明記する必要があります。

定義

@attached(member) @attached(peer)
macro Suite(
    _ displayName: String? = nil,
    _ traits: any SuiteTrait...
)

SuiteTraitについて

端的に言うと、テストに対して条件を加えたり補足情報を加えたりできるTraitになります。種類は以下の通りです。

Topics 利用方法
.enabled テストの有効化
.disabled テストの有効化
.timeLimit 実行時間の制限を加える
.serializes テストの実行を連続・並列化する
.tags テストに対してタグ情報を加える
.bug テストにバグ情報(Issueなど)を追加する
.comment テストにコメントを加える
.prepare 事前にテスト合格が必要なテストを関連付ける

@Testマクロ

@Testはテスト対象であることを明示するマクロです。テスト実施に必須のマクロです。

@Test("Simple test") func example() {
    // ...
}

定義

@attached(peer)
macro Test(
    _ displayName: String? = nil,
    _ traits: any TestTrait...
)

TestTraitについて

Traitプロトコルを継承しているため、SuiteTraitと同様にTraitが利用可能です。テストに対して条件を加えたり補足情報を加えたりできるTraitになります。種類は以下の通りです。

Topics 利用方法
.enabled テストの有効化
.disabled テストの有効化
.timeLimit 実行時間の制限を加える
.serializes テストの実行を連続・並列化する
.tags テストに対してタグ情報を加える
.bug テストにバグ情報(Issueなど)を追加する
.comment テストにコメントを加える
.prepare 事前にテスト合格が必要なテストを関連付ける

リファレンス

https://developer.apple.com/documentation/testing/test(::arguments:)-3rzok

#expectマクロ

かなりシンプルです。

    #expect(1 == 1)

XCTestとSwift Testの評価関数対応表

https://developer.apple.com/documentation/testing/migratingfromxctest#Record-issues

色々とテストパターンを考えてみる

1.オブジェクト共通化

クラスのテストコードを書く場合、毎回生成と破棄を繰り返さなくてもよい場合はsetupteardownを活用して、オブジェクト共通化をすることでテスト実施の高速化が期待できます。

Calculator.swift
class Calculator {
    func add(_ x: Int, _ y: Int) -> Int {
        return x + y
    }
}
CalculatorTest.swift
@Suite struct CalculatorTest {
    // オブジェクト共通化
    fileprivate let calc: Calculator;
    
    // setup関数相当
    init() {
        self.calc = .init()
    }

    @Test func addTest() {
        let expected: Int = 7;
        let actual = calc.add(2, 5)
        
        #expect(actual == expected)
    }

    @Test func addMinusValueTest() {
        let expected: Int = -2;
        let actual = calc.add(-5, 3)
        
        #expect(actual == expected)
    }
}

2.テストに実施な要素が揃っているか事前にチェックする (#require)

try #require()を使って、検査実施前に要素が揃っているかチェックします。

CalculatorTest.swift
@Suite struct CalculatorTest {
    // オブジェクト共通化
    fileprivate let calc: Calculator;
    
    // setup関数相当
    init() {
        self.calc = .init()
    }

>    @Test func addTest() throws {
        let expected: Int = 7;
        let actual = calc.add(2, 5)
        
+       try #require(calc != nil)
        #expect(actual == expected)
    }

+    // テスト失敗のケース
+    @Test func addTestExpectsTestFailed() throws {
+        let calc : Calculator? = nil
+        let expected: Int = 7;
+        
+        try #require(calc != nil)
+        
+        let actual = calc!.add(2, 5)
+        #expect(actual == expected)
+    }

}

3.期待する例外が発生したかチェックする

エラー系のテストケースです。#expectを使う点は変わりません。

Calculator.swift
class Calculator {
    func add(_ x: Int, _ y: Int) -> Int {
        return x + y
    }
    
+    func divide(_ numerator: Int, _ denominator: Int) throws -> Double {
+        if denominator == 0 {
+            throw CalculatorError.NotDividedByZero;
+        }
+        
+        return Double((numerator/denominator));
+    }
}
CalculatorError.swift
import Foundation

enum CalculatorError: LocalizedError {
    case Unknown
    case NotDividedByZero
    
    var errorDescription: String? {
        switch self {
            case .Unknown: return "Unknown error happened"
            case .NotDividedByZero: return "Not devided by Zero"
        }
    }
}
CalculatorTest.swift
@Suite struct CalculatorTest {
    // オブジェクト共通化
    fileprivate let calc: Calculator;
    
    // setup関数相当
    init() {
        self.calc = .init()
    }

    // 何かしらのErrorが発生したか検査する
    @Test func throwAnyCalculatorErrorTest() throws {
        #expect(throws: CalculatorError.self) {
                  try calc.divide(2, 0)
        }
    }
    
    // NotDividedByZeroエラーがきちんとが投げられたか検査する
    @Test func throwDividedByZeroErrorTest() throws {
        #expect(throws: CalculatorError.NotDividedByZero) {
                  try calc.divide(2, 0)
        }
    }

    // 期待するErrorと異なるエラーがが投げられたか検査する
    @Test func throwMissingErrorTest() throws {
        #expect(throws: CalculatorError.Unknown) {
                  try calc.divide(2, 0)
        }
    }
}

Discussion