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 日

#826过程宏


用于 narrative

MIT/Apache

110KB
3K SLoC

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使用特性行为的声明来编写场景

换句话说,场景是一个接口,步骤实现取决于它。

仪表盘使用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")

依赖关系

~320–780KB
~19K SLoC