11
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

HameeAdvent Calendar 2020

Day 6

テストを簡単にする DI を簡単に説明してみるテスト::Android風味

Last updated at Posted at 2020-12-05

はじめに

こんにちは。これは Hamee Advent Calendar 2020 6日目の記事です。

これって何の話?

私の現在のお仕事は主に Android アプリの開発なのですが、その中では Dependency Injection (DI) を多用しています。
そのこと自体は今となってはそれほど珍しくないことだと思いますが、DI は少しとっつきにくい概念でもあるので、「DI って何がおいしいの?」とか「よくわからんけど DI が良いらしいから使うべ」という人もいるんじゃないかなと勝手に思ったりしています。
だって「Dependency Injection」でググっても概念的な解説が多くて、なかなか頭に入らないというか、分かったような分からないようなだったりしません?私だけかな。。

というわけでこの記事では、概念的な話からではなく、具体的な例から DI とは何なのかを紐解いてみようかなと思います。ついでに Android のいわゆるモダンな開発での活用方法みたいな話まで持っていこうと思います。

あ、具体的な例から解説していくと、どうしても網羅的な説明は難しくなってしまいます(DI は解説するなら○○も書くべきだ的な)。そこはどうかご理解いただきたいです。。。1

例題1: 円柱の体積を計算する機能

「半径と高さから円柱の体積を計算する」アプリを作ることを考えてみたいと思います。

機能の分解

昨今のソフトウェア開発では複雑な機能をなるべく単純な機能に分解して開発していくのが一般的です。
今回の例題では

  • 機能A : 半径と高さから円柱の体積を計算する

という機能が求められていますが、この機能は少し複雑な気がしないでもないような気がしますので(異論は認めない!)2、以下の2つに機能に分解しましょう。

  • 機能B : 半径から円の面積を計算する
  • 機能C : 面積と高さから体積を計算する

コードにするとこんな感じでしょうか。

object CylinderVolumeCalculator {
    // 機能A: 半径と高さから円柱の体積を計算する
    fun calcCylinderVolume(radius: Double, height: Double): Double {
        return calcVolume(calcCircleArea(radius), height)
    }

    // 機能B: 半径から円の面積を計算する
    private fun calcCircleArea(radius: Double): Double {
        return radius * radius * Math.PI
    }

    // 機能C: 面積と高さから体積を計算する
    private fun calcVolume(area: Double, height: Double): Double {
        return area * height
    }
}

このコードは問題なく動くはずです。本体プログラムから CylinderVolumeCalculator.calcCylinderVolume() 静的メソッドを呼べば正しく動作するでしょう。ですがこういう書き方は、以下のような観点からあまりよろしくないと言われています。

  • 問題1: 分解された機能B、機能Cが非公開のためテストできない。
  • 問題2: 公開されている機能Aもテストしにくい。

問題1: 分解された機能ごとのテストができない

複雑さを嫌って機能を分解したのですから、単体テストも分解された単純な機能ごとに行うべきでしょう。それなのに機能Bと機能Cのメソッドが非公開(privateフィールド)になっていてはテストのしようがありません。まずはこれらを公開メソッドにしてあげましょう。

object CylinderVolumeCalculator {
    // 機能A: 半径と高さから円柱の体積を計算する
    fun calcCylinderVolume(radius: Double, height: Double): Double {
        return calcVolume(calcCircleArea(radius), height)
    }

    // 機能B: 半径から円の面積を計算する
    fun calcCircleArea(radius: Double): Double {
        return radius * radius * Math.PI
    }

    // 機能C: 面積と高さから体積を計算する
    fun calcVolume(area: Double, height: Double): Double {
        return area * height
    }
}

これで機能B, Cの単体テストは問題なく書けるでしょう。

問題2: 公開されている機能Aもテストしにくい

calcCylinderVolume() メソッドは元から公開されていますからテストを書くことはできます。が、ちょっと厄介な問題を抱えています。
このメソッドの機能は、本来的には「入力された半径と高さから円柱の体積を計算して返却する」ですが、機能を分解した結果、単体としての機能(スペック)は以下のように変わっています。

  • 機能Bに半径を渡して円の面積を受け取る。
  • 機能Cに面積と高和を渡して体積を受け取る。
  • 受け取った体積を返却する。

