Rust で Cucumber によるテストを書く
概要
この記事は 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 のコードで記載していきます。
Given a hungry cat
↓
#[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"] }
// 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 |
#[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
#[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