前置き
この記事では、iOSアプリ開発時に開発者自身が特に行ったほうがいいと思われるテストについて備忘録としてまとめたものです(前編)。
前編でテストコードのメリットなどは記載したのでこの記事では、より現場で起こりがちな事象についてテスタブルなコードという概念がどう有効であるのかについて着目したものになります。
見出し
1, 契約
2, swiftの便利な記法
内容
1, 契約
どのような業界でコードを書くにしても、基本的に仕様に沿ってプログラミングすることが多いと思います(研究やPOCなどは別かもしれないですが)。仕様に沿ってコードを実装する場合、以下のような問題点が生じることがよくあります。
・仕様の不明瞭
・OSや環境の考慮もれ
・入力条件のぬけもれ
例えば以下のような仕様があったとします。
「電話番号を登録するAPIのリクエストを作成する。リクエストのbodyはphoneNumberをkeyとして、valueはstring形式とする。」
上記の仕様には様々な不明瞭点があります。まず、phoneNumberのvalueについてですが、特に形式などを指定されていないですがもし入力フィールドがある場合「XXX-XXXX-XXXX」みたいにハイフンが入る場合も想定されるべきです(TextFieldなどで制御するなどの方法もありますが一旦おいておいて)。また、先頭の文字が0スタートでないときや桁数がおかしい時など明らかな入力ミスは許容してサーバーで処理を行うのか、phoneNumberは必須項目かといった点はしっかりと設計とすり合わせをすべきです。後から修正の対応が必要になったり最悪の場合システムを壊す原因にもなりうるので、早めにすり合わせを行い合意を取るべきだと考えています。
設計とすり合わせが完了して、実装に入る時には、先ほどの合意した点について「契約による設計」をベースに関数を呼び出す前の事前条件チェックや操作後の状態が期待通りであることの事後チェック、不変な値のチェックなどを取り入れて実装し、怪しい部分については早期にクラッシュさせバグの原因となる部分を発見し解決するべきだと考えています。
class ItemUpdater {
var items: [Item]
init(items: [Item]) {
self.items = items
}
func updateItems(newItem: Item) {
// 保存するための元のアイテム数
let originalItemCount = items.count
// 更新処理
items[0] = newItem
// 不変条件チェック:アイテム数が変わらないことを確認
assert(items.count == originalItemCount, "The number of items should not change")
// 事後条件チェック:更新されたアイテムが正しいことを確認
assert(items[0] == newItem, "The first item should be the new item")
}
}
// ItemManagerクラスのインスタンスを作成
let updater = ItemUpdater()
// 更新用のデータ
let newItem = Item()
// 事前条件チェック
guard !items.isEmpty else {
assert(false, "Items array should not be empty before the update")
return
}
// updateItems関数を呼び出す
updater.updateItems(newItem: newItem)
参考資料
https://www.amazon.co.jp/%E9%81%94%E4%BA%BA%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9E%E3%83%BC-%E7%AC%AC2%E7%89%88-%E7%86%9F%E9%81%94%E3%81%AB%E5%90%91%E3%81%91%E3%81%9F%E3%81%82%E3%81%AA%E3%81%9F%E3%81%AE%E6%97%85-David-Thomas/dp/4274226298
https://scior.hatenablog.com/entry/2019/04/04/202352
https://developer.apple.com/documentation/swift/precondition(_:_:file:line:)
https://developer.apple.com/documentation/swift/assert(_:_:file:line:)
2, swiftの便利な記法
swiftは様々な便利で使い勝手のよい特徴がありますが、特にenumやguardはクリーンでテスタブルなコードを書く上でもとても便利なので積極的に使用すべきだと考えています。
APIのレスポンス状態をBoolとError?を使って管理するというシンプルな例で考えてみましょう。
func handleResponse(success: Bool, error: Error?) {
if success {
print("Request succeeded")
} else if let error = error {
print("Request failed with error: \(error)")
} else {
print("Request failed, no error information available")
}
}
この実装は動作しますが、BoolとError?を組み合わせることで3つの状態(成功、失敗、エラーなし)を表現しようとしています。この場合、新しい状態(例えば、ネットワーク接続がない)を追加しようとすると、すべてのhandleResponseの呼び出し箇所を見つけてコードを修正しなければならないです。ここでenumを使うと以下のように改善できます。
enum ApiResponse {
case success
case failure(Error)
case notConnected
}
func handleResponse(_ response: ApiResponse) {
switch response {
case .success:
print("Request succeeded")
case .failure(let error):
print("Request failed with error: \(error)")
case .notConnected:
print("No network connection")
}
}
これで、handleResponse関数はApiResponse型の引数だけを取るようになりました。これにより、APIのレスポンス状態を表現するための型が一つになり、新たな状態を追加する際も、enumに新たなケースを追加すれば良いだけになります。ということで抜け漏れを防ぐという面でも既存のリファクタリングの容易さの面でもenumはとても便利なので条件分岐の場面では積極的に使用すべきだと思います。
また、guardについてもswiftは強力です。開発において、コードの明瞭さは品質を向上させる重要な要素です。例としてある関数が、複数の条件を満たす場合にのみ特定の処理を実行し、それ以外の場合は何もせずに関数から早くリターンしたいとします。このようなロジックをif文で書くと、ネストが深くなり、コードの可読性が損なわれる可能性があります。
func process(data: Data?) {
if let validData = data {
if !validData.isEmpty {
// do something with validData
} else {
return
}
} else {
return
}
}
このコードは、データがnilでなく、かつ空でない場合にのみ処理を行います。しかし、この条件を満たさない場合、処理をスキップして関数からリターンしたいと考えています。その場合、if文のネストが深くなり、可読性が低下してしまいます。
これをguardを使って書き換えると以下のようになります。
func process(data: Data?) {
guard let validData = data, !validData.isEmpty else {
return
}
// do something with validData
}
guardを用いた上記のコードでは、データがnilでなく、かつ空でないことを一行でチェックしています。これらの条件が満たされない場合、すぐに関数からリターンします。これにより、ネストの深さが減り、コードの可読性が向上します。
また、guardによりアンラップされたvalidDataは、その後のコードで利用できます。このことにより、if letでアンラップした場合のネストと比べて、より読みやすく、保守しやすいコードを書くことができます。
このようにSwiftのguard文は早期リターンを実現し、コードの可読性と一貫性を向上させます。なので特に、多くの条件を満たす必要がある場合や、条件を満たさない場合に早期にリターンしたい場合には、guard文を積極的に使うべきだと考えます。