Skip to content

Instantly share code, notes, and snippets.

@ryo33
Created April 5, 2024 03:48
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ryo33/aac88b2fbc347652679de3591669c5d6 to your computer and use it in GitHub Desktop.
Save ryo33/aac88b2fbc347652679de3591669c5d6 to your computer and use it in GitHub Desktop.

Narrative

ストーリー駆動開発のためのシンプルなライブラリ

概要

Narrativeは、Rustのトレイトで表現されたストーリーに基づいてソフトウェアの全体または一部を開発するためのライブラリです。主にE2Eテスト用に設計されていますが、そのシンプルさから様々な用途に対応できます。

目標

  • ストーリー駆動: コードがストーリーを尊重する、その逆ではない
  • データ駆動: ストーリーに構造化されたデータを含めることができる
  • 追加のツール不要: 余分なインストールや学習の必要性を排除
  • 既存のエコシステムを活用: 少ない実装で豊かな体験を提供
  • ランタイムコストゼロ: ストーリーはコンパイル時に処理される

用語

このライブラリでの重要な用語は以下の通りです:

  • ストーリー: トレイトとして記述される、一連のステップ
  • ステップ: ストーリーの中の1つのアクションまたはアサーション
  • ストーリートレイト: ストーリーを表すマクロ生成されたトレイト。各ステップに対応するメソッドを持つ
  • ストーリーコンテキスト: ストーリーに関連するすべての情報やデータを保持する構造体
  • ストーリーの環境: ストーリートレイトを実装するデータ構造

使い方

  1. Cargoの依存関係にnarrativeを追加します。

  2. 最初のストーリーをトレイトとして書きます。

#[narrative::story("これが私の最初のストーリーです")]
trait MyFirstStory {
    #[step("こんにちは、私はユーザーです")]
    fn as_a_user();
    #[step("私は{count}個のリンゴを持っています", count = 1)]
    fn have_one_apple(count: u32); 
    #[step("私は{count}個のオレンジを持っています", count = 2)]
    fn have_two_oranges(count: u32);
    #[step("私は合計{total}個のフルーツを持っているはずです", total = 3)]  
    fn should_have_three_fruits(total: u32);
}

わぁ、素敵ですね!

  1. ストーリーをRustで実装します。
pub struct MyFirstStoryImpl {
    apples: u8,
    oranges: u8,
};

impl MyFirstStory for MyFirstStoryImpl {
    type Error = ();

    fn as_a_user(&mut self) -> Result<(), Self::Error> {
        println!("こんにちは、私はユーザーです");
        Ok(())
    }

    fn have_one_apple(&mut self, count: u32) -> Result<(), Self::Error> {
        self.apples = count as u8;
        Ok(())
    }

    fn have_two_oranges(&mut self, count: u32) -> Result<(), Self::Error> {
        self.oranges = count as u8;
        Ok(())
    }

    fn should_have_three_fruits(&mut self, total: u32) -> Result<(), Self::Error> {
        assert_eq!(self.apples + self.oranges, total as u8);
        Ok(())
    }
}

トレイトメソッドのシグネチャが宣言と少し異なることに気づくかもしれませんが、問題ありません。

  1. コード内でストーリーを使用します。
fn main() {
    let mut story = MyFirstStoryImpl { apples: 0, oranges: 0 };
    // ストーリーを実行し、結果を取得できます
    let story_result = story.run_all();
    // ストーリーをステップごとに実行できます
    for step in story.get_context().steps() {
        let step_result = step.run();
    }
}

微妙だけど重要な点

Narrativeを使用する上で知っておくべきいくつかのポイントがあります。

非同期版も自動的に定義される

ストーリーではasyncキーワードを使用する必要はなく、同期版と非同期版の両方が自動的に定義されます。

impl AsyncMyFirstStory for MyFirstStoryImpl {
    type Error = ();

    async fn as_a_user(&mut self) -> Result<(), Self::Error> {
        println!("こんにちは、私はユーザーです");
        Ok(())
    }

    async fn have_one_apple(&mut self, count: u32) -> Result<(), Self::Error> {
        self.apples = count as u8;
        Ok(())
    }

    async fn have_two_oranges(&mut self, count: u32) -> Result<(), Self::Error> {
        self.oranges = count as u8;
        Ok(())
    }

    async fn should_have_three_fruits(&mut self, total: u32) -> Result<(), Self::Error> {
        assert_eq!(self.apples + self.oranges, total as u8);
        Ok(())
    }
}

