Kotlin
assertj

ユニットテストのassertionにAssertJ 3.9を利用するサンプルコード

概要

Kotlinを利用したプロジェクトのユニットテストでassertionにAssertJを利用したサンプルコードです。

環境

  • Windows 10 Professional
  • Java 1.8.0_162
  • Kotlin 1.2.21
  • JUnit 4.12
  • AssertJ 3.9.0
  • Intellij IDEA 2017.3

参考

導入

build.gradleの依存関係に下記を追加します。

dependencies {

    testCompile("junit:junit:4.12")
    testCompile("org.assertj:assertj-core:3.9.0")
}

テストクラスでは、Assertionsクラスのassertionメソッドをimportしておくとコード量が減って見やすくなります。

import org.assertj.core.api.Assertions.*

日付時刻

LocalDateやLocalDateTimeを検証するサンプルコードです。

この記事内で期間の表現に使う用語の定義を、以下のようにしています。

期待する意味
以前 2018年1月27日以前 2018年1月27日を含む、それより前の期間
以後 2018年1月27日以後 2018年1月27日を含む、それより後の期間
2018年1月27日前 2018年1月27日を含まない、それより前の期間
2018年1月27日後 2018年1月27日を含まない、それより後の期間
  • ちなみに昭和以前・昭和以後という言い方をしたときに、一般的には以前は昭和時代は含まず、以後は昭和時代を含むと思いますので、人によって用語の解釈に違いがあるかと思います。

また、参考として数量の範囲を表現する場合の定義をあげておきます。

期待する意味
以下 100以下 100を含む、それより少ない数
以上 100以上 100を含む、それより多い数
未満 100未満 100を含まない、それより少ない数
超える 100超 100を含まない、それより多い数

一致、不一致の検証

実際値(actual)と期待値(expected)が一致する、または一致しないことを期待します。

var actual: LocalDate = LocalDate.of(2018, 1, 27)

// 一致することを期待
assertThat(actual).isEqualTo(LocalDate.of(2018, 1, 27))
// 一致しないことを期待
assertThat(actual).isNotEqualTo(LocalDate.of(2018, 1, 28))

期待値に日付の文字列表現を指定することもできます。

var actual: LocalDate = LocalDate.of(2018, 1, 27)

// 一致することを期待
assertThat(actual).isEqualTo("2018-01-27")
// 一致しないことを期待
assertThat(actual).isNotEqualTo("2018-01-28")

境界、範囲の検証

実際値が期待値の”以後”または”後”であることを期待します。

var actual: LocalDate = LocalDate.of(2018, 1, 27)

// 境界は含まない actual > expected
assertThat(actual).isAfter("2018-01-26")
// 境界を含む actual >= expected
assertThat(actual).isAfterOrEqualTo("2018-01-27")

実際値が期待値の”以前”または”前”であることを期待します。

var actual: LocalDate = LocalDate.of(2018, 1, 27)

// 境界は含まない actual < expected
assertThat(actual).isBefore("2018-01-28")
// 境界を含む actual <= expected
assertThat(actual).isBeforeOrEqualTo("2018-01-27")

実際値が2つの期待値の範囲内であることを期待します。

var actual: LocalDate = LocalDate.of(2018, 1, 27)

assertThat(actual).isBetween("2018-01-27", "2018-01-27")
assertThat(actual).isBetween("2018-01-01", "2018-01-31")

実際値が期待値を起点とした前後x日の範囲内であることを期待します。
この例では期待値の"2018-01-31"を起点として前後5日の範囲(2018-01-26 ~ 2018-02-05)を期待します。

var actual = LocalDate.of(2018, 1, 27)

assertThat(actual).isCloseTo("2018-01-31", within(5, ChronoUnit.DAYS))

同じような感じで、前後1カ月の範囲内であることを期待する場合に以下のようなコードで検証すると、期待通りにならないかもしれません。

たとえば期待日(2018-01-01)の前後1カ月の範囲を2017-12-01から2018-02-01としたい場合、このコードでは実際に許可される範囲は2017-11-02から2018-03-01となり期待通りになりません。
これは1カ月と10日というような期間では、1カ月未満の半端な日数が無視されてしまうためです。

var actual = LocalDate.of(2017, 12, 1)

assertThat(actual).isCloseTo("2018-01-01", within(1, ChronoUnit.MONTHS))

期待日の前後xカ月の範囲を期待したい場合は、下記のように書くしかないようです。

