1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Swift Testingをはじめたい

Posted at

はじめに

Swift Testingという新しい単体テストフレームワークが登場してしばらく経ったので、そろそろ実戦でも使っていきたいという思いが出てきました。
今回はSwift TestingをXCTestと比較しながらどうやって使っていくのか、本当にXCTestから移行する価値があるのかを考えていきたいと思います。

Swift Testingを試した環境

  • Xcode16.2
  • Swift6
  • macOS15

Swift Testing とは

WWDC24 で発表された Swift で単体テストを書くための新しいフレームワークで
今まで単体テストはXCTestというフレームワークで行うのが一般的でしたが、その後継にあたるものという印象です。

XCTest と比較しての Swift Testing の特徴

公式ドキュメントよりXCTestが提供する機能は基本備えている旨の記載がありましたが、新しい機能が追加されている部分はあったので、そこを中心に説明していきます。

The testing library provides much of the same functionality of XCTest, but uses its own syntax to declare test functions and types. Here, you’ll learn how to convert XCTest-based content to use the testing library instead.

日本語訳

テスト ライブラリは XCTest とほぼ同じ機能を提供しますが、テスト関数と型を宣言するために独自の構文を使用します。ここでは、XCTest ベースのコンテンツを変換して、代わりにテスト ライブラリを使用する方法を学習します。

テストケースメソッドの命名の制約として、頭にtestが必要無くなった

XCTest の場合、テストケースとして認識させたい場合は、関数名の頭にtestがついていないと認識してくれませんでした。
Swift Testing の場合は@Testマクロが付与されていれば名前は何にしてもテストケースとして認識してくれるようになります。

XCTestの場合
final class XCUnitTest: XCTestCase {

    func なんでもいいよ() throws { // テストケースとして認識してくれない

    }

    func testExample() throws { // テストケースとして認識してくれる

    }
}
Swift Testingの場合
struct Test {
    @Test
    func なんでもいいよ() throws { // テストケースとして認識してくれる

    }

    @Test
    func testExample() throws { // テストケースとして認識してくれる

    }
}

XCTestではタイポなどでtestをつけ忘れて、意図せずテストケースとして認識されていない関数が生まれると言う事故が起こりうる点で怖かったのですが、
Swift Testingでは@Testがついていればこれはテストケースだと直感的にわかるようになったので、そういう事故も検知しやくすなり、リスクが減ったのかなと思いました。

テストスイートやテストケースに対して表示名のカスタマイズができるようになった

@Suite("サンプルテスト関連のテストスイート")
struct Test {

    @Test("サンプルテスト")
    func test() throws {

    }
}

Xcode 左のテストケースのタブで、指定した表示名で表示されるようになります。

image.png

文字列で指定できるため、関数の名前では使用できないカンマだったりも使用できます。
単体テストは、見れば仕様が把握できるというドキュメントの側面もあり、テストケースの名前は検証する振る舞いを表現することで、読み手に仕様を伝えます。
ただ、それを関数名で表現すると、使えない文字(カンマなど)があるなど制約もあるのでどうしても表現力が落ちることもあると思っています。
そういう意味で、検証しようとしている振る舞いをよりわかりやすく説明する強力なツールになると感じました。

ただ関数名で十分表現できる場合は、あえてカスタマイズする必要はないとも思いました。
関数名が十分に自己説明的なのに、表示名でも振る舞いの説明を追加すると二重管理になり仕様変更があって振る舞いが変わった時にメンテナンスする対象が増えるだけかという意見です。(コードコメントと似ている)

たとえば以下のような例では、関数名で十分説明できたりもするのでその場合はあえて表示名をカスタマイズする必要はないとも思います。

struct CalculatorTest {

    @Test
    func 割り算は右辺が0の場合はnilを返す() throws { // 関数名で検証する振る舞いは十分伝わる

        #expect(nil == Calculator.division(10, 0))
    }
}


enum Calculator {

    static func division(_ value1: Double, _ value2: Double) -> Double? {

        if value2 == .zero {

            return nil
        }

        return value1 / value2
    }
}

テストスイートやテストケースをグルーピングできるようになった

テストケースメソッド(@Testのついた関数)を持つ型(struct や class,enum など)はテストスイートとして扱われます。
テストスイートは、XCTest でいうところのXCTestCaseに準拠した型です。
テストスイートはネストさせると、階層構造を持つテストスイートを作ることができます。

具体例が以下です。

struct CalculatorTest {

    struct MultiplicationTest {

