🌊

Rust で Cucumber によるテストを書く

2023/12/08に公開

概要

この記事は Rust Advent Calendar 2023 の8日目の記事です。

Rust で Cucumber を使う機会があったのでその紹介をしたいと思います。
サンプルプロジェクト: https://github.com/Takaichi00/rust-cucumber-sample

Cucumber とは

  • Gherkin 記法を用いてテストケースを記述し、実行できるツール
  • ビジネスサイドとのギャップを埋めたり、 BDD をサポートするツールと銘打っている
  • 自然言語を用いてテストケースを記載できるという点が一番の特徴
  • 日本語でテストケースを書くことも可能

セットアップ

  • 基本的には Cucumber Rust Book の QuickStart を参考にします

  • Cucumber (Gherkin) Full Support - Visual Studio Marketplace

    • この VSCode のプラグインを入れておくと、.feature ファイルのフォーマットをしてくれるので便利です。ただし日本語の feature ファイルでは現状うまくフォーマットしてくれないので、英語で利用をしています。
  • プロジェクトの作成 (今回は rust-cucumber-sample という名前にします)

cargo new rust-cucumber-sample --bin
  • Cargo.toml を下記に編集します
[package]
name = "rust-cucumber-sample"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

[dev-dependencies]
cucumber = "0.20.2"
futures = "0.3"

[[test]]
name = "rust-cucumber-sample" # this should be the same as the filename of your test target
harness = false  # allows Cucumber to print output instead of libtest
  • tests/features/book/animal.feature を作成します
Feature: Animal feature

    Scenario: If we feed a hungry cat it will no longer be hungry
        Given a hungry cat
        When I feed the cat
        Then the cat is not hungry
  • tests/rust-cucumber-sample.rs を作成します
use cucumber::{given, World};

// These `Cat` definitions would normally be inside your project's code,
// not test code, but we create them here for the show case.
#[derive(Debug, Default)]
struct Cat {
    pub hungry: bool,
}

impl Cat {
    fn feed(&mut self) {
        self.hungry = false;
    }
}

// `World` is your shared, likely mutable state.
// Cucumber constructs it via `Default::default()` for each scenario.
#[derive(Debug, Default, World)]
pub struct AnimalWorld {
    cat: Cat,
}

// Steps are defined with `given`, `when` and `then` attributes.
#[given("a hungry cat")]
fn hungry_cat(world: &mut AnimalWorld) {
    world.cat.hungry = true;
}

// This runs before everything else, so you can setup things here.
fn main() {
    // You may choose any executor you like (`tokio`, `async-std`, etc.).
    // You may even have an `async` main, it doesn't matter. The point is that
    // Cucumber is composable. :)
    futures::executor::block_on(AnimalWorld::run("tests/features/book"));
}
  • test を実行します
cargo test -p rust-cucumber-sample --test rust-cucumber-sample

すると下記のように実行結果が表示されました

    Finished test [unoptimized + debuginfo] target(s) in 0.27s
     Running tests/rust-cucumber-sample.rs (target/debug/deps/rust_cucumber_sample-c2ca7addc25a6651)
Feature: Animal feature
  Scenario: If we feed a hungry cat it will no longer be hungry
   ✔  Given a hungry cat
   ?  When I feed the cat
      Step skipped: tests/features/book/animal.feature:5:9
[Summary]
1 feature
1 scenario (1 skipped)
2 steps (1 passed, 1 skipped)

ここまでの動く最低限の成果物: https://github.com/Takaichi00/rust-cucumber-sample/commit/f588b20ec717422f484e05f1bba01f886137f9e1

用語の簡単な解説

  • feature: Gherkin 記法でテストケースを記載するためのファイルです。Gherkin 記法に関しては Gherkin Reference - Cucumber Documentation が参考になります。
  • steps: Gherkin 記法における given,when,then で定義される処理です。定義に合致するような関数を Rust のコードで記載していきます。
.feature
Given a hungry cat

step.rs
#[given("a hungry cat")]
fn hungry_cat(world: &mut AnimalWorld) {
    world.cat.hungry = true;
}
  • World オブジェクト: シナリオには複数の steps が存在することが想定されます。steps をまたいで状態を保持する際に用いられるのが World オブジェクトです。また、シナリオ間で状態を分離する目的でも使われます。(参考)

tokio を導入して非同期処理を可能にする

  • シナリオテストの中でも非同期処理を実行したいことは多いかと思います。cucumber-rs では非同期ランタイムを tokio のものにすることができます。
[dev-dependencies]
tokio = { version = "1.10", features = ["macros", "rt-multi-thread", "time"] }
rust-cucumber-sample.rs
// This runs before everything else, so you can setup things here.
#[tokio::main]
async fn main() {
    // You may choose any executor you like (`tokio`, `async-std`, etc.).
    // You may even have an `async` main, it doesn't matter. The point is that
    // Cucumber is composable. :)
    futures::executor::block_on(AnimalWorld::run("tests/features/book"));
}

