この記事は、「NEWT Product Advent Calendar 2024」Day16 および「iOS Advent Calendar 2024」Day23の記事となります。
こんにちは!「NEWT Product Advent Calendar 2024」16日目は、令和トラベル iOSエンジニアのRickが、EM miisanからバトンをもらい、「GraphQL Schemaから、Swiftコードのモックを自動生成する」について紹介します。ぜひ最後までお楽しみください!
はじめに
私が開発する海外旅行予約アプリ「NEWT(ニュート)」では、API SchemaにGraphQLを採用しており、GraphQLクライアントには apollo を利用しています。詳しくはこちらの記事をご覧ください。
NEWTのiOSアプリ開発では、GraphQL Schemaから生成されたSwiftの型を、Viewの描画やロジックに直接利用することがよくあります。そのため、Unit TestやXcodePreviewsでのUI確認、さらには開発全般において、APIレスポンスのモックが不可欠です。
本記事では、GraphQL Schemaで定義された型をSwiftの型としてモック化する方法について詳しく解説します。
環境(2024/12時点)
- Xcode 16.1
- apollo-ios : v1.15.3
- apollo-ios-codegen : v1.15.3
まずは、apollo-iosとapollo-ios-codegenを使用して、Schemaから生成されたSwift型をどのようにモック化できるか、基本的な手段を見ていきましょう。
apollo-ios-codegenを利用したモック生成方法
デフォルト設定では、Dictionaryを以下のように定義し、それをData型のInitializerに渡して初期化することになります。
let title = "Merry Christmas🎄" let data: [String: Any] = [ "data": [ "__typename": "Query", "christmas": [ "__typename": "Christmas", "mainImage": [ "__typename": "Image", "url": "christmas_tree_url", ], "title": title ] ] ] let model = try ChristmasQuery.Data(data: data) XCTAssertEqual(model.christmas.title, title)
Codegen時にOptionで
selectionSetInitializers
を指定すると、各プロパティを引数に取るInitializerを生成できます。(ref : Output options)
public struct ChristmasFragment: NEWTAPI.SelectionSet, Fragment { ・・・ public let __data: DataDict public init(_dataDict: DataDict) { __data = _dataDict } public static var __parentType: any ApolloAPI.ParentType { NEWTAPI.Objects.Christmas } public static var __selections: [ApolloAPI.Selection] { [ ・・・ ] } public var mainImage: MainImage { __data["mainImage"] } public var title: String { __data["title"] } public init( mainImage: MainImage, title: String ) { self.init(_dataDict: DataDict( data: [ "__typename": Objects.Christmas.typename, "mainImage": mainImage._fieldData, "title": title._fieldData, ], fulfilledFragments: [ ObjectIdentifier(ChristmasFragment.self) ] )) }
しかし、開発を進める上で、実際のUnit Testでは、特定のプロパティにのみ関心を持つケースが多くあります。Xcode Previewsで特定のパターンを確認する際も同様です。このような場合、結果に影響を与えない引数については、値を指定しなくても良い仕組みがあると便利です。
これに対して、型安全なモックを作成するための型と機能が提供されています。Codegen時に、configurationでモック用コードの生成先を指定することで実現できます(ref : codegen-configuration#file-input)。
・・・ let fileStructure = CustomFileStructure() ・・・ let codegenConfiguration = ApolloCodegenConfiguration( schemaNamespace: fileStructure.schemaNamespace, input: ApolloCodegenConfiguration.FileInput(schemaPath: fileStructure.schemeOutputURL.path), output: ApolloCodegenConfiguration.FileOutput( schemaTypes: ApolloCodegenConfiguration.SchemaTypesFileOutput( path: fileStructure.generatedFolderURL.path, moduleType: .other ), testMocks: .absolute(path: fileStructure.mockURL.path, accessModifier: .public) ) )
生成された型を利用することで、型安全にモックが可能になります。
let title = "Merry Christmas🎄" let mock = Mock<Query>( christmas: Mock<Christmas>( mainImage: Mock<MainImage>( url: "christmas_tree_url", ), title: title ) ) let model = ChristmasQuery.Data.from(mock) XCTAssertEqual(model.christmas.title, title)
しかし、このMock型はすべてのケースに適用できるわけではありません。では、どのような場合に問題が生じるのでしょうか?
apollo-ios-codegenを使ったモック生成で直面する問題
Fragmentは別の型として生成される
apollo-ios-codegenを使用してモックを生成すると、Mock型の型パラメーターに指定する型が生成され、これらはMockObjectに準拠しています。
// @generated // This file was automatically generated and should not be edited. import ApolloTestSupport public class Christmas: MockObject { public static let objectType: ApolloAPI.Object = Objects.Christmas public static let _mockFields = MockFields() public typealias MockValueCollectionType = Array<Mock<Christmas>> public struct MockFields { ・・・ @Field<Image>("mainImage") public var mainImage @Field<String>("title") public var title } } public extension Mock where O == Christmas { convenience init( ・・・, mainImage: Mock<Image>? = nil, mainImage: Mock<String>? = nil, ) { self.init() ・・・ _setEntity(mainImage, for: \.mainImage) _setEntity(title, for: \.title) } }
そのため、Fragmentを受け取るコードでは、モックを使ってもFragment型に置き換える必要があります。
let closure: (ChristmasFragment) -> Void = { print($0) } closure(Mock<Christmas>) // 🙅♀️ closure(ChristmasFragment(・・・)) // 🙆♀️
Optional unwrappingが必要になる
Mock型のプロパティにアクセスする際は、dynamic member lookupを利用します。MockのInitializerの各引数にはデフォルトでnilが指定されており、nilの可能性があるため、Optionalとして取り出す必要があります。そのため、毎回Optional unwrappingを行う手間がかかります。
型パラメーターの指定が面倒
例外もありますが、基本的にはMock型を初期化する際に、型パラメーターを指定する必要があります。
let mock = Mock<Christmas>() // 🤷♀️ let mock = Christmas.placeholder() // ☺️ static関数に関しては、後述します
また、
let a = Mock<Christmas>(
のように記述したとしても、Xcodeで引数の補完が効かない場合もあり、型パラメーターに指定する型の中身を確認する必要がありため、手間がかかります。これらの困りごとを解決する方法を考えたので、次のセクションで紹介します。
SourceryでTestDoubleを生成
Sourceryは、stencilテンプレートを使ってSwiftのコードを自動生成するツールで、ボイラープレートコードの自動生成などに広く活用されます。
inputに指定した範囲のSwiftコードを読み取り、その情報に基づいてコードを自動生成します。まずは、今回モックを生成したい型の情報を確認しましょう。
モック対象となる型は、以下の2つです。
- OperationのInnerTypeとして存在するData型
- Operationが返却するPrimitiveでない型
これらの型はすべて、SelectionSetプロトコルまたはInlineFragmentプロトコルに準拠しています。
public struct ChristmasFragment: SelectionSet, Fragment { ・・・ public struct MainImage: SelectionSet {・・・} public struct AsXXX: InlineFragment { ・・・ } public struct ChristmasQuery: GraphQLQuery { ・・・ public struct Data: SelectionSet {・・・} ・・・ }
そのため、以下のようなStencilを記述することで、上記の型に対するモックを生成できます。これは、先ほど紹介した
selectionSetInitializers
オプションで生成したInitializerを利用しています。そうでなければ、プロパティの参照では関係のないものまで含まれてしまうためです。{% for module in argument.imports %} import {{ module }} {% endfor %} {% for struct in types.structs where struct.based["SelectionSet"] or struct.based["InlineFragment"] %} // MARK: {{ struct.name }} placeholder extension {{ struct.name }} { {% for initializer in struct.initializers where initializer.name != "init(_dataDict: DataDict)" %} {{ struct.accessLevel }} static func placeholder( {% for parameter in initializer.parameters %} {{ parameter.argumentLabel }}: {{ parameter.typeName }} = .placeholder(){% if not forloop.last %},{% endif %} {% endfor %} ) -> {{ struct.name }} { {{ struct.name }}( {% for parameter in initializer.parameters %} {{ parameter.argumentLabel|replace:"`","" }}: {{ parameter.argumentLabel }}{% if not forloop.last %},{% endif %} {% endfor %} ) } {% endfor %} } {% endfor %}
末端に存在するPrimitiveな型・enumを表現するGraphQLEnum<T: EnumType>型に対しては、自前でモックを用意します。
public extension Bool { static func placeholder() -> Bool { false } } public extension Int { static func placeholder() -> Int { 0 } } public extension Optional { static func placeholder() -> Optional { .none } } public extension String { static func placeholder() -> String { "" } } ・・・
import ApolloAPI // MARK: GraphQLEnum placeholder public extension GraphQLEnum { static func placeholder() -> Self { .unknown("") } }
結果、以下のようなコードが生成されました 🎉
デフォルト引数には
.placeholder()
が再起的に指定されており、必要な値だけを指定することができます。extension ChristmasFragment { public static func placeholder( mainImage: MainImage = .placeholder() title: String = .placeholder() ) -> ChristmasFragment { ChristmasFragment( mainImage: mainImage, title: title ) } } ・・・
apollo-ios-codegenで生成するモック機構の不便さを解消し、関心の対象に集中できる関数を用意できました。
let title = "Merry Christmas🎄" let mock = Christmas.placeholder( // mainImage: .placeholder(), title: title ) print(mock.title) // "Merry Christmas🎄" print(mock.mainImage.url) // ""
他に検討した案
GraphQLのディレクティブを活用し、SchemaからSwiftのコードを生成する際に、一緒にモックを生成することも検討しました。それにより、Sourceryのinstall時間・実行時間を削減できる可能性もあります。しかし、Sourceryに慣れていたこと・将来置き換えが難しくないことを理由に、短期的なスピードを優先し、Sourceryを使った生成方法を採用しました。
最後に
いかがでしたか?
Sourceryを使用してGraphQLの型に対するモック生成方法を説明しました。apollo等を利用して、Schemaから型生成を利用している皆さんにとってのアイディアの一つになれば幸いです。
NEWTにはまだまだ成長の余地が多分にあります。私のように、旅好きなメンバーが多い会社ではありますが、もちろんそうでない方も大歓迎です!
スタートアップの事業グロースにコミットしたい、社会やカスタマーに価値提供できるプロダクトに携わりたい、など令和トラベルにジョインする理由はさまざまですので、少しでもご興味を持っていただけましたら、ぜひ一度カジュアルにお話しさせてください。
「NEWT Tech Talk」のお知らせ
令和トラベルでは、技術的な知識や知見・成果を共有するLT会を毎月実施しています。発表テーマや令和トラベルに興味をお持ちいただいた方は、誰でも気軽に参加いただけます。
2025年1月のテーマは、 ” プロダクトマネジメント ”
新年第1弾のNEWT Tech Talkは、「NEWT(ニュート)」のプロダクト開発を牽引するPM 藤沼・Backendエンジニア兼PM 木村の2名が、”カスタマーファーストを実現するプロダクトマネジメントの舞台裏” と題して、LT形式で発表を行います。
令和トラベルでは一緒に働く仲間を募集しています
この記事を読んで会社やプロダクトについて興味をお持ちいただけましたら、ご連絡お待ちしています!フランクに話だけでも聞きたいという方は、カジュアル面談も実施できますので、お気軽にお声がけください。
今年最後の特大セールも開催中!
NEWTでは、12/26 お昼11:59まで、2024年最後のおトクなセールを開催中です!
📣宣伝
最後までお読みいただき、ありがとうございました!
次回の「NEWT Product Advent Calendar 2024」Day17は、EMのyoshikeiが担当します。事業フェーズにあわせた組織開発とマネジメントスタイルの変遷について紹介する予定です。次のブログもお楽しみに!