        @Test
        func 掛け算は左辺と右辺を掛けた値を返す() throws {

            #expect(10 == Calculator.multiplication(2, 5))
        }
    }

    struct DivisionTest {

        @Test
        func 割り算は右辺が0でなければ左辺を右辺で割った値を返す() throws {

            #expect(5 == Calculator.division(10, 2))
        }

        @Test
        func 割り算は右辺が0の場合はnilを返す() throws {

            #expect(nil == Calculator.division(10, 0))
        }
    }
}

image.png

グルーピングできるもう一つの方法として、テストスイートもしくはテストケースに対してタグを付与する機能も提供されています。
タグが付与されると、そのタグの単位でテスト実行するということができるようになります。

たとえば、CalculatorTeststableのタグを付与する。

+   @Suite(.tags(.stable))
    struct CalculatorTest { ... }

+   extension Tag {
+
+       @Tag static var stable: Tag
+   }

すると以下のように Tags の項目ができ、タグごとにテストを実行することができるようになる。

image.png

タグの用途を考察するために、ドキュメントの説明を見てみる。

A complex package or project may contain hundreds or thousands of tests and suites. Some subset of those tests may share some common facet, such as being critical or flaky. The testing library includes a type of trait called tags that you can add to group and categorize tests.

Tags are different from test suites: test suites impose structure on test functions at the source level, while tags provide semantic information for a test that can be shared with any number of other tests across test suites, source files, and even test targets.

日本語訳

複雑なパッケージやプロジェクトには、数百または数千のテストとスイートが含まれる場合があります。これらのテストの一部のサブセットは、クリティカルまたは不安定などの共通の側面を共有している場合があります。テスト ライブラリには、テストをグループ化および分類するために追加できるタグと呼ばれる特性のタイプが含まれています。

タグはテスト スイートとは異なります。テスト スイートはソース レベルでテスト関数に構造を課しますが、タグはテスト スイート、ソース ファイル、さらにはテスト ターゲット全体で任意の数の他のテストと共有できるテストのセマンティック情報を提供します。

上記説明より、テストスイートを跨いで分類したい場合は、タグを用いて分類してねと読み取れます。
要はテストスイートを使ったグルーピングができないのであれば、タグでグルーピングしてということと解釈しました。

ではテストスイートを跨いで分類したいケースとはどんななのか、思いつくのは flaky テスト(不安定なテスト)とそうでないテストを分類したい場合とかでしょうか。

自動テストの主たる目的として、ソフトウェアの成長を持続可能なものにするところが挙げられますが、
flaky テストが混ざっていると、この目的を果たせなくなるリスクが高まります。
なぜなら、flaky テストはテスト対象自体に問題はないのにテストが失敗する、
いわゆる偽陽性になることがあり、これが慢性的に起こると開発者はテストの結果を信用できなってしまいます。
そうなると新規機能の追加やリファクタリングなどソフトウェアを成長させる活動行うたび、信用できない自動テストの代わりに地道に手動テストで品質を担保するしかなくなり、
成長する速度は落ちてしまい成長の持続可能性が失われてしまうことになります。

上記記事にもありますが、自動テストを信頼できなくなることが成長の持続可能性を下げることにつながるので、
そういった flaky テストはタグを用いて分類し、信頼できるテストケースから隔離することで自動テストの結果が信頼できなくなるという問題は抑えられるのかなと感じました。

テストコードにコメントを付与することで、テスト失敗時にコメントの内容を表示できるようになった

以下のように、@Suite or @Testの直上にコメントを加えることで、テスト失敗時にコメントを表示してくれるようになります。

+   // 計算ロジックの検証
    @Suite(.tags(.stable))
    struct CalculatorTest {

+       // 掛け算の検証
        struct MultiplicationTest {

+           // 掛け算の結果を検証
+           // 失敗した場合はCalculator.multiplicationに問題がある
            @Test
            func 掛け算は左辺と右辺を掛けた値を返す() throws {

                #expect(1 == Calculator.multiplication(2, 5))
            }
        }

        ...
    }
