こんにちは、スマートバンクでアプリエンジニアをしている ロクネム です。
弊社の開発する 家計簿サービス「B/43」 のiOSアプリでは、ドメイン・データ層では単体テストを、UI層ではXcode Previewsを書いて小さい範囲ですぐに動作を確認できるようにしています。
単体テストやPreviewを書く上で必要になるのがテストデータです。 テストを実装する上では、必要な値を指定してオブジェクトを生成し、テストデータとして利用する場面が多くあることと思います。
弊社ではこのテストデータの生成および保守に課題感を持っていました。
テストデータの生成・保守への課題感
ある日、B/43アプリの中でも中心的なModelに一つのpropertyを追加するPRを作成しました。
本来であれば数ファイルの影響範囲に留まるはずですが、テストデータをあらゆるファイルで初期化していたため変更の入ったファイルは十数ファイルにのぼりました。
プロジェクトの規模が大きくなるにつれて、テストおよびPreviewの数が増え、property追加時の修正コストが次第に肥大化していくことが予想され、少しずつ課題感を持ち始めました。
解決策: Builderパターン
これを防ぐために、データ生成にBuilderパターンを採用するという方法を考えました。
Builderパターンとは、GoFの23個のデザインパターンのうちの一つで、同じ作成過程で異なる表現形式の結果を得るためのパターンとされています。
Swiftで書くと以下のようになります。
struct Person { let name: String let age: Int let address: Address let hobby: String? init( name: String, age: Int, address: Address, hobby: String? ) { self.name = name self.age = age self.address = address self.hobby = hobby } struct Builder { var name: String = "" var age: Int = 0 var address: Address = Address.Builder().build() var hobby: String? func build() -> Person { Person( name: name, age: age, address: address, hobby: hobby ) } } } let person = Person.Builder(name: "rockname").build()
もっとも、Swiftではデフォルト引数を使用できるので、以下のようにより簡潔に書けます。
struct Person { let name: String let age: Int let address: Address let hobby: String? init( name: String, age: Int, address: Address, hobby: String? ) { self.name = name self.age = age self.address = address self.hobby = hobby } static func build( name: String = "", age: Int = 0, address: Address = .build(), hobby: String? = nil ) -> Person { Person( name: name, age: age, address: address, hobby: hobby ) } }
Builderパターンを用いることで、データ初期化時にすべてのpropertyの値を渡す必要がなくなります。
テストやPreviewでデータを生成する際にこのBuilderを使用することで、呼び出し側では必要最低限のpropertyのみを設定するだけで済み、propertyの追加があった場合の修正範囲はBuilderのみに留めることができます。
さらに、テストケースのシナリオにおいてどの引数が重要であるかを強調できるようになるという効果もあります。
var person = Person.build(name: "rockname") person.changeName(to: "rockname2") XCTAssertEqual(person.name, "rockname2")
自分の前職で開発に携わっていた 家族アルバム みてね においても同様の取り組みが導入されていて、約2年以上その環境下でテストを書いていましたが非常に快適でした。
Builderメソッドを毎回書くコストへの課題感
テストデータの生成にはBuilderメソッドを使用しましょう!とチームで導入してからしばらく経ち、新たな課題感が生まれました。
それは、Builderメソッドを書くのが非常にめんどくさいというものです。
毎回テストやPreviewを書く際にModelの持つpropertyをすべて引数に持つBuilderメソッドを手で書くのはなかなかに辛い作業です。
先述のみてねにおける事例においても自動生成が活用されていたので、弊社でも同様の対応を考えました。
解決策: Swift Macros
Builderメソッドを自動生成するツールとして Swift Macros を採用しました。
Swift MacrosはSwift 5.9で導入された機能で、コンパイル時にボイラープレート的なコードを自動生成することが可能です。
B/43では @Buildable
Macroを実装し、struct等のinitializerに付与することでBuilderメソッドの自動生成を実現しました。 (実装については後述)
struct Person { let name: String let age: Int let address: Address let hobby: String? @Buildable init( name: String, age: Int, address: Address, hobby: String? ) { self.name = name self.age = age self.address = address self.hobby = hobby } // 🤖 expanded static func build( name: String = "", age: Int = 0, address: Address = .build(), hobby: String? = nil ) -> Person { Person( name: name, age: age, address: address, hobby: hobby ) } }
各型に対応するデフォルト値はルールベースで決まったものを生成するようにしています。
ex) String → “”
/ Optional → nil
/ … / その他 → .build()
任意のデフォルト値を指定したい場合はMacroの引数にて指定します。
struct LocalDate { let year: Int let month: Int let day: Int @Buildable(["year": 1970, "month": 1, "day": 1]) init(year: Int, month: Int, day: Int) { self.year = year self.month = month self.day = day } // 🤖 expanded static func build( year: Int = 1970, month: Int = 1, day: Int = 1 ) -> LocalDate { LocalDate( year: year, month: month, day: day ) } }
このようにBuilderメソッドをMacroで自動生成することで、たとえ新しくpropertyが増えてもそれに対応したBuilderメソッドが自動生成されるため、テストデータ生成周りで修正が不要になります。
お気づきの方もいらっしゃるかと思いますが、この
@Buidable
はProductionのコードからも参照可能であるため、誤って本番で不正なデータを使用してしまう可能性を抱えています。
対応案として、自動生成されたBuilderメソッドを #if DEBUG
で囲んでしまうのはどうかと考えましたが、Previewを書く際に毎回 #if DEBUG
を書くことの煩わしさや、デモアプリにおけるダミーデータの生成にも対応できない点から見送りました。
代替案として採用されたのが「命名でカバー」です。 @BuildableForTesting
/ func buildForTesting
のように明らかにProductionで使っちゃいけない感のある命名にすることで対応することとしました。
@Buildable
Macroの実装
Swift Macrosには大きく2種類があります:
- Freestanding Macros: 単独で呼び出すことができるMacro
- Attached Macros: 特定の宣言に関連づけて呼び出されるMacro
今回の要件としては、struct等のinitializerに関連づけてMacroを呼び出す必要があるので、 Attached Macrosとして実装しています。
Attached Macrosの中にも用途に応じていくつか種類が用意されていますが、 @Buildable
では関連づけられた宣言とBulderメソッドを並列に生成するため、 Peer Macros として実装しています。
以下、実装の要点を抜粋してご紹介します:
宣言モジュール
// Buildable/Buildable.swift @attached(peer, names: arbitrary) public macro Buildable( _ defaultValues: [String: Any] = [:] // ✅ デフォルト値を引数で受け取る ) = #externalMacro( module: "BuildableMacros", // ✅ Macroの実装モジュールを指定 type: "BuildableMacro" )
実装モジュール
// BuildableMacros/BuildableMacros.swift @main struct BuildableMacros: CompilerPlugin { let providingMacros: [Macro.Type] = [ BuildableMacro.self ] }
// BuildableMacros/BuildableMacro.swift struct BuildableMacro: PeerMacro { static let builderName = "build" public static func expansion( of node: AttributeSyntax, providingPeersOf declaration: some DeclSyntaxProtocol, // ✅ Macroが付与された宣言 in context: some MacroExpansionContext ) throws -> [DeclSyntax] { if let initializer = declaration.as(InitializerDeclSyntax.self) { // ✅ initializerに対応するBuilderメソッドを生成 let builderFunction = try InitializerBuilderFunctionGenerator( node: node, initializer: initializer ).generate() return [DeclSyntax(builderFunction)] } else if let enumCase = declaration.as(EnumCaseDeclSyntax.self) { // 💡 enumのcaseにも対応している (詳細は割愛) ... } else { // 🚨 期待している宣言以外にMacroが付与された場合はエラーを返す throw DiagnosticsError(diagnostics: [ Diagnostic( node: node, message: BuildableDiagnostic.unexpectedDeclaration ) ]) } } }
// BuildableMacros/InitializerBuilderFunctionGenerator.swift struct InitializerBuilderFunctionGenerator { private let node: AttributeSyntax private let initializer: InitializerDeclSyntax init( node: AttributeSyntax, initializer: InitializerDeclSyntax ) { self.node = node self.initializer = initializer } func generate() throws -> FunctionDeclSyntax { // ✅ initializerの引数を整形 let parameters = initializer.signature.parameterClause.parameters.map { parameter in Parameter( name: parameter.firstName.trimmed, type: parameter.type.trimmed ) } // ✅ Macroの引数で指定されたデフォルト値を取得 (詳細は割愛) let defaultValues = try BuilderDefaultValueArgumentsParser(node: node, parameters: parameters).parse() // ✅ Failable initializerの場合は戻り値をOptionalにする let returnType: TypeSyntaxProtocol = if initializer.optionalMark != nil { OptionalTypeSyntax(wrappedType: IdentifierTypeSyntax(name: .keyword(.Self))) } else { IdentifierTypeSyntax(name: .keyword(.Self)) } // ✅ Builderメソッドの組み立て return FunctionDeclSyntax( modifiers: DeclModifierListSyntax { // ✅ 元々指定されていた `public` 等のaccess modifierを引き継ぐ for modifier in initializer.modifiers { modifier } // ✅ `static` を付与 DeclModifierSyntax(name: .keyword(.static)) }, name: .identifier(BuildableMacro.builderName), signature: FunctionSignatureSyntax( parameterClause: FunctionParameterClauseSyntax( parameters: FunctionParameterListSyntax { for parameter in parameters { FunctionParameterSyntax( leadingTrivia: .newline, firstName: parameter.name, type: parameter.type, // ✅ Macroの引数で指定されたデフォルト値があれば設定し、 // なければあらかじめ用意している各型に対応したデフォルト値を設定 defaultValue: InitializerClauseSyntax( value: defaultValues[parameter.name.text] ?? BuilderDefaultValueMapper().getDefaultValue(for: parameter.type) ) ) } }, rightParen: .rightParenToken(leadingTrivia: .newline) ), // ✅ initに付与された `async` `throws` を引き継ぐ effectSpecifiers: initializer.signature.effectSpecifiers, returnClause: ReturnClauseSyntax(type: returnType) ) ) { // ✅ Builderメソッドのbodyにおけるinitializerの呼び出し let functionCall = FunctionCallExprSyntax( calledExpression: DeclReferenceExprSyntax(baseName: .keyword(.Self)), leftParen: .leftParenToken(), arguments: LabeledExprListSyntax { for (index, parameter) in parameters.enumerated() { LabeledExprSyntax( leadingTrivia: .newline, label: parameter.name, colon: .colonToken(), expression: DeclReferenceExprSyntax(baseName: parameter.name), trailingComma: index == parameters.count - 1 ? nil : .commaToken() ) } }, rightParen: .rightParenToken(leadingTrivia: .newline) ) // ✅ initに付与された `async` `throws` に応じて呼び出し方を調整 let throwsSpecifier = initializer.signature.effectSpecifiers?.throwsSpecifier let asyncSpecifier = initializer.signature.effectSpecifiers?.asyncSpecifier switch (throwsSpecifier, asyncSpecifier) { case (.none, .none): functionCall case (.some, .none): TryExprSyntax(expression: functionCall) case (.none, .some): AwaitExprSyntax(expression: functionCall) case (.some, .some): TryExprSyntax(expression: AwaitExprSyntax(expression: functionCall)) } } } }
// BuildableMacros/BuilderDefaultValueMapper.swift struct BuilderDefaultValueMapper { // ✅ 各型に対応するデフォルト値 private let mapping: [String: ExprSyntax] = [ "String": "\"\"", "Int": "0", "Bool": "false", ... ] func getDefaultValue(for type: TypeSyntax) -> ExprSyntax { if type.kind == .optionalType { ExprSyntax(NilLiteralExprSyntax()) } else if type.kind == .implicitlyUnwrappedOptionalType { ExprSyntax(NilLiteralExprSyntax()) } else if type.kind == .dictionaryType { "[:]" } else if type.kind == .arrayType { "[]" } else if let defaultValue = mapping[type.trimmedDescription] { defaultValue } else { // ✅ 上記のどの分岐にも該当しない場合は `.build()` を返す ".\(raw: BuildableMacro.builderName)()" } } }
まとめ
- テストデータの生成・保守のコストはプロジェクトの規模に比例して大きくなる
- Builderパターンによるテストデータ生成はテストに必要なpropertyのみを指定することができるため、生成および保守のコストを低減できる
- Swift MacrosでBuilderメソッドを自動生成することでさらに生成にかかるコストを削減できる
本記事が少しでもみなさんの参考になりましたら幸いです。
スマートバンクではアプリエンジニアを積極採用中です🔥
興味のある方は下記の採用リンクから奮ってエントリーをお願いします!
カジュアル面談も受け付けておりますので、気になるけれどいきなり選考はちょっと…という方はぜひご応募いただけると嬉しいです。