はじめに
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を使用する
import Testing
@Test(arguments: 0 ... 12) func inputIsGreaterThanTenTest(value: Int) {
#expect(value < 10)
}
require
nilの場合にテストを失敗させたい場合は#requireを使用する
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)")
}
}
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のコンパイラが並行性の安全性を強化できるようです。
struct StructTest {
@Test func structTest() {}
}
actor ActorTest {
@Test func actorTest() {}
}
テスト名のカスタマイズ
@Test属性に文字列リテラルを引数として指定することで、IDEやコマンドラインに表示されるテスト関数の名前をカスタマイズすることができます。
@Test("xxxテスト") func xxxTest() {}
@Test func yyyTest() {}
テストのサブグループ
Suite属性に準拠した型のネスト内でさらにSuite属性に準拠した型を定義することで、テストをサブグループとして形成できます。
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:)を利用することで実現することができます。
@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) }
}
}
パラメタライズテスト
引数を変えることで同一テストの反復実行を行うことができるので1メソッドで多くのテストケースを網羅することができます。
単一のパラメータでパラメタライズテストしたい場合
以下のようにargumentsにパラメータを指定することで反復してテストを実施することができます。
import Testing
@testable import <ProjectName>
@Test(arguments: ["りんご", "オレンジ", "バナナ"])
func occursForSpecificFruitsTest(name: String) {
#expect(name == "オレンジ")
}
複数のパラメータでパラメタライズテストしたい場合
以下のようにargumentsに複数のパラメータを渡して反復テストを行うこともできます。
@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)'")
}
}
複数のパラメータでパラメタライズテストしたい場合(改善版)
前のセクションの例では、@Test属性のargumentsに複数のパラメータを渡してテストを実施していました。しかし、パラメータが増えるとメンテナンスが難しくなり、可読性も低下してしまいます。
そこで、構造体を使ってテストケースを管理する方法を採用することにしました。
これにより、@Test属性のargumentsに構造体を渡すことで、各テストケースの内容が明確に定義され、テストの内容がよりわかりやすくなります。
変更点
- ①構造体を作成する
- ②構造体を使ってテストケースを管理する
- ③@Test属性のargumentsにテストケースを渡すように変更する
@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)'")
}
}
テストの利用制限
新しいバージョンのOSや特定のSwiftバージョンでのみ実行したい場合、属性をテスト関数の宣言時に使用することで、テストの実行を制限することができます。
import Testing
@available(swift, introduced: 5.9, message: "Swift5.9以上でないとテストを実行できません")
@Test func test() { /* ... */ }
@Suite
複数のテストを管理したい場合などに@Suiteをつけることでテストをグループ化することができます。
例えばログイン画面のメールアドレス、パスワードのバリデーションのチェックをテストしたい場合などのケースがあると思いますが以下のように@Suiteをつけることでテストをまとめることができます。
@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
}
但し、struct, actor, class内に@Testをつけた関数が含まれている場合は、自動的にテストスイートとして扱われるため、明示的に属性を付ける必要はないようです。
以下の例はstruct ValidationTestsにて@Suiteをつけなかった場合の例となります
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
}
@Suiteを明示的につけていませんがテストを実行することができます。
Traits
テストに特製をつけたり表現力向上や制約の追加に活用することができます。
Traitsを使うことで柔軟なテストを実装することができます。
.comment Trait
@Test属性のTrait引数として指定することで、コメントを追加することができます。
CI等で失敗時にログを残すことができます。
@Test(
.comment("failCommentTestに失敗しました。テストが失敗するとXcode上やCI上に表示される")
)
func failCommentTest() {
let text = "Hello"
#expect(text == "Hello!")
}
.enable Trait
特定の条件の場合にテストを有効にすることができます。
enableはif文を使って実行可否を制御することができます
private var condition1 = false
private var condition2 = true
@Test(
.enabled(if: condition1, "テストがスキップされた場合はこのコメントが記録される"),
.disabled(if: condition2, "こちらのコメントは記録されない")
)
func combinedTest() {}
.bug Trait
.bug Traitを使うことで特定のバグを再現、または修正の検証とテストを関連付けることは非常に有用です。
.bug()では第二引数としてBug.Relationshipを指定することがで、バグとテストの関係性を明示することが可能となります。
@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を選択できるようになっています。
導入手順
新規プロジェクト作成の場合
既存のプロジェクトに追加する場合
2.new TargetでTesting Sysystemを Swift Testingを選択する(デフォルト)
Xcode15.2以下のバージョンで導入する場合
- Xcode15ではSwift Testingは導入されていないのでSPMで導入する必要があるのでPackage.swiftに依存を追加してください
2. 以下内容のScaffolding.swiftをPackageのTest Target内に追加する
Xcode15系では、Swift Package Managerに統合されていませんが、Swiftのパッケージで本ライブラリを使用し始めたい開発者のための一時的な仕組みが提供されています。
パッケージのテストターゲットにScaffolding.swiftという新しいSwiftのソースファイルを追加することで導入することができます。
import XCTest
import Testing
final class AllTests: XCTestCase {
func testAll() async {
await XCTestScaffold.runAllTests(hostedBy: self)
}
}
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
import Testing
@Test func helloWorldTest() {
let greeting = "Hello, world!"
#expect(greeting == "Hello, world!")
}
XCTest
import XCTest
class HelloWorldXCTest: XCTestCase {
override func setUp() {}
override func tearDown() {}
func testHelloWorld() {
let greeting = "Hello, world!"
XCTAssertEqual(greeting, "Hello, world!")
}
}
サンプル2:非同期処理の場合
Swift Testing
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
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アプリの開発を行っています。 アプリ開発に興味がある方は是非、採用ページをご確認ください