テスト結果ログ
    ◇ Test run started.
    ↳ Testing Library Version: 102 (arm64-apple-ios13.0-simulator)
    ◇ Suite CalculatorTest started.
    ◇ Suite DivisionTest started.
    ◇ Suite MultiplicationTest started.
    ◇ Test 掛け算は左辺と右辺を掛けた値を返す() started.
    ◇ Test 割り算は右辺が0でなければ左辺を右辺で割った値を返す() started.
    ◇ Test 割り算は右辺が0の場合はnilを返す() started.
    ✔ Test 割り算は右辺が0でなければ左辺を右辺で割った値を返す() passed after 0.001 seconds.
    ✔ Test 割り算は右辺が0の場合はnilを返す() passed after 0.001 seconds.
    ✔ Suite DivisionTest passed after 0.001 seconds.
    ✘ Test 掛け算は左辺と右辺を掛けた値を返す() recorded an issue at SwiftTestingSampleTests.swift:23:13: Expectation failed: (1 → 1.0) == (Calculator.multiplication(2, 5) → 10.0)
    ✘ Test 掛け算は左辺と右辺を掛けた値を返す() failed after 0.002 seconds with 1 issue.
+   ↳ // 掛け算の結果を検証
+   ↳ // 失敗した場合はCalculator.multiplicationに問題がある
    ✘ Suite MultiplicationTest failed after 0.002 seconds with 1 issue.
    ✘ Suite CalculatorTest failed after 0.003 seconds with 1 issue.
+   ↳ // 計算ロジックの検証
    ✘ Test run with 3 tests failed after 0.003 seconds with 1 issue.

注意点として、テストスイートにコメントを付与する場合、@Suiteがついていないとコメントも付与されません。
上記例でも、MultiplicationTestにはコメントを付与しているものの、テスト結果ログにはそのコメントは表示されませんでした。

コメント機能の意義を考察するにあたって、公式ドキュメントの説明を見てみます。

It’s often useful to add comments to code to:

  • Provide context or background information about the code’s purpose

  • Explain how complex code implemented

  • Include details which may be helpful when diagnosing issues

Test code is no different and can benefit from explanatory code comments, but often test issues are shown in places where the source code of the test is unavailable such as in continuous integration (CI) interfaces or in log files.

Seeing comments related to tests in these contexts can help diagnose issues more quickly. Comments can be added to test declarations and the testing library will automatically capture and show them when issues are recorded.

日本語訳

コードにコメントを追加すると、次のような場合に便利です。

  • コードの目的に関するコンテキストや背景情報を提供する

  • 複雑なコードがどのように実装されているかを説明する

  • 問題の診断に役立つ可能性のある詳細を含める

テスト コードも同様で、説明的なコード コメントの恩恵を受けることができますが、テストの問題は、継続的インテグレーション (CI) インターフェイスやログ ファイルなど、テストのソース コードが利用できない場所に表示されることがよくあります。

これらのコンテキストでテストに関連するコメントを確認すると、問題をより迅速に診断するのに役立ちます。テスト宣言にコメントを追加することができ、問題が記録されたときにテスト ライブラリが自動的にコメントをキャプチャして表示します。

テスト結果ログから、どういった不具合があるのか簡単に分析できるような情報を提供することを期待しているようです。

ただ個人的には全てのテストケースやテストスイートに対してコメントを付与するのは、
やり過ぎかと感じました。
もしコメントをするなら、テストスイートの型名やテストケース名などから何が問題なのか分析しづらいような状況でのみコメントをつけるで良いと思います。

仕様変更などで記載しているコメントと実際の振る舞いが乖離を防ぐためメンテナンスをする必要が�る性質上、むやみやたらにコメントを入れると、何にも寄与しないがメンテナンスコストだけ掛かるコメントが増えてしまうというデメリットを享受することになると感じます。

テストケース名は、基本的に検証対象の振る舞いを表現するので、それができていればテスト結果ログから関数名を見るだけで何が問題かはわかると思うので、基本的に使わなくても良いのではという感想です。

テストとバグを関連づけられるようになった

@Testマクロにbugの引数に、バグトラッキングツールで管理しているバグの URL であったり ID を指定することで、バグとテストを関連づけることができます。
具体的な実装としては以下です。

    struct DivisionTest {

-       @Test
+       @Test(.bug(id: "12-345"))
        func 割り算は右辺が0でなければ左辺を右辺で割った値を返す() throws {

            #expect(5 == Calculator.division(10, 2))
        }

-       @Test
+       @Test(.bug("https://developer.apple.com/documentation/testing/trait/bug(_:_:)"))
        func 割り算は右辺が0の場合はnilを返す() throws {

            #expect(nil == Calculator.division(10, 0))
        }
    }

https://developer.apple.com/documentation/testing/trait/bug(_:id:_:)-10yf5

パラメタライズドテストが行えるようになった

一つの振る舞いを検証するときに、入力するバリーション毎にテストケースを書くということがありますが、
パラメタライズドテストを導入することで複数のバリエーションの検証を一つのテストケースメソッドで表現できるようになりました。

