inSmartBank

B/43を運営する株式会社スマートバンクのメンバーによるブログです

Swift Macrosを活用してテストデータ生成および保守のコストを削減する

こんにちは、スマートバンクでアプリエンジニアをしている ロクネム です。

弊社の開発する 家計簿サービス「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年以上その環境下でテストを書いていましたが非常に快適でした。

実践!「みてね」における自動生成活用例 (https://speakerdeck.com/ushisantoasobu/shi-jian-mitene-niokeruzi-dong-sheng-cheng-huo-yong-li?slide=22)

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メソッドが自動生成されるため、テストデータ生成周りで修正が不要になります。

💡 Productionのコードでも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メソッドを自動生成することでさらに生成にかかるコストを削減できる

本記事が少しでもみなさんの参考になりましたら幸いです。


スマートバンクではアプリエンジニアを積極採用中です🔥

興味のある方は下記の採用リンクから奮ってエントリーをお願いします!

smartbank.co.jp

smartbank.co.jp

カジュアル面談も受け付けておりますので、気になるけれどいきなり選考はちょっと…という方はぜひご応募いただけると嬉しいです。

smartbank.co.jp

We create the new normal of easy budgeting, easy banking, and easy living.
In this blog, engineers, product managers, designers, business development, legal, CS, and other members will share their insights.