Upgrade to Pro — share decks privately, control downloads, hide ads and more …

どうすりゃいいんだ? こうすりゃいいんだ モバイルアプリの設計とテストコード

どうすりゃいいんだ? こうすりゃいいんだ モバイルアプリの設計とテストコード

Avatar for akito hagio (おはぎ)

akito hagio (おはぎ)

October 17, 2025
Tweet

More Decks by akito hagio (おはぎ)

Other Decks in Programming

Transcript

  1. 今日話すこと、話さないこと 今⽇話すこと • 主にテストコードの話 • テストコードを書きやすい設計 • モバイル開発での実践例 話さないこと (でも重要)

    • テストコードに関係しない話 • 端末の多様性 • E2Eテストツールなどの話 発表で 触れられなかった話題もぜひOSTで話しましょう!
  2. つらみ①:アプリ固有 状態遷移:ライフサイクル 状態遷移 複雑さ • バックグラウンド ⇄ フォアグラウンド • メモリ不足で

    強制終了 ライフサイクルメソッド • Android: onCreate, onResume, onDestory... • iOS: viewDidLoad, viewWillAppear… どこで 何をすべきか? いつ 保存‧復元する? どうテストする?
  3. つらみ②:多様な非同期処理 多様な非同期処理 API通信、内部DBアクセス、ファイルアクセス、 位置情報 取得、メディア再生、 Bluetooth 連鎖する非同期処理(Voicy 例) 広告タグ取得 →

    広告素材取得 → 広告再生 → 音声再生 +エラーハンドリング、タイムアウト、キャンセル タイミング依存 バグ 再現困難 コールバック地獄になりがち
  4. つらみ③:UI と画面遷移 • 多様な遷移パターン ◦ iOS: Push/モーダル遷移、Android: Fragment遷移、バックスタック ◦ 画面間で

    データ 受け渡しや結果 返却 • 画面サイズ対応 ◦ Adaptive Layout(スマホ/タブレット/フォルダブル) • アニメーション ◦ スライドイン、フェード、拡大縮小 ◦ タイミング依存 挙動 複雑になることもあるUI固有 ロジック ビジネスロジックが混在すると複雑さが増す
  5. 境界を引け 、テスト 劇的にやりやすくなる! つらみ 本質:境界 喪失 • 外部依存(SDK)と ◦ Activity,

    ViewController, MediaPlayer, Room, Core Data... • 何が起きているか ◦ UIコンポーネントにビジネスロジックが散在 ◦ ViewModelが直接SDKを呼び出し • 結果 ◦ SDKを動かさないとテストできないため実機やエミュレータが必須 ◦ 遅く不安定なテスト
  6. Inbound / Core / Outbound • Inbound(入り口) ◦ アプリ内:ユーザー操作、APIレスポンス ◦

    アプリ外:通知、ディープリンク ◦ ライブラリ:Activity, Fragment, ViewController • Core(心臓部) ◦ 純粋なビジネスロジック ◦ SDK/DB/UIに依存しない • Outbound(出口) ◦ アプリ内:UI 表現、DB ◦ 端末内:位置情報、広告SDK ◦ 端末外:APIリクエスト Inbound Core Outbound DataFlow DataFlow Dependency Dependency
  7. • 依存 方向 ◦ Inbound → Core ← Outbound ◦

    Core 何にも依存しない • メリット ◦ Core テスト 簡単、高速、安定 ◦ PureなKotlin/Swiftで記述される ◦ ロジックがプラットフォームから独立する 依存 方向が重要 Inbound Core Outbound DataFlow DataFlow Dependency Dependency
  8. • 問題 ◦ ExoPlayer/AVAudioPlayerを直接使用 ◦ プラットフォーム固有 詳細に依存 ◦ テストが困難 •

    解決策:インターフェース定義 インターフェースで抽象化(1) Interface Abstraction Layer Outbound Core DataFlow Dependency interface AudioPlayer { suspend fun play(audioId: String) suspend fun pause() }
  9. • メリット ◦ ビジネスロジック Interfaceだけに依存 ◦ MediaPlayer 詳細 隠蔽 ◦

    テスト時 テストダブル(ex. Fake)を使用 インターフェースで抽象化(2) Interface Abstraction Layer Outbound Core DataFlow Dependency class ExoPlayerAdapter( private val context: Context ) : AudioPlayer { private var exoPlayer: ExoPlayer? = null override suspend fun play(audioId: String) { mediaPlayer = ExoPlayer.create(context, ...) mediaPlayer?.start() } }
  10. Inject • 注意:単体テストにおいてMockを濫用しない ◦ 一貫した振る舞いを実現するため 最小限 ◦ 単に「メソッドが呼 れたか」だけ 不十分

    依存性注入 & Mock or Fake Interface Abstraction Layer Outbound Core DataFlow Implement class MusicViewModel( private val audioPlayer: AudioPlayer // 外から注入 ) : ViewModel() { fun playMusic() { ... } }
  11. アプリ固有の型への変換(1) data class ContentUiState( val userName: String, val shouldShowPaywall: Boolean

    ) • 問題 ◦ APIレスポンスをUI層まで持ち込む ◦ 外部システム 都合がアプリ全体に漏れる • 解決策:アプリ固有 型を定義 ◦ UserResponse(APIレスポンス) ◦ ContentUiState(UI状態) Inbound Core Outbound DataFlow DataFlow Dependency Dependency APIレスポンス UI状態
  12. アプリ固有の型への変換(2) fun UserResponse.toContentUiState(): ContentUiState { return ContentUiState( userName = this.name,

    shouldShowPaywall = !this.isPremiumMember ) } @Test fun `プレミアム会員 ペイウォールをスキップ `() { val response = UserResponse( name = "太郎", isPremiumMember = true, ) val state = response.toContentUiState() assertFalse(state.shouldShowPaywall) } Pureな関数として表現された、テスト可能性が高いビジネスロジック
  13. Before:すべてが混在 class PlayerActivity : AppCompatActivity() { override fun onCreate(...) {

    lifecycleScope.launch { val adTag = api.getAdTag() val adUrl = api.getAdCreative(adTag.id) val adPlayer = ExoPlayer.create(...).apply { /* 音声を再生する */ } adPlayer.setOnCompletionListener { player = ExoPlayer().apply { /* 広告を再生する */ } } } } } 実装とロジック が混在 SDKを直接使用している Activityなしに テスト不可
  14. After:全体構成 Inbound PlayerViewModel Core PlaybackUseCase Outbound AudioPlayer AdRepository DataFlow DataFlow

    Dependency Dependency 各層 役割を明確に分離 • Inbound ◦ Coreロジックを注入される • Core ◦ 純粋なビジネスロジック表現 • Outbound ◦ 抽象化された外部依存呼び出し
  15. class PlayerViewModel(...) { fun play(audioUrl: String) { playbackUseCase .playWithAd(audioUrl) }

    } After:各層の実装 interface AudioPlayer { suspend fun play(url: String) } class ExoPlayerAdapter : AudioPlayer { ... } class PlaybackUseCase(...) { suspend fun playWithAd(audioUrl: String) { audioPlayer.play(adUrl) audioPlayer.play(audioUrl) } } Inbound Core Outbound 境界を引いた設計
  16. After:テスト 高速・安定・SDK不要 @Test fun `広告再生後に音声を再生する `() = runTest { val

    fakePlayer = FakeAudioPlayer() val fakeAdRepository = FakeAdRepository() val useCase = PlaybackUseCase(fakePlayer, fakeAdRepository) useCase.playWithAd("https://example.com/audio.mp3") assertEquals(2, fakePlayer.playedUrls.size) } Pureに記述された ロジック 高速に実行されるテスト
  17. Pure ロジック層のユニットテスト • Pure ロジック層と ◦ Core層 外部依存を持たないロジック ◦ SDK/DB/UIに依存しない

    • 3つ メリット ◦ 高速・安定・書きやすい • 効果 ◦ ビジネスロジック 正しさを直接検証 ◦ 開発サイクルを高速化 ◦ リファクタリングも安心 テスト 「つらい」→「楽しい」 に!
  18. ここから始めよう:Adapter & 統合テスト 段階的な改善とガードレール • Adapter パターン ◦ 責務:SDK 隠蔽

    み ◦ ビジネスロジック 書かない ◦ レガシーコード改善にも有効 ◦ 薄く保つ • 統合テスト ◦ 単体テストが優先 ◦ レガシーコード ガードレールとして有効 ◦ 振る舞いを固定→リファクタ→単体テストに置換 今、私も苦戦してます。OSTで語り合いましょう!
  19. まとめ 境界を引け 、テスト 楽になる • 根本原因 ◦ 外部依存とビジネスロジック 境界が曖昧 •

    テストしやすい設計 ◦ Inbound/Core/Outbound で依存 方向を明確に ◦ インターフェースでSDKを隠蔽 ◦ アプリ固有 型で Pure なロジックを実現 ◦ Pure 層 ユニットテストに注力 ◦ Adapter で段階的改善 ◦ どこをテストすべきか、どうやってテストすべきかが洗練される 一歩ずつ改善すれ 、「つらい」→「楽しい」