例として、パラメタライズドテストがない状態で、掛け算のテストにバリエーションを追加してみます。
追加したテストケースは、もともとあったテストケースと比べると、入力する値と期待する結果だけで、それ以外は同じです。

    struct MultiplicationTest {

        @Test
        func 掛け算は左辺と右辺を掛けた値を返す() throws {

            #expect(10 == Calculator.multiplication(2, 5))
        }

+       @Test
+       func 掛け算は左辺に0があると右辺の値に関わらず0を返す() throws {
+
+           #expect(0 == Calculator.multiplication(0, 5))
+       }
+
+       @Test
+       func 掛け算は右辺に0があると左辺の値に関わらず0を返す() throws {
+
+           #expect(0 == Calculator.multiplication(5, 0))
+       }
    }

パラメタライズドテストで書くと以下のように、一つのテストケースにまとまります。

    @Test(arguments: zip([2, 0, 5], [5, 5, 0]))
    func 掛け算は左辺と右辺を掛けた値を返す(
        right: Double,
        left: Double
    ) throws {

        #expect(right * left == Calculator.multiplication(right, left))
    }

@Testargumentsの引数として、Array 型で設定した値がテストメソッドの引数に割り当てられます。

引数を 2 つ取る場合は、`zip'を用いることで対応できます。
そうして実行されるのが以下入力値の組み合わせで3回テストケースが実行されます。

  • right: 2, left: 5
  • right: 0, left: 5
  • right: 5, left: 0

zipを使わず以下の様に書くこともできますが、
その場合は直積となりzipを使った場合とは異なるテスト結果となります。

以下具体例です。

    @Test(arguments: [2, 0, 5], [5, 5, 0])
    func 掛け算は左辺と右辺を掛けた値を返す(
        right: Double,
        left: Double
    ) throws {

        #expect(right * left == Calculator.multiplication(right, left))
    }

この場合、以下の引数の組み合わせで 9 回実行されることになります。

  • right: 2, left: 5
  • right: 2, left: 5
  • right: 2, left: 0
  • right: 0, left: 5
  • right: 0, left: 5
  • right: 0, left: 0
  • right: 5, left: 5
  • right: 5, left: 5
  • right: 5, left: 0

そして、このテストケースを実行すると以下の様なログが出力されクラッシュしてしまいます。

XCTest/HarnessEventHandler.swift:282: Fatal error: Internal inconsistency: No test reporter for test case argumentIDs: Optional([Testing.Test.Case.Argument.ID(bytes: [53]), Testing.Test.Case.Argument.ID(bytes: [53])]) in test SwiftTestingSampleTests.CalculatorTest/MultiplicationTest/掛け算は左辺と右辺を掛けた値を返す(right:left:)/SwiftTestingSampleTests.swift:45:10

フォーラムで同じ様な事象があり、こちらによると同じ入力が複数ある場合は上記のクラッシュが発生するようです。

開発者からしても同じ入力のケースを置いておく理由はないので、
削除するべきですが意図せずこういったケースが混入してしまうこともあるので(特に直積のケースでは)パラメータを考えるときは注意が必要です。

以下の様に直すとクラッシュの問題は消え、正常にテストが実行されます。

-   @Test(arguments: [2, 0, 5], [5, 5, 0])
+   @Test(arguments: [2, 0, 5], [5, 0])
    func 掛け算は左辺と右辺を掛けた値を返す(
        right: Double,
        left: Double
    ) throws {

        #expect(right * left == Calculator.multiplication(right, left))
    }

ちなみに、直積によるパラメタライズドテストは引数 2 つまでしか対応できません。
zipを用いた様な直積でないパラメタライズドテストであれば、引数は 3 つ以上でも可能です。
たとえば、掛け算のテストで右辺、左辺の入力と掛け算の期待する出力の値の 3 つを引数に取りたい場合、以下のようにタプルの配列をargumentsに設定することで引数に設定できます。

-   @Test(arguments: [2, 0, 5], [5, 0])
+   @Test(arguments: [
+       (2, 5, 10),
+       (0, 5, 0),
+       (5, 0, 0)
+   ])
    func 掛け算は左辺と右辺を掛けた値を返す(
        right: Double,
        left: Double,
+       expected: Double
    ) throws {

-       #expect(right * left == Calculator.multiplication(right, left))
+       #expect(expected == Calculator.multiplication(right, left))
    }

パラメタライズドテストは、XCTest でもやろうと思えばできたようですが(この記事書く前は知らなかった!)、Swift Testing では@Testマクロによってボイラープレート的な書き方が減って、可読性はSwift Testingの方が優れているなと感じました。

XCTest でパラメタライズドテストを行うための具体的な方法は、以下記事で紹介されています。

検証時に使用する API の変更

基本的に、公式ドキュメントの XCTest から Swift Testing に移行するガイドにて API がどう変わっていくかが詳しく説明されています。

ここではよく使う結果の比較、非同期コードの検証、オプショナルの確認、テストを失敗させる時のケースを説明します。

結果の比較

テスト対象の出力の結果と期待値とを比較してテストが成功しているかどうかを確認するのは、
XCTest ではXCTAssert系のメソッドで行われています。
Swift Testing では、#expectマクロを使用するようになります。

    @Test
    func 割り算は右辺左辺いずれも有限の数値であれば左辺を右辺で割った値を返す() throws {

        let result = try Calculator.division(10, 2)
        #expect(5 == result)
    }

例外が出力されることを期待する場合も、#expectマクロを使用できます。

    @Test
    func 割り算は右辺が有限の数値でなければ例外を返す() throws {

        #expect(throws: CalculateError.self, performing: {

            let _ = try Calculator.division(10, .infinity)
        })
    }

XCTest では結果の比較を行うのに、XCTAssertEqualeXCTAssertTrueXCTAssertThrowsError
など検証する結果の型などによってメソッド名が複数ありました。
Swift Testing では#expectでまとまっているので、結果の検証をするときに何のメソッドを使おうか悩むことが減るのかなと思いました。

非同期コードの検証

XCTest では非同期コードを検証するときは、XCTestExpectationを用いた方法だでしたが、
Swift Testing ではconfirmationという API を使って非同期コードの検証を行います。

confirmationの優れた点として、confirmationクロージャがasyncになっているので非同期コードがかけることがあります。
Swift Concurrency とより親和性の高い API になっているという点で、XCTest の API よりも優れていると思いました。

例のコードは以下

    @Test
    func 表示すると表示イベントを出力する() async throws {

        var store = Store()

        await confirmation { complete in

            store.watch { event in

                #expect(event == .onAppear)
                complete()
            }

            // onAppearを呼び出したら、watchのクロージャに`onAppear`イベントが渡ってくることを検証する
            store.onAppear()
        }
    }

confirmationのクロージャの引数であるConfirmationを呼び出すとconfirmationでの検証が成功となります。
confirmationメソッドのexpectedCountという引数を指定すると、confirmationが期待するConfirmationの呼び出し回数を変更することもできます(デフォルトは 1 回)

以下公式ドキュメントに、非同期コードの検証を XCTest から Swift Testing に移行する実例も載っています。

オプショナル値の確認

オプショナルな値が nil な場合はテストを失敗させたいといったケースでは、XCTest ではXCTUnwrapが使用できました。
Swift Testing では#requireマクロを使用します。

以下が例のコードです

    @Test
    func 割り算は右辺左辺いずれも有限の数値であれば左辺を右辺で割った値を返す_2() throws {

        let unwrapResult = try #require(try Calculator.division(10, 2))
        #expect(5 == unwrapResult)
    }

上記例のように、オプショナルな値を#requireで囲むことでアンラップすることができます。
もし囲んだ値が nil であった場合はテストが失敗します。

オプショナル値の確認を XCTest から Swift Testing へ移行は、以下公式ドキュメントでも解説されています。

#requireマクロはオプショナルの確認だけでなく、コンディションの確認にも使用できます。
#requireBool型の値を入力することで、falseであればテストが失敗するというような動作にもなります。

テストを失敗させたいとき

テストの失敗で任意のコメントを生成したい場合、
XCTest ではXCTFail()を使用できました。
Swift Testing ではIssue.record()を使います。

    @Test
    func 割り算は右辺左辺いずれも有限の数値であれば左辺を右辺で割った値を返す_3() throws {

        let result = #require(try Calculator.division(10, 2))

        if result.isNaN {

            Issue.record("結果がNanのためテスト失敗")
        }

        #expect(5 == result)
    }

上記の例では、Calculator.division(10, 2)の戻り値がNanの場合は
"結果が Nan だったから失敗した"とコメントを生成した上でテストを失敗させます。

テスト失敗した場合は、以下の様にIssue.recordの部分でも赤くなります。

image.png

使い心地は正直XCTFail()変わりませんでした。

Swift Testing のメリット・デメリット

ここまでで、ざっとSwift Testingの新しい機能であったりAPIの紹介をしてきました。
続いて、Swift Testingを導入する価値があるのかを考察するために、メリデメを考えていきます。

メリット

テストコードの可読性向上を促す仕組みが増えた

テストケース名の命名制約の撤廃と表示名のカスタマイズによる可読性向上

具体的にはまずテストケース名の命名に関する制約がなくなったのと、表示名を自由にカスタマイズできる様になったことで、テストケース名の表現力が増した点です。(テストスイート名も)
テストケース名がテストの可読性に影響することは往々にしてあると思っており、
たとえばアンチパターンとして番号などテストケースの説明に寄与しない名前をつけるといったものがありますが、
これを行うとテストケースのコードを見ることでしか何を確認しているのかがわからなくなってしまいます。
開発者であれば、コード見ればわかるしいいじゃん!という意見もあると思いますが、開発者でも認知的負荷がかかることが避けられないのは事実です。
また、新規参画者などからしてみれば、慣れないコードということもあり認知的負荷はより高まり、テストコードからプロダクトの仕様を読み取るのは困難極まります。

@Test
func 左辺を2右辺を5にして掛け算をすると10を返す() throws {
    #expect(10 == Calculator.multiplication(2, 5))
}

@Test
func test_multiplication_1() throws {
    #expect(10 == Calculator.multiplication(2, 5))
}

上記テストコードを見比べたときに、前者の方が簡単に何を検証しているということがわかるかと思います。
これはテストケース名が検証したい振る舞いを自己説明できているからです。(つまりテストコードを見なくても、メソッド名で理解できる)

テストケース名がテストコードの可読性に影響することがわかってもらえた上で、
"テストケース名の命名に関する制約がなくなったのと、表示名を自由にカスタマイズできる様になったことで、テストケース名の表現力が増した点"
がどの様に可読性向上に貢献してくれるかを見てみます。

まずテストケース名が検証する振る舞いを自己説明しているべきという観点で見ると、テストケース名の頭にtestをつけても振る舞いの説明には寄与せずむしろ無駄な文字と言えます。(テストケースであることしか説明していない)
そう思うと、Swift Testing ではよりテストケース名を振る舞いを説明するという目的に特化させることができる様になるので、
その点で可読性の向上に寄与していると考えています。

表示名のカスタマイズでもテストケースの振る舞いを説明してくれる良いツールと考えられるという点で、可読性の向上に寄与していると思っています。

テストスイートでのグルーピングによる可読性の向上

テストスイートをネストさせたりすることで、テストスイートの階層構造が作れたりと、
Xcode の"Test navigator"タブの内容をかなり自由に表現できる様になりました。

このグルーピングがどのように可読性に寄与するかというと、
大きなテストスイートをより粒度の低いテストスイートの方が、このテストスイートは何を検証していくのかわかりやすいという性質に関連しています。

たとえば、以下は計算の振る舞いを検証するテストスイートがあり、その中に掛け算と割り算の振る舞いを検証するテストスイートがあります。

image.png

それとは対象に、計算の振る舞いというテストスイートのみの場合、計算とは何の計算(足し算?引き算?掛け算?割り算?)なのか、このテストスイートでは何を検証しているのかがテストケースを全て見て確認する必要があります。

image.png

上記を見比べると、計算という大きな粒度のテストスイートよりも粒度をさらに細かくしたテストスイートの方が、何を検証しようとしているのかわかりやすいです。
テストスイートの階層構造を作れることで、テストスイートの粒度を分割しやすくなったので、テストの可読性につながりやすいと考えています。

バリエーションテストのテストケースの見通し改善

Swift Testing がパラメタライズドテストをサポートしたことによって、バリエーションテストを行うときも
一つの振る舞いを検証するテストケースとして定義することができ、余計にテストケースを増やさずに済んだのでテストスイートの可読性に寄与していると思います。

XCTest でバリエーションテストを行う際に取れた手段として、
入力のバリエーションの数だけテストケースを書くか、
以下記事で紹介されているパラメタライズドテストを行うかかと思います。

ただ、前者の方法であればテストケースが多くなりすぎて、テストスイートを見た時にどんなテストが含まれているかやや見づらくなります。
後者の方法であれば、一つのテストケースでバリエーションを網羅することができるので、本質的な振る舞いを表現するテストケースだけが残りテストスイートの可読性がより増すと考えています。

XCTest でもパラメタライズドテストが行えるので、Swift Testing になったからってバリエーションテストのテストケースの見通しという観点で特別可読性の向上してないと思われるかもですが、
以下理由によりSwift Testingの方がより見通しは良くなるかと思います。

XCTest でパラメタライズドテストを行おうとすると、テストケースメソッド内でバリエーションのパラメータを用意することになるため、
メソッド内の準備フェーズ(AAA パターンの Arrange)が肥大化してメソッド内の見通しが悪くなってしまいがちかと思います。
それとは対照的に、Swift Testing では@Testマクロの引数としてパラメータを指定することができメソッド内部の処理と切り離せるので、検証処理が肥大化することなく実装できます。
その点で、XCTest よりも Swift Testing の方がより可読性の高いバリエーションテストをかけるようになっているのです。

導入のハードルの低さ

フレームワークの導入は一般的にハードルが高いものですが、Swift Testing においてはかなりハードルが低くなっています。
まず Xcode16 からは標準モジュールとして使えるようになっているし、プロジェクト内で XCTest ですでにテストケースを実装していた状態でも
Swift Testing は問題なく動きます。
例えば、新しい Feature からは Swift Testing を導入するといった、部分的な導入も行えるのです。

デメリット

パラメタライズドテストの乱用によるテストケースの可読性低下のリスク

メリットにパラメタライズドテストが可読性向上に寄与すると言っておいて、
デメリットとしてパラメタライズドテストが可読性低下のリスクにもつながるという一見矛盾した話になります。

ここで言いたいのは、上手く使えば可読性向上に寄与するが、使い方を間違えると可読性が下がると言うことです。(何事もそうかもなのですが、、)

可読性が低くなるパターンとして、
一つの振る舞いに対して正常系、無効系、異常系全てを網羅するようなバリーションをパラメタライズドテストで定義するようなケースがあります。

たとえば、掛け算の振る舞いを検証するときに、有限の数字を入力する正常系と Nan や無限数を入力する異常系を同じテストケースで表現しようとすると、以下のようになります。

    @Test(
        "掛け算は左辺*右辺の値を返す。左辺右辺どちらかが有限の数でないもしくはNanの場合は例外を返す。",
        arguments: [
        (2, 5, 10),
        (0, 5, 0),
        (5, 0, 0),
        (Double.nan, 5, nil),
        (5, Double.nan, nil),
        (Double.infinity, 5, nil),
        (5, Double.infinity, nil)
    ])
    func 掛け算は左辺と右辺を掛けた値を返す(
        right: Double,
        left: Double,
        expected: Double?
    ) throws {

        if let expected {

            let result = try #require(try Calculator.multiplication(right, left))
            #expect(expected == result)
        }
        else {

            #expect(throws: CalculateError.self, performing: {

                let _ = try Calculator.multiplication(right, left)
            })
        }
    }

こうなると以下のような理由で可読性が下がると考えています。

  • どの入力値なら正常なのか、異常なのかわかりずらい(上記は簡単なケースだからまだマシだけど、実務に落とし込むときついと思う)
  • 複数の振る舞い(正常系、異常系)が含まれることになるので、必然的にケース名も複雑になるしメソッド内の処理も分岐が生まれたり複雑になる

パラメタライズドテストが便利であるがゆえに、ついつい使いたくなってしまいますが、
検証するのは一つの振る舞いとすることを意識して使用しないと、
上記例のようにむしろ可読性が下がってしまうこともありますので注意です。

パラメタライズドテストの落とし穴についても、以下書籍を参考にしています。
https://www.amazon.co.jp/%E5%8D%98%E4%BD%93%E3%83%86%E3%82%B9%E3%83%88%E3%81%AE%E8%80%83%E3%81%88%E6%96%B9-%E4%BD%BF%E3%81%84%E6%96%B9-Vladimir-Khorikov/dp/4839981728

コマンドラインログの整形ツールに対応していないものがある

CI/CD を運用する時にxcodebuildでビルドしたりテストしたりしますが、そのまま実行しちゃうととんでもない量のログを吐くので、
xcprettyといったログ整形ツールを噛ませると言うのはあるあるだと思います。

ただxcprettyは並列テストをサポートしていないため、デフォルトが並列テストの Swift Testing とは相性が悪く、直列で実行されるように設定しないとテスト結果が表示されません。
https://devcenter.bitrise.io/ja/testing/testing-ios-apps/running-xcode-tests.html

並列テストをサポートしているxcbeautifyであれば上手くいくかと思いましたが、パラメタライズドテストのテスト結果だけは出力してくれませんでした。

テストケース一覧
image.png

テスト時にxcbeautifyを噛ませたログ

% xcodebuild -project SwiftTestingSample.xcodeproj -scheme SwiftTestingSampleTests -sdk iphonesimulator -configuration Debug -destination "platform=iOS Simulator,OS=18.2,name=iPhone 16 Pro" -scmProvider xcode test | xcbeautify
・・・省略
Testing started
Test Suite ParameterizedSampleTest started on 'Clone 1 of iPhone 16 Pro - SwiftTestingSample (68135)'
Test Suite CalculatorTest started on 'Clone 1 of iPhone 16 Pro - SwiftTestingSample (68135)'
Test Suite AssertionSampleTest started on 'Clone 1 of iPhone 16 Pro - SwiftTestingSample (68135)'
Test Suite AssertionSampleTest/StoreTest started on 'Clone 1 of iPhone 16 Pro - SwiftTestingSample (68135)'
Test Suite AssertionSampleTest/CalculatorTest started on 'Clone 1 of iPhone 16 Pro - SwiftTestingSample (68135)'
Test Suite ParameterizedSampleTest/CalculatorTest started on 'Clone 1 of iPhone 16 Pro - SwiftTestingSample (68135)'
Test Suite AssertionSampleTest/CalculatorTest/DivisionTest started on 'Clone 1 of iPhone 16 Pro - SwiftTestingSample (68135)'
Test Suite ParameterizedSampleTest/CalculatorTest/MultiplicationTest started on 'Clone 1 of iPhone 16 Pro - SwiftTestingSample (68135)'
    ✔ [AssertionSampleTest/StoreTest] 表示すると表示イベントを出力する on 'Clone 1 of iPhone 16 Pro - SwiftTestingSample (68135)' (0.002 seconds)
    ✔ [AssertionSampleTest/CalculatorTest/DivisionTest] 割り算は右辺左辺いずれも有限の数値であれば左辺を右辺で割った値を返す_2 on 'Clone 1 of iPhone 16 Pro - SwiftTestingSample (68135)' (0.002 seconds)
    ✔ [AssertionSampleTest/CalculatorTest/DivisionTest] 割り算は右辺左辺いずれも有限の数値であれば左辺を右辺で割った値を返す on 'Clone 1 of iPhone 16 Pro - SwiftTestingSample (68135)' (0.014 seconds)
    ✔ [AssertionSampleTest/CalculatorTest/DivisionTest] 割り算は左辺が有限の数値でなければ例外を返す on 'Clone 1 of iPhone 16 Pro - SwiftTestingSample (68135)' (0.019 seconds)
    ✔ [CalculatorTest] 掛け算は左辺と右辺を掛けた値を返す on 'Clone 1 of iPhone 16 Pro - SwiftTestingSample (68135)' (0.018 seconds)
    ✔ [CalculatorTest] 割り算は右辺が0の場合はnilを返す on 'Clone 1 of iPhone 16 Pro - SwiftTestingSample (68135)' (0.018 seconds)
    ✔ [CalculatorTest] 割り算は右辺が0でなければ左辺を右辺で割った値を返す on 'Clone 1 of iPhone 16 Pro - SwiftTestingSample (68135)' (0.018 seconds)
    ✔ [AssertionSampleTest/CalculatorTest/DivisionTest] 割り算は右辺が有限の数値でなければ例外を返す on 'Clone 1 of iPhone 16 Pro - SwiftTestingSample (68135)' (0.020 seconds)
    ✔ [CalculatorTest] 掛け算は右辺に0があると左辺の値に関わらず0を返す on 'Clone 1 of iPhone 16 Pro - SwiftTestingSample (68135)' (0.016 seconds)
    ✔ [CalculatorTest] 掛け算は左辺に0があると右辺の値に関わらず0を返す on 'Clone 1 of iPhone 16 Pro - SwiftTestingSample (68135)' (0.018 seconds)

実際のテストケースと、ログを見比べるとわかるように、ParameterizedSampleTest(パラメタライズドテストを集めたテストスイート)のみログで表示されていないです。

結論

基本的に実戦でも導入する価値がある。
CI/CDなどの運用において、テスト結果のレポート出力などの都合上Swift Testingのパラメタライズドテストは使えないとなる場合は、パラメタライズドテストだけXCTestで実装することもできます。
Swift Testingを導入して困ることは基本無く(困るところはXCTestのままで良いので)、メリットを受ける点が大きいというのが最終的な気持ちです。

まとめ

今後のアプリ開発ではSwift Testingを積極的に導入していこうと思いました。
テストの考え方など、書籍をインプットに自分の考えを書いているところもあるので、こっちの方がいいのではといった意見もございましたら、ぜひご教授いただけますと幸いです!

1
2
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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?