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

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

Sponsored · Ship Features Fearlessly Turn features on and off without deploys. Used by thousands of Ruby developers.

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

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 で段階的改善 ◦ どこをテストすべきか、どうやってテストすべきかが洗練される 一歩ずつ改善すれ 、「つらい」→「楽しい」