仕様書とコードの二重管理を避けるために、自動テストでCucumberを採用した話

おでんチーム(管理画面リメイクチーム)の向後 宗一郎です!
主にWallet Station管理画面の改修を行っています!

最近マッチングアプリを使い始め、性格診断をしたら「縁の下の力持ち」という結果でした!
チームメンバーからも異論なし。最近のマッチングアプリの凄さを実感しました笑

さて、今回は「Wallet Station管理画面の自動テスト(E2Eテスト, インテグレーションテスト)でCucumberを採用した」ことについて話します!

Cucumberとは

公式ドキュメントを参考に紹介します。

Cucumberとはビヘイビア駆動開発(BDD)をサポートするツールです。
※ 今回はBDDについては触れません。

プレーンテキストで書かれた仕様書を基に、テストを実行し検証」します。
仕様書はGherkin形式で記述します(markdownで動作させることも可能)。

Gherkin形式.feature

Scenario: ログインできる
  Given ログイン画面に遷移する
  When id と password を入力する
  * ログインボタンをクリックする
  Then ホーム画面が表示される

Cucumber は、仕様に準拠していることを検証し、各シナリオについて ✅成功または❌失敗を示すレポートを生成することができます。

公式が出してるレポートのデモはこちら

Cucumberのレポートの他、カバレッジレポートも出力できます。
レポートについてはこちら

採用のモチベ

採用のモチベとしては大きく分けて2つあります。

コードを読まなくてもテストの仕様がわかるようにしたい

Wallet Station管理画面自体テストを十分に行えていなかったこともあり、管理画面の改修プロジェクトに合わせて「API毎のインテグレーションテスト」や「フロントエンド→バックエンドを通したE2Eテスト」を導入することになりました(以下、自動テストと表記)。

その中で、

  • 仕様書がSpread Sheetだったり
  • 仕様書通りにテストが行われていなかったり
  • そもそも仕様書がないからテストコードを見たり

のような状態でした。

エンジニアだったらテストコードでもスラスラ読めるから問題なくない?

という意見もあると思います。

ですが、どのようなテストを実施しているかは品質分析等にも使用します。
品質をアピールするなど、営業に使うこともあるでしょう。

よって、コードを読まなくてもエンジニア以外の人がテストの仕様を把握できるようにするべきだと思います。

「じゃあ、テスト仕様書 or 設計書を作成してテスト実装するか」
「仕様書、設計書は継続的に管理しよう」

よくある流れだと思います。
でも「テスト仕様書 ≒ テストコード」というのがエンジニア側の気持ちでした。

  • 二度手間、二重管理!
  • 陳腐化するでしょ!
  • そんなの作りたくないよ!
  • めんどくさい!

エンジニアは面倒なこと、非効率的なことが大嫌いです。

なのでチーム内で話し合い、下記のようなことが実現できれば私たちエンジニアも満足すると考えました。

  • テスト仕様書の作成自動化
  • テスト実装のフローで自然と仕様書が作成される(コードと二重管理にならない前提)

テストコードの見通しを良くしたい

自動テスト実装当初は

  • テストコード内にテストデータを記述したり
  • データの記述方法がバラバラだったり
  • 個人毎でテスト実装方法が若干異なっていたり

と、ごちゃごちゃしてコードの見通しが良いとは言えない状態でした。

↓は見通しが悪かったコードの例です。
例なのでコード量少なくてそこまで気にならないですがmm。

見通しの悪かったコード.kt

@SpringBootTest
@AutoConfigureMockMvc
internal class HogeTest {
    @Autowired
    lateinit var mockMvc: MockMvc

    @Test
    fun `hoge正常系`() {
        // --- ここから ---
        val form = Form(
            key1 = "piyo",
            key2 = "fuga",
        )
        val objectMapper = ObjectMapper()
        objectMapper.registerModule(JavaTimeModule())
        // --- ここまではテストデータの定義やテストデータの変換等、テスト自体ではないので切り離して見通し良くしたい + 人によって実装差が出ないようにしたい ---
        val result = mockMvc.perform(
            post("/hoge")
                .content(objectMapper.writeValueAsString(form))
                .contentType(MediaType.APPLICATION_JSON)
        )
            .andExpect(status().isOk)
        // アサーションが多くなると見づらくなるので、粒度毎に分けたい
    }
    // 前提条件のセットアップ等、なんやかんやテストコード長くなりがち
}

なので、以下のようなことができればテストコードの見通しが良くなるとチーム内で考えました。

  • テストコードと、テストデータや前提条件を別のファイルにする
    • 例1: テストコードはAPIの実行やアサーションを集約
    • 例2: テストデータやテスト実行時の状態などのテスト条件は仕様書に集約
  • 記述がある程度統一される仕組みを導入する
    • 例1: テストデータの記述が統一される
    • 例2: テスト実行時の状態を定義する記述が統一される
    • 例3: アサーションを記述する箇所が統一される

