この記事は every Tech Blog Advent Calendar 2024(夏) 27日目の記事です。
目次
はじめに
DELISH KITCHENのiOSアプリ開発を担当している池田です。DELISH KITCHENでは皆様の料理体験がより良いものになるよう、日々新しい機能を追加しています。今回は「リアーキテクチャを支えるテスト駆動開発:効果的なリファクタリングの方法」について、実際の経験をもとにお伝えします。テスト駆動開発の重要性を改めて確認しながら、効果的なリファクタリングの方法を紹介します。
背景と問題点
DELISH KITCHENのiOSアプリは2016年のリリース以来、様々な機能を追加してきました。しかし、しっかりとした設計方針がないまま開発を続けてきたため、今後も継続的に機能を追加することが困難になっていました。そのため、一度まとまった時間を取り、リファクタリングを行うことにしました。
既存設計の問題
DELISH KITCHENのiOSアプリでは、SPMを用いたマルチモジュールで設計されています。現在は下図のようなモジュール構成になっています。
例えば、Networkingモジュールは通信に関する実装を含んでいますが、以下のような問題がありました:
- レスポンスそのものをアプリ全体で使い回しているため、通信を意識する必要のないUIモジュールがNetworkingモジュールに依存している。
- レスポンスがサーバの返却するJSONをそのままの形でパースしたものであり、アプリで使いやすい形ではない。
新しい設計方針
上記の問題を解決するために、クリーンアーキテクチャを元にした設計に移行することにしました。クリーンアーキテクチャとは、ドメインモデルを中心とした設計であり、各層が独立して動作することを目指します。下図のような構成を目標にリファクタリングを進めていきます。
リファクタリングの準備
テストを書く
リファクタリングを行う前に、まず既存の動作をテストすることが重要です。レスポンスの変換に対してテストを行うことで、変更後の動作を担保します。以下に、簡易的なデコードテストの実装例を示します:
// テストコード例 class DecodableTests: XCTestCase { func testGetRecipeResponseDecoding() { if let _: GetRecipeResponse = Self.decodeJSON(from: "GetRecipeResponse") { XCTAssert(true) } } } extension DecodableTests { /// 共通のJSONデコードテストメソッド static func decodeJSON<T: Decodable>(from fileName: String) -> T? { guard let fileURL = Bundle.module.url(forResource: fileName, withExtension: "json") else { XCTFail("Failed to find file \(fileName).json") return nil } do { let data = try Data(contentsOf: fileURL) let decodedObject = try JSONDecoder.decoder.decode(T.self, from: data) return decodedObject } catch { XCTFail("Decoding failed: \(error)") return nil } } }
このテストではOpenAPIのJSONレスポンスをJSONファイルとしてプロジェクトのローカルに配置し、そのJSONのデコードが失敗しないことをチェックしています。
リファクタリング作業
実装する
設計方針に従って少しずつリファクタリングを行います。今回は、NetworkingモジュールからModelを切り出し、サーバから取得したレスポンスをドメインモデルに変換するように変更します。以下に、リファクタリングのステップとコード例を示します:
- Modelモジュールを作り、アプリで利用しやすいドメインモデルを定義し直す。
- Networkingモジュールではサーバから取得したレスポンスをドメインモデルへと変換する。
既存実装ではレスポンスとModelの型を一致させることで、ModelをCodableに準拠させ、JSONからの変換のコードの実装が不要でした。今回はModelをレスポンスに依存させるのではなく、レスポンスをModelに依存させるように関係を変更すること、レスポンスとModelの構造が異なることからJSONから変換する処理を実装する必要が出てきました。
// リファクタリング前のコード例 // Networkingモジュール struct Recipe: Codable { let id: Int let title: String let category: String // 以下略 }
// リファクタリング後のコード例 // Modelモジュール struct Recipe { let id: Int let title: String let category: Category // 以下略 enum Category { case unknown case typeA case typeB } } // Networkingモジュール(将来的にはInfraモジュールへ変更予定) extension Recipe: Decodable { enum CodingKeys: String, CodingKey { case id case title case category // 以下略 } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let id = try container.decode(Int.self, forKey: .id) let title = try container.decode(String.self, forKey: .title) let category = try container.decode(Category.self, forKey: .category) // 以下略 self.init( id: id, title: title, category: category // 以下略 ) } } extension Recipe.Category: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let value = try container.decode(String.self) switch value { case "typeA": self = .typeA case "typeB": self = .typeB default: self = .unknown } } }
テストする
ビルドできるコードが実装できたら、テストを実行します。全てのテストが成功すればここで終了ですが、変更内容が大きいため、いくつかのテストは失敗することが予想されます。失敗したテストの該当する実装を修正しながら、全てのテストが成功するまで続けます。
まとめ
リファクタリングを実施するにあたり、テスト駆動開発の考え方を取り入れました。既存コードに対してテストを書いてからリファクタリングを行うことで、変更後のコードに問題がないことを確認でき、比較的大きな規模のリファクタリングでも安心して進めることができました。今後も継続的にリファクタリングを行い、リアーキテクチャを進め、開発しやすいコードを目指していきます。
終わりに
テスト駆動開発は、効果的なリファクタリングを実現するための強力な手法です。テスト駆動開発の重要性を改めて認識し、日々の開発に取り入れることで、より健全で拡張性の高いアプリケーションを構築することができます。この記事が、皆さんの開発においても役立つことを願っています。