テストクラスを継承するとインターフェイスのテストが捗る

User オブジェクトの永続化を行う UserRepository というインターフェイスがあるとします。 また、それを実装した2つのクラス InMemoryUserRepositoryMySqlUserRepository があるとします。 これらのクラスのテストはどのように書けばよいでしょうか? (テストフレームワークは JUnit5 です)

普通に考えると InMemoryUserRepositoryMySqlUserRepository の2つのテストを個別に書かないといけないように思います。 しかし、これらのクラスはどちらも UserRepository として振る舞うはずなので、UserRepositoryインターフェイスのみを使ってテストを書けば1回テストを書くだけで2つの具象クラス両方をテストできます。

UserRepository が以下のようなインターフェイスだったとします。 (パスワードをそのままデータベースに保存するのはアウトですが、例なので許してください)

interface UserRepository {
    // 指定した id を持つユーザーを取得する。
    // 存在しないときは null を返す。
    fun getUser(id: UserId): User?

    // 名前とパスワードを指定して新しくユーザーを作成する。
    fun createUser(userName: UserName, password: Password): User
}

実装として、InMemoryUserRepositoryMySqlUserRepository があります。

class InMemoryUserRepository : UserRepository {
    private var nextUserId = 1L
    private val users = HashMap<UserId, User>()

    override fun getUser(id: UserId) = users[id]

    override fun createUser(userName: UserName, password: Password): User {
        val id = UserId(nextUserId)
        nextUserId += 1

        val user = User(id, userName, password)
        users[id] = user

        return user
    }
}
class MySqlUserRepository(
    private val dataSource: DataSource
) : UserRepository {

    override fun getUser(id: UserId): User? {
        // 略
    }

    override fun createUser(userName: UserName, password: Password): User {
        // 略
    }
}

まずは、インターフェイスである UserRepository に対してテストを書きます。 テストクラスは abstract class にしておき、テスト対象である UserRepository は abstract val として宣言します。

internal abstract class UserRepositoryTest {

    abstract val sut: UserRepository

    @Test
    @DisplayName("getUser - 正常系")
    fun getUser() {
        // Setup
        sut.createUser(UserName("alice"), Password("open sesame"))

        // Exercise
        val user = sut.getUser(UserId(1))

        // Verify
        val expected = User(UserId(1), UserName("alice"), Password("open sesame"))
        assertThat(user).isEqualTo(expected)
    }

    @Test
    @DisplayName("getUser - 存在しないユーザーを取得すると null が返る")
    fun getNonexistentUser() {
        // Exercise
        val user = sut.getUser(UserId(100))

        // Verify
        assertThat(user).isNull()
    }

    @Test
    @DisplayName("createUser - 正常系")
    fun createUser() {
        // Exercise
        val user = sut.createUser(UserName("bob"), Password("secret"))

        // Verify
        val actual = sut.getUser(user.id)
        val expected = User(user.id, UserName("bob"), Password("secret"))
        assertThat(actual).isEqualTo(expected)
    }
}

これでテストケースの定義が書けました。では、このクラスを継承してテストの実体を作ります。 まずは InMemoryUserRepositoryTest から。

internal class InMemoryUserRepositoryTest : UserRepositoryTest() {
    override val sut: UserRepository = InMemoryUserRepository()
}

たった3行でした。

次は、MySqlUserRepositoryTest を書きます。

internal class MySqlUserRepositoryTest : UserRepositoryTest() {
    private val dataSource = /* テスト用 MySQL の DataSource を作成する処理 */
    override val sut: UserRepository = MySqlUserRepository(dataSource)

    init {
        val flyway = Flyway.configure()
            .dataSource(dataSource)
            .load()
        flyway.clean()
        flyway.migrate()
    }
}

MySQL を使う場合は DataSource を作ったり、テストケースごとにデータベースを初期化したりする必要があるので、InMemory よりもちょっとコードが増えています。 この例では Flyway を使ってデータベースの初期化を行っています。

この状態でテストを実行すると InMemory と MySQL それぞれに対して UserRepositoryTest で定義した3つのテストケースが実行されます。 2つの具象クラスに対するテストを共通化できたというわけです。

以上、テストクラスを継承することでインターフェイスのテストを行う例でした。