テストデータや条件の記述箇所が明確になるので、データの追加や修正などがスムーズにでき「テスト拡充しよう!」という心理的に良い状態にもなるのかなと思いました。

採用にあたっての検討事項

検討事項は大きく分けて3つです。

テスト実装の際に自然と仕様書ができるか

前述したとおり、コードと仕様書の二重管理は避けたいので

  • テスト仕様書の作成自動化
  • テスト実装のフローで自然と仕様書が作成される(二重管理ではない)

いずれかが必須条件でした。

仕様書の記述は見やすいか

仕様書が作られるようになったは良いものの、仕様書自体が読みづらかったら本末転倒です。
多少の慣れは必要だとしても、違和感がない読みやすい仕様書の記述であることを重要視しました。

テスト実装しやすいか

仕組みを導入したことにより、テスト実装自体が億劫になってしまうのは避けたいと思いました。

  • 仕様書を作るために、テストコードの記述が倍になる
  • テストコードが実装しづらい、可読性が低い

↑の状態だとテストを書きたくないですよね。

「テストが実装しやすい、読みやすい」とテストを実装したり、拡張しようという気持ちにもなると思います。
個人的には、メンタル面での抵抗を減らす、ハードルを下げることが良いプロダクトを作ることに繋がると思っています。

検証した他ツール

最初は「Gauge」を検討していました。

しかし、Gaugeはあくまでアプリの外部からE2Eテストを行うためのツールです。
おでんチームではSpring BootのMockMVCを使いたかったため、検証後に不採用になりました。

※ E2Eテストは外部から行うものですが管理コスト等を考慮した結果、バックエンドでの自動テストはMockMVCを用いたインテグレーションテストで代替することになった

Cucumberを採用した理由

Cucumberを採用した理由は、前述の検討事項を満たすことができたからです。

自然とテスト仕様書ができるフローであった

  1. Gherkin記法で仕様書を書き、それにに沿ってテスト実装を行う
  2. 仕様書を書かないと実行できないので、自然と仕様書が作られる
  3. コードとは別で仕様書を管理する必要がないので二重管理にならない
  4. テスト仕様に変更がある場合は仕様書も変更しないと実行できないので陳腐化しない(仕様書と実際のテストに差異がでない)

Cucumberではテスト実行のためにGherkin記法で書いた仕様書が必要になります。
その仕様書を基にテストコードを実装する」という無駄のない自然な開発フローで仕様書が作成されるため、余計な負担が増えなかったのが好印象でした。

仕様書が見やすかった

  1. "Given"、"When"、"Then"、"But"等で仕様(ステップ)を定義し、それをシナリオとしてグループ化するので非常に見やすい
  2. "Examples"でテストデータを複数パターン(テーブル形式)定義できる
  3. "Background"で同じファイル内の複数シナリオで共通の前処理(DBの状態などの前提条件等)を定義できる
  4. 読み方を一度レクチャーすれば(正直、初見でも全然読めると思う)、エンジニア以外もテスト仕様を簡単に理解できる

直感的でシンプルなのと、テストデータをまとめて定義できたり、冗長記述になりがちな前提条件を共通化できるので非常に見やすい仕様書だと思いました。

仕様書の例.feature

Feature: hogeテスト
  Background:
    Given DBのhogeレコードを全削除する

  Scenario Outline: hoge登録を行い、200が返ってくる
    When hoge登録リクエストを送る
      """application/json
      {
        "name": "<name>",
        "remarks": "<remarks>"
      }
      """
    Then レスポンス200を受け取る
    Examples:
      | name     | remarks           |
      | hoge     | This is hoge.     |
      | hogehoge | This is hogehoge. |

テスト実装しやすかった

  1. "Given", "When", "Then", "But"等、ステップ毎に何を実装するか明確である
  2. テストデータとテストコードがファイルで分かれるので、テストコード側は実装に集中できる
  3. 別シナリオで同じ前処理を実行する"Background"記述があるので、仕様書及びテストコードの無駄な記述が減る
  4. 仕様書の記述の粒度(ステップ毎)で実装できるので書きやすい、読みやすい、細分化される
  5. 細分化されているので、別シナリオで同じステップ定義を使い回すことができ、実装を共通化しやすい
  6. 共通のステップ定義が増えると仕様書を書くだけでテストを拡充できるようになる

仕様書をステップ毎に記述するので、テスト実装も同じようにステップ毎に実装します。
細分化されるので、テスト実装も非常にやりやすかったです。

また、ステップに渡す値の型だけ決めておけばテストデータ自体を実装時に考えなくても良いので、実装に注力することができました。
通化もしやすく、テストコードが増えるとドンドン楽になっていくのも良い点です。