val exptected = LocalDate.of(2018, 1, 1)
val expectedFrom = exptected.minusMonths(1)
val expectedTo = exptected.plusMonths(1)

assertThat(actual).isBetween(expectedFrom, expectedTo)

時刻の部分を無視して検証する

LocalDateTime型の検証で時刻の単位を無視して検証したい場合、isEqualToIgnoringXxxxxメソッドを使います。

var actual: LocalDateTime = LocalDateTime.of(2018, 1, 27, 22, 46, 51)

// hours以下を切り落として比較
// yyyy-MM-ddが一致していることを期待
assertThat(actual).isEqualToIgnoringHours(LocalDateTime.of(2018, 1, 27, 13, 11, 25))

// minutes以下を切り落として比較
// yyyy-MM-dd HHが一致していることを期待
assertThat(actual).isEqualToIgnoringMinutes(LocalDateTime.of(2018, 1, 27, 22, 15, 31))

// seconds以下を切り落として比較
// yyyy-MM-dd HH:mmが一致していることを期待
assertThat(actual).isEqualToIgnoringSeconds(LocalDateTime.of(2018, 1, 27, 22, 46, 1))

Map

サイズの検証

val actual = emptyMap<Int, String>()

assertThat(actual)
    .isNullOrEmpty()

マップが特定のサイズであることを検証するにはhasSizeメソッドを使います。

val actual = mapOf(0 to "A", 1 to "B", 2 to "C", 3 to "D", 4 to "E", 5 to "F", 6 to "G")

assertThat(actual)
    .hasSize(7)
    .describedAs("The expected value is 7")

サイズを以上、以下で検証するにはsizeメソッドを使います。

val actual = mapOf(0 to "A", 1 to "B", 2 to "C", 3 to "D", 4 to "E", 5 to "F", 6 to "G")

assertThat(actual)
    .size()
        .isGreaterThanOrEqualTo(1)
        .isLessThanOrEqualTo(7)
    .describedAs("The expected value is 1 - 7")

サイズの検証に続けてマップのentryを検証するにはreturnToMapメソッドを使います。

val actual = mapOf(0 to "A", 1 to "B", 2 to "C", 3 to "D", 4 to "E", 5 to "F", 6 to "G")

assertThat(actual)
    .size()
        .isGreaterThanOrEqualTo(1)
        .isLessThanOrEqualTo(7)
    .returnToMap()
        .allSatisfy { key, value ->
            // マップの各entryを検証するコード
            assertThat(key).isGreaterThanOrEqualTo(0).isLessThanOrEqualTo(6)
            assertThat(value).containsPattern("[A-G]")
        }

マップの各entryの検証

実際値のmapに期待するキー、値が含まれているか検証します。

val actual = mapOf(
    0 to "alpha", 1 to "beta",
    2 to "gamma", 3 to "delta",
    4 to "epsilon", 5 to "zeta")

// 単一のキー
assertThat(actual).containsKey(0)
// 複数のキー
assertThat(actual).containsKeys(0, 2, 4)

// 単一の値
assertThat(actual).containsValue("alpha")
// 複数の値
assertThat(actual).containsValues("alpha", "beta", "gamma")

実際値のmapに期待するentryが含まれているか検証します。
期待値が単一のentryの場合は、containsEntryメソッドを使います。

val actual = mapOf(
    0 to "alpha", 1 to "beta",
    2 to "gamma", 3 to "delta",
    4 to "epsilon", 5 to "zeta")

// 単一のentry
assertThat(actual).containsEntry(0, "alpha")

期待値が複数のentryの場合は、org.assertj.core.api.Assertions.entryで一度ラップする必要があります。

// 複数のentry
assertThat(actual).contains(
    entry(0, "alpha"),
    entry(1, "beta"),
    entry(2, "gamma"))

Map型で準備した期待値を利用したい場合はcontainsAllEntriesOfメソッドを使います。

val expected = mapOf(0 to "alpha", 2 to "gamma", 4 to "epsilon")

assertThat(actual).containsAllEntriesOf(expected)

containsメソッドには、下記の類似メソッドがあります。

  • containsAnyOf : 期待値のいずれかのentryが含まれている
  • containsOnly : 期待値のentryと実際値のentryが一致する
  • containsExactly : 期待値のentryと実際値のentryが並びも含めて一致する

これらのメソッドは期待値を可変長引数として取るので、この記事では下記のようなユーティリティー関数を定義して使っています。

fun <T, U> convertToArray(map: Map<T, U>): Array<MapEntry<T, U>> =
    map.asSequence()
       .map { Assertions.entry(it.key, it.value) }
       .toSet()
       .toTypedArray()