どういうことかというと、仮に機能Bの実装にミスがあって円の面積が間違っていたとしてもそれは機能B側の問題であって機能Aの問題ではありません。同様に機能Cの実装にミスがあって体積が間違っていたとしてもそれは機能Cの問題であって機能Aの問題ではありません。機能Aの役割はあくまで、受け取ったデータを機能Bや機能Cに渡したり受け取ったり返却したりすることだけです。言い換えれば、機能Aの単体テストは、受け取ったデータを正しく機能Bや機能Cに渡したり返却したりできているかだけを確認できれば良いことになります(もちろん機能Bと機能Cは十分にテストされていることが前提となります)。

このような場合、機能B、機能Cの想定される正しい挙動を模したモックを用意して機能Aのテストを行うのが一般的です。しかし calcCylinderVolume() の実装は、calcCircleArea() メソッドや calcVolume() メソッドを直接呼んでしまっているため、モックの使いようがありません。どうしたものでしょうか?

モックを使えるようにする

機能B、機能Cのモックを作成し、また使えるようにするには、一般に各機能のインタフェースを用意します。3

// 機能Bのインタフェース
interface CalcCircleAreaFunction {
    companion object {
        fun newInstance(): CalcCircleAreaFunction = CalcCircleAreaFunctionImpl()
    }
    
    fun calcCircleArea(radius: Double): Double
}

// 機能Bの実装
class CalcCircleAreaFunctionImpl : CalcCircleAreaFunction {
    override fun calcCircleArea(radius: Double): Double {
        return radius * radius * Math.PI
    }
}

// 機能Cのインタフェース
interface CalcVolumeFunction {
    companion object {
        fun newInstance(): CalcVolumeFunction = CalcVolumeFunctionImpl()
    }

    fun calcVolume(area: Double, height: Double): Double
}

// 機能Cの実装
class CalcVolumeFunctionImpl : CalcVolumeFunction {
    override fun calcVolume(area: Double, height: Double): Double {
        return area * height
    }
}

こんな感じですね。ちなみに各インタフェースにファクトリメソッド(newInstance())を用意しているのは、これらの機能を利用する側が実装クラス(CalcCircleAreaFunctionImplCalcVolumeFunctionImpl)を意識しなくて済むようにするためです。こうすれば、機能Aはとりあえず以下のように書き換えることができます。

    // 機能Aの実装(円柱の体積を計算する)
    fun calcCylinderVolume(
        radius: Double,
        height: Double,
        calcCircleAreaFunction: CalcCircleAreaFunction = CalcCircleAreaFunction.newInstance(),
        calcVolumeFunction: CalcVolumeFunction = CalcVolumeFunction.newInstance()
    ): Double {
        return calcVolumeFunction.calcVolume(calcCircleAreaFunction.calcCircleArea(radius), height)
    }

機能Aのメソッド calcCylinderVolume() に引数を追加して機能B、機能Cの関数オブジェクトを受け取れるように拡張しています。デフォルトで各機能の通常の実装が渡されるようになっているため calcCylinderVolume() メソッドの使い方に変化はありませんが、単体テストではこれらの引数に各機能のモックオブジェクトを渡すことで calcCylinderVolume() メソッドをテストできます。たとえば以下のように書けるでしょう。45

class CylinderVolumeCalculatorTest {
    @Test
    fun calcCylinderVolume_success() {
        // テストで使うパラメータ
        val requestRadius = 2.0
        val requestHeight = 3.0

        // 機能Bのモックが返す値
        val expectArea = requestRadius * requestRadius * 3.14

        // 機能Cのモックが返す値
        val expectVolume = expectArea * requestHeight

        // 機能Bのモック
        val calcCircleAreaFunctionMock = object : CalcCircleAreaFunction {
            override fun calcCircleArea(radius: Double): Double {
                // 機能Aは機能Bに対して requestRadius の値を渡していなければならない
                assertThat(radius, `is`(requestRadius))
                return expectArea
            }
        }

        // 機能Cのモック
        val calcVolumeFunctionMock = object : CalcVolumeFunction {
            override fun calcVolume(area: Double, height: Double): Double {
                // 機能Aは機能Cに対して requestHeight の値を渡していなければならない
                assertThat(height, `is`(requestHeight))
                // 同様に機能Aは機能Cに、機能Bが返した expectArea の値を渡していなければならない
                assertThat(area, `is`(expectArea))
                return expectVolume
            }
        }

        // テスト対象のメソッドを実行する
        // その際、機能B、機能Cのモックを渡す
        val volume = CylinderVolumeCalculator.calcCylinderVolume(
            requestRadius,
            requestHeight,
            calcCircleAreaFunctionMock,
            calcVolumeFunctionMock
        )
        // 機能Aは機能Cが返した expectVolume の値を返却しなければならない
        assertThat(volume, `is`(expectVolume))
    }
}

