Cucumber JVM, Android の BDD

この記事は[Kotlin Advent Calendar 2024](https://qiita.com/advent-calendar/2024/kotlin) 15日目の記事です。

[2024/11/26 Kotlin愛好会](https://love-kotlin.connpass.com/event/335521/) にて お話ししたCucumber を記事にします。



# Cucumber

- 振舞駆動開発; behavior driven development, BDD をサポートするツール
- Featureファイルに記述した自然言語のテストケース  テスト関数を生成してくれるような仕組み
- サポート言語は多岐にわたる


### Featureファイル

例えば

```
Feature: Belly
  Scenario: a few cukes
    Given I have 42 cukes in my belly
    When I wait 1 hour
    Then my belly should growl
```

Gherkin記法でテストケースを記述するファイル用意して, BDD-jvm に食わせると以下が吐き出されるので それにテストコードを書いていく形

```kotlin
Given("I have {int} cukes in my belly", (Integer int1) -> {
    // Write code here that turns the phrase above into concrete actions
    throw new io.cucumber.java8.PendingException();
});
When("I wait {int} hour", (Integer int1) -> {
    // Write code here that turns the phrase above into concrete actions
    throw new io.cucumber.java8.PendingException();
});
Then("my belly should growl", () -> {
    // Write code here that turns the phrase above into concrete actions
    throw new io.cucumber.java8.PendingException();
});
```

### Gherkin記法

この記法自体も Cucumberのコミュニティが メンテナンスしてる
BDD に沿った記述のしやすい記法

```
Feature: Guess the word

  # The first example has two steps
  Scenario: Maker starts a game
    When the Maker starts a game
    Then the Maker waits for a Breaker to join

  # The second example has three steps
  Scenario: Breaker joins a game
    Given the Maker has started a game with the word "silky"
    When the Breaker joins the Maker's game
    Then the Breaker must guess a word with 5 characters
```

ざっくり以下
```
Feature: 機能名の記述

	Scenario: テストシナリオの記述、ビジネスルールの記述 etc
	  Given テストの前提条件
	  When アクションやイベントを記述
	  Then 期待される結果 「~するべき」 の部分
```

全ては紹介しきれないが 他にも色々👇

- [`Feature`](https://cucumber.io/docs/gherkin/reference/#feature)
- [`Rule`](https://cucumber.io/docs/gherkin/reference/#rule) (as of Gherkin 6)
- [`Example`](https://cucumber.io/docs/gherkin/reference/#example) (or `Scenario`)
- [`Given`](https://cucumber.io/docs/gherkin/reference/#given), [`When`](https://cucumber.io/docs/gherkin/reference/#when), [`Then`](https://cucumber.io/docs/gherkin/reference/#then), [`And`](https://cucumber.io/docs/gherkin/reference/#and-but), [`But`](https://cucumber.io/docs/gherkin/reference/#and-but) for steps (or [``](https://cucumber.io/docs/gherkin/reference/#Asterisk))
- [`Background`](https://cucumber.io/docs/gherkin/reference/#background)
- [`Scenario Outline`](https://cucumber.io/docs/gherkin/reference/#scenario-outline) (or [`Scenario Template`](https://cucumber.io/docs/gherkin/reference/#scenario-outline))
- [`Examples`](https://cucumber.io/docs/gherkin/reference/#examples) (or [`Scenarios`](https://cucumber.io/docs/gherkin/reference/#examples))


# 例えば
Android では Instrumentation Test をサポートしている

https://github.com/cucumber/cucumber-android


### 認証したらアプリのサービスを確認できる みたいなシナリオを書いてみると
```
Feature: ログイン
  ユーザストーリー: ログインするとホームを確認できる # descriptionが書ける

  Scenario: ログインするとホームを確認できる
    Given ログインID "anpanman" パスワード "password"
    When ログインした
    Then ホーム画面が確認できる

  Scenario: ログインに失敗する
    Given ログインID "aaaaaaa" パスワード "passward"
    When ログインしようとする
    Then ホームが表示されない
```

```kotlin
@HiltAndroidTest
class LoginScenario @Inject constructor(
    private val composeRuleHolder: ComposeRuleHolder
): SemanticsNodeInteractionsProvider by composeRuleHolder.activityComposeRule {

    private val rule = composeRuleHolder.activityComposeRule
    
    @Given("ログインID {string} パスワード {string}")
    fun ログインid_パスワード(string: String, string2: String) {
        rule.waitUntilAtLeastOneExists(
            hasTestTag("login-screen-content"),
            3000
        )
        onNodeWithTag("id-textfield")
            .performTextInput(string)
        onNodeWithTag("pass-textfield")
            .performTextInput(string2)
    }
    
    @When("ログインした")
    fun ログインした( ) {
        onNodeWithTag("login-button")
            .performClick()
    }
    
    @Then("ホーム画面が確認できる")
    fun ホーム画面が確認できる( ) {
        rule.waitUntilAtLeastOneExists(
            hasTestTag("home"),
            5000
        )
        onNodeWithTag("home").assertExists()
    }

    @When("ログインしようとする")
    fun ログインしようとする( ) {
        onNodeWithTag("login-button")
            .performClick()
    }

    @Then("ホームが表示されない")
    fun ホームが表示されない( ) {
        rule.waitUntilAtLeastOneExists(
            hasTestTag("login-circular"),
            5000
        )
        rule.waitUntilDoesNotExist(
            hasTestTag("login-circular"),
            5000
        )
        onNodeWithTag("home").assertDoesNotExist()
    }
}
```

# セットアップは少々Stepあり

Cucumberは テストスイートを `CucumberOptions`で定義して Cucumberがそれを見つけて実行するような仕組み
composeRule や ActivityTestRule はそのままだとテストファイルに宣言しても使えないため RuleHolder的なコンテナをHiltで注入する仕組みになっている。

```gradle
    implementation(libs.hilt.android) // androidTestImplementationのみでは警告
    ksp(libs.hilt.compiler)
    kspAndroidTest(libs.hilt.compiler)
    androidTestImplementation(libs.hilt.testing)
    androidTestImplementation(libs.cucumber.android)
    androidTestImplementation(libs.cucumber.android.hilt)
```

エントリとなるテストスイートを 専用の `CucumberOption`アノテーションで作成する。Hiltのテストのセットアップもここで行う。
```kotlin
@CucumberOptions(
    features = [ "features" ],
    glue = ["com.example.examplecucumberandroid"]
)
class MyAndroidCucumberTestRunner : CucumberAndroidJUnitRunner() {
    override fun newApplication(
        cl: ClassLoader?,
        className: String?,
        context: Context?
    ): Application {
        return super.newApplication(
            cl,
            HiltTestApplication::class.java.name,
            context
        )
    }
}
```

前述の コンテナを準備  `@WithJunitRule` で `org.junit.Rule`を使用するためクラスを作る
```kotlin
@WithJunitRule
@Singleton
class ComposeRuleHolder @Inject constructor() {
    @get:Rule(order = 1)
    val composeRule = createComposeRule()

    @get:Rule(order = 1)
    val activityComposeRule = createAndroidComposeRule<MainActivity>()
}
```

あとは 前述通り

```kotlin
@HiltAndroidTest
class LoginScenario @Inject constructor(
    private val composeRuleHolder: ComposeRuleHolder
): SemanticsNodeInteractionsProvider by composeRuleHolder.activityComposeRule {

    private val rule = composeRuleHolder.activityComposeRule

    @Given("ログインID {string} パスワード {string}")
    fun ログインid_パスワード(string: String, string2: String) {...

```

# うまみはあるか？

以下目的ではいいのかもしれない

- 仕様側と 開発側が そこそこ明確に分かれて、仕様と実装の間に 共通言語となるドキュメントが欲しい

- iOS , Android みたいな 各プラットフォームで共通のテスト仕様 を 書く

- BDD , TDD 自体を推進したい
    - BDD で決めた振る舞い ベースのテスト仕様から TDD に繋げて プロダクションコードを設計する みたいな手法があるが それの一部で使うみたいなイメージ