前置き
この記事では、iOSアプリ開発時に開発者自身が特に行ったほうがいいと思われるテストについて備忘録としてまとめました。
一口にテストといってもいろいろな種類があります。単体テストや結合テスト、UIテストなど多岐にわたりその種類に応じて、テスト難易度やテスト実行者の数など変わるかと思います。今回は前置き通り開発者自身が行うべき単体テストに着目していきたいと思います。理由としては仕様は基本的に流動的であり、ModelとRepository間など複数オブジェクトでの処理やUIについては特に変わりやすいので、テストメンテナンスのコストがとても大きくなってしまうといったことが考えています。なのでこの記事では、テスト対象を細かく分割し、単体テストを充実させる方針に着目しています。
見出し
1, テストを書くメリット
2, テストしやすい構造
内容
1, テストを書くメリット
開発時にテストを書かないでも、実際に動くアプリを作ることは可能です。そうなるとテストを書く必要性はなんであるのか、開発を進めた方が進捗がでていいじゃないか思うことがあるかと思います(当初は僕もそう思っていました)。確かに開発コードだけ書いていると最初は進捗が出ているように見えますが、後々になってバグを生み出したりする原因になる可能性があります。下の例をみていましょう。
1.1 テストを書かないデメリット: DogListModelの例
この例では、MVCモデルでModelにDBから犬の情報をひっぱてきて条件ロジックをもたせて、ViewControllerでその処理を呼び出してViewに表示させる様子です。
class DogListViewController: UIViewController {
var listView = CustomListView()
var model = DogListModel()
override func viewDidLoad() {
super.viewDidLoad()
model.delegate = self
model.getDogsInfoFromDB()
}
}
extension DogListViewController: DogListModelDelegate {
func didGetDogsInfo() {
// DBから取得した仕様に基づく条件を満たした犬についてviewに渡して表示する
listView.setInfo(model.targetDogsInfo)
}
}
protocol DogListModelDelegate {
func didGetDogsInfo()
}
class DogListModel {
// 仕様書に基づいた特定の条件の犬
var targetDogsInfo = [DogInfo]()
weak var delegate: DogListModelDelegate?
struct DogInfo {
var name: String
var age: Int
var weight: Int
}
func getDogsInfoFromDB() {
targetDogsInfo = [DogInfo]()
DogRepository.getDogsInfo { [weak self] dogs in
self?.targetDogsInfo.compactMap {
// 仕様としては3歳以上であるが、ここではちょうど3歳であることを条件としているため、実装が間違っている
guard !$0.name.isEmpty && 3 == $0.age && 20 <= $0.weight else {
return nil
}
DogInfo(name: $0.name,
age: $0.age,
weight: $0.weight)
}
delegate?.didGetDogsInfo()
}
}
}
ここで、仕様の表示条件が「名前が空文字でなく、3歳以上で、体重が20以上である」という条件があるとします。ここで開発時に実装が終わり一旦ビルドして確認してみるか〜と思い、テストデータがたまたま名前が”チョコ"で、ちょうど3歳で体重が25キロのデータがあり、それ以外のデータが存在しなかったとします。そうなると、テストアプリが起動するとチョコのデータが画面に表示されるので条件式が正しくはないけどたまたま正常な挙動をふるまっているようにみえてしまい、バグがのこったまま運が悪いとリリースまでされていまいます。後々このバグが発見されると、バグの原因調査のために、View、ViewControllerといった本来の原因でないところも確認をする必要もでてきてしまい結果的に開発スピードの減少につながってしまいます。逆にこれらの正常系、エッジケース、異常系などを網羅したテストコードを書いておけば、先ほどのバグに直ぐに気づくこともできますし、後からリファクタリングしたいといった時にもリグレッションのリスクを抑えることにもつながります。
1.2 テストを書くメリット: Modelのリファクタリング例
以下がリファクタリングの例で、この例では、Studentの中から身長が180以上で、好きな食べ物がラーメンの人が何人いるかを計算するロジックについてのコードになります。
class Model {
struct Student {
var name: String
var age: Int
var height: Double
var favoriteFood: String
}
func countTallRamenStudent(studentsInfo: [Student]) -> Int {
var count = 0
for info in studentsInfo {
if 180.0 <= info.height && info.favoriteFood == "ramen" {
count += 1
}
}
return count
}
}
func testDogListModel_countTallRamenStudent() throws {
let model = Model()
let testData = [Model.Student(name: "Bob",
age: 18,
height: 180.2,
favoriteFood: "ramen"),
Model.Student(name: "Alice",
age: 18,
height: 170.2,
favoriteFood: "ramen"),
Model.Student(name: "Ken",
age: 18,
height: 170.2,
favoriteFood: "udon"),
Model.Student(name: "Taku",
age: 18,
height: 180.2,
favoriteFood: "rice"),
Model.Student(name: "Pon",
age: 18,
height: 185.2,
favoriteFood: "ramen")]
XCTAssert(model.countTallRamenStudent(studentsInfo: testData) == 2, "計算ロジックミス")
}
このようにテストコードを書いておくことで(実際はもっとエッジケースなどを網羅すべきですが)、以下のようにリファクタリングした後もいちいち、テストデータをいじって確認などもいらずにテストを走らせるだけで簡単にリファクタリングコードがリグレッションしていないかを確認できます。
func countTallRamenStudent(studentsInfo: [Student]) -> Int {
return studentsInfo.filter { 180.0 <= $0.height && $0.favoriteFood == "ramen" }.count
}
2, テストしやすい構造
Swiftでは2015年のWWDCであるように
https://developer.apple.com/videos/play/wwdc2015/408/?time=681
プロトコル指向で実装することが推奨されていますが、これに関連してprotocolを使用したテストしやすい実装がどういうものなのかについて見ていきたいと思います。まずは例から見ていきたいと思います。
2.1 テストしにくい悪い例: DataModelの例
以下の例では、ModelがRepositoryになんらかのデータをDBから取得する処理を考えます。
class DataModel {
private let repository: NetworkDataRepository
init(repository: NetworkDataRepository) {
self.repository = repository
}
func retrieveData() {
repository.fetchData { result in
// なんらかの処理を行う
}
}
}
class NetworkDataRepository {
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
// Network request
}
}
上記の例では、DataModelがNetworkDataRepositoryに依存してしまっていてDataModelのテストを行う際にNetworkDataRepositoryが必要となってしまうというデメリットも存在します。こうなると実際にDBやサーバーにサンプルデータを格納する必要がでたり、DataModel以外のオブジェクトの処理がテスト結果に影響する可能性がでてきてしまい、結果的にDataModel単体のテストを行うことができなくなってしまいます。これを改善するために依存性の逆転の概念を使います。
依存性の逆転とは"上位レベルのモジュールも下位レベルのモジュールもお互い抽象に依存すべきであり、詳細が抽象に依存すべき"というものであり、具体的にはswiftではオブジェクト間を通じた処理がある場合、オブジェクトを直接みるのではなく、プロトコル(抽象)を参照しましょうということです。先ほどの例で言うと、DataModelはNetworkDataRepositoryをもちそのクラスのメソッドであるgetDataを呼び出していましたが、以下のようにprotocolを見るようにするということです。
2.2 protocolを用いたテストしやすい改善例: DataModelの例
class DataModel {
private let repository: DataRepositoryProtocol
init(repository: DataRepositoryProtocol) {
self.repository = repository
}
func retrieveData() {
repository.fetchData { result in
// Handle result here
}
}
}
protocol DataRepositoryProtocol {
func fetchData(completion: @escaping (Result<Data, Error>) -> Void)
}
class NetworkDataRepository: DataRepositoryProtocol {
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
// Network request logic here
}
}
class MockDataRepository: DataRepositoryProtocol {
// テストシナリオに応じて結果を設定できるようにします
var resultToReturn: Result<Data, Error>!
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
completion(resultToReturn)
}
}
class DataModelTests: XCTestCase {
func testRetrieveData_handlesSuccess() {
// Arrange
let mockRepository = MockDataRepository()
let model = DataModel(repository: mockRepository)
let testData = Data() // ダミーデータを作成します
mockRepository.resultToReturn = .success(testData)
// Act
model.retrieveData()
// Assert
// modelが成功を適切に処理したことを確認します
}
func testRetrieveData_handlesFailure() {
// Arrange
let mockRepository = MockDataRepository()
let model = DataModel(repository: mockRepository)
let testError = NSError() // ダミーエラーを作成します
mockRepository.resultToReturn = .failure(testError)
// Act
model.retrieveData()
// Assert
// modelがエラーを適切に処理したことを確認します
}
}
このようにすることで、簡単にダミーのクラスを注入してDataModel自体のテストを行うことが可能となり、実際に通信などの処理を行わずに適当なデータを設定するだけでテストすることができます。
また、テストしやすさという点に関連して純粋関数という概念があります。一般的にModelなどではRepositoryにDBやAPI処理などを以上してデータを取得してそれをViewで使う形に変換して保持することが多いかと思います。このように関数内でプロパティに値をセットしたり変えたりすることを副作用と呼びます。副作用が多いとテストをするのに変更したプロパティをチェックして全てについて正しい値かどうかを判定する必要がでてくるのでテスト工数の増大につながります。純粋関数とは以下のように返り値が一意に定まり、副作用がない関数のことです。純粋関数を使えば、その関数を有するオブジェクトのプロパティやその他外部のオブジェクトに影響を与えることはないので、簡単にテストを記述することができます。
2.3 純粋関数の例: UserModelの例
struct User {
let firstName: String
let lastName: String
var fullName: String {
return firstName + " " + lastName
}
}
class UserModel {
// 純粋関数の例
func getFullName(user: User) -> String {
return user.fullName
}
}
ただ、API通信やDB操作がある以上すべての関数を純粋関数にするのは難しいですが、できるだけ関数の粒度を細かくしてできるところは純粋関数にすることでテストのしやすさも向上すると考えています。