XCTestからSwiftTestingへ:モダンなiOSテスト手法とBDDによる仕様書化
株式会社ココナラアプリ開発グループ、iOSチームの上田です。
今回はココナラのiOSアプリのテストについてご紹介したいと思います。
1. はじめに
iOSアプリ開発において、テストは品質保証の重要な柱です。
Appleは長年XCTestフレームワークを提供してきましたが、Swift言語の進化に合わせて、より表現力豊かでモダンなテストフレームワーク「SwiftTesting」が登場しました。
今回は、XCTestからSwiftTestingへの移行方法と、ViewModelのテストをBDD(Given-When-Then)アプローチで実装することで、テストが仕様書としても機能する方法を解説します。
普段の開発でこんな経験はありませんか?
- サービスの成長に伴い、レガシー機能の仕様書が存在しないか、最新状態に維持されていない
- 既存機能の改修時に仕様書がなく、アプリケーションの動作確認やソースコードから仕様の確認をする必要がある
- 非可視的な処理(バックグラウンド処理)を見落とし、重要なKPI計測用のログ出力やイベント発火が実装から漏れるリスクがある
テストの仕様書化は、テストとしてサービスの品質を担保するだけでなく、後から参加したメンバーの仕様書としても機能し、上記の課題を解決する事ができます。
2. SwiftTestingとは何か
SwiftTestingは、Swift言語の特性を活かした新しいテストフレームワークです。
XCTestとの主な違いは、マクロベースの構文とSwiftの型システムをフル活用した設計にあります。
swift
// XCTestの例
func testUserLogin() {
XCTAssertTrue(viewModel.isLoggedIn)
}
// SwiftTestingの例
@Test("ユーザーログインが成功する")
func userLoginSucceeds() {
#expect(viewModel.isLoggedIn)
}
SwiftTestingへの移行により、より読みやすく、型安全なテストコードを書くことができます。
また、マクロベースの設計により、ボイラープレートコードを減らし、テストの意図をより明確に表現できます。
SwiftTestingで用意されているマクロや関数などの機能についてはこの記事では取り扱いません。
公式サイトや、SwiftTestingについて分かりやすくまとめられている記事がありますので、こちらを参考にしてください。
3. ココナラのテストについて
ココナラのiOSチームではViewModelのテストにBDD手法を取り入れています。
BDD(Behavior-Driven Development)は、システムの振る舞いを自然言語に近い形で記述するアプローチで、Given -> When -> Thenの流れでテストコードを記述します。
Given-When-Thenの構造は、以下の要素で構成されます:
- Given: テストの前提条件
- When: テスト対象のアクション
- Then: 期待される結果
func testSample() {
// Given: テストの前提条件
⚫︎⚫︎の時に
// When: テスト対象のアクション
▲▲したら
// Then: 期待される結果
XXとなっていること
}
ViewModelの設計ポイント
ViewModelのInputを、ユーザーアクションやViewライフサイクルに対応させています。
class HogeViewModel: ObservableObject {
/// 画面を表示した時
func onAppear() {
}
/// 「次へ」をタップした時
func onTapNext() {
}
/// ログイン通知を受け取った時
func onReceiveLoginNotification(notification: Notification) {
}
/// その他子コンポーネントのActionやDelegateなど
}
このような設計により、「ユーザーが画面を表示したとき」というような、実際のアプリ使用シナリオに基づいたテストを書くことができます。
Mock/Stubの用意
Mock, Stubの生成はMockoloを利用しています。
ここでは詳細な使い方は省きますが、Mock, Stubを用意したいprotocolに@mockable
アノテーションを付与して、生成コマンドを実行することでMock,Stubを生成する事が可能です。
/// @mockable
protocol RegisterUseCaseProtocol {
...省略
}
/// コマンドを実行すると自動生成される
public class RegisterUseCaseProtocollMock: RegisterUseCaseProtocol {
...省略
}
4. XCTest時代のテストコード
会員登録時の「個人」「法人」選択画面を例に、まずはXCTestのコードを紹介します。
![]() |
![]() |
この画面の仕様やVMの実装、XCTestは以下のアコーディオンからご確認下さい。
画面仕様
※ テスト紹介のため、実際の仕様とは異なり、簡素化しています。
- onAppear: 画面を表示した時
- ログを送信する
- 「次へ」は非活性
- onTapIndividual: 「個人」をタップした時
- 「個人」が選択状態になる
- 「次へ」が活性化する
- onTapCorporation: 「法人」をタップした時
- 「法人」が選択状態になる
- 「次へ」が活性化する
- onTapNext: 「次へ」をタップした時
- APIを叩き、成功であれば次の画面へ遷移
- APIを叩き、失敗したらエラーアラートを表示
ViewModelの実装
※ テスト紹介のため、実際の実装とは異なり、簡素化しています。
class RegisterOrganizationViewModel: ObservableObject {
// MARK: - DI
private let registerUseCase: RegisterUseCaseGRPCProtocol
private let bigQueryUseCase: BigQueryUseCaseProtocol
// MARK: - Output
@Published var currentSelection: EntryFormOrganizationType?
@Published var isLoading = false
@Published var alert: AlertModel?
@Published var isShownAlert = false
@Published var isShownBasicInfoScreen = false
var isEnabledNextButton: Bool {
currentSelection != nil && !isLoading
}
// MARK: - Init
init(registerUseCase: RegisterUseCaseGRPCProtocol, bigQueryUseCase: BigQueryUseCaseProtocol) {
self.registerUseCase = registerUseCase
self.bigQueryUseCase = bigQueryUseCase
}
// MARK: - Input
/// 画面表示時
func onAppear() {
// ログを送信
bigQueryUseCase.addUserActionLog()...省略
}
/// 「個人」をタップした時
func onTapIndividual() {
currentSelection = .individual
}
/// 「法人」をタップした時
func onTapCorporation() {
currentSelection = .corporation
}
/// 「次へ」をタップした時
func onTapNext() {
registerUseCase.updatePrivateCorporation()...省略
// - APIを叩き、成功であれば次の画面へ遷移: isShownBasicInfoScreenをtrueに
// - APIを叩き、失敗したらエラーアラートを表示
}
}
XCTestを使ったテストコード
※ テスト紹介のため、実際の実装とは異なり、簡素化しています。
...省略
import XCTest
class RegisterOrganizationViewModelTests: XCTestCase {
private var viewModel: RegisterOrganizationViewModel!
private var registerUseCaseMock: RegisterUseCaseGRPCProtocolMock!
private var bigQueryUseCaseMock: BigQueryUseCaseProtocolMock!
override func setUp() {
super.setUp()
registerUseCaseMock = RegisterUseCaseGRPCProtocolMock()
bigQueryUseCaseMock = BigQueryUseCaseProtocolMock()
viewModel = RegisterOrganizationViewModel(
registerUseCase: registerUseCaseMock,
bigQueryUseCase: bigQueryUseCaseMock
)
}
/// 画面を表示した時
func testOnAppear() {
XCTContext.runActivity(named: "画面表示時、ログを送信し、「次へ」ボタンが非活性であること") { _ in
// Given
bigQueryUseCaseMock.addUserActionLogHandler = { log in
// 個人法人画面表示のログのActionが正しいこと
XCTAssertEqual(log.action, "期待するAction名")
return .empty()
}
// When
viewModel.onAppear() // 画面を表示した時
// Then
XCTAssertEqual(bigQueryUseCaseMock.addUserActionLogCallCount, 1) // ログを送信していること
XCTAssertFalse(viewModel.isEnabledNextButton) // 「次へ」ボタンが非活性であること
}
}
/// 「個人」をタップした時
func testOnTapIndividual() {
XCTContext.runActivity(named: "「個人」をタップした時、個人が選択状態になり、「次へ」が活性化されていること") { _ in
// Given
// When
viewModel.onTapIndividual() // 「個人」をタップ
// Then
XCTAssertEqual(viewModel.currentSelection, .individual) // 個人が選択状態になっていること
XCTAssertTrue(viewModel.isEnabledNextButton) // 「次へ」ボタンが有効化されていること
}
}
/// 「法人」をタップした時
func testOnTapCorporation() {
XCTContext.runActivity(named: "「法人」をタップした時、個人が選択状態になり、「次へ」が活性化されていること") { _ in
// Given
// When
viewModel.onTapCorporation()
// Then
XCTAssertEqual(viewModel.currentSelection, .corporation) // 法人が選択されていること
XCTAssertTrue(viewModel.isEnabledNextButton) // 「次へ」ボタンが有効化されていること
}
}
/// 「次へ」をタップした時
func testOnTapNext() {
XCTContext.runActivity(named: "個人を選択中に「次へ」をタップしたら、登録APIを叩き、基本情報画面に遷移すること") { _ in
// Given
viewModel.currentSelection = .individual
registerUseCaseMock.updatePrivateCorporationHandler = { organization in
// 「個人」でリクエストされていること
XCTAssertEqual(organization, .individual)
return .just(API_RESPONSE_MODEL()) // API通信に成功
}
// When
viewModel.onTapNext()
// Then
XCTAssertEqual(registerUseCaseMock.updatePrivateCorporationCallCount, 1) // 登録APIを叩いていること
XCTAssertTrue(viewModel.isShownBasicInfoScreen) // 基本情報画面に遷移すること
}
setUp()
XCTContext.runActivity(named: "法人を選択中に「次へ」をタップしたら、登録APIを叩き、基本情報画面に遷移すること") { _ in
// Given
viewModel.currentSelection = .corporation
registerUseCaseMock.updatePrivateCorporationHandler = { organization in
// 「法人」でリクエストされていること
XCTAssertEqual(organization, .corporation)
return .just(API_RESPONSE_MODEL()) // API通信に成功
}
// When
viewModel.onTapNext()
// Then
XCTAssertEqual(registerUseCaseMock.updatePrivateCorporationCallCount, 1) // 登録APIを叩いていること
XCTAssertTrue(viewModel.isShownBasicInfoScreen) // 基本情報画面に遷移すること
}
setUp()
XCTContext.runActivity(named: "登録APIでエラーが発生した場合、エラーダイアログが表示されること") { _ in
// Given
viewModel.currentSelection = .individual
registerUseCaseMock.updatePrivateCorporationHandler = { organization in
return .error(ErrorModel()) // API通信に失敗
}
// When
viewModel.onTapNext()
// Then
XCTAssertEqual(registerUseCaseMock.updatePrivateCorporationCallCount, 1) // 登録APIを叩いていること
XCTAssertTrue(viewModel.isShownAlert) // アラートが表示されていること
XCTAssertFalse(viewModel.isShownBasicInfoScreen) // 基本情報画面に遷移しないこと
}
}
}
生成されたテストレポート
onTapNext内の各テストケースが詳細を開かないと確認できない。
XCTestでのテストコードは、Inputごとにtest関数を定義し、Input内で複数条件が分岐する場合はその関数内でXCTContext.runActivity()
でテストを分けるようにしていました。
こうすることで、以下のようなメリットがあると思っています。
- InputがViewライフサイクルやユーザーアクションで定義されていることで、ユーザー行動ベースでテストを実装することができる
- Inputごとに1つのテスト関数を用意するので、その関数を見ればそのInputにどのような機能があるかを判断できる
- どのような操作をした時にどうなっているべきなのかが明確なので、実装者以外が見た時も仕様を把握しやすい(レビューしやすい)
ただし、以下のデメリットもあります。
-
testOnTapNext()
内のテストが直列で実行されるのでテスト時間が長くなる - 途中でテストに失敗した場合、後続の
XCTContext.runActivity()
のテストが実行されなくなってしまう - Input内でXCTContextによってテストケースが変わる場合に、
setUp()
の呼び出しを忘れると一つ前のテスト結果が引き継がれてしまう
SwiftTesting移行では今の構成(メリット)を保ちつつ、デメリットも解消できる方法を検討しました。
5. SwiftTesting移行後の実装
ココナラではXCTestからSwiftTestingに移行するにあたって、同じInputでも複数条件があるテストをどのように書くかという点で悩みましたが、Suiteを使い一部構成を変更することで対応しました。
...省略
import Testing
@Suite
@MainActor
struct RegisterOrganizationViewModelTests {
// Point1
static func setUp() -> (
viewModel: RegisterOrganizationViewModel,
registerUseCaseMock: RegisterUseCaseGRPCProtocolMock,
bigQueryUseCaseMock: BigQueryUseCaseProtocolMock
) {
let registerUseCaseMock = RegisterUseCaseGRPCProtocolMock()
let bigQueryUseCaseMock = BigQueryUseCaseProtocolMock
let viewModel = RegisterOrganizationViewModel(
registerUseCase: registerUseCaseMock,
bigQueryUseCaseMock: bigQueryUseCaseMock
)
return (viewModel, registerUseCaseMock, bigQueryUseCaseMock)
}
@Test("onAppear: 画面表示時、ログを送信し、「次へ」ボタンが非活性であること")
func onAppear() {
// Given
// Point2
let (viewModel, registerUseCaseMock, bigQueryUseCaseMock) = RegisterOrganizationViewModelTests.setUp()
bigQueryUseCaseMock.addUserActionLogHandler = { log in
#expect(log.action == "期待するAction名", "個人法人画面表示のログのActionが正しいこと")
return .empty()
}
// When
viewModel.onAppear() // 画面を表示した時
// Then
#expect(bigQueryUseCaseMock.addUserActionLogCallCount == 1, "ログを送信していること")
#expect(!viewModel.isEnabledNextButton, "「次へ」ボタンが非活性であること")
}
@Test("onTapIndividual: 「個人」をタップした時、個人が選択状態になり、「次へ」が活性化されていること")
func onTapIndividual() {
// Given
let (viewModel, _, _) = RegisterOrganizationViewModelTests.setUp()
// When
viewModel.onTapIndividual() // 「個人」をタップ
// Then
#expect(viewModel.currentSelection == .individual, "個人が選択状態になっていること")
#expect(viewModel.isEnabledNextButton, "「次へ」ボタンが有効化されていること")
}
@Test("onTapCorporation: 「法人」をタップした時、個人が選択状態になり、「次へ」が活性化されていること")
func onTapCorporation() {
// Given
let (viewModel, _, _) = RegisterOrganizationViewModelTests.setUp()
// When
viewModel.onTapCorporation()
// Then
#expect(viewModel.currentSelection == .corporation, "法人が選択されていること")
#expect(viewModel.isEnabledNextButton, "「次へ」ボタンが有効化されていること")
}
// Point3
@Suite("onTapNext: 「次へ」をタップした時")
@MainActor
struct onTapNext {
// Point4
@Test("個人を選択中に「次へ」をタップしたら、登録APIを叩き、基本情報画面に遷移すること")
func 個人を選択中に次へをタップしたら登録APIを叩き基本情報画面に遷移すること() {
// Given
let (viewModel, registerUseCaseMock, _) = RegisterOrganizationViewModelTests.setUp()
viewModel.currentSelection = .individual
registerUseCaseMock.updatePrivateCorporationHandler = { organization in
#expect(organization == .individual, "「個人」でリクエストされていること")
return .just(API_RESPONSE_MODEL()) // API通信に成功
}
// When
viewModel.onTapNext()
// Then
#expect(registerUseCaseMock.updatePrivateCorporationCallCount == 1) // 登録APIを叩いていること
#expect(viewModel.isShownBasicInfoScreen) // 基本情報画面に遷移すること
}
@Test("法人を選択中に「次へ」をタップしたら、登録APIを叩き、基本情報画面に遷移すること")
func 法人を選択中に次へをタップしたら登録APIを叩き基本情報画面に遷移すること() {
// Given
let (viewModel, registerUseCaseMock, _) = RegisterOrganizationViewModelTests.setUp()
viewModel.currentSelection = .corporation
registerUseCaseMock.updatePrivateCorporationHandler = { organization in
#expect(organization == .corporation, "「法人」でリクエストされていること")
return .just(API_RESPONSE_MODEL()) // API通信に成功
}
// When
viewModel.onTapNext()
// Then
#expect(registerUseCaseMock.updatePrivateCorporationCallCount == 1, "登録APIを叩いていること")
#expect(viewModel.isShownBasicInfoScreen, "基本情報画面に遷移すること")
}
@Test("登録APIでエラーが発生した場合、エラーダイアログが表示されること")
func 登録APIでエラーが発生した場合エラーダイアログが表示されること() {
// Given
let (viewModel, registerUseCaseMock, _) = RegisterOrganizationViewModelTests.setUp()
viewModel.currentSelection = .individual
registerUseCaseMock.updatePrivateCorporationHandler = { organization in
return .error(ErrorModel()) // API通信に失敗
}
// When
viewModel.onTapNext()
// Then
#expect(registerUseCaseMock.updatePrivateCorporationCallCount == 1, "登録APIを叩いていること")
#expect(viewModel.isShownAlert, "アラートが表示されていること")
#expect(!viewModel.isShownBasicInfoScreen, "基本情報画面に遷移しないこと")
}
}
}
SwiftTesting移行では主に4つのポイントがあります。
Point1, Point2: ViewModelやMockの初期化を変更
XCTestの時はviewModelやMockをプロパティとして定義し、setUp()
で初期化するようにしていましたが、SwiftTestingではプロパティとして持つのではなく、テストケース内で定義するようにしました。
// Point1
static func setUp() -> (
viewModel: RegisterOrganizationViewModel,
registerUseCaseMock: RegisterUseCaseGRPCProtocolMock,
bigQueryUseCaseMock: BigQueryUseCaseProtocolMock
) {
let registerUseCaseMock = RegisterUseCaseGRPCProtocolMock()
let bigQueryUseCaseMock = BigQueryUseCaseProtocolMock
let viewModel = RegisterOrganizationViewModel(
registerUseCase: registerUseCaseMock,
bigQueryUseCaseMock: bigQueryUseCaseMock
)
return (viewModel, registerUseCaseMock, bigQueryUseCaseMock)
}
@Test("onAppear: 画面表示時、ログを送信し、「次へ」ボタンが非活性であること")
func onAppear() {
// Given
// Point2
let (vm, registerUseCaseMock, bigQueryUseCaseMock) = RegisterOrganizationViewModelTests.setUp()
...省略
}
SwiftTestingでもinit()
関数を使えば、XCTestのsetUp()
と同様にテスト実行時に処理を実行することが可能です。
しかし、@Suiteでstructをネストした時に、ネストしたStructのテストの実行時に親のinit()
が呼ばれない問題がありました。
また、本来ViewModelの初期化や依存先の設定などはsetUp()
で行わずにテストケースごとに定義するのが適切という考えがあったため、SwiftTestingではテストケースごとに必要なオブジェクトを初期化するように変更しました。
Point3: 一つのInputに複数テストケースがある場合は@Suiteで階層化
@Suiteで階層化することにより、そのInputの各条件をstructにまとめる事が可能です。
// Point3
@Suite("onTapNext: 「次へ」をタップした時")
@MainActor
struct onTapNext() {
...省略
}
また、XCTestの時は一つの関数内でXCContext.runActivity()
を使ってテストケースを分けていましたが、@Suite内で各テストケースを@Test
を付与した関数を用意することで、XCTest時のデメリットであった以下を解消しています。
-
testOnTapNext()
内のテストが直列で実行されるのでテスト時間が長くなる- 各@Testが並列で実行されるように
- 途中でテストに失敗した場合、後続の
XCTContext.runActivity()
のテストが実行されなくなってしまう- @Testごとに独立したテストとなるため、テストが失敗したとしても他のテストは実行される
- Input内でXCTContextによってテストケースが変わる場合に、
setUp()
の呼び出しを忘れると一つ前のテスト結果が引き継がれてしまう- @TestごとにViewModelやMockを初期化するので、テスト結果が引き継がれない様に
Point4: @Suite内の複雑な条件のメソッド名は日本語で
// Point4
@Test("個人を選択中に「次へ」をタップしたら、登録APIを叩き、基本情報画面に遷移すること")
func 個人を選択中に「次へ」をタップしたら登録APIを叩き基本情報画面に遷移すること() {
}
@Suiteが付与されたstructの命名を見ればどのInputのテストを行っているかが分かるため、@Suite内の@Testは日本語で命名するルールにしました。
あとは、複雑な条件を英語で命名することが大変なのも理由の一つです。
生成されたテストレポート
Suiteを使った階層化により、onTapNext内の各テストケースも確認する事ができました。
6. パラメタライズテストについて
SwiftTestingではパラメタライズテストをサポートしています。
例えばonTapNext()
のTestはパラメタライズテストを使って以下のように記述することが可能です。
protocol TestCase {
associatedtype Given
associatedtype Then
var comment: Comment { get }
var given: Given { get }
var then: Then { get }
}
struct OnTapNextTestCase: TestCase {
struct Given {
let currentSelection: EntryFormOrganizationType // 個人・法人の選択状態
let hasError: Bool // API通信がエラーになったか
}
struct Then {
let organization: EntryFormOrganizationType // APIに送信する個人・法人の状態
let isShownBasicInfoScreen: Bool // 次の画面に遷移したか
let isShownAlert: Bool // エラーアラートが表示されたか
}
let comment: Comment
let given: Given
let then: Then
}
static let onTapNextTestCases: [OnChangeAmountTestCase] = [
.init(
comment: "個人を選択中に「次へ」をタップしたら、登録APIを叩き、基本情報画面に遷移すること",
given: .init(currentSelection: .individual, hasError: false),
then: .init(organization: .individual, isShownBasicInfoScreen: true, isShownAlert: false)
),
.init(
comment: "法人を選択中に「次へ」をタップしたら、登録APIを叩き、基本情報画面に遷移すること",
given: .init(currentSelection: .corporation, hasError: false),
then: .init(organization: .corporation, isShownBasicInfoScreen: true, isShownAlert: false)
),
.init(
comment: "登録APIでエラーが発生した場合、エラーダイアログが表示されること",
given: .init(currentSelection: .individual, hasError: true),
then: .init(organization: .individual, isShownBasicInfoScreen: false, isShownAlert: true)
)
]
@Test("onTapNext: 「次へ」をタップした時", arguments: onTapNextTestCases)
func onTapNext(testCase: OnTapNextTestCase) {
// Given
viewModel.currentSelection = .individual
registerUseCaseMock.updatePrivateCorporationHandler = { organization in
expect(organization == testCase.then.organization)
return testCase.given.hasError ? .error(ErrorModel()) : .just(API_RESPONSE_MODEL())
}
// When
viewModel.onTapNext()
// Then
#expect(registerUseCaseMock.updatePrivateCorporationCallCount == 1, "登録APIを叩いていること")
#expect(vm.isShownBasicInfoScreen == testCase.then.isShownBasicInfoScreen)
#expect(vm.isShownAlert == testCase.then.isShownAlert)
}
ViewModelにパラメタライズテストは最適ではないかも
ViewModelでも使おうと思えばパラメタライズテストで記述することができますが、以下の理由からViewModelのテストで使うのは最適ではないと考えています。
- テストコードにロジックが入ってしまう
- パラメタライズでテストケースで個別にハンドリングする処理がテストしにくい
具体的には以下の様なケース
// APIがエラーか成功かをgivenの値によって変えている
return testCase.given.hasError ? .error(ErrorModel()) : .just(API_RESPONSE_MODEL())
画面が複雑になり、用意するGivenやThenが大きくなるにつれ、上記のようなロジックが増えていき、テスト自体も書きづらくなってきます。
このパラメタライズ化までをルールとして定義してしまうと、テストケースによってはテストケースの実装自体が大変になってしまいます。
ココナラでは一つのInputに複数条件がある場合は、基本は@Suiteを使ってそれぞれテストケースを実装し、可能であればパラメタライズテストで書くというルールにしました。
パラメタライズテストが有効なケース
個人的にViewModelのパラメタライズ化は使えなくはないが最適ではないという判断で、以下のようなテストではパラメタライズテストがとても有効に機能すると考えています。
- 値を受け取って表示するViewComponent
- Model
- UseCase(バリデーションなど)
今回はViewModelに絞っているので、こちらのテストについてはまた別の機会で共有できればと思います。
最後に
今回はココナラのテスト構成について解説致しました。
ココナラでは、XCTestからSwiftTestingへの移行で、よりモダンな書き方になるだけでなく、XCTest時代の負債も解消する事ができました。
BDDアプローチとの組み合わせにより、テストコードが読みやすくなり、仕様書としての価値も高まります。
今回紹介した内容が、何か一つでも参考になれば幸いです。
ココナラでは、一緒に事業のグロースを推進していただける様々な領域のエンジニアを募集しています。iOSアプリ開発だけでなく、フロントエンド領域・バックエンド領域などでも積極的にエンジニア採用を行っています。少しでも興味を持たれた方がいましたら、エンジニア採用ページをご覧ください。
Discussion