#bdd #test-framework #atdd #story-driven

narrative

一个极为简单的面向故事驱动的开发库

8 个版本 (4 个破坏性更新)

0.5.3 2024 年 6 月 25 日
0.5.2 2024 年 6 月 25 日
0.5.0 2024 年 5 月 14 日
0.4.0 2024 年 3 月 7 日
0.1.0 2023 年 12 月 19 日

#98测试

MIT/Apache

33KB
453

Narrative

一个极为简单的面向故事驱动的开发库

!!!! 尚未发布 !!!!

第一版开发中。已发布到 crates.io 以保留名称。

概述

Narrative 是一个专门用于基于 Rust 特性表达的故事的软件开发库。尽管其主要设计用于端到端测试,但其简洁性支持各种用例。

目标

  • 故事驱动:代码尊重故事,而非反之
  • 数据驱动:使故事能够包含结构化数据
  • 无需额外工具:消除额外安装和学习的需求
  • 利用现有生态系统:以较少的实现获得丰富经验
  • 零运行时成本:故事在编译时处理

术语

本库中的关键术语包括

  • 故事:一系列步骤,用特性编写
  • 步骤:故事中的一个单独操作或断言
  • 故事特性:表示故事的宏生成的特性。每个步骤都有一个方法。
  • 故事上下文:一个结构,包含故事的全部相关信息或数据。
  • 故事环境:实现故事特性的数据结构。

使用方法

  1. narrative 添加到您的 cargo 依赖项。

  2. 将您的第一个故事作为特性编写。

#[narrative::story("This is my first story")]
trait MyFirstStory {
    #[step("Hi, I'm a user")]
    fn as_a_user();
    #[step("I have an apple", count = 1)]
    fn have_one_apple(count: u32);
    #[step("I have {count} orages", count = 2)]
    fn have_two_oranges(count: u32);
    #[step("I should have {total} fruits", 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!("Hi, I'm a user");
        Ok(())
    }

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

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

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

您可能会注意到特性方法的签名与声明略有不同,但这没关系。

  1. 在您的代码中使用故事。
fn main() {
    let mut story = MyFirstStory { apples: 0, oranges: 0 };
    // You can run the story, and get the result.
    let story_result = story.run_all();
    // You can run the story step by step.
    for step in story.get_context().steps() {
        let step_result = step.run();
    }
}

微妙但重要的要点

以下是一些您应该了解的关于使用 Narrative 的要点。

异步操作也是自动定义的

故事不需要使用异步关键字,同步和异步版本都是自动定义的。

impl AsyncMyFirstStory for MyFirstStoryImpl {
    type Error = ();
    async fn as_a_user(&mut self) -> Result<(), Self::Error> {
        println!("Hi, I'm a user");
        Ok(())
    }
    async fn have_one_apple(&mut self, count: u32) -> Result<(), Self::Error> {
        self.apples = count;
        Ok(())
    }
    async fn have_two_oranges(&mut self, count: u32) -> Result<(), Self::Error> {
        self.oranges = count;
        Ok(())
    }
    async fn should_have_three_fruits(&mut self, total: u32) -> Result<(), Self::Error> {
        assert_eq!(self.apples + self.oranges, total);
        Ok(())
    }
}

步骤方法的参数不能是标准库中未定义的数据结构

这使得您的故事真正独立于任何实现。

但是,您可以使用与确切故事相关联的特性作为参数

Rust的类型系统赋予我们编写正确代码的能力,同时不失生产效率,这在编写故事(在叙事中)时也是如此。为了在不向故事添加任何依赖项的情况下获得这些好处,我们可以定义新的结构体或特质,使其仅与故事紧密耦合,并将其用作故事特质的关联类型。

不用担心特质/结构体名称的冲突,它与其他故事有不同的命名空间。

#[narrative::story("This is my first story")]
trait MyFirstStory {
    fn data() {
        struct UserName(String);
    
        trait UserId {
            /// Generate a new user id with random uuid v4.
            fn new_v4() -> Self;
        }
    }

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