依存する機能の「注入」とは

ここでもう一度、機能A(calcCylinderVolume メソッド)の実装を見てみましょう。

    // 機能Aの実装(円柱の体積を計算する)
    fun calcCylinderVolume(
        radius: Double,
        height: Double,
        calcCircleAreaFunction: CalcCircleAreaFunction = CalcCircleAreaFunction.newInstance(),
        calcVolumeFunction: CalcVolumeFunction = CalcVolumeFunction.newInstance()
    ): Double {
        return calcVolumeFunction.calcVolume(calcCircleAreaFunction.calcCircleArea(radius), height)
    }

機能Aは機能B(calcCircleAreaFunction)と機能C(calcVolumeFunction)を、引数として受け取り、そしてそれらを利用して、自分自身の機能を実現しています。機能Aは機能Bと機能Cに依存していて、それら「依存する機能」が外部から送り込まれているようにも見えます。そのためこのような設計パターンは Dependency Injection (DI) と呼ばれています(個人的にはこの名称はあまり好きではないのですが…)。

ただし、一般に DI と言った場合、上記の実装のように依存する機能を直接メソッドに渡すことはあまりありません。というのも、機能Aを使う側(calcCylinderVolume メソッドを呼び出す側)から見ると、本来なら半径と高さの2つのパラメータさえ渡せば良いはずのメソッドなのに、なんだかよく分からない引数を余計に渡さないといけないように見えてしまうからです。それに第3引数と第4引数を渡さなかった場合、つまりデフォルト値が利用された場合、CalcCircleAreaFunctionImplクラスとCalcVolumeFunctionImplクラスがインスタンス化されてしまうので、機能Aが連続して呼ばれるようなシーンではパフォーマンス面でも問題になるかも知れません。

そのため依存する機能はメソッドに直接渡すのではなく、そのメソッドが属するクラスに渡すのが一般的だと思います。

class CylinderVolumeCalculator(
    private val calcCircleAreaFunction: CalcCircleAreaFunction = CalcCircleAreaFunction.newInstance(),
    private val calcVolumeFunction: CalcVolumeFunction = CalcVolumeFunction.newInstance()
) {
    // 機能A: 半径と高さから円柱の体積を計算する
    fun calcCylinderVolume(radius: Double, height: Double): Double {
        return calcVolumeFunction.calcVolume(calcCircleAreaFunction.calcCircleArea(radius), height)
    }
}

この例ではクラスのコンストラクタで依存する機能を渡すようにしていますが、クラスがインスタンス化されたあとに渡すようにすることもあります。
ちなみにこうした場合、テストコードは以下のように書き換える必要があります(変更箇所のみ抜粋)。

        // テスト対象のメソッドを実行する
        // その際、機能B、機能Cのモックを渡す
        val volume = CylinderVolumeCalculator(calcCircleAreaFunctionMock, calcVolumeFunctionMock)
            .calcCylinderVolume(requestRadius, requestHeight)

例題2: BGM の再生と停止をする機能

次に、もう少し Android アプリ開発っぽい例を考えてみましょう。
アプリ内で BGM を再生したり停止したりする機能を作成してみようと思います。

ざっと実装

Android で音楽を再生するにはMediaPlayerクラスを使うのが一般的だと思います。そこでMediaPlayerクラスを簡単に使うためのラッパークラスを書いてみましょう。

interface BgmPlayer {
    companion object {
        fun newInstance(context: Context): BgmPlayer =
            BgmPlayerImpl(context)
    }

    // 再生
    fun play()
    // 停止
    fun stop()
}

class BgmPlayerImpl(context: Context) : BgmPlayer {
    private val mediaPlayer = MediaPlayer.create(context, R.raw.battle_bgm).apply {
        isLooping = true
    }

    // 再生
    override fun play() {
        mediaPlayer.start()
    }

    // 停止
    override fun stop() {
        mediaPlayer.stop()
    }
}

BgmPlayerImplクラスは問題なく動作するはずです。ですがこの実装にはいくつか問題があります。一つずつ見ていきましょう。