仕様書の例のステップ.kt

class HogeSteps {
    @Autowired
    lateinit var ds: DataSource

    @Autowired
    lateinit var mockMvc: MockMvc

    @Autowired
    lateinit var testResultActionsInformation: TestResultActionsInformation
    
    @Given("DBのhogeレコードを全削除する")
    fun truncateAdminUser() {
        dbSetup(to = ds) {
            truncate("hoge")
        }.launch()
    }

    @When("hoge登録リクエストを送る")
    fun createHoge(docString: DocString) {
        val servletPath = "/hoge"
        testResultActionsInformation.result = mockMvc.perform(
            post(servletPath)
                .with(TestRequestProcessor(servletPath))
                .content(docString.content)
                .contentType(MediaType.APPLICATION_JSON)
        )
    }
}

共通化するStepは抽象的に.kt

class VerifyResponseStatusSteps {
    @Autowired
    lateinit var testResultActionsInformation: TestResultActionsInformation

    @Then("レスポンス200を受け取る")
    fun verifyResponseWith200() {
        testResultActionsInformation.result.andExpect(status().isOk)
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
    }
}

Cucumber内用のBean定義は@ScenarioScope.kt

@Component
@ScenarioScope
class TestResultActionsInformation {
    lateinit var result: ResultActions
}

フロントエンドでもCucumberを導入できた(結果的に)

当初フロントエンド側のE2EテストではCucumberを導入する予定ではなかったですが、フロントエンドのE2Eテストで採用しているCypressでもCucumberを動作させることができると判明しました。
結果的にフロントエンド、バックエンド共に同じ記述の仕様書やテストコードで自動テストを実行できるようになったのも決め手の一つです。

困った、大変だったこと

初使用のツールでベストプラクティスが分からない

どんな技術でも社内に有識者がいないとあるあるな課題だとは思いますが、共通のステップ定義の作成やディレクトリ構成、テストデータの定義の仕方等、何が良いのか全然分かりませんでした。

特にGherkin記法はテストデータの定義方法や渡し方が複数あるので大変でした。
有識者がいないので、「とりあえずテストを一つ作ってみる → レビュー → 改善 → 別テストで計画」(DCAP的な)を繰り返して改善しました。

今思えば「ちょっといいですか?」ができるチームだったので成功したのかなと感じてます(心理的安全性って大事だな)。

改善の中で発見したTips

やっぱり慣れは必要(当たり前)

どんなツールにも言えることですが、 - Gherkin記法が書きやすい、読みやすい - Cucumberのテスト実装がしやすい

といえど、多少の慣れは必要でした(テスト実装を1つすれば慣れるくらいのレベルですが)。
特に、Gherkin記法でのテストデータの定義や渡し方null, 空文字, 改行の扱い方など)で少し慣れが必要でした。
初めて触る人でも簡単にキャッチアップできるようにドキュメント化すれば大きな問題にはならないと思います。

ツールに縛られるのが良いのか悪いのか問題

社内で「ツールに縛られることでメンバーが動きづらくなってしまうのでは?(制約)」という意見が一度出ました。

これに関しては「良い制約」と「悪い制約」があると考えます。

「制約を設けることで誰が書いても同じようなコード、仕様書にすることができる ∧ 読み書きがしやすい」
のような状態であれば良い制約だと言えます。

反対に
「読み書きが難しい、コードの記述が凄く多くなる」
のような状態なら悪い制約なので、検討事項で述べたように避けるべきだと思います。

今回は前者なので、ツールに縛られたことは間違っていないと考えます。

まとめ

自動テストにCucumberを採用することで、陳腐化しないテスト仕様書の作成テストコードの見通し向上を行うことができました!
まだ導入して間もないので、Tipsなどの知見が溜まったら本記事に追記するか新しい記事を投稿したいと思っています!

以上、Wallet Station管理画面の自動テストでCucumberを採用した話でした!

Tips

JSON形式でリクエストする場合、テストデータを渡す記述はDataTableよりStringDocを使うと見やすい。
また、JSON形式で記述できるのでテストコード側で変換いらずでリクエストできる。

StringDoc.feature

Feature: 登録テスト
  Scenario Outline: 登録したデータを参照できるか
    # テストコード側でDataTableからJSONに変換する必要がある
    When 登録する (DataTable)
      | key1 | <key1> |
      | key2 | <key2> |

    # JSONなのでテストコード側でそのまま渡せる
    When 登録する (JSON)
      """application/json
      {
        "key1": "<key1>",
        "key2": "<key2>"
      }
      """
    Then 参照する
    
    Examples:
      | key1 | key2 |
      | hoge | fuga |
      | hogehoge | fugafuga |