    #[step("I'm a user with 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的“实现缺失成员”功能来减轻。

设计决策

这些决策突出了Narrative的独特之处,尤其是在与知名的端到端测试框架Gauge相比时。

Narrative被设计成仅使用Rust来实现故事(尽管它仍然可以用于测试其他语言的测试项目)。

在Narrative中支持其他语言会引入设计、实现和使用中的大量复杂性。Narrative利用Rust的核心功能和rust-analyzer来提供丰富的开发体验。虽然Rust不是编写端到端测试的最佳语言,但我们也相信它在这一领域仍然具有优势,拥有出色的编译器、稳健且简单的类型系统,以及充满活力的社区提供的库。

用户可以动态获取故事上下文,因此您可以在其他编程语言中实现步骤,并从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使用Markdown,这是一个用于编写规范、文档和故事且可由非程序员阅读的绝佳格式。但是,它不是表达结构化数据的最佳格式。我们认为故事更像是一种数据而不是文档,应该以结构化的方式表达。通过结构化数据,我们可以利用软件在处理它们时的力量。在Narrative中,我们使用特质来表达故事。

使用Markdown编写故事还有另一个好处,那就是它避免了故事和实现之间的紧密耦合。如果故事依赖于特定的实现,则故事不是纯净的,我们也会失去许多以故事驱动开发的益处。其中一个益处是,包括非程序员在内的我们都可以自由编写故事,而不必考虑实现,这给了我们在开发中一种灵活性。

但是,在Narrative中并非如此,尽管它允许你在Rust中编写故事。在Narrative中,故事被编写为特性,并且它不依赖于实现,它只是故事和实现之间的一种合同。Narrative不会失去使用Markdown的好处,相反,它会使情况变得更好。

Narrative明确地将故事和实现分开,并强制了依赖的方向。使用Markdown,我们知道故事是开发的核心,但有时我们会忘记它或产生一种认知失调。它在我们开发中似乎是一种显而易见的经验,比如,“我们需要知道实现中定义的标签才能编写正确的故事”,“如果没有步骤实现,我们在故事编辑器上会有错误”,或者“由于从编辑器的建议中选择的步骤没有按预期实现,我们无法编写正确的故事”。在narrative中,任何人都可以随时编写故事,编写的故亊可以以有效的真实属性存在,即使实现完全未完成也没有错误。

故事是实现合同的这一概念使得开发过程和逻辑依赖图变得清晰简单,尽管实现故事需要更多的努力,但它会在开发的长远中给我们带来很多好处。

有人可能会认为编写或阅读Rust特性对非程序员来说是不可能的或不切实际的,但我们对此持更加乐观的态度。我们处于一个许多人可以在伟大工具和AI的帮助下阅读和编写代码的时代,并且我个人认为,清晰明了的代码对于程序员和非程序员来说都比文档更有价值,我不认为非程序员不能阅读和编写代码。

Narrative鼓励不重用步骤

我们鼓励您每次都带着全新的思路编写故事,而不重用现有的步骤,因为我们认为故事应该是自包含的。这种情况会带来以下所述的巨大优势。

新手可访问性

它赋予了不熟悉现有代码库的故事编写者权力。他们不必知道已经存在哪些步骤,不必为使用哪些步骤而挣扎,也不必担心所选步骤是否按预期实现。

上下文清晰度

从其他故事中复制步骤往往会导致上下文混淆,并且很难理解故事的关键点(如果没有为常见步骤附加适当的别名)。虽然我们倾向于有许多故事具有相同的步骤,这些步骤共享相同的上下文和实现,但在我们添加、删除或修改故事时,维护具有相同逻辑的连贯性具有挑战性。

这种方法的缺点是,故事在编写风格上可能会存在不一致,但可以通过将具有相同上下文的故事组织在一起来减轻。这促使作者以一致的方式编写故事。

简单性

重用步骤或步骤组可能是复杂性的来源。修改被许多故事使用的步骤而不会破坏它们是一个噩梦。

细粒度抽象

步骤是一个相对较大的可重用或抽象的单位。我们不应该共享整个步骤,而应该在故事之间共享代码。但是,这应该通过提取常见的、与故事无关的和原子逻辑单元来完成。步骤实现应该是由这样的单元组成的,并且不应该在抽象中泄露故事上下文。例如,如果一个步骤是关于点击提交按钮的,那么它可能被实现为以下原子逻辑的组合:find_element_by(id)click(element)wait_for_page_load(),而不是泄露上下文,如 click_submit_button()click_button("#submit")

依赖关系

~0.4–1MB
~22K SLoC