問題1: BgmPlayerImplクラス自体をテストしにくい

これは例題1でも問題になった「依存する機能を内包してしまっている」ことに起因します。今回の例題ではBgmPlayerImplクラスが内部的にMediaPlayerクラスに依存しており、かつ private メンバとして保持しているためモックに置き換えることができません。そこで例題1でやったのと同じように、MediaPlayerをコンストラクタで渡せるようにしてみましょう。

interface BgmPlayer {
    companion object {
        fun newInstance(mediaPlayer: MediaPlayer): BgmPlayer =
            BgmPlayerImpl(mediaPlayer)

        fun newInstance(context: Context): BgmPlayer =
            BgmPlayerImpl(
                MediaPlayer.create(context, R.raw.battle_bgm).apply {
                    isLooping = true
                }
            )
    }

    // 再生
    fun play()
    // 停止
    fun stop()
}

class BgmPlayerImpl(
    private val mediaPlayer: MediaPlayer
) : BgmPlayer {
    // 再生
    override fun play() {
        mediaPlayer.start()
    }

    // 停止
    override fun stop() {
        mediaPlayer.stop()
    }
}

BgmPlayerImplクラスのコンストラクタはMediaPlayerを受け取るようにして、「ContextからMediaPlayerを生成する」という処理はファクトリメソッド側で行うよう修正しました。6
これでBgmPlayerImplクラスにMediaPlayerを注入できるようになりましたね。
こうすればBgmPlayerImplクラスの単体テストは、たとえば以下のように書けそうです。

class BgmPlayerTest {
    @Test
    fun start_and_stop_success() {
        var isStartCalled = false
        var isStopCalled = false

        // MediaPlayer のモックを作る
        val mediaPlayerMock = object : MediaPlayer() {
            override fun start() {
                isStartCalled = true
            }

            override fun stop() {
                isStopCalled = true
            }
        }

        // テスト対象のクラスをインスタンス化する
        // ただし MediaPlayer にはモックを使う
        val bgmPlayer = BgmPlayerImpl(mediaPlayerMock)

        // play メソッドのテスト: MediaPlayer の start メソッドが呼ばれること
        bgmPlayer.play()
        assertTrue(isStartCalled)

        // stop メソッドのテスト: MediaPlayer の stop メソッドが呼ばれること
        bgmPlayer.stop()
        assertTrue(isStopCalled)
    }
}

問題2: 複数個所から呼ばれる場合どうするか

BGM の再生/停止は、アプリの色々な箇所から呼ばれる可能性があります。画面が複数あるアプリでは、たとえば画面Aで BGM の再生を開始し、画面Bに遷移してから BGM を停止するということも十分考えられます。そのような場合、たとえば以下のように実装しても良いものでしょうか?

FragmentA.kt
    private val bgmPlayer: BgmPlayer by lazy { BgmPlayer.newInstance(requireContext()) }

    // 画面Aの Fragment で BGM 再生開始
    private fun playBgm() {
        bgmPlayer.play()
    }
FragmentB.kt
    private val bgmPlayer: BgmPlayer by lazy { BgmPlayer.newInstance(requireContext()) }

    // 画面Bの Fragment で BGM 停止
    private fun stopBgm() {
        bgmPlayer.stop()
    }

現在の Android アプリの開発では、多くの場合、画面ごとに異なる Fragment を用意してその Fragment ごとに動作を制御するのが一般的です。上記の例でもそれに倣って、画面Aの Fragment (FragmentA) と画面Bの Fragment (FragmentB) にそれぞれ再生と停止のコードを入れてみました。ですがこれでは正しく動作しません。なぜならそれぞれの箇所でBgmPlayerImplクラスの新しいインスタンスを生成してしまっているためです。正常に動作させるにはFragmentAFragmentBで同じインスタンスを共有する必要があります。異なる Fragment 間でオブジェクトを共有するにはどうすれば良いでしょうか?

その一つの方法として、共有したいオブジェクトをApplicationクラスの派生クラスに保持させることが考えられます7。たとえば以下のようにします。

DIContainer.kt
// アプリ内で共有するオブジェクトのコンテナ
class DIContainer(context: Context) {
    val bgmPlayer: BgmPlayer by lazy { BgmPlayer.newInstance(context) }
}
MainApplication.kt
class MainApplication : Application() {
    lateinit var diContainer: DIContainer
        private set

