Swiftの構造体のFixtureを自動生成するOSSを自作してみた
🎄 Qiita の Swift Advent Calendar 2024 の 16 日目を担当させていただきます @ruwatana です 🙂
TL;DR
先日、Swift の構造体 (struct) の Fixture を自動生成できる OSS 「Swifixture」を公開しました!
非常に薄いライブラリですが、開発に至ったモチベーションや、作成過程、使用技術、そして現状の使い所や課題といったあたりを覚えているうちに開発手記として残しておこうと思い、執筆しています。
今回初めて自身で OSS を作成・公開したのですが、Swift 製の OSS ってそもそもどうやって作るんだろう?という興味がある人にも何かしら参考になれば幸いです 🙏
Swifixture について
モチベーション
最近、そこそこの規模のアプリをフルスクラッチで業務委託にて開発するという機会があり、その中で、テスト実装の煩雑さに課題を感じることが多く、その課題解決をしたかったのが主なモチベーションです。
日頃採用しているアーキテクチャについて
筆者がよく用いるアーキテクチャがこの OSS のユースケースに密接に関係しているため説明します。
3 層のレイヤード構造で UI 層・ドメイン層・データ(インフラ)層という形を採用することが多いです。
理由としては、それぞれの責務におけるテスタビリティが大きく向上し、高いテストカバレッジを比較的容易に実現できるという点が大きいからです。
下記の図は Android 公式の アプリ アーキテクチャガイドを拝借しておりますが、今回の iOS プロジェクトにおいてもこれとほぼ同等の構成になっています。
optional と記述されている Domain Layer に関しては実装レスにしていて、 Foundation ライブラリのみを import した純粋な構造体 (struct) としての Model と、それらを I/O とした Data Layer にあたる Repository 実装のインターフェース (protocol) という定義のみの最小構成をとっています。
簡単にツリー構造で表現すればこのようなイメージです。
% tree
.
├── Data
│ └── Repository
│ └── UserRepositoryImpl.swift
├── Domain
│ ├── Model
│ │ └── User.swift
│ └── Repository
│ └── UserRepository.swift
└── UI
└── Feature
└── Home
├── HomeView.swift
└── HomeViewModel.swift
ユニットテスト実装のつらみ
さて、このようなアーキテクチャを採用すると、非常に多くのファイルにテストをカバレッジさせることが容易になりますが、それゆえにテスト実装だけでも膨大な量になるので一つ一つの実装コストというつらみがいくつか発生します。
モックを自作するのがつらい
レイヤー構成にすると冗長なテスト実装を省略するため、境界をモックしたくなってきます。
例えば、 ViewModel のテストで Repository のインターフェースを実装したテスト用のモッククラスを DI するのはよくありますが、地味に大変です。
愚直にモックを実装すると、下記のようになります。
インターフェース定義が変更されるたびにモックも修正をしなければならないため、変更に脆いです。
処理の中身を柔軟に変更できるだけでなく、例えばコールされたカウントやセットされた引数のリストを記録するようなアサーションが必要な場合は、その実装自体も適宜メンテしなければなりません。
import Foundation
struct User {
id: Int
name: String
address: String?
}
protocol UserRepository: Sendable {
func fetch(id: Int) async throws -> User
}
final class UserRepositoryMock: UserRepository {
// 1つのメソッドのモックだけでもこれだけの実装が必要になる
private(set) var fetchCallCount = 0
private(set) var fetchArgs = [Int]()
var fetchHandler: ((Int) async throws -> User)?
func fetch(id: Int) async throws -> User {
fetchCallCount += 1
fetchArgs.append(id)
if let fetchHandler {
return fetchHandler()
}
fatalError("mock of fetch() has not implemented")
}
}
ただ、この課題には既に素晴らしいソリューションが存在しています。
OSS の「uber/mockolo」は protocol の定義の Doc Comment に /// @mockable
と書いて実行コマンドを叩くだけで、この上記のモック実装と同等のものを自動的に生成してくれます! Xcode Project の Build Phase にコマンド実行を仕込めば、ビルドのたびに最新のモックを出力してくれます。
スタブを自作するのがつらい
モック自体の定義はできましたが、モックの返却値となるスタブの用意も厄介です。
逐一、テストのたびに全方向イニシャライザを使ってインスタンスを生成していると、プロパティが膨大な構造体では行数も嵩みます・・・
import Testing
@testable import App
struct HomeViewModelTest {
let userRepoMock: UserRepositoryMock
let viewModel: HomeViewModel
init() async throws {
userRepoMock = .init()
viewModel = .init(userRepo: userRepoMock)
}
@Test func onAppear() async throws {
userRepoMock.fetchHandler = { _ in
// Userのスタブをテストのたびにinitializeしないといけない...
User(id: ..., name: ..., address: ...)
}
}
}
ソリューション
ここでの解決策が Fixture です。
Fixrure は、テストにてよく用いられる言葉で、特にユニットテスト下においては、入力値やスタブ値などのデータ群の総称を指したりするようです。
すべてのフィールドに対するデフォルト引数がある Factory メソッド (= Fixture) を用意しておくことで、各テストで必要なフィールドのみを書き換えるだけでよくなり、テスト実装を簡潔にしてくれる働きがあります。
import Foundation
@testable import App
extension User {
static func fixture(
id: Int = 0,
name: String = "name",
address: String? = nil
) -> Self {
.init(
id: id,
name: name,
address: address
)
}
}
import Testing
@testable import App
struct HomeViewModelTest {
let userRepoMock: UserRepositoryMock
let viewModel: HomeViewModel
init() async throws {
userRepoMock = .init()
viewModel = .init(userRepo: userRepoMock)
}
@Test func onAppear() async throws {
userRepoMock.fetchHandler = { _ in
// 任意の値でよければ引数に何も指定しなくても良い
.fixture()
}
}
}
さて、この課題にも自動生成のソリューションが存在するかな?と思っていたのですが、ちょうどいい感じのものは見つかりませんでした。
Sourcery を使うソリューションは既にあり、素晴らしい!と思ったのですが、そもそも Sourcery 自体の導入が必要だったり、少し導入のコストがかかるように思えました。
そこで、上記のアーキテクチャにおける Model に対する Fixture の自動生成くらいであれば、割と Mockolo ライクな感じで実現できるのではと思い、開発に至りました。
OSS 開発
実現したいMVP要件
まずは、実現したい最低限の機能を下記として整理しました。
- 上述のレイヤードなアーキテクチャのモデルの役割のような、純粋な構造体に対して fixture の自動生成ができる
- Doc Comment に
/// @fixturable
というコメントをつけると自動生成できる - 整数型・文字列型・配列などの最低限のプリミティブな型に対するデフォルト値生成に対応する
- 2以外のカスタムな型などは、デフォルト値が .fixture() となる
- 任意の enum 型に関してもなんらかのデフォルト値を返却できる
- デフォルト値を任意の値に変更できるオプションがある
- Doc Comment に
- Swift Package にて依存を追加するだけで実行が可能
- Xcode Project の Build Phase に追加することで、ビルド時に自動生成をトリガーできる
- 任意の入出力指定やimport, @testable importを設定できるオプションがある
技術選定
上記機能要件からもわかるとおり、基本的に Mockolo にめちゃくちゃインスパイアされたので、そちらの使用技術も参考に、オプションなどコマンドラインツール全般を作るのを容易にしてくれる Swift Argument Parser 、および Swift ファイルの解析に便利な Swift Syntax といった OSS 2 つのみを使用して実現することにしました。
それ以外では、テストには最新の Swift でプリインとなった Swift Testing 、ホスティングには GitHub、CI には GitHub Actions を使用することにしました。
実装の流れ
詳細はコードベースを参照いただきたいですが、こんな流れで動いてますというのを書いておきます。
- Swift Package Manager にて実行可能ターゲットと依存ライブラリなどを定義
- コマンドラインツールとしてのエントリーポイントと引数を定義
- コマンドライン引数を解析して、ファイルパスを取得
a. バリデーションは Swift Argument Parser が全部よしなにやってくれます - ファイルパスを起点に、指定されたディレクトリ配下の全ての Swift ファイル名を再帰的に探索
- Swift ファイルの中身を読み込む
- 探索したファイルの中から、
/// @fixturable
というコメントがついた struct を抽出 - 抽出した struct のフィールドを解析して、それぞれのフィールドに対するデフォルト値を生成
a./// @fixturable(override: key = value)
設定があればその値を使用
b. String 型の場合は、他と識別しやすいよう引数名をそのままデフォルト値として使用
c. デフォルト値の生成に対応していない型の場合はネスト利用を想定し .fixture() を指定 - 生成したデフォルト値をもとに、fixture メソッドと extension のコードブロックを生成
- 生成したコードをソート・改行して体裁を整え、ヘッダーをつけて指定のファイルに保存
a. この時追加の import のオプションがあればそれもインポート
b. @testable import のオプションがあればそれもインポート
任意のデフォルト値を入れられる機能は、override
オプションを用意しました(これも Mockolo を参考にしています)。
これによって、デフォルト値サポート外の enum 型などでもデフォルト値を指定できたり、任意のクラスや構造体の初期値も指定可能になることを実現しています。
/// @fixturable(override: address = "address", role = .user)
struct User {
enum Role {
case admin
case user
}
let id: Int
let name: String
let address: String?
let role: Role
}
///
/// Generated by Swifixture
///
import Foundation
@testable import App
extension User {
static func fixture(
id: Int = 0,
name: String = "name",
address: String? = "address",
role: Role = .user
) -> Self {
.init(id: id, name: name, address: address, role: role)
}
}
現状の使い勝手と課題
初期段階の現在は、Foundation ライブラリの中でも特にプリミティブな型のみデフォルト値を用意する対応のみとしています。そのため、デフォルト設定ではプリミティブな型のみを持つ構造体に対してのみ使うことが可能というのが現状です。
下記のように whitelist にて愚直にデフォルト値を扱っております 😇
enum 型への対応として、開発当初は一つ目の case をデフォルト値として動的に持ってくることを実現したかったのですが、Swift Syntax はあくまでコードを解析するためのツールであり、コンパイラとしての機能などは持たないため、参照を辿っていくような処理を実現できず、 override 設定が必須というのが現状です。
(もしかしたら、リフレクションなど他のソリューションでもできるのかもしれませんが、その辺りいいアイデアお持ちの方いればぜひ教えてください 🙏)
また、UIKit や SwiftUI などを import した UI 用の構造体やクラスのモデルに対して使用するのは現状では非常に困難なため、今後どうしていくか検討していく必要があります。(こちらも、 override 設定を駆使すればできなくはないですがしんどいです 😓)
おわりに
このように、現状ではまだまだ機能も少なく課題もたくさんあるのですが、それでもユニットテストのための Fixture の自動生成が最低限できるようになったというのは、手動で作っていたのに比べると、テスト実装を非常に楽にしてくれるという大きなメリットを得られています。
Swifixture を Mockolo と合わせて利用すると、protocol の Mock から struct の Fixture まで一挙に自動生成できるようになり、テスト実装のコストを大きく下げることができると思います。
今後もより多くのユースケースに対応できるようにしていければと思います(Issue / PR 大歓迎です)!
最後までお読みいただきありがとうございました 🙇
良い年末年始をお過ごしください 🎄
Discussion