ステップメソッドの引数は標準ライブラリで定義されていないデータ構造にはできない

これにより、ストーリーを実装から真に独立させることができます。

ただし、特定のストーリーに厳密に結合したデータ型やトレイトを引数として使用できる

Rustの型システムは、生産性を損なうことなく正しいコードを書くための力を与えてくれます。これはストーリーを書く場合も同じです。ストーリーに依存関係を追加せずにメリットを得るために、ストーリーにのみ強く結合された新しい構造体やトレイトを定義し、それをストーリートレイトの関連型として使用できます。

トレイト/構造体の名前の衝突を心配する必要はありません。他のストーリーとは別の名前空間を持っています。

#[narrative::story("これが私の最初のストーリーです")]
trait MyFirstStory {
    fn data() {
        struct UserName(String);
    
        trait UserId {
            /// UUIDv4を使って新しいユーザーIDを生成する
            fn new_v4() -> Self;
        }
    }

    const user_id: Self::UserId = Self::UserId::new_v4();

    #[step("私はID: {id}のユーザーです", id = user_id, name = UserName("Alice".to_string()))]
    fn as_a_user(id: Self::UserId, name: UserName);
}

正しいRustの構文を知っている人にとっては本当に奇妙ですが、同じ場所で新しい構造体やトレイトを定義するという選択肢の中では最良のものです。

ストーリーを書く際に、ストーリーの実際の実装について忘れることができる

私たちは、ストーリーはそれらの実際の実装を考慮すべきではないと考えています。したがって、async&self&mut self-> Result<(), Self::Error>などの煩雑な詳細は、ストーリーの定義では必要ありません。このような驚くべき動作は、rust-analyzerの「不足しているメンバーを実装する」機能を使用することで軽減できます。

設計上の決定

これらの決定は、特に有名なE2EテストフレームワークのGaugeと比較して、Narrativeのユニークな側面を強調しています。

### NarrativeはRustでのみストーリーを実装するように設計されている(ただし、他の言語のプロジェクトのテストにも使用できる)

他の言語をNarrativeでサポートすると、設計、実装、使用に多くの複雑さが生じます。Narrativeは、Rustのコア機能とrust-analyzerを活用して、豊富な開発体験を提供します。RustはE2Eテストを書くのに最適な言語ではないかもしれませんが、優れたコンパイラ、堅牢でシンプルな型システム、活発なコミュニティからのライブラリを備えているため、この分野でも利点があると私たちは信じています。

ユーザーは動的にストーリーコンテキストを取得できるため、他のプログラミング言語でステップを実装し、Rustコードから動的に呼び出すことができます:

fn execute_story(context: impl narrative::StoryContext) {
    for step in context.steps() {
        send_to_external_process(step.text(), step.arguments().map(|arg| Argument {
                name: arg.name(),
                ty: arg.ty(),
                debug: arg.debug(),
                json: step.serialize(serde_json::value::Serializer).unwrap(),
        }));
    }
}

Narrativeはフレームワークではなく、ライブラリである

Narrativeにはテストランナー、プラグインシステム、専用の言語サーバーはありません。フレームワークではなく、Narrativeはストーリーを実装するための単一のマクロを提供するライブラリです。それは、ストーリーと単なるRustコードの間の小さな結びつきに過ぎません。したがって、ユーザーはストーリーを使って独自のテストランナーや非同期ランタイムを構成でき、rust-analyzerの機能をフルに活用できます。

Narrative自体は、ストーリーをトレイトとして宣言し、Rustコードで実装するというコア機能以外の機能は提供しません。このライブラリのシンプルさと拡張性の基礎を築いています。

以下は、Narrativeに欠けている機能であり、このライブラリでは実装されることはありません。ただし、コア機能を活用すればそれらを実現できることを忘れないでください。

  • ステップのグループ化
  • ストーリーのグループ化
  • テストの準備とクリーンアップ
  • テーブル駆動テスト
  • タグ
  • スクリーンショット
  • リトライ
  • 並列化
  • エラー報告

Narrativeはストーリーを書くためにトレイトの宣言を使用する

つまり、ストーリーはインターフェースであり、ステップの実装はそれに依存します。

Gaugeはマークダウンを使用しており、非プログラマーにも読みやすい仕様、ドキュメント、ストーリーを書くのに最適なフォーマットです。しかし、構造化された方法でデータを表現するには最適なフォーマットではありません。私たちは、ストーリーはドキュメントよりもデータに近いものであり、構造化された方法で表現されるべきだと考えています。構造化されたデータを使用すると、それらの処理においてソフトウェアの力を活用できます。Narrativeでは、ストーリーを表現するためにトレイトを使用します。