    override fun onCreate() {
        super.onCreate()
        diContainer = DIContainer(applicationContext)
    }
}

こうしておけば、各 Fragment で同一インスタンスの BgmPlayer を以下のように取得できます。

FragmentA.kt
class FragmentA : Fragment() {
    private lateinit var diContainer: DIContainer
    private lateinit var bgmPlayer: BgmPlayer

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        diContainer = (requireActivity().application as MainApplication).diContainer
        bgmPlayer = diContainer.bgmPlayer
    }
}

これが DI コンテナを使った DI となります。これまで見てきた、コンストラクタを介しての「機能の注入」とは少し勝手が違いますが、「特定の機能を外部から受け取っている」という意味で同じ意味合いを持ちます。

Hilt の導入

ここまで DI の基本的な考え方を書いてきました。単体テストを書く上で多くのメリットがある手法であることは確かなようですね。
その一方で DI を導入するには少し面倒くさいコードを書かなければならないというデメリットがあることも分かったと思います。例題2で見たDIContainerの実装と、それを使えるようにするためのApplicationクラスやFragmentへのコードの追加がそれです。

ですがこれらの面倒くさいコードは、便利な DI コンテナライブラリを導入することで多くの部分を省略できるようになります。ここでは Hilt というライブラリを導入して例題2のコードを書き換えてみます。

なお、この記事では Hilt のさわり程度しか解説しません。より詳しく知りたい方はこちら等を参考にしてみてください。

Hilt の初期設定

まずは開発環境に Hilt を組み込みます。

build.gradle
buildscript {
    dependencies {
        classpath 'com.google.dagger:hilt-android-gradle-plugin:2.28-alpha'
    }
}
app/build.gradle
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'

android {
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}

dependencies {
    implementation "com.google.dagger:hilt-android:2.28-alpha"
    kapt "com.google.dagger:hilt-android-compiler:2.28-alpha"
}

Applicationクラスにアノテーションを付ける

次にApplicationクラス(の派生クラス)に@HiltAndroidAppのアノテーションを付けます。
また Hilt の導入によって自前の DI コンテナは不要になるためDIContainerのインスタンス化やそれを保持するためのインスタンス変数は不要になります。つまりMainApplicationクラスの定義は以下だけで十分となります。

MainApplication.kt
@HiltAndroidApp
class MainApplication : Application()

ActivityFragmentにもアノテーションを付ける

さらにActivityFragment(の派生クラス)に@AndroidEntryPointのアノテーションを付けます。

MainActivity.kt
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    // 省略
}
FragmentA.kt
@AndroidEntryPoint
class FragmentA : Fragment() {
    // 省略
}
FragmentB.kt
@AndroidEntryPoint
class FragmentB : Fragment() {
    // 省略
}

Hilt モジュールを作る

Hilt を使うには DI して使いたいクラス、つまり例題2でいうBgmPlayerのインスタンス化の方法を Hilt に知らせる必要があります。そのためには Hilt モジュールと呼ばれる object を定義する必要があります。この object は名前は何でも構いませんが@Module, @InstallInのアノテーションを付ける必要があります。また@Providesアノテーションを付けたメソッドを定義する必要もあります。

BgmPlayerModule.kt
@Module
@InstallIn(ApplicationComponent::class)
object BgmPlayerModule {
    @Provides
    fun provideBgmPlayer(
        @ApplicationContext context: Context
    ): BgmPlayer = BgmPlayer.newInstance(context)
}

これでBgmPlayerの機能が要求されたときのBgmPlayerのインスタンス化の方法を Hilt に教えることができました。

BgmPlayerImplクラスをシングルトンにする

上記の設定でBgmPlayerのインスタンス化の方法を規定できましたが、このままだとBgmPlayerを注入するたびに新しいBgmPlayerImplインスタンスが生成されてしまいます。例題2でも見たように、このクラスは複数個所から同じインスタンスを使われる想定になっています。つまりシングルトンである必要があります。
Hilt ではシングルトンにしたいクラスに@Singletonアノテーションを付けるだけでそれを実現できます。

@Singleton
class BgmPlayerImpl(
    private val mediaPlayer: MediaPlayer
) : BgmPlayer {
    // 省略
}

これでお膳立ては整いました。

BgmPlayerの注入

以上で Hilt を使ってBgmPlayerを DI できるようになりました。
たとえば例題2で見たのと同じように Fragment でBgmPlayerを使いたい場合は以下のように注入できます。

