Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

「swift-testingはじめました」 Quick/Nimbleからの置き換えの最初の一歩

「swift-testingはじめました」 Quick/Nimbleからの置き換えの最初の一歩

potatotips#88での登壇資料になります。

WWDC2024で「swift-testing」に関するセッションが公開されていた経緯や、同僚が個人開発で利用していた事から関心を持ち、自分が携わるiOSプロジェクト内でも導入してみる前段の調査内容をまとめた資料になります。

自分もこれまではQuick/Nimbleを活用してUnitTestを記載した経験の方が長かったです。元々Rails等にも触れた経験もあったのでRSpecに近い形には慣れていた事もありましたが、この機会にできる所から徐々に書き換えにトライしてみました。

そもそもの書き方が変化する事は想定できましたが、取り組み始めた際に「実はこの部分が少し苦労した」という点をご紹介できればと考えています。
(※今回は元々のコードを愚直に置き換える場合を想定しています)

Fumiya Sakai

July 05, 2024
Tweet

More Decks by Fumiya Sakai

Other Decks in Technology

Transcript

  1. 自己紹介 ・Fumiya Sakai ・Mobile Application Engineer アカウント: ・Twitter: https://twitter.com/fumiyasac ・Facebook:

    https://www.facebook.com/fumiya.sakai.37 ・Github: https://github.com/fumiyasac ・Qiita: https://qiita.com/fumiyasac@github 発表者: ・Born on September 21, 1984 これまでの歩み: Web Designer 2008 ~ 2010 Web Engineer 2012 ~ 2016 App Engineer 2017 ~ Now iOS / Android / sometimes Flutter
  2. 自分が携わるiOSプロジェクト内で始めて導入してみました 最初は試験的な導入ではありますが現在も本格利用すべく試行錯誤中です。 まずは「swift-testing」を利用する事に加えて、既存UnitTestを置き換える事に焦点を当てています。 1. Combineを利用した形であっても「swift-testing」を利用したUnitTestを試してみる: async/awaitをこれから本格的に導入を試していく中で、まずは「swift-testing」に触れながら、試していこうと感じました。 またWWDC2024の発表で正式に追加された発表があった事により、今後はこちらをより利用できるのでは?と感じました。 2. 元々は使い慣れたQuick/Nimbleを利用した形のUnitTestを利用していました: 自分もこれまではQuick/Nimbleを活用してUnitTestを記載した経験の方が長かったです。元々Rails等にも触れた経験もあったの

    でRSpecに近い形には慣れていた事もありましたが、この機会にできる所から徐々に書き換えにトライしてみました。 3. Quick/Nimbleからの書き換えを試した際の所感について: そもそもの書き方が変化する事は想定できましたが、取り組み始めた際に「実はこの部分が少し苦労した」という点をご紹介で きればと考えています。(※今回は元々のコードを愚直に置き換える場合を想定しています)
  3. Combine&Quick/Nimbleの構成からの移行にトライ そのままではCombineのUnitTestがしづらいので、その点を上手く補う Combineを利用したUnitTestを便利にするライブラリ: https://github.com/groue/CombineExpectations CombineExpectations: https://github.com/pointfreeco/combine-schedulers combine-schedulers: 今後の展望: 1. Combine処理は今後async/awaitへ置き換えていく予定

    2. Quick/Nimbleで元々記述されているUnitTestも置換する予定 参考: iOSDC Japan 2023の原稿内容 https://github.com/fumiyasac/iosdc2023_pamphlet_manuscript_vol2 Publisherで流れてくる値の履歴を取得可能にする 3. Combine処理を補うライブラリ依存も徐々に減らしてく予定
  4. 簡単なViewModel処理例とQuick/NimbleでのUnitTest(1) シンプルな処理をする様なViewModelの事例とUnitTestを考えてみます final class NewsViewModel: ObservableObject // MEMO: APIリクエストを実行してお知らせデータ一覧の取得をするメソッド func

    fetchNewsItems() { api.getNewsItems() .sink(receiveCompletion: { completion in switch completion { case .finished: // MEMO: APIから値取得処理が「成功」した際に実行される部分 case .failure(let error): // MEMO: APIから値取得処理が「失敗」した際に実行される部分 } }, receiveValue: { [weak self] hashableObjects in // 👉 お知らせデータ一覧が取得できた場合 // SwiftUI側でも利用する`@Published`で定義した変数へ反映する self?.newsItem = hashableObjects }) .store(in: &cancellables) } } private let api: APIRequestManagerProtocol private var cancellables: [AnyCancellable] = [] // MARK: - NewsViewModelOutputs // 👉 `@Published`で定義した変数をそのままOutputとして利用する // (1) SwiftUI製のView要素に直接Bindして利用する場合もあります。 // (2) アクセス修飾子はBindするSwiftUI要素に合わせて変更する場合もあります。 // ※この値を利用した「Computed Property」を別途定義して利用する場合もあります。 @Published private(set) var newsItem: [NewsItem] = [] // MARK: - Initializer init(api: APIRequestManagerProtocol) { // MEMO: 適用するAPIリクエスト用の処理 self.api = api } APIからニュース一覧情報を取得する様な想定:
  5. 簡単なViewModel処理例とQuick/NimbleでのUnitTest(2) シンプルな処理をする様なViewModelの事例とUnitTestを考えてみます Quick/Nimbleを利用したUnitTest例: describe("#fetchNewsItems") { // 期待する初期値 & 実行後に期待する値を定義する let

    emptyItems: [NewsItem] = [] let expectedNextItems: [NewsItem] = [ NewsItem(id: 1, title: "夏祭り2023開催のお知らせ", category: "イベント"), NewsItem(id: 2, title: "美味しい餃子の販売開始", category: "新商品"), NewsItem(id: 3, title: "美味しいプリンの販売開始", category: "新商品") ] // 👉 NewsViewModelをインスタンス化する際に、想定するAPIリクエストのMockを適用 // ※補足: func getNewsItems() -> AnyPublisher<[NewsItem], Never> を想定 let sut = NewsViewModel(api: APIRequestManagerMock()) // CombineExpectationを利用してViewModel内の変数:newsItemの変化を記録する想定 // 👉 変数:newsItemで`@Published`を利用するのでこの値変化を記録対象とする var newsItemsRecorder: Recorder<[NewsItem], Never>! // context 〜 it の流れで検証したいケースを作成する } context("API通信処理が成功した場合") { // 👉 UnitTest実行前後で実行する処理 beforeEach { newsItemsRecorder = sut.$newsItem.record() } afterEach { newsItemsRecorder = nil } // 値変化を記録対象とする変数が、期待した変化をすること確認する it("初期値:emptyItemsの内容 → 実行後:expectedNextItems となること") { // 👉 ViewModel内処理を実行 sut.fetchNewsItems() // 0.16秒間の変化を見て、期待した値変化となることを確認する let newsItemsRecorderResult = try! self.current .wait(for: newsItemsRecorder.availableElements, timeout: 0.16) expect(newsItemsRecorderResult) .to(equal([emptyItems, expectedNextItems])) } } final class NewsViewModelTest: QuickSpec
  6. こちらを「swift-testing」で書き直す時はどうする? シンプルなテストケースからまずは「形」を素直に作ってみる所から始める ①基本の形を見比べてみる 既存のQuick/Nimbleでの記述と比べ て具体的に何がどう変化するのか? ②Matcher処理部分 使い慣れたMatcherとの類似点・相 違点はあるか? ③テストケース記述の変化 テスト自体を一目でわかり易い形す

    る工夫(given/when/thenの形)。 (注意)Xcode15で動作させる場合: import XCTest import Testing final class AllTests: XCTestCase { func testAll() async { await XCTestScaffold.runAllTests(hostedBy: self) } } Xcode15で「swift-testing」を利用する際にはこの記述が必要になる。 段階的に少しずつ置き換えていく想定をした場合なのと自分がまだお試し段階なのでまずは置き換えに注目 ※ swift-testingとXCTestは共存する事が可能
  7. 実際に前述したCombineのテストを書き換えてみる swift-testingとCombineExpectationsの利用でも何とかできそうでした // 期待する初期値 & 実行後に期待する値を定義する let emptyItems: [NewsItem] =

    [] let expectedNextItems: [NewsItem] = [ NewsItem(id: 1, title: "夏祭り2023開催のお知らせ", category: "イベント"), NewsItem(id: 2, title: "美味しい餃子の販売開始", category: "新商品"), NewsItem(id: 3, title: "美味しいプリンの販売開始", category: "新商品") ] // 👉 NewsViewModelをインスタンス化する際に、想定するAPIリクエストのMockを適用する // ※補足: func getNewsItems() -> AnyPublisher<[NewsItem], Never> を想定 let sut = NewsViewModel(api: APIRequestManagerMock()) // CombineExpectationを利用してViewModel内の変数:newsItemの変化を記録するようにしたい // 👉 変数:newsItemで`@Published`を利用しているのでこの値変化を記録対象とする var newsItemsRecorder: Recorder<[NewsItem], Never>! @Test @MainActor func fetchNewsItemsSuccess() throws { // 👉 ViewModel内処理を実行 sut.fetchNewsItems() // 👉 変数:newsItemが変化した記録を確認する // (1) availableElements.get()を利用して内容を取得する let newsItemsRecorderResult = try newsItemsRecorder.availableElements.get() // (2) 変化の内容がArrayで記録されていること // 初期値:emptyItems → 実行後:expectedNextItems #expect(newsItemsRecorderResult == [emptyItems, expectedNextItems]) } swift-testingを使って(強引に)書き換えた例: @Suite final class NewsViewModelTest init() { newsItemsRecorder = sut.$newsItem.record() } deinit { newsItemsRecorder = nil } UnitTestに必要なProperty定義群 UnitTestの本丸になる部分はここ @Suite class (or struct or actor) ~ @Test func … の様な形を取ります
  8. Quick/Nimbleからswift-testingへ書き換えのヒント(1) 両方のドキュメントから基本形は結構似ている点がありそうだという所感 私はこの様な感じになるかな?と考えました: Quick / Nimble 利用時 swift-testing 利用時 final

    class NewsViewModelTest: QuickSpec @Suite final class NewsViewModelTest init() { … } deinit { … } describe("#fetchNewsItems") { … context("API通信処理が成功した場合") { … it("初期値:emptyItemsの内容 → 実行後:expectedNextItems") { … } } } beforeEach { … } afterEach { … } @Test @MainActor func fetchNewsItemsSuccess() throws { … } 事前準備・後始末 事前準備・後始末 ① describeを入れ子にする際は@Suite内にクラスや構造体を準備 UnitTestを構築する際のポイント: ② テストに必要な変数・Mock・Stub等を定義する点は似ている ※(describe) or (context)に該当する
  9. Quick/Nimbleからswift-testingへ書き換えのヒント(2) 検証項目自体の組み立てはそれ程大きな変化はなくMatcherも使いやすくなった itに該当する部分はそれ程変化はない&Matcherはシンプル(より複雑なテストで要検証): Quick / Nimble 利用時 swift-testing 利用時 final

    class NewsViewModelTest: QuickSpec @Suite final class NewsViewModelTest // 👉 ViewModel内処理を実行 sut.fetchNewsItems() // 👉 変数:newsItemが変化した記録を確認する // (1) availableElements.get()を利用して内容を取得する let newsItemsRecorderResult = try! self.current.wait( for: newsItemsRecorder.availableElements, timeout: 0.16) // (2) 変化の内容がArrayで記録されていること // 初期値:emptyItems → 実行後:expectedNextItems expect(newsItemsRecorderResult).to(equal([emptyItems, expectedNextItems])) // ※重要※ @MainActor内でこのテストを実行 // 👉 ViewModel内処理を実行 sut.fetchNewsItems() // 👉 変数:newsItemが変化した記録を確認する // (1) availableElements.get()を利用して内容を取得する let newsItemsRecorderResult = try newsItemsRecorder.availableElements.get() // (2) 変化の内容がArrayで記録されていること // 初期値:emptyItems → 実行後:expectedNextItems #expect(newsItemsRecorderResult == [emptyItems, expectedNextItems]) 🌾 self.current.wait(…)は不要かも? https://swiftpackageindex.com/apple/swift-testing/main/documentation/testing/migratingfromxctest ※ XCTestとのMatcher比較:
  10. 書き換える際に少し悩んでしまった部分 Quick/NimbleにあるSharedExample & itBehavesLike(it部分の共通化)機能 1. shared_examples: itの部分ないしはcontext〜itの部分を共通化して名前をつけて管理する。 2. it_behaves_like: shared_examplesで共通化したテストコードを呼び出す。該当呼び出したいshared_examplesにつけた名前を付与する。

    RSpec.shared_examples 'start_wonderful_event' do it '何かすごいイベントが始まる' do # expect ... で期待する結果を記載する end end describe 'Wonderful Event' do let(:event) { create(:event) } # 個別にitを書くのではなく定義したshared_examplesを呼び出す it_behaves_like 'start_wonderful_event' end ※ 異なるテストケースではあるけれども、比較項目を共通化してまとめている場合がある。その際にはどうするか?と考えてみる。 Ruby on RailsのRSpecに同じ機能がある
  11. SharedExample&itBehavesLikeを手っ取り早く置き換え sharedExamples("イルカが食べるもの") { (sharedExampleContext: @escaping SharedExampleContext) in it("イルカが食べて幸せになること") { //

    … ※ itの処理を共通化する処理 … } } final class DolphinSpec: QuickSpec { override func spec() { describe("イルカ🐬 さんが食べて幸せになるものを調べるテスト") { // sharedExamplesを利用してitの部分を共通化する // 👉 Closureでテスト対象のものを引き渡した後に期待する答えか否かを確かめる // テスト対象のcontext部分を組み立てる context("鯖🐟 さんを食べる場合") { itBehavesLike("イルカが食べるもの") { Mackerel() } } context("鱈🐟 さんを食べる場合") { itBehavesLike("イルカが食べるもの") { Cod() } } } } } ① shearedExampleの部分はprivateメソッドでまとめる 共通化する部分に関するアプローチ例: ② 場合によっては引数をClosureにしてみる @Suite final class NewsDolphinTest { @Test @MainActor func checkDolphinEatsMackerel() { // … UnitTestを実行 checkDolphinEatsFish(fish: Mackerel()) } @Test @MainActor func checkDolphinEatsCod() { // … UnitTestを実行 checkDolphinEatsFish(fish: Cod()) } private func checkDolphinEatsFish(fish: Edible) { // … ※ itの処理を共通化する処理 … } }