クラスを再帰的に評価する usingRecursiveComparison()

こんにちは。株式会社 Interfamilia の Waka です。

早いもので、もう9月になりました。まだまだ暑さが続きますね
先月は自分の身の回りで体調を崩す方が続出していました。
皆さんも熱中症には十分お気を付けください🥺

今回の記事では、AssertJに関するTIPSをご紹介したいと思います!


目次

クラスを再帰的に評価する usingRecursiveComparison()

AssertJは、JVM系言語向けのアサーションライブラリです。
テスト結果を検証する時に便利なメソッドが沢山用意されています。

中でも usingRecursiveComparison() は非常に便利なメソッドです。

AssertJ 3.12.0 で追加されたメソッドで、 入れ子になってるクラスの各要素1を再帰的に評価できます。
実際の使用例を交えて、紹介したいと思います。

使用例

バージョン・環境

kotlin: 1.9.10
assertj-core: 3.20.2

比較するクラス

以下のMonsterクラス同士を比較する場合を考えてみます。

data class Monster(
    val id: Long,
    val name: String,
    val gender: Gender,
    val skill: Skill,
)

enum class Gender { MALE, FEMALE, NONE }

data class Skill(
    val skill1: String,
    val skill2: String? = null,
)

テストが成功するケース

2つのMonster型 (monster1, monster2) を usingRecursiveComparison() で比較する時の記述例です。
メソッドチェーンで isEqualTo() を繋ぎ、2つのMonster型を比較しています
プロパティ値が全て等しいので、テストが成功します。

@Test
fun test() {
    val monster1 = Monster(
        id = 445,
        name = "ガブリアス",
        gender = Gender.MALE,
        skill = Skill(
            skill1 = "ドラゴンダイブ",
        ),
    )
    val monster2 = Monster(
        id = 445,
        name = "ガブリアス",
        gender = Gender.MALE,
        skill = Skill(
            skill1 = "ドラゴンダイブ",
        ),
    )

    assertThat(monster1)
        .usingRecursiveComparison()
        .isEqualTo(monster2)
}

テストが失敗するケース

以下の例は gender,skill のプロパティ値が異なるので、テストが失敗します。

@Test
fun failTest() {
    val monster1 = Monster(
        id = 445,
        name = "ガブリアス",
        gender = Gender.MALE,
        skill = Skill(
            skill1 = "ドラゴンダイブ",
            skill2 = null,
        ),
    )
    val monster2 = Monster(
        id = 445,
        name = "ガブリアス",
        gender = Gender.FEMALE, // 差分
        skill = Skill(
            skill1 = "ドラゴンダイブ",
            skill2 = "じしん", // 差分
        ),
    )

    assertThat(monster1)
        .usingRecursiveComparison()
        .isEqualTo(monster2)
}

テスト失敗時、ログに表示されるエラーメッセージは以下になります。
一致しなかった箇所だけがピックアップされるので、特定が容易です。

Expecting actual:
  Monster(id=445, name=ガブリアス, gender=MALE, skill=Skill(skill1=ドラゴンダイブ, skill2=null))
to be equal to:
  Monster(id=445, name=ガブリアス, gender=FEMALE, skill=Skill(skill1=ドラゴンダイブ, skill2=じしん))
when recursively comparing field by field, but found the following 2 differences:

field/property 'gender' differ:
- actual value  : MALE
- expected value: FEMALE

field/property 'skill.skill2' differ:
- actual value  : null
- expected value: "じしん"

The recursive comparison was performed with this configuration:
- no overridden equals methods were used in the comparison (except for java types)
- these types were compared with the following comparators:
  - java.lang.Double -> DoubleComparator[precision=1.0E-15]
  - java.lang.Float -> FloatComparator[precision=1.0E-6]
  - java.nio.file.Path -> lexicographic comparator (Path natural order)
- actual and expected objects and their fields were compared field by field recursively even if they were not of the same type, this allows for example to compare a Person to a PersonDto (call strictTypeChecking(true) to change that behavior).
- the introspection strategy used was: DefaultRecursiveComparisonIntrospectionStrategy

ちなみに、AssertJ標準のassertEquals()でも同様の比較 (クラス内の要素を再帰的に評価) は可能です。
ただし、プロパティ値が一致しなかった時のエラーメッセージが、非常に分かりにくくなっています。

以下が assertEquals() で比較した場合のエラーメッセージ全文です。

expected: <Monster(id=445, name=ガブリアス, gender=FEMALE, skill=Skill(skill1=ドラゴンダイブ, skill2=じしん))> but was: <Monster(id=445, name=ガブリアス, gender=MALE, skill=Skill(skill1=ドラゴンダイブ, skill2=null))>
予想:Monster(id=445, name=ガブリアス, gender=FEMALE, skill=Skill(skill1=ドラゴンダイブ, skill2=じしん))
実際:Monster(id=445, name=ガブリアス, gender=MALE, skill=Skill(skill1=ドラゴンダイブ, skill2=null))

こうしてみると、テスト結果と期待値の等値比較が、たった数行のコードで実現できる usingRecursiveComparison() の方が便利に感じられます。

ignoringFields()で、一部フィールドを無視できる!

