テストを拡充させていくにあたり,検証に都合のよいデータを手軽に用意できるようにした作業内容のメモ.
サンプルコード→gaussbeam/TestDataloader
やりたいこと
- あらかじめ用意しておいた
.json
ファイルと型を指定することで,jsonファイル内のデータをもとに必要な形式のデータを取得できるようにする
なぜやりたいのか
- テストコード内で
init()
すると,テスト自体とは直接関わりのないコードが増え,テストの内容が把握しづらくなる - そもそも
init(from decoder: Decoder)
しか実装していないようなオブジェクトの場合,コードからのテストデータ生成のために別途イニシャライザを定義する必要がある - 異なるテストケースで同様のデータを利用する場合,その都度データ生成用のコードを書くのは冗長
やったこと
以下に示す方法でjsonファイルから任意の型のテストデータを生成できるようにした.
1. Loader
Protocolを定義
このProtocolにより,バンドル内のファイル取得→Data
までの変換を共通的に行うようにした.
また,変換後の型を任意のものにできるよう,associatedtype
を指定させるようにしている.
実装は以下の通り.
protocol Loader {
associatedtype DataType
var bundle: Bundle { get }
func load(resourceName: String) -> DataType
func loadData(resourceName: String) -> Data
func convert(data: Data) -> DataType
}
extension Loader {
var bundle: Bundle {
return Bundle(identifier: "jsonファイルを含んだバンドルのID")!
}
func load(resourceName: String) -> DataType {
let data = loadData(resourceName: resourceName)
return convert(data: data)
}
func loadData(resourceName: String) -> Data {
let path = bundle.path(forResource: resourceName, ofType: "json")!
let url = URL(fileURLWithPath: path)
return try! Data(contentsOf: url)
}
}
Loader
は以下のような流れでjsonからオブジェクトへの変換を行う.
- 所定のバンドル内から,指定されたjsonファイルを取得
- 取得したjsonファイルの内容を
Data
型に変換 -
Data
型からDataType
型(associatedtypeとして紐付け)に変換 - 変換された
DataType
型のオブジェクトを返す
上記の処理について,1~4までの全体の流れはload()
メソッドの,1~2はloadData()
としてデフォルト実装としてそれぞれ定義している.
3の変換処理についてはconvert()
が該当するが,この処理(およびDataType
の指定)については,Loader
の適合先でそれぞれ実装させることで,任意の型への変換ができるようにした.
2. 型ごとのLoaderを定義
JSON(=[String: Any]
)とDecodable
に適合したオブジェクト用のLoader
を定義する場合,以下のようになる.
/// jsonファイルから取得した内容を`[String: Any]`に変換するLoader
struct JsonLoader: Loader {
typealias DataType = [String: Any]
func convert(data: Data) -> DataType {
return try! JSONSerialization.jsonObject(with: data, options: []) as! DataType
}
}
/// jsonファイルから取得した内容を`Decodable`に適合した任意の型に変換するLoader
struct DecodableObjectLoader<T: Decodable>: Loader {
typealias DataType = T
func convert(data: Data) -> DataType {
return try! JSONDecoder().decode(DataType.self, from: data)
}
}
上記の例以外にも,Data
から変換可能な型はLoader
を定義できる.
例えば,JSONのパースにObjectMapperを使っているような場合,以下のように定義できる.
struct MappableObjectLoader<T: Mappable>: Loader {
typealias DataType = T
func convert(data: Data) -> DataType {
let jsonString = String(data: data, encoding: .utf8)!
return DataType(JSONString: jsonString)!
}
}
3. Loader
とテストデータをプロジェクトファイル内に組み込み,テストを記述.
Loader
とテストデータ,およびテストコードを以下のように配置することで,XCTestで利用可能になる.
※ Frameworkを分けている場合に,テストデータやLoaderを共通化したい場合は注意が必要(これはそのうち別記事で書く)
Loader
によるテストデータの取得(およびテストを実行)例は以下の通り.
(詳細はサンプルコードを参照)
class CouponTests: XCTestCase {
func testIsUsed() {
// ジェネリクスでデコード先の型を指定
let loader = DecodableObjectLoader<Coupon>()
XCTContext.runActivity(named: "usedDate==nilの場合,isUsedはfalse") { _ in
// 引数としてテストデータのファイル名を指定
let c = loader.load(resourceName: "unusedCoupon")
XCTAssertFalse(c.isUsed)
}
XCTContext.runActivity(named: "usedDate!=nilの場合,isUsedはtrue") { _ in
let c = loader.load(resourceName: "usedCoupon")
XCTAssertTrue(c.isUsed)
}
}
}
なお,上記のコードで使用したオブジェクトの定義およびjsonファイルは以下の通り.
// (参考)Loader経由で取得するオブジェクトの型
struct Coupon: Decodable {
var id: Int
var title: String
var usedDate: Date?
var isUsed: Bool {
return usedDate != nil
}
}
{
"id": 123,
"title": "Some coupon",
"usedDate": null
}
APIのモックに利用する
上記の例ではCoupon
型にデコードした値のアサーションを行っているが,別の用法として[String: Any]
としてデコードした結果をAPIのモックに利用することも可能.
例:Mockingjayと組み合わせた場合
class SomeApiTests: XCTestCase {
func testSomeApi() {
…
let body = JsonLoader().load(resourceName: "usedCoupon")
stub(http(.get, uri: "https://example.com/api/someApi"), json(body))
…
// usedCoupon.jsonの内容で書き換えたAPIレスポンスに対するテスト
}
}
やってみて
よかったこと
- テストデータ生成を1行で行えるようになった
- 当初の動機であった「テスト自体とは直接関わりのないコードの増加を避ける」ことができた.
また,所定のBundle内にあるファイルからData
型で取得しそれを変換し…というプロセスもLoader
内に集約したので,テストコード自体の見通しが良くなった.
- 当初の動機であった「テスト自体とは直接関わりのないコードの増加を避ける」ことができた.
- 1つのデータをいろいろな形式で使えるようになった
- 例で挙げたように,
Decodable
に適合したオブジェクトやJSON(=[String: Any]
)など任意の型で取得できるので,異なる観点・やり方のユニットテストにおいて,1つのjsonファイルを使い回せるようになった.
- 例で挙げたように,
- レスポンスの書き換えにも利用できる
-
Charlesと組み合わせることで,APIレスポンスの書き換えにも利用できる.
当初は想定していなかったことだが,ユニットテストのみならず,デバッグ実行時の振る舞い確認においても任意の状況を作ることができ,より開発がしやすくなった.
-
Charlesと組み合わせることで,APIレスポンスの書き換えにも利用できる.
改善したいこと
- テストデータの置き場
- 先にも少し触れたが,特定のテストターゲット内にバンドルしているため,Frameworkを分けている場合にテストデータを共用できない.
-
Kickstarterでは共用Framework(
Library
)の中にTestHelper
というグループを作り,Frameworkをまたいでテスト用のヘルパを共有している- この方法をそのまま使うとテストデータもアプリ本体にバンドルされてしまうのでもうひと工夫必要になりそう
-
Kickstarterでは共用Framework(
- 先にも少し触れたが,特定のテストターゲット内にバンドルしているため,Frameworkを分けている場合にテストデータを共用できない.
- Bundleの取得方法
- Bundle自体もIdentifierをベタ書きしているのでポータビリティが低い(一箇所指定するだけなのでそこまで実害はないが…).
- これについては,Extensionのデフォルト実装でやっていることに起因しているので,役割を分けてBundleを取得するクラスを使うのがよさそう.
- Bundle自体もIdentifierをベタ書きしているのでポータビリティが低い(一箇所指定するだけなのでそこまで実害はないが…).
「もっとうまいやり方あるよ!」という場合にはコメントにてお願いします