// 実際値
val actual = mapOf(
    0 to "alpha", 1 to "beta",
    2 to "gamma", 3 to "delta",
    4 to "epsilon", 5 to "zeta")

// 並びが逆順の実際値
val revActual = actual.toSortedMap(Comparator.reverseOrder())

// すべてのentryを期待する
val expectedAnyEntries = convertToArray(
    mapOf(0 to "alpha", 1 to "beta",
          2 to "gamma"))

// いずれかのentryを期待する
// keyの6,7は実際値には含まれないentry
val expectedAnyWithUnknownEntries = convertToArray(
    mapOf(0 to "alpha",
          6 to "eta",
          7 to "theta"))

// 実際値と同じentryを期待する
val expectedAllEntries = convertToArray(
    mapOf(0 to "alpha", 1 to "beta",
          2 to "gamma", 3 to "delta",
          4 to "epsilon", 5 to "zeta"))

実際値のentryに、期待値の何れかのentryが1つ以上含まれていることを期待します。

assertThat(actual).containsAnyOf(*expectedAnyWithAEntries)

// これはこれはAssertionError
// assertThat(actual).contains(*expectedAnyWithAEntries)

実際値のentryと期待値のentryが一致することを期待します。

assertThat(actual).containsOnly(*expectedAllEntries)

// entryの並び順は無視される
assertThat(revActual).containsOnly(*expectedAllEntries)

// これはAssertionError
// assertThat(actual).containsOnly(*expectedAnyEntries)

実際値のentryと期待値のentryが並びも含めて一致することを期待します。

assertThat(actual).containsExactly(*expectedAllEntries)

// これはAssertionError
// assertThat(revActual).containsExactly(*expectedAllEntries)

コレクション

コンディションを使った検証

val FRUIT_SET = setOf("apple", "banana", "cherry", "durian", "elderberry")
// create condition
val FRUIT = Condition<String>(Predicate { it -> FRUIT_SET.contains(it) }, "is fruit")

is/isNotでコンディションに一致するか検証します。

assertThat("apple").`is`(FRUIT)
assertThat("broccoli").isNot(FRUIT)

コレクションの場合は、are/areNotを使います。

assertThat(setOf("apple", "banana")).are(FRUIT)
assertThat(setOf("arugula", "broccoli")).areNot(FRUIT)

コンディションでフィルタリングを行う

コレクションから条件に一致する要素のみを抽出して検証するには、コンディションとfilteredOnメソッドを使います。

この例では、名前がaから始まるフルーツを期待します。

フィルターを行うコンディション

fun startsWith(letter: String): Condition<String> {
    return Condition(Predicate { it.startsWith(letter) }, "name begins with a" )
}

コンディションを使ったフィルタリングと検証

val actual = setOf("apple", "banana", "cherry", "durian", "elderberry", "apricot", "mango")

assertThat(actual)
    .filteredOn(startsWith("a"))
    .containsOnly("apple", "apricot")

この例は下記のようにシンプルに書き直すことができます。

assertThat(actual)
    .filteredOn { it.startsWith("a") }
    .containsOnly("apple", "apricot")

ファイル

テスト対象が作成または更新するテキストファイルを検証するサンプルコードです。
テストで使用するtest.txtというファイルは、テスト対象コードが出力したファイルという想定です。

テストで使用するファイル

test.txt
first line
2nd line
3rd line
// ファイルを作成、更新するテストコードを記述
val file = TestFileUtils.getResourceFile("test.txt").toFile()

// ファイルの存在を検証
assertThat(file)
    .exists()
    .isFile()
    .isAbsolute()
    .hasName("test.txt")

// ファイルの内容を検証
assertThat(contentOf(file))
    .hasLineCount(3)
    .startsWith("first line")
    .contains("2nd line")
    .endsWith("3rd line")

例外

テスト対象が例外をスローする、またはスローしないことを検証するサンプルコードです。

このサンプルコードで使用するカスタム例外の定義は下記のようになっています。

class SomeKindException(
    message: String,
    cause: Throwable?,
    val line: Int?,
    val column: Int?): Exception(message, cause) {

    constructor(message: String): this(message, null, null, null)
    constructor(message: String, cause: Throwable): this(message, cause, null, null)
    constructor(message: String, line: Int, column: Int): this(message, null, line, column)
    constructor(cause: Throwable): this(cause.toString(), cause, null, null)

    override fun toString(): String {
        return "message:$message, line:$line, column:$column, cause:${cause.toString()} "
    }
}

