4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

iOS新テストフレームワーク「Swift Testing」を触ってみた話

Last updated at Posted at 2024-07-10

はじめに

bravesoft株式会社でエンジニアをしているはっしーです。

Swift TestingがXcode16から正式に統合されたため今後社内の案件等で採用できないかを検討するにあたり調査した結果をまとめてみました。

Swift Testingについて

Swift TestingとはSwiftでテストを記述するための新しいテストフレームワークです。
従来のXCTestに比べて、より簡潔でモダンな書き方を提供し、Swiftの言語機能や最新のベストプラクティスに沿ったテストが書けるように設計されています。
ライブラリ自体は以前から公開されいて、注目を浴びており、Xcode16から正式に採用されたので今後新規にプロダクトを開発する場合は積極的にSwift Testingを採用していければと思います。

Swift Testingのテスト記法について

expect & #require

#expect()および#require()というExpression Macrosを導入することで、テストメソッドの数を極端に減らすことができます。

  • @Testを用いて宣言した関数内部で利用する
  • XCTAssertEqualなどの検証関数の代替

expect

テスト検証したい場合は#expectを使用する

SampleTesting.swift
import Testing

@Test(arguments: 0 ... 12) func inputIsGreaterThanTenTest(value: Int) {
    #expect(value < 10)
}

スクリーンショット 2024-07-09 11.30.47.png

require

nilの場合にテストを失敗させたい場合は#requireを使用する

ParameterTest.swift
import Testing

@Test func stringOrNilTest() {
    let stringOrNil: String? = nil
    do {
        try #require(stringOrNil)
        // テスト失敗: エラーが発生しなかった場合
        fatalError("Expected an error to be thrown.")
    } catch {
        // エラーが発生した場合は成功
        print("Caught error: \(error)")
    }
}

スクリーンショット 2024-07-08 12.25.52.png

XCTestからのマイグレーションについて

XCTestからのマイグレーションについては以下のサイトを参考に移行していけば良さそうです。

XCTestとSwift Testingで利用されているテストメソッドの比較は以下の表のようになります。
この表はMigrating a test from XCTestから引用しています。

XCTest Swift Testing
XCTAssert(x), XCTAssertTrue(x) #expect(x)
XCTAssertFalse(x) #expect(!x)
XCTAssertNil(x) #expect(x == nil)
XCTAssertNotNil(x) #expect(x != nil)
XCTAssertEqual(x, y) #expect(x == y)
XCTAssertNotEqual(x, y) #expect(x != y)
XCTAssertIdentical(x, y) #expect(x === y)
XCTAssertNotIdentical(x, y) #expect(x !== y)
XCTAssertGreaterThan(x, y) #expect(x > y)
XCTAssertGreaterThanOrEqual(x, y) #expect(x >= y)
XCTAssertLessThanOrEqual(x, y) #expect(x <= y)
XCTAssertLessThan(x, y) #expect(x < y)
XCTAssertThrowsError(try f()) #expect(throws: (any Error).self) { try f() }
XCTAssertThrowsError(try f()) { error in … } #expect { try f() } throws: { error in return … }
XCTAssertNoThrow(try f()) #expect(throws: Never.self) { try f() }
try XCTUnwrap(x) try #require(x)
XCTFail("…") Issue.record("…")

@Test

Swift Testingを導入すると、テスト関数をグローバル関数や型内のインスタンス・静的メソッドとして定義できます。これには常に明示的に@Testを付ける必要があります。

従来のXCTestでは、XCTestCaseを継承したクラス内にテストメソッドを定義し、関数名をPrefixはtestで始める必要がありましたが、この制約がなくなります。また、テスト関数にはasync、throws、mutatingのキーワードを使用できるため柔軟にテストを実装できます。

※Swift Testingの例ではテスト関数と明示的にわかるようSufixにTestとつけています

XCTest
func testHelloWorld() {}

Swift Testing
@Test func helloWorldTest() {}

テストクラスについて

XCTestでは、XCTestCaseを継承するために主にclassを使用していましたが、Swift Testingでは、XCTestでクラスを使用していたテストを、構造体やアクターでまとめることが推奨されます。
これにより、Swiftのコンパイラが並行性の安全性を強化できるようです。

SampleTest.swift
struct StructTest {
    @Test func structTest() {}
}

actor ActorTest {
    @Test func actorTest() {}
}

テスト名のカスタマイズ

@Test属性に文字列リテラルを引数として指定することで、IDEやコマンドラインに表示されるテスト関数の名前をカスタマイズすることができます。

SampleTest.swift
@Test("xxxテスト") func xxxTest() {}
@Test func yyyTest() {}

テストのサブグループ

Suite属性に準拠した型のネスト内でさらにSuite属性に準拠した型を定義することで、テストをサブグループとして形成できます。