ストーリーにマークダウンを使用することには、ストーリーと実装の間の密結合を避けるという別のメリットもあります。ストーリーが特定の実装に依存していると、ストーリーは純粋ではなくなり、ストーリー駆動開発の多くのメリットを失います。そのメリットの1つは、非プログラマーを含めて、実装に関係なく自由にストーリーを書くことができることであり、開発に一種のアジリティをもたらします。

ただし、Rustでストーリーを書くことができるにもかかわらず、Narrativeではそうではありません。Narrativeでは、ストーリーはトレイトとして記述され、実装に依存せず、ストーリーと実装の間の契約に過ぎません。Narrativeは、マークダウンを使用することのメリットを失うことはなく、むしろ状況を改善するでしょう。

Narrativeは明示的にストーリーと実装を分離し、依存関係の方向を強制します。マークダウンを使用すると、ストーリーが開発の中心であることはわかりますが、時々それを忘れたり、認知的不協和音を感じたりします。これは、「正しいストーリーを書くために、実装で定義されたタグを知る必要がある」、「ステップの実装がない場合、ストーリーエディタにエラーが発生する」、「エディタの提案から選択したステップが期待通りに実装されていないため、正しいストーリーを書くことができなかった」などの開発における明白な経験として現れます。Narrativeでは、誰でもいつでもストーリーを書くことができ、実装が完全に行われていなくても、書かれたストーリーはエラーのない有効な実際の資産として存在できます。

ストーリーは実装への契約であるという概念は、開発プロセスと論理的な依存関係グラフをクリーンでシンプルにし、ストーリーを実装するのに少し努力が必要ですが、開発の長期的な過程で多くのメリットをもたらすでしょう。

非プログラマーにとって、Rustのトレイトを書いたり読んだりすることは不可能または非現実的だと考える人もいるかもしれませんが、私たちはもっと楽観的に考えています。優れたツールやAIの助けを借りて、多くの人がコードを読み書きできる時代になっています。個人的には、プログラマーにとっても非プログラマーにとっても、明確なコードはドキュメンテーションに勝ると信じています。非プログラマーがコードを読み書きできないとは思いません。

Narrativeはステップの再利用を奨励しない

私たちは、既存のステップを再利用せずに、毎回新鮮な気持ちでストーリーを書くことをお勧めします。なぜなら、ストーリーは自己完結型であるべきだと考えているからです。この状況になると、以下のような大きな利点があります。

新人にとってのアクセシビリティ

既存のコードベースに精通していないストーリーライターに力を与えます。どのステップがすでに存在するかを知る必要がなく、どのステップを使うべきかで悩むこともなく、選択したステップが期待通りに実装されているかどうかを心配する必要もありません。

文脈の明確さ

他のストーリーからステップをコピーすると、文脈が混同されがちで、ストーリーの要点を解読するのが難しくなります(共通のステップに適切なエイリアスを付けない限り)。同じ文脈を共有し、同じ実装を持つ多くのストーリーを持つ傾向がありますが、ストーリーを追加、削除、変更しながら、同じロジックを共有することの一貫性を維持するのは難しいことです。

このアプローチの欠点の1つは、ストーリー間で文体に矛盾が生じる可能性があることですが、同じ文脈を持つストーリーを近くに配置することで緩和できます。書き手に一貫した方法でストーリーを書くよう促します。

シンプルさ

ステップやステップのグループを再利用することは、複雑さの原因になる可能性があります。それらを壊さずに多くのストーリーで使用されるステップを変更するのは悪夢です。

細かい抽象化

ステップは、再利用や抽象化には比較的大きな単位です。ステップ全体を共有するのではなく、ストーリー間でコードを共有すべきです。ただし、これは、ストーリーに依存しない共通の原子的なロジックの単位を抽出することで行う必要があります。ステップの実装は、そのような単位の組み合わせであるべきであり、ストーリーの文脈を抽象化に漏らすべきではありません。たとえば、ステップが送信ボタンをクリックすることについてのものである場合、それはfind_element_by(id)click(element)wait_for_page_load()のような原子的なロジックの組み合わせとして実装される可能性がありますが、click_submit_button()click_button("#submit")のような文脈を漏らすべきではありません。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment