テストケース作成を仕様詳細化の手段とする実験

こんにちは。 テストエンジニアからサービス開発エンジニアにロールチェンジした、茂呂一子です。 先日リリースしました、iOSクックパッドアプリのリニューアルプロジェクトに参加し、サービス開発エンジニアとしての第一歩を踏み出しました。

今回は、アプリのリニューアルをすすめていく中で、試してみたことについて、お話しします。

アプリリニューアルの内容やそのデザイン意図については、13年続いた「つくれぽ」をリニューアルした話|Misaki Kubosaka|noteが詳しいので、こちらをお読みください。

リニューアルプロジェクト第1フェーズの問題点

iOSアプリのリニューアルプロジェクトは、とても大きく、機能を段階的にリリースするため、3つのフェーズに分けて開発していくことが決まっていました。 そのため、開発チームはメンバーの追加をしつつ、複数回の開発サイクル(仕様決定、設計、実装、検証)を繰り返すことになりました。

クックパッドのサービス開発では、主に、ディレクターとデザイナーが企画と仕様決めを行い、エンジニアが実装し、ディレクター/デザイナーが作成するテストケースを元に検証を行うという方法が取られます。 以後の「ディレクター」は、企画と仕様決めに責任を持つディレクターとデザイナーの両方を指します。

私が参加したのは第1フェーズの途中からで、そこではテストケース作成をはじめとした検証を担当しました。 第1フェーズでは、 テスト期間の開始間際まで仕様の整理が行われていたり、不明瞭になっていた箇所への仕様追加がされたりしていました。 どうにか第1フェーズの開発を終え、リリースすることはできましたが、仕様が不明瞭なまま開発をすすめていくことに大きな不安を感じました。

その次の第2フェーズでは、私は開発エンジニアとしてモバイルアプリ開発をすることにしていました。 第1フェーズの反省から、いかに仕様の決定を早期に行うかを考え、仕様の抜けを早く検知する手段を講じる必要がありました。

第1フェーズのすすめ方の問題点はいくつかありました。

  • ディレクターが仕様を決めるが、複雑なユーザー状態すべてを考慮できなかった
  • 影響範囲が大きいため、たくさんの仕様の検討会が行われており、ディレクターが結論を精査する時間がとれなかった
  • どこまで決まっているかの管理をディレクター任せにしてしまったことで、ごく少数の人間だけが仕様を知っている状態が発生した
  • 実装担当は共有された情報から仕様を理解していたが、細かな認識の齟齬があることに後々まで気づけなかった
  • 後々発覚した認識の齟齬を埋めるために、仕様追加がされ、開発スケジュールがずるずると伸びた

仕様を実現していく上で必要な情報共有が不足している、ディレクターから実装者への一方通行であることが問題と考えました。

そこで、仕様の不明瞭な点を実装開始前に明かにする、そのために情報共有の精度をあげる方法を探すことにしました。

テストケースの作成を通じて、仕様を詳細化する

仕様の情報共有の精度をあげる方法を2案考えました。

  1. ディレクターに仕様詳細化をお願いし、その共有を実装担当者とする時間を設ける
    • 第1フェーズでは、やっているつもりだができていない状態だった
    • タイトなスケジュールの中では、実現可能性を考慮してセカンドプランを選択するべき場面があるが、それをディレクターだけでは判断できない
  2. ディレクターと実装担当者が会話した上で、実装担当者が仕様を詳細化する
    • 仕様の検査に確実に2者の視点が入るので、情報共有の不足を低減できる
    • 実装担当者のシリアルタスクのため、実装前に詳細化を完了しやすくなり、その結果、仕様追加の追跡がしやすく無理な変更の抑制ができる

案1は、第1フェーズで結果的にうまくいかなかった方法とあまり差がないこと、また、実装のコストや難易度の反映が遅くなる危険があったため、案2を採用しました。

私が属した機能グループでこの取り組みを行いました。大きく2つの機能を実装するグループです。

新機能に対して、ディレクターと実装担当者間で仕様の共有会を行い、そのインプットを元に実装担当者が仕様を詳細化しました。 このとき、ユーザー状態や利用状況の網羅性をあげるため、分析が網羅的になるようテストケースを作成するという手段をとりました。

テストケースというフォーマット

テストケースは、一般に、状況設定と操作と期待結果の組み合わせを列挙するものです。 とある機能において取り得る状況を網羅するには、テストケースの状況設定を細かく分析できるかが鍵です。 状況設定を細かく分析しやすいと考えたので、テストケースのフォーマットを利用することにしました。

そのとき使用したフォーマットは以下のものです。

f:id:ichiko_revjune:20200316114550j:plain
テストケースのテンプレート

一部は選択式にしつつ、その他のデータやユーザーの条件は自由設定にしています。 これまでの経験から仕様に現れない操作は忘れられたがちなため、操作は選択式にしました。

例えば、画面の通信エラーが起きたときの振舞いは未定義になりがちなので「通信エラー」、文字入力に関するバリデーションエラーの扱いを考慮してもらうために「文字入力」などを用意しました。 他には、細かな機能の出し分けがあるため、ユーザーステータスも選択式にしました。

仕様詳細化の効果

実装担当者は、テストケース作成を通じて、不明点/検討されていない点をリストアップしました。それをディレクター、デザイナーと共に解消した上で、実装を開始しました。

成果物としたテストケースは、その後ディレクターが加筆して検証フェーズで利用されました。 加筆といっても、この取り組みをしたのは新機能まわりだけだったので、ディレクターが新規に作成した既存機能との関連を見るテストケースの方が圧倒的に多いです。

検証フェーズでは、実装担当者が作成したテストケースの周辺では、ディレクターの追加したテストケースによって、大きな仕様差異が発覚することはありませんでした。

仕様詳細化をする段階で、問題を発見できたことで、第1フェーズに比べて安定した進行でリリースまで漕ぎ着けることができました。

リリース後の振り返りでは、エンジニアからは「実装開始前にテストケースができていたことで、仕様の不明点が洗い出せてよかった」と高評価を得ました。 一方、ディレクター陣からは、エンジニアが作ったテストケースにディレクターが手を入れるという形をとったため、検証の信頼性が低いので今後はディレクターがテストケースを作る、という評価を得ました。

実装担当者がテストケースを作成する是非

一般的に、実装担当者が作成するテストケースでは見つけられない不具合が多い、という信頼性の低さがあります。 テストケースが先でも、実装が先でも、先に考えた理解の範囲に引きずられて、網羅性が上がらないことは想像しやすいと思います。

ディレクター陣からの評価が下がった原因も、「実装担当者が作成したテストケースを検証に使った」ことにあると考えられます。

詳細化の分析手段としてテストケース作成のフォーマットに載せたことで、仕様の検証精度は上がった可能性はありました。 実装担当者からの反応はよかったので、仕様を理解する、過不足なく機能を実現することには貢献したと考えられます。

しかし、その成果物を流用させてしまったことで、 検証能力が低いテストケースであるために不具合件数が減ったのか、 実装精度が高いことで不具合件数が減ったのか、の区別ができなくなっていました。

私が検証のためのテストケース作成と完全に分離せず、成果物の流用を許容してしまったことで、有用性の評価が十分にできなかったのは残念でした。 あくまで詳細化の過程の成果物であり、検証用途ではないとするべきでした。

この手の信頼性問題は、コンセンサスを得ていない手段を使ったことで結果が下がった可能性をゼロにできなければ、マイナスに取られる他ないので致し方ないと思っています。

反省点はありますが、「仕様を考えている人以外も混じえて、実装開始前に仕様を明かにする」ことで、スムーズに開発をすすめることはできました。

次回は、成果物が一人歩きしても問題ないよう、用途を制限することと、誤解の生じる利用のされ方を回避することが必要になるでしょう。 分析の助けになることが重要のため、成果物の形をテストケース以外にできると、外部からの誤解も減らせると思えるので、他の形を模索したいと思います。

クックパッドではモバイルアプリの品質を安定させたいiOS/Androidエンジニアを募集しています。 https://info.cookpad.com/careers/

スプリングインターンシップをオンラインで開催致します!

こんにちは、レシピ事業開発部の赤松( @ukstudio )です。

毎年恒例となっている春のインターンシップですが、今年も開催致します!新型コロナウィルスの影響をふまえ、オンラインで実施することに致しました。当日はお手持ちのマシンからZoomで参加頂く予定です。

大規模トラフィックを支える技術

今年はエンジニア向けに1コース用意しました。題しまして「春ダッシュスペシャル 大規模トラフィックをさばくアプリケーションのパフォーマンスチューニングを学ぼう!」です!以下の日程・場所で開催致します。

  • 開催日: 4月29日(水・祝) 13時〜17時
  • 開催場所: オンライン(Zoom)

クックパッドは現在74カ国/地域・32言語に展開しており、月間の利用者数も約9,300万人にのぼる大規模サービスです。ユーザーからのアクセスだけでもピーク時には秒間数千アクセスにも達します。

このコースではこのような大規模トラフィックを支えるための技術を実践を通して学ぶことができる内容となっています。大規模トラフィックに関する技術の経験は個人で得るにはなかなか難しい部分もあると思いますので、この機会にぜひ体験してみてください。

以下のインターンシップ特別サイトからご応募頂けます。

internship.cookpad.com

オンラインでの開催について

当日は講師がZoomで画面共有をしながら講義を進めることになります。実はこの形式だと、オフラインの時と比べてスクリーンが見やすい、声が聞きやすい部分もあります。

一方でオンライン開催だと質問がしづらいんじゃないか、講義についていけなかったら置いてけぼりになるんじゃないかという不安があるかもしれません。当日はメインで話す講師とは別にTAも用意しております。TAがSlackでのサポートや、場合によっては1対1でのZoom MTGでサポートします。

実際に既にオンラインでの勉強会やイベントを弊社で行なっていますが、スプリングインターンもオンラインでできる!という手応えを感じています。ぜひオンラインというところに気後れせずに応募して頂けたらなと思います。

デザイナー向け UIデザインとサービス開発

デザイナー向けにも1コース用意しています。こちらは「クックパッド流!UIデザインをFigmaで体験しよう」とFigmaを用いたUIデザインとサービス開発の基礎を体験することができるコースです。デザイナーの方はぜひこちらにお申し込みください。詳細については後日noteの方に記事が公開される予定なので、そちらを見てもらえればと思います。

note.com

  • 開催日: 4月25日(土) 13時〜17時
  • 開催場所: オンライン(Zoom)

応募締切は4月6日

エンジニア向けもデザイナー向けも応募の締め切りは4月6日までとなっております。みなさまのご応募お待ちしております!

internship.cookpad.com

iOSアプリのメモリリークを発見、改善する技術

こんにちは。事業開発部の岡村 (@iceman5499) です。 普段はクックパッドアプリ(iOS)を開発しています。

先日、アプリケーションが特定の条件で意図せぬ状態に陥り、アプリケーションが重くなって端末が発熱する、というバグが発見されました。 調査の結果、このバグはメモリリークが原因で発生していました。 この反省を踏まえメモリリークを検知するテストを導入したため、本記事ではその事例を紹介したいと思います。

(本記事ではクックパッドアプリとはiOS版の「クックパッド」アプリのことを指すものとします)

クックパッドアプリにおけるメモリリークの影響

クックパッドアプリはレシピの検索をコア機能としています。 検索は重い処理ですがAPIを通してサーバ上で行われるため、アプリは結果を表示するだけです。そのためメモリを多く必要としません。 これまでにも何度かメモリリークが発生している状況はありましたが、メモリを多く必要としないため多少の無駄があってもアプリの動作に影響がありませんでした。

クックパッドアプリで用いられているクラスの大半は自力で動くようなことはせず、RunLoop等のイベントループによって動作します。 UIKitを使用しているとインスタンスのRunLoopからの除去は自然に実現できるため、メモリリークが起こってもそのインスタンスは止まっていて、無害な状態であることが多いです。

しかしながら、今回は不運にも単独でイベントループを発生させるインスタンスがメモリリークしてしまいました。 本来はそのインスタンスがメモリから解放されたタイミングでイベントループが止まるはずでしたが、メモリから解放されなかったことにより停止されないイベントループが永遠に無駄な処理を続けていました。 その結果、そのインスタンスが多数メモリリークしてしまうとアプリケーションの動作に影響するほど負荷がかかってしまいました。

どのようにしてメモリリークが起こってしまうのか

iOSアプリケーション開発の現場で起こるメモリリークは大抵、循環参照が原因となっています。 循環参照によるメモリリークは参照カウント方式のガベージコレクション環境において発生しうる問題で、SwiftやObjective-Cを使っていると起こりうるものです。(なお、ここでは循環参照がどのような状態であるかの説明は省略します。) クックパッドアプリではRxSwiftというライブラリを多用しているため、クロージャを経由してうっかりメモリリークする形の循環参照を引き起こしてしまうケースが多いです。

よくある循環参照の例

実際にクックパッドアプリで起こっていた循環参照の実装の例を紹介します。

自身が所持するObservableのobserverとして自身が所持されるパターン

ちょっとタイトルがややこしいですが、最もシンプルなタイプのものです。

// ViewController.swift
let stream: PublishSubject<Void>
let disposeBag = DisposeBag()
func bind() {
    stream.subscribe(onNext: {
        self.doSomething() // selfがキャプチャされている
    })
    .disposed(by: disposeBag)
}

RxSwiftでは Observable を購読するとその購読がobserverオブジェクトとして Observable に保持されます。(例の PublishSubjectObservable の一種です。) この例ではobserverはさらに onNext: に渡されているクロージャを保持します。そしてさらにクロージャは self を保持しています。ここで self はこの実装を持つ ViewController クラスであるとします。 その結果、 self → stream → observer → クロージャ → self として循環参照となります。

この循環参照の解決策としては、次のように self を弱参照でキャプチャする方法が挙げられます。

stream.subscribe(onNext: { [weak self] in
    self?.doSomething()
})

暗黙クロージャ渡しパターン

次の例はどうでしょうか。これもたまにある例で、気づくことが難しい循環参照です。

// ViewController.swift
let stream: PublishSubject<Int>
let disposeBag = DisposeBag()
func bind() {
    stream.subscribe(onNext: doSomething) // この場合もselfが強参照されてしまう
        .disposed(by: disposeBag)
}

func doSomething(_ value: Int) { ... }

onNext: にクロージャを使わずに関数を渡すことで、すっきりとした表記になっています。 ところがこの doSomething 、一見関数ポインタを渡しているように見えますが、実際はコンパイラが裏側で self をキャプチャしたクロージャを生成しているため、次のコードと同じ意味になっています。

stream.subscribe(onNext: { self.doSomething($0) })

これは先程と同じパターンの循環参照となります。 対処法としては先程と同じように [weak self] を用いるのが良いでしょう。 あるいは、 doSomething の処理が self に依存していないならばそれをstatic関数にしてしまうという手もあります。(static関数になった場合その関数の実行に self が必要なくなるため、上で紹介したコンパイラが裏側でやる処理が無くなります。)

func bind() {
    stream.subscribe(onNext: Self.doSomething)
        .disposed(by: disposeBag)
}

static func doSomething(_ value: Int) { ... }

複数クラスにまたがって循環しているパターン

// Presenter.swift
class Presenter {
    let value: Observable<Int>
    init(view: View, interactor: Interactor) {
    value = interactor.stream
        .filter { view.isXXX } // ここで view を強参照でキャプチャしている
        .map { ... }
    }
}

// View.swift
class ViewController: View {
    var presenter: Presenter!
    var isXXX: Bool {
        ...
    }
    let disposeBag = DisposeBag()

    func bind(presenter: Presenter) {
        presenter.value.subscribe(...)
            .disposed(by: disposeBag)
        ...
        self.presenter = presenter
    }
}

これは複数のクラスにまたがる例です。 Presenter が生成する value には view がキャプチャされており、その川を view である ViewController クラスが購読します。 つまり ViewController からみて、 self → presenter → value → filterの内部で使われるオブジェクト → クロージャ → view(== self) として参照関係が発生し、循環参照が成立します。

self がクロージャにキャプチャされている場合はなんとなくアンテナが反応しやすいのですが、そうでないケースはうっかり見過ごされやすいです。 これの対応も同様に弱参照を用いることになります。

.filter { [weak view] _ in view?.isXXX == true }

メモリリークを検知するテストの導入

「よくある循環参照の例」を見てわかるように、循環参照はうっかり見逃しやすいため人目のレビューをすり抜けてしまいます。 またコンパイラによって検知することもできません。

そこで、XCTAssertNoLeakを使ってテストを書くことにしました。

github.com

XCTAssertNoLeakは対象のインスタンス内でメモリリークが発生しているかを検知する機能を提供するテスト用ライブラリです。 2019年のtry!Swiftで発表されたライブラリで、メモリリークを検知するテストを書くことができます。

f:id:iceman5499:20200303111804p:plain
XCTAssertNoLeak

https://github.com/tarunon/XCTAssertNoLeak のREADME.mdより

ただ引数にオブジェクトを渡すだけで、簡単にリークしているオブジェクトをリストしてくれる素敵なライブラリです。

XCTAssertNoLeakの動作原理

XCTAssertNoLeakはどのようにしてメモリリークを検知しているのでしょうか。 基本的な戦略としては、インスタンスをweakポインタに格納し、スコープが変化したタイミングでポインタの中身が nil になっているかどうかで判定をしています。