特定の例外がスローされることを期待する

assertThatThrownBy {
    // ここに例外をスローするテストコードを記述
    throw IOException("test IO exception")
}
.isInstanceOf(IOException::class.java)
.hasMessage("test IO exception")

別の書き方としてassertThatExceptionOfTypeメソッドを使う方法もありあます。

assertThatExceptionOfType(IOException::class.java).isThrownBy {
    // ここに例外をスローするテストコードを記述
    throw IOException("test IO exception")
}
.withMessage("test IO exception")

特定の例外がCauseに設定されていることを期待する

別の原因を持つ例外がスローされていることを検証するにはwithCauseメソッドを使います。

assertThatExceptionOfType(SomeKindException::class.java).isThrownBy {
    // ここにカスタム例外をスローするテストコードを記述
    val ex = IOException("test IO exception")
    throw SomeKindException("test custom exception", ex)
}
.withMessage("test custom exception")
.withCause(IOException("test IO exception"))

逆に別の原因をもっていないことを検証するにはwithNoCauseメソッドを使います。

エイリアスを使う

assertThatExceptionOfTypeには、よく使われるJava例外用のエイリアスがあります。

  • assertThatNullPointerException
  • assertThatIOException
  • assertThatIllegalArgumentException
  • assertThatIllegalStateException
assertThatNullPointerException().isThrownBy {
    // ここにNullPointerExeptionをスローするテストコードを記述
    throw NullPointerException("test NP exception")
}
.withMessage("test NP exception")

カスタム例外の特定のフィールドを検証する

カスタム例外の特定のフィールドを検証したい場合、スローされた例外オブジェクトを返すcatchThrowableOfTypeメソッドを使います。

catchThrowableOfType({
    // ここにカスタム例外をスローするテストコードを記述
    throw SomeKindException("test custom exception", 10, 75)
}, SomeKindException::class.java)
.also { exception ->
    assertThat(exception).hasMessage("test custom exception")
    // 任意のフィールドへのassertionはassertThatで行う
    assertThat(exception.line).isEqualTo(10)
    assertThat(exception.column).isEqualTo(75)
}

例外がスローされないことを期待する

テスト対象が例外をスローしないことを検証したい場合は、doesNotThrowAnyExceptionメソッドを使います。

assertThatCode {
    // ここに例外をスローしないテストコードを記述
    // val result = 1 / 0
}
.describedAs("not to raise a throwable")
.doesNotThrowAnyException()

SoftAssertions

1つのテストメソッド内で複数の検証を行う場合、どれかが失敗した時点で残りの検証は行われずに終了します。
失敗する検証があっても残りの検証を引き続き行いたい場合に、SoftAssertionを使用します。

基本的な使い方

最後にassertAllメソッドを呼ぶ必要があります。

SoftAssertions().run {
    assertThat("abc").describedAs("abc != ABC").isEqualTo("ABC")
    assertThat(1).describedAs("1 != 2").isEqualTo(2)
    assertAll() //必要
}

ラムダ式を使う方法

SoftAssertions.assertSoftly { softly ->
   softly.assertThat("abc").describedAs("abc != ABC").isEqualTo("ABC")
   softly.assertThat(1).describedAs("1 != 2").isEqualTo(2)
   // softly.assertAll() 不要
}

use関数を使う方法

この方法は、AutoCloseableを実装したAutoCloseableSoftAssertionsを使い、リソースを自動的に閉じてくれるuse関数を使うものです。
上記の書き方の方がシンプルなので使うことはあまりないと思います。

AutoCloseableSoftAssertions
public class AutoCloseableSoftAssertions extends SoftAssertions implements AutoCloseable
AutoCloseableSoftAssertions().use { softly ->
    softly.assertThat("abc").describedAs("abc != ABC").isEqualTo("ABC")
    softly.assertThat(1).describedAs("1 != 2").isEqualTo(2)
    // softly.assertAll() 不要
}

JUnitのRuleを使う方法

ルールを宣言します。

@Rule
@JvmField
val softlyRule: JUnitSoftAssertions = JUnitSoftAssertions()

テストメソッドの終了後にassertAllが自動的に呼ばれるので明示的に記述する必要はありません。(正確にはassertAllメソッドは定義されていないので呼ぶことはできません)

softlyRule.assertThat("abc").describedAs("abc != ABC").isEqualTo("ABC")
softlyRule.assertThat(1).describedAs("1 != 2").isEqualTo(2)
// assertAll() は不要