SubGroupTests.swift
extension Tag {
    @Tag static var subTest1: Self
    @Tag static var subTest2: Self
}

struct SubGroupTests {
    @Test func sampleTest() {}

    @Suite(.tags(.subTest1))
    struct SubTests {
        @Test func sample1Test() {}
        @Test func sample2Tes() {}
    }

    @Suite(.tags(.subTest2))
    struct SubTests2 {
      @Test func sampleATest() {}
      @Test func sampleBTest() {}
    }
}

アクターの利用について

XCTestでは、同期テストメソッドはデフォルトでメインアクター上で実行されましたが、Swift Testingでは全てのテスト関数が任意のタスクで実行されます。そのため、テスト関数をメインスレッドで実行したい場合は、@MainActorを使用してメインアクターに隔離するか、MainActor.run(resultType:body:)を利用することで実現することができます。

MainThreadTest.swift
@Suite("MainThreadのテスト")
actor MainThreadTest {
    // 失敗する
    @Test func fatalMainThredTest() {
        #expect(Thread.isMainThread)
    }

    // 成功する
    @Test @MainActor func mainActorTest() {
        #expect(Thread.isMainThread)
    }

    //成功する
    @Test func taskMainActorTest() {
        Task { @MainActor
            #expect(Thread.isMainThread)
        }
    }

    //成功する
    @Test func awaitMainActorTest() async {
        await MainActor.run { #expect(Thread.isMainThread) }
    }
}

スクリーンショット 2024-07-09 12.52.36.png

パラメタライズテスト

引数を変えることで同一テストの反復実行を行うことができるので1メソッドで多くのテストケースを網羅することができます。

単一のパラメータでパラメタライズテストしたい場合

以下のようにargumentsにパラメータを指定することで反復してテストを実施することができます。

SingleParameterizedTest.swift
import Testing
@testable import <ProjectName>

@Test(arguments: ["りんご", "オレンジ", "バナナ"])
func occursForSpecificFruitsTest(name: String) {
    #expect(name == "オレンジ")
}

スクリーンショット 2024-07-09 15.17.03.png

複数のパラメータでパラメタライズテストしたい場合

以下のようにargumentsに複数のパラメータを渡して反復テストを行うこともできます。

LanguageTranslationTests.swift
@Suite("言語翻訳テスト")
actor LanguageTranslationTests {
    // 仮想的な翻訳機能を実装
    func translate(_ text: String) -> String {
        switch text {
        case "apple":
            return "りんご"
        case "lemon":
            return "レモン"
        case "banana":
            return "バナナ"
        case "kiwi":
            return "キウイ"
        default:
            return "不明な言葉"
        }
    }

    @Test(
        // 引数に渡したい値群をargumentsに渡すことで必要なパターンごとのテストを実行できる
        arguments:
            [
                ("apple", "りんご"),
                ("lemon", "レモン"),
                ("banana", "バナナ"),
                ("kiwi", "キウイ"),
                ("orange", "不明な言葉")
            ]
    ) func translationTest(
        _ input: String,
        expectedOutput: String
    ) {
        let translatedText = translate(input)
        #expect(translatedText == expectedOutput, "翻訳が正しくありません: '\(input)' -> '\(translatedText)'")
    }
}

スクリーンショット 2024-07-09 11.39.56.png

複数のパラメータでパラメタライズテストしたい場合(改善版)

前のセクションの例では、@Test属性のargumentsに複数のパラメータを渡してテストを実施していました。しかし、パラメータが増えるとメンテナンスが難しくなり、可読性も低下してしまいます。

そこで、構造体を使ってテストケースを管理する方法を採用することにしました。

これにより、@Test属性のargumentsに構造体を渡すことで、各テストケースの内容が明確に定義され、テストの内容がよりわかりやすくなります。

変更点

  • ①構造体を作成する
  • ②構造体を使ってテストケースを管理する
  • ③@Test属性のargumentsにテストケースを渡すように変更する
LanguageTranslationTests.swift
@Suite("言語翻訳テスト")
actor LanguageTranslationTests {

        // ①構造体を作成する
    struct Translation {
        var input: String
        var expectedOutput: String
    }

    // 仮想的な翻訳機能を実装
    func translate(_ text: String) -> String {
        switch text {
        case "apple":
            return "りんご"
        case "lemon":
            return "レモン"
        case "banana":
            return "バナナ"
        case "kiwi":
            return "キウイ"
        default:
            return "不明な言葉"
        }
    }

    // ②構造体を使ってテストケースを管理する
    static let testCases: [Translation] = [
        Translation(input: "apple", expectedOutput: "りんご"),
        Translation(input: "lemon", expectedOutput: "レモン"),
        Translation(input: "banana", expectedOutput: "バナナ"),
        Translation(input: "kiwi", expectedOutput: "キウイ"),
        Translation(input: "orange", expectedOutput: "不明な言葉")
    ]

        // ③@Test属性のargumentsにテストケースを渡すように変更する
    @Test(arguments: Self.testCases)
    func translationTest(
        _ testCase: Translation
    ) {
        let translatedText = translate(testCase.input)
        #expect(translatedText == testCase.expectedOutput, "翻訳が正しくありません: '\(testCase.input)' -> '\(translatedText)'")
    }
}

スクリーンショット 2024-07-09 11.41.11.png

テストの利用制限

新しいバージョンのOSや特定のSwiftバージョンでのみ実行したい場合、属性をテスト関数の宣言時に使用することで、テストの実行を制限することができます。

AvailableTesting.swift
import Testing
@available(swift, introduced: 5.9, message: "Swift5.9以上でないとテストを実行できません")
@Test func test() { /* ... */ }

@Suite

複数のテストを管理したい場合などに@Suiteをつけることでテストをグループ化することができます。

例えばログイン画面のメールアドレス、パスワードのバリデーションのチェックをテストしたい場合などのケースがあると思いますが以下のように@Suiteをつけることでテストをまとめることができます。

ValidationTests.swift
@Suite("メールアドレスと、パスワードのバリデーションテスト")
struct ValidationTests {
    @Test
    func validEmailTest() {
        let email = "test@example.com"
        #expect(isValidEmail(email) == true)
    }

    @Test
    func invalidEmailTest() {
        let email = "invalid-email"
        #expect(isValidEmail(email) == false)
    }

    @Test
    func passwordValidTest() {
        let password = "ValidPassword123!"
        #expect(isValidPassword(password) == true)
    }

    @Test
    func invalidPasswordTest() {
        let password = "short"
        #expect(isValidPassword(password) == false)
    }
}

func isValidEmail(_ email: String) -> Bool {
    // 簡単なメールアドレスのバリデーションロジック
    return email.contains("@")
}

func isValidPassword(_ password: String) -> Bool {
    // 簡単なパスワードのバリデーションロジック
    return password.count >= 8
}

スクリーンショット 2024-07-09 12.57.32.png

但し、struct, actor, class内に@Testをつけた関数が含まれている場合は、自動的にテストスイートとして扱われるため、明示的に属性を付ける必要はないようです。

以下の例はstruct ValidationTestsにて@Suiteをつけなかった場合の例となります

ValidationTests.swift
struct ValidationTests { // 明示的に@Suiteはつけていない
    @Test // struct内にに@Test関数が含まれている
    func validEmailTest() {
        let email = "test@example.com"
        #expect(isValidEmail(email) == true)
    }

    @Test
    func invalidEmailTest() {
        let email = "invalid-email"
        #expect(isValidEmail(email) == false)
    }

    @Test
    func validPasswordTest() {
        let password = "ValidPassword123!"
        #expect(isValidPassword(password) == true)
    }

    @Test
    func invalidPasswordTest() {
        let password = "short"
        #expect(isValidPassword(password) == false)
    }
}

func isValidEmail(_ email: String) -> Bool {
    // 簡単なメールアドレスのバリデーションロジック
    return email.contains("@")
}

func isValidPassword(_ password: String) -> Bool {
    // 簡単なパスワードのバリデーションロジック
    return password.count >= 8
}

スクリーンショット 2024-07-09 12.58.25.png

@Suiteを明示的につけていませんがテストを実行することができます。

スクリーンショット 2024-07-09 12.59.09.png

Traits

テストに特製をつけたり表現力向上や制約の追加に活用することができます。
Traitsを使うことで柔軟なテストを実装することができます。

.comment Trait

@Test属性のTrait引数として指定することで、コメントを追加することができます。
CI等で失敗時にログを残すことができます。

TraitTest.swift
@Test(
  .comment("failCommentTestに失敗しました。テストが失敗するとXcode上やCI上に表示される")
)
func failCommentTest() {
    let text = "Hello"
    #expect(text == "Hello!")
}

スクリーンショット 2024-07-08 10.45.48.png

.enable Trait

特定の条件の場合にテストを有効にすることができます。
enableはif文を使って実行可否を制御することができます

TraitTest.swift
private var condition1 = false
private var condition2 = true

@Test(
  .enabled(if: condition1, "テストがスキップされた場合はこのコメントが記録される"),
  .disabled(if: condition2, "こちらのコメントは記録されない")
)
func combinedTest() {}

スクリーンショット 2024-07-08 11.13.10.png

.bug Trait

.bug Traitを使うことで特定のバグを再現、または修正の検証とテストを関連付けることは非常に有用です。
.bug()では第二引数としてBug.Relationshipを指定することがで、バグとテストの関係性を明示することが可能となります。

TraitTest.swift
@Test(
    .comment("Bugとの紐付け確認"),
    .bug("特定の条件でバグが発生する", relationship: .uncoveredBug))
func someTest() {}

Bug.Relationshipについて

uncoveredBug

バグの存在が明らかとなった場合に使用

reproducesBug

既知のバグを再現するテストの場合に使用

verifiesFix

バグの修正が確認され、バグが再発しないことを示したい場合に使用

failingBecauseOfBug

関連しないバグのために失敗している場合に使用

unspecified

上記のどのケースにも当てはまらない場合

確認環境

  • macOS 14.5
  • Xcode16.0 beta2
  • Swift Testing
    ※Projectを作成する際にtestライブラリはswift-testingを選択できるようになっています。

導入手順

新規プロジェクト作成の場合

  1. new Projectを作成する

  2. Testing Sysystemを Swift Testingを選択する(デフォルト)
    スクリーンショット 2024-07-08 8.09.41.png

  3. TargetにUT/UI Testが追加される
    スクリーンショット 2024-07-08 8.06.53.png

既存のプロジェクトに追加する場合

  1. TargetからUniit Testing Bundleを選択する
    スクリーンショット 2024-07-08 8.01.08.png

2.new TargetでTesting Sysystemを Swift Testingを選択する(デフォルト)
スクリーンショット 2024-07-08 8.01.14.png

3.TargetにUT/UI Testが追加される
スクリーンショット 2024-07-08 8.06.53.png

Xcode15.2以下のバージョンで導入する場合

  1. Xcode15ではSwift Testingは導入されていないのでSPMで導入する必要があるのでPackage.swiftに依存を追加してください

スクリーンショット 2024-07-08 14.32.09.png

2. 以下内容のScaffolding.swiftをPackageのTest Target内に追加する
Xcode15系では、Swift Package Managerに統合されていませんが、Swiftのパッケージで本ライブラリを使用し始めたい開発者のための一時的な仕組みが提供されています。

パッケージのテストターゲットにScaffolding.swiftという新しいSwiftのソースファイルを追加することで導入することができます。

Scaffolding.swift
import XCTest
import Testing

final class AllTests: XCTestCase {
    func testAll() async {
        await XCTestScaffold.runAllTests(hostedBy: self)
    }
}
HelloWorldTest.swift
import Testing

@Test func helloWorldTest() {
    let greeting = "Hello, world!"
    #expect(greeting == "Hello, world!")
}

Swift Package Managerなどの既存のツールと本ライブラリの統合をサポートするために一時的に提供されており、将来のリリースで削除されるかもしれません。Xcode16.0 beta2以上を使う場合はXcodeにSwift Testingが統合されているのでScaffolding.swiftは不要となります。

XCTestとSwift Testingの比較

XCTestとSwift Testingの違いを簡単な例で比較してみましょう。

サンプル1:単純なユニットテストの場合

Swift Testing

HelloWorldTest.swift
import Testing

@Test func helloWorldTest() {
    let greeting = "Hello, world!"
    #expect(greeting == "Hello, world!")
}

XCTest

HelloWorldXCTest.swift
import XCTest

class HelloWorldXCTest: XCTestCase {
    override func setUp() {}
    override func tearDown() {}

    func testHelloWorld() {
        let greeting = "Hello, world!"
        XCTAssertEqual(greeting, "Hello, world!")
    }
}

サンプル2:非同期処理の場合

Swift Testing

AsyncTest.swift
import Testing

@Test func fetchDataTest() async throws {
    let data = try await fetchData()
    #expect(data == "Data fetched successfully")
}

func fetchData() async throws -> String {
    try await Task.sleep(nanoseconds: 2_000_000_000) // 2秒待機
    return "Data fetched successfully"
}

XCTest

AsyncXCTest.swift
import XCTest
import Foundation

class AsyncXCTest: XCTestCase {

    func testFetchData() async throws {
        let data = try await fetchData()
        XCTAssertEqual(data, "Data fetched successfully")
    }

    func fetchData() async throws -> String {
        try await Task.sleep(nanoseconds: 2_000_000_000) // 2秒待機
        return "Data fetched successfully"
    }
}

まとめ

Swift Testingについて簡単なサンプルで解説しながらで実装方法についてまとめてみました。
XCTestに比べて柔軟にテストをかけたり導入が楽なのでSwift Testingを積極的に採用して自社サービス、受託案件で品質をあげていければと思います。


bravesoftではiOSアプリやAndroidアプリの開発を行っています。 アプリ開発に興味がある方は是非、採用ページをご確認ください

4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?