ignoringFields() をメソッドチェーンで繋ぐと、比較時に無視したい要素をスキップできます。
ID、タイムスタンプ等、固有のユニークな値を無視して、それ以外の要素を等値比較したい時に便利です。

以下の例は ignoringFields() を使用して id,gender,skill 以外の要素を比較しています。
それ以外のプロパティ値が等しいので、テストが成功します。

@Test
fun ignoringFieldTest() {
    val monster1 = Monster(
        id = 132,
        name = "ガブリアス",
        gender = Gender.NONE,
        skill = Skill(
            skill1 = "へんしん",
        ),
    )
    val monster2 = Monster(
        id = 445,
        name = "ガブリアス",
        gender = Gender.MALE,
        skill = Skill(
            skill1 = "ドラゴンダイブ",
            skill2 = "じしん",
        ),
    )

    assertThat(monster1)
        .usingRecursiveComparison()
        .ignoringFields("id", "gender", "skill")
        .isEqualTo(monster2)
}

サンプルコード

上の使用例を全て纏めたサンプルコードです。

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals

class UsingRecursiveComparisonTest {
    // テストが成功するケース (usingRecursiveComparison)
    @Test
    fun test() {
        val monster1 = Monster(
            id = 445,
            name = "ガブリアス",
            gender = Gender.MALE,
            skill = Skill(
                skill1 = "ドラゴンダイブ",
            ),
        )
        val monster2 = Monster(
            id = 445,
            name = "ガブリアス",
            gender = Gender.MALE,
            skill = Skill(
                skill1 = "ドラゴンダイブ",
            ),
        )

        assertThat(monster1)
            .usingRecursiveComparison()
            .isEqualTo(monster2)
    }

    // テストが成功するケース (assertEquals)
    @Test
    fun test() {
        val monster1 = Monster(
            id = 445,
            name = "ガブリアス",
            gender = Gender.MALE,
            skill = Skill(
                skill1 = "ドラゴンダイブ",
            ),
        )
        val monster2 = Monster(
            id = 445,
            name = "ガブリアス",
            gender = Gender.MALE,
            skill = Skill(
                skill1 = "ドラゴンダイブ",
            ),
        )

        assertEquals(monster2, monster1)
    }

    // テストが失敗するケース (usingRecursiveComparison)
    @Test
    fun failTest() {
        val monster1 = Monster(
            id = 445,
            name = "ガブリアス",
            gender = Gender.MALE,
            skill = Skill(
                skill1 = "ドラゴンダイブ",
                skill2 = null,
            ),
        )
        val monster2 = Monster(
            id = 445,
            name = "ガブリアス",
            gender = Gender.FEMALE, // 差分
            skill = Skill(
                skill1 = "ドラゴンダイブ",
                skill2 = "じしん", // 差分
            ),
        )

        assertThat(monster1)
            .usingRecursiveComparison()
            .isEqualTo(monster2)
    }

    // テストが失敗するケース (assertEquals)
    @Test
    fun failTestAssertEquals() {
        val monster1 = Monster(
            id = 445,
            name = "ガブリアス",
            gender = Gender.MALE,
            skill = Skill(
                skill1 = "ドラゴンダイブ",
                skill2 = null,
            ),
        )
        val monster2 = Monster(
            id = 445,
            name = "ガブリアス",
            gender = Gender.FEMALE, // 差分
            skill = Skill(
                skill1 = "ドラゴンダイブ",
                skill2 = "じしん", // 差分
            ),
        )

        assertEquals(monster2, monster1)
    }

    // テストが成功するケース (ignoringFieldsで一部プロパティを無視)
    @Test
    fun ignoringFieldTest() {
        val monster1 = Monster(
            id = 132,
            name = "ガブリアス",
            gender = Gender.NONE,
            skill = Skill(
                skill1 = "へんしん",
            ),
        )
        val monster2 = Monster(
            id = 445,
            name = "ガブリアス",
            gender = Gender.MALE,
            skill = Skill(
                skill1 = "ドラゴンダイブ",
                skill2 = "じしん",
            ),
        )

        assertThat(monster1)
            .usingRecursiveComparison()
            .ignoringFields("id", "gender", "skill")
            .isEqualTo(monster2)
    }
}

data class Monster(
    val id: Long,
    val name: String,
    val gender: Gender,
    val skill: Skill,
)

enum class Gender { MALE, FEMALE, NONE }

data class Skill(
    val skill1: String,
    val skill2: String? = null,
)

最後に

以上、AssertJの便利なメソッド usingRecursiveComparison() の紹介でした。
テストを書く時に重宝しているのですが、意外と知名度が低く、ネット上に情報が少ないように感じたので、今回改めて記事にした次第です。

他にも色々と便利なオプションがあるので、公式ドキュメント もご確認いただければと思います。

それではまた次回!


参考リンク


会社を一緒に盛り上げてくれる仲間を募集しています!

応募フォームには、外部サービスengageを利用しています。
ご応募に際して、何かわからないことなどございましたら こちら からお気軽にお問い合わせください。


  1. Kotlinにおけるプロパティ、Javaにおけるフィールドを指します。 ↩︎