FragmentA.kt
@AndroidEntryPoint
class FragmentA : Fragment() {
    // BgmPlayerを注入する
    @Inject
    lateinit var bgmPlayer: BgmPlayer

    // BGMを再生する
    fun playBgm() {
        bgmPlayer.play()
    }
}

インスタンス変数bgmPlayerは初期化されていないように見えますが、@Injectアノテーションを付けることで Hilt が自動的に初期化してくれます。その際 Hilt モジュール(上で定義したBgmPlayerModule object)が参照され、適切にBgmPlayerImplクラスがインスタンス化されます。あるいは、すでにBgmPlayerImplのインスタンスが存在する場合はそれが利用されます(@Singletonアノテーションがあるため)。

例題2では DI の仕組みを自前で実装しましたが Hilt を使えば「お作法」に従って書いていくだけで済みます。自分でごちゃごちゃ考える必要がなくなりますし、コードもすっきりして見通しが良くなります。例題2でやった自前実装とほぼ同じことを Hilt が自動的にやってくれているわけですね。

おまけ:ViewModel への注入

MVVM で開発する場合は ViewModel に注入することも可能です。

app/build.gradle
android {
    kotlinOptions {
        jvmTarget = "1.8"
    }
}

dependencies {
    implementation "androidx.fragment:fragment-ktx:1.2.5"
    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
    implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha02'
    kapt 'androidx.hilt:hilt-compiler:1.0.0-alpha02'
}
AViewModel.kt
class AViewModel @ViewModelInject constructor(
    // BgmPlayerを注入する
    private val bgmPlayer: BgmPlayer
) : ViewModel() {
    // BgmPlayerを使ってBGMを再生する
    fun playBgm() {
        bgmPlayer.play()
    }
}
FragmentA.kt
@AndroidEntryPoint
class FragmentA : Fragment() {
    private val viewModel: AViewModel by viewModels()

    // AViewModel#playBgm()メソッドを呼んでBGMを再生する
    fun playBgm() {
        viewModel.playBgm()
    }
}

おわりに

初めて Dependency Injection という言葉を耳にしたとき皆さんはどう感じたでしょうか?「はぁ?なんだそりゃ?」と思った人が多いのではないでしょうか?私もそのうちの一人です。私は中毒患者じゃないぞ。
DI のとっつきにくさはその名称にも原因があるんじゃないかなと思っています。依存性の注入…本当になんだそりゃですよ。。。

なのでこの記事では DI という言葉に振り回されないよう、どんなところからこういう考え方が生まれたのかという「ことの発端」みたいなところから書いてみたつもりです。またその話を「ふーん、概念はなんとなく分かった」で終わらせず、最新のモダンな開発で活かせるよう Hilt の使い方まで引っ張りました。執筆時間をあまり取れなかったのでだいぶ端折ってしまいましたが、参考にしてくれる人が少しでもいてくれたら嬉しいです。

  1. マサカリはいったん床に置きましょう!

  2. 言うまでもないと思いますが、こんな簡単な処理をわざわざ分解するなんて、普通はやらないと思います。

  3. Kotlin ならインタフェースじゃなく高階関数を使えば良いんじゃない?というツッコミが飛んできそうですが、はい、この例ではそうですよね。ですがモック化する対象のオブジェクトは通常、複数のメソッドを持っていますので、インタフェースを使うということでご理解いただきたく…(例が悪いという説)

  4. この記事ではモックをすべて自前で実装していますが MockitoMockK を使えばもっと簡単に実装できます。

  5. このテストコード、「円柱の体積の計算なんてめっちゃ単純な処理のためにこんな面倒なテスト書かなきゃいけないんかい!」というツッコミをもらえそうですね。もちろんこれは「DI とは何か」を解説するために単純な処理を題材にしたからなのですが、ある意味、「必要以上に機能を分解してもデメリットが多い」ことを示す例にもなっていますね。

  6. 「ContextからMediaPlayerを生成する」処理をBgmPlayerImplのセカンダリコンストラクタとして実装する方法もありますが、「MediaPlayerの生成処理はテスト対象外である」ことが明示的になるようファクトリメソッド側に書いてしまうのが個人的には好みです。

  7. アプリ全体で共有するオブジェクトを Application クラスに保持させる理由は以前書いた記事をご参照いただければ!

11
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?