インスタンスが持つプロパティ群に格納された子インスタンスや、そのさらに孫インスタンスを全て確保するためには Mirror が用いられています。

Mirrorを使い全プロパティを探索して参照型の値を全てweakポインタに確保し、スコープを抜けたあとそのweakポインタがちゃんと nil になっているか確認する、という感じです。

テスト記述に関する注意点

XCTAssertNoLeakは非常に簡単に利用できるようになっていますが、仕組み上いくつか気をつけないといけない部分があります。

ローカル変数のスコープに注意する

XCTAssertNoLeak は引数に渡したオブジェクトが検査されますが、ローカル変数にオブジェクトを保持してしまうとそのローカル変数が参照を持つためにテストに用いられるweakポインタが nil にならず、メモリリーク扱いになってしまいます。

// NG
let viewController = MyViewController()
XCTAssertNoLeak(viewController) // faild! 

回避するためには XCTAssertNoLeak の引数にオブジェクトを右辺値として渡す必要があります。 クックパッドアプリでは次のようにしてテストを記述しています。

func build() -> AnyObject {
    RecipeDetailsViewBuilder.build(....) // 初期化処理
}
XCTAssertNoLeak(build())

初期化処理をローカル関数にラップし、返り値をそのまま XCTAssertNoLeak に放り込むことでローカル変数にテスト対象のインスタンスを保持しないようにしています。

シングルトンは例外設定

次のテストを見てみましょう。 f:id:iceman5499:20200303111725p:plain

NotificationCenterがリークしていると怒られています。 シングルトンは開放されないため、XCTAssertNoLeakから見るとメモリリークしているものとして判定されます。 このような状況に対応するために CustomTraversable というプロトコルが用意されています。

extension NotificationCenter: CustomTraversable {
    var ignoreAssertion: Bool { true }
}

メモリリークしていると判定されるクラスに対してextensionで ignoreAssertion: Bool を実装することで、そのエラーを無視することができます。 CustomTraversable にはこの手のケースに対応するための口がいくつか用意されています。

シングルトンが保持するオブジェクト

シングルトンを無視設定するところまでは良かったですが、 ignoreAssertion するだけではそのオブジェクトに連なっているオブジェクトがさらにリーク判定されてしまいます。( ignoreAssertion はそのインスタンスの子プロパティ群ごと無視はしません)

class AwesomeObject {}

class MySingleton {
    static let shared = MySingleton()
    private let object = AwesomeObject()
}

extension MySingleton: CustomTraversable {
    var ignoreAssertion: Bool { true }
}

class MyViewController: UIViewController {
    let object = AwesomeObject()
    let dependency = MySingleton.shared
}

class NoLeakTests: XCTestCase {
    func testMyViewController() {
        // failed - 1 object occured memory leak.
        //        - self.dependency.object
        XCTAssertNoLeak(MyViewController())
    }
}

このコードの例の場合、 self.dependency.object がリークしたという判定になります。 あまりこのようなケースはありませんが、現実のアプリケーションは複雑であるためまれにこのケースに遭遇します。

これの対応を考えることは難しいです。例えば次のようにシングルトンに持たれた AwesomeObject のみ例外設定することを考えます。

extension AwesomeObject {
    var ignoreAssertion: Bool {
        self === MySingleton.shared.object // コンパイルエラー: 'object' is inaccessible due to 'private' protection level
    }
}

このように書きたいところですが、アクセス修飾子の関係でこの処理は記述することができません。 対象のオブジェクトがアプリケーション内に実装されていればやりようはあるかもしれませんが、外部のライブラリなどとなると難しくなってきます。

クックパッドアプリではこのケースは諦めて AwesomeObjectignoreAssertion は常に true を返すようにしています。

まとめ

XCTAssertNoLeakのおかげで、メモリリークを検知するテストを実現することができました。 このテストを実装してすぐ、僕の変更で循環参照を引き起こしてしまいテストに怒られてしまったので早速効果を発揮しました。 テストを導入する過程で見つかったメモリリークもいくつかあり、今まで見つかってなかったメモリリークも浮き彫りにすることができました。

このようにしてiOSアプリのメモリリークは解消しましたが、モバイルアプリの品質安定にはまだまだ手が足りていない状況です。 クックパッドではモバイルアプリの品質を安定させたいiOS/Androidエンジニアを募集しています。 https://info.cookpad.com/careers/