直列実行にする

非同期処理を有効にした場合デフォルトではシナリオは同時実行されます。しかし同一 DB を参照しており、シナリオごとに DB の migration を行いたい場合などは不都合が生じてしまいます。そのような場合は @serial タグを Scenario の上につけることでそのシナリオは直列に実行されるようになります

Feature: Animal feature

    @serial
    Scenario: If we feed a hungry cat it will no longer be hungry
        Given a hungry cat
        When I feed the cat
        Then the cat is not hungry


    @serial
    Scenario: If we feed a satiated cat it will not become hungry
        Given a satiated cat
        When I feed the cat
        Then the cat is not hungry

タグを活用する

上記で @serial というタグを紹介しましたが、タグは自作のものも利用できます。例えば開発途中のものは @development をつけ、 @development をつけたものだけを実行するということもできます。

Feature: Animal feature

    @serial
    Scenario: If we feed a hungry cat it will no longer be hungry
        Given a hungry cat
        When I feed the cat
        Then the cat is not hungry


    @development
    @serial
    Scenario: If we feed a satiated cat it will not become hungry
        Given a satiated cat
        When I feed the cat
        Then the cat is not hungry

cargo test -p rust-cucumber-sample --test rust-cucumber-sample -- -t @development

すると、@developmentがついたシナリオのみ実行されるようになります。

$ cargo test -p rust-cucumber-sample --test rust-cucumber-sample -- -t @development
    Finished test [unoptimized + debuginfo] target(s) in 0.72s
     Running tests/rust-cucumber-sample.rs (target/debug/deps/rust_cucumber_sample-430182efbed25a00)
Feature: Animal feature
  Scenario: If we feed a satiated cat it will not become hungry
   ✔  Given a satiated cat
   ✔  When I feed the cat
   ✔  Then the cat is not hungry
[Summary]
1 feature
1 scenario (1 passed)
3 steps (3 passed)

また、タグは Scenario 以外にもつけることができたり、@allow.skipped というテストが失敗してもスキップするというタグを使うことができます。
詳細はこちら

Data Table

  • Data Table を使うことで複雑なデータ構造を整理することができます
    Scenario: If we feed a hungry animal it will no longer be hungry
        When I feed the animal multiple times
            | animal | times |
            | cat    | 2     |
            | dog    | 3     |
            | 🦀     | 4     |
step.rs
#[when("I feed the animal multiple times")]
async fn feed_animal(world: &mut AnimalWorld, step: &Step) {
    if let Some(table) = step.table.as_ref() {
        for row in table.rows.iter().skip(1) {
            // NOTE: skip header
            let animal = &row[0];
            let times = row[1].parse::<usize>().unwrap();

            for _ in 0..times {
                world.animals.get_mut(animal).map(Animal::feed);
            }
        }
    }
}

Scenario Outline

  • Scenario Outline を使うことで、いつくかの違う値で同じシナリオをテストすることができます
    Scenario Outline: If we feed a hungry animal it will no longer be hungry
        Given a hungry <animal>
        When I feed the <animal> <n> times
        Then the <animal> is not hungry

        Examples:
            | animal | n |
            | cat    | 2 |
            | dog    | 3 |
            | 🦀     | 4 |

事前・事後処理を実装する

詳細はこちら

事前処理 (Background)

事前処理には Background のキーワードを使うか、Before hook を使うことで対応できます。
ただ Before hook を使うと feature に記載されていない処理が実行されてしまうことになるので、ビジネスロジックに関わる処理に関しては Background のキーワードが推奨されています

Feature: Animal feature

    Background:
        Given サンプル前処理1
        Then サンプル前処理2
step.rs
#[given("サンプル前処理1")]
async fn background_sample1(world: &mut AnimalWorld) {
    println!("サンプル前処理1");
}

#[then("サンプル前処理2")]
async fn background_sample2(world: &mut AnimalWorld) {
    println!("サンプル前処理2");
}

事前処理・事後処理 (Before hook)

#[tokio::main]
async fn main() {
    AnimalWorld::cucumber()
        .before(|_feature, _rule, _scenario, _world| Box::pin(before_scenario()))
        .after(|_feature, _rule, _scenario, _ev, _world| Box::pin(after_scenario()))
        .run_and_exit("tests/features/delivery")
        .await;

    // futures::executor::block_on(AnimalWorld::run("tests/features/book"));
}

async fn before_scenario() {
    println!("Before Scenario!!");
}

async fn after_scenario() {
    println!("After Scenario!!");
}

参考リンク

Discussion