上下文使测试更具可重用性

Lobsters Hottest 工具

摘要

作者分享了在Guile中设计测试框架的经验,重点探讨了向测试定义添加上下文如何使测试更可重用并改善开发者体验。

<p><a href="https://lobste.rs/s/idzeov/context_makes_tests_reusable">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/06/24 11:57

# 上下文让测试可复用 —— Andrew Tropin 来源:https://trop.in/blog/context-makes-tests-reusable ## 上下文让测试可复用 我设计和实现一个测试框架大约有一年了。API 长期保持简单稳定,但有几件事一直困扰着我:测试环境的搭建和拆除很麻烦,测试的复用也不方便。 我发现 API 的一个小调整能带来巨大差异,显著改善用户体验,因此在此与大家分享我的观察和发现。 这是一篇测试库设计笔记,列举了一个设计决策带来的诸多有趣成果,它让一整类任务变得更容易。 前两节提供了理解代码示例语法和语义的必要背景。第三节介绍了“问题”。接下来的章节立刻揭示了看似简单的设计决策,并逐步展示它如何解决前一节提到的问题,并从根本上改善整体情况。 ## 原始设计与语法 经过数十次迭代、数周研究以及多次绕开隐式和显式约束的尝试,我最终得到了一套相当清晰的语法和三个主要实体:断言(assertion)、测试(test)和套件(suite)。以下是测试定义语法: ``` (suite "outer" (suite "nested" (test "the test" ; 测试可以多次重新执行 (define val (prepare-value somehow)) (is (good? val)) ; 断言 (is (really-good? val))) ; 再次断言 (test "small" (is (= 4 (+ 2 2)))))) ``` 它的工作方式(可能)不直观,但很简单:`test` 宏捕获其主体,将其转换为一个 thunk(零参数过程),并将该过程连同一些额外数据发送给测试运行器。这个过程称为测试加载:测试运行器注册一个测试(测试/主体过程 + 测试/描述 + 测试/元数据),稍后可以在需要时执行和重新执行测试/主体过程。 测试作为运行时的一等实体,是交互式开发工作流(REPL 驱动或类似)的必备属性。此外,有了测试以及之前运行的信息,我们可以非常有效地安排下一次执行,并将运行与其他开发工具集成,例如,我们可以安排之前失败的测试中最快的那些优先执行,一旦失败,就暂停/中止进一步执行并立即调出调试器。 测试可以独立存在,但对于大项目,保持组织性更好。**suite** 实体有助于此,它有两个目的:对相关实体进行分组和构建层次结构。加载 `outer` 套件后,测试运行器将知道测试层次结构: ``` 📂 outer └─ 📂 nested ├─ 📄 the test └─ 📄 small ``` 这本身就很酷,但当我们把元数据特性加入其中时,它就变得更酷了。元数据的概念很简单,你可以向测试或套件添加任意数据。像这样: ``` (test "my test" 'metadata '((tags . (slow integration))) (is (good? val))) ``` 这样,我们可以为测试运行器提供更多信息,这些信息之后可以用来简化我们的生活,例如,我们可以用 `slow` 标签标记一些测试,并让测试运行器在[重新]执行时临时跳过它们。这将使我们的反馈循环更紧凑,但仍然允许偶尔轻松地运行所有测试。当然,这只是一个用例,我敢打赌你已经有了一堆关于如何利用测试运行器内部机制和这个机制的想法,但让我们先探索套件的元数据。 语法上看起来完全一样,但内部工作起来略有不同。测试运行器加载整个层次结构,并为每个测试通过合并所有外层套件和测试本身的元数据来计算 `test/compound-metadata` 值。因此,测试基本上“继承”了其祖先(套件)的元数据。这样,我们可以用特定标签标记整个测试套件,或者为所有与数据库相关的测试一次性提供用于设置数据库连接的 fixture。 ## 动态作用域变量 一个小的几乎离题但重要的部分,通过一个简短示例解释参数(动态作用域变量)。下一节中我们需要理解它。 ``` (define s-s-v 6) (define (fn) (display s-s-v)) (let ((s-s-v 7)) ;; let 不影响被 fn 捕获的静态/词法作用域变量 s-s-v (fn) ; 打印 6 (display s-s-v) ; 打印 7 ) (define d-s-v (make-parameter 6)) (define (fn2) (display (d-s-v))) (parameterize ((d-s-v 7)) ;; parameterize 直接影响动态作用域变量 d-s-v 的值 (fn2) ; 打印 7 (display (d-s-v)) ; 打印 7 ) ``` 据我所知,在现代编程语言理论中,对于通用编程语言,词法作用域优于动态作用域。具有词法作用域变量的代码更容易推理和维护。 有时动态作用域变量可能很有用,例如,当你想向函数添加一个新参数,但又不想将额外的参数传播给所有调用者时。在这种情况下,动态作用域变量可以是一种折衷:它们仍然比全局可变变量好,而且比更新所有调用者签名并添加额外参数所需的重构工作少。 尽管如此,它们引入了不必要的耦合,使控制流更加繁琐且不透明,因此应尽可能避免使用。 ## 原始设计的不便之处 所有前提都已讨论就绪,现在回到测试库。套件为我们提供了分组、层次结构、元数据继承,虽然这些都很酷,但有一个根本问题:只有测试运行器可以访问它们。测试对其周围环境一无所知,这限制了它的能力。让我们勾勒一个假设的测试套件并进行剖析。 ``` (define db* (make-parameter #f)) (define create-admin-fixture (lambda (f) (init-db-with-admin-user! (db*) ...) (f))) (define create-user-fixture (lambda (f) (init-db-with-basic-user! (db*) ...) (f))) (define db-connection-fixture ;; 在实际代码中,最好使用 dynamic-wind 来确保 ;; 在异常或其他非局部控制转移时执行拆除。 (lambda (f) (parameterize ((db* (open-db-connection ...))) (f) (close-db-connection! (db*))))) (define-suite (user-tests) 'metadata `((fixtures ,create-user-fixture)) (test "user is present" (is (user-exists? (db*) "user"))) (test "user id is set" (is (number? (user-id (db*) "user")))) (test "user is not admin" (is (not (member "admins" (get-user-groups (db*) "user"))))) (define-suite (admin-tests) 'metadata `((fixtures ,create-admin-fixture)) (test "user is present" (is (user-exists? (db*) "admin"))) (test "user id is set" (is (number? (user-id (db*) "admin")))) (test "user is admin" (is (member "admins" (get-user-groups (db*) "admin"))))) (define-suite (multiple-users-tests) 'metadata `((fixtures ,create-admin-fixture ,create-user-fixture)) ;; TODO: [Andrew Tropin, 2026-06-15] 使测试不知道数据库, ;; 通过将用户引入上下文来进行 db-csv-export-test (test "there are two users" (define users (get-users (db*))) (is (= 2 (length users)))) (test "admin and user are present" (define users (get-users (db*))) (define user-names (map user-name users)) (is (member "admin" user-names)) (is (member "user" user-names)))) (define-suite (db-tests) 'metadata `((fixtures ,db-connection-fixture)) (user-tests) (admin-tests) (multiple-users-tests)) ``` 这段代码相对直接,由于规模较小,看起来仍然相当优雅,但当我们在项目中增加测试数量时,就会出现一些问题并开始困扰我们。让我们集中讨论两个主要问题,它们在这个套件中已经可见。 从库设计介绍部分我们记得,测试是独立可执行的单元,可以按任意顺序多次运行和重新运行。它们必须能够应对运行顺序和重新运行次数。这意味着,每次运行测试时,我们都需要设置一个恰当干净的环境,这完全正常且符合预期。 一个简单的选择是手动在每个测试中重复 setup 和 teardown,但当多个测试的 setup 相同时,会导致大量重复,额外的 setup 代码在视觉上掩盖了测试的逻辑。 幸运的是,在上面的例子中,我们通过 fixture 来做到这一点。Fixtures 是可复用和可组合的。这里的问题在于测试运行器与测试之间没有直接的通信通道。这意味着没有明确的方式与测试共享执行上下文。 1. 对于 fixtures,这意味着我们被迫通过动态作用域变量提供执行环境。它通过 `db*` 参数将测试和 fixtures 耦合在一起。它包含了参数的所有缺点。过度耦合也不好。 2. 无法从测试内部访问执行上下文意味着我们无法调整测试的行为并在多个不同上下文中复用相同的测试。在实际中这通常很有用,例如,用同一个测试套件测试 API 兼容的实现,或者当我们想要运行相同的检查但使用稍有不同设置或数据源时。 我们将这些问题称为过度耦合和上下文无感知。这样更容易引用它们并讨论解决方案如何解决它们。 ## 解决方案 语法上,解决方案非常简单,我们只需给测试添加一个参数,可以在其主体中引用。我们将其括在括号中,使其看起来更清晰。就是这样: ``` ;; 旧语法 (test "description" (is (ok? (some-external-dependency*))) (is (good? value))) ;; 变为 => (test ("description" ctx) (is (ok? (assoc-ref ctx 'value))) (is (good? value))) ;; ctx, _, context 都可以 ``` 除了语法变化,我们的测试的 `test/body-procedure` 从零参数过程变为单参数过程。我们还需要更新测试运行器实现,以便正确构建上下文并将其传递给 `test/body-procedure`,但这很简单。 现在,我们准备好了,让我们看看效果。 ### 上下文感知 上下文感知提供了多种好处,但我们将从去重开始。你可能已经注意到 `user-tests` 和 `admin-tests` 中的重复模式。现有的检查几乎相同,只是要检查的用户名略有不同。原始代码是: ``` (define-suite (user-tests) 'metadata `((fixtures ,create-user-fixture)) (test "user is present" (is (user-exists? (db*) "user"))) (test "user id is set" (is (number? (user-id (db*) "user")))) (test "user is not admin" (is (not (member "admins" (get-user-groups (db*) "user"))))) (define-suite (admin-tests) 'metadata `((fixtures ,create-admin-fixture)) (test "user is present" (is (user-exists? (db*) "admin"))) (test "user id is set" (is (number? (user-id (db*) "admin")))) (test "user is admin" (is (member "admins" (get-user-groups (db*) "admin"))))) ``` 让我们将公共部分提取到一个单独的套件中,将其重构为新语法并泛化。 ``` (define-suite (user-set-correctly-tests) (test ("user is present" ctx) (is (user-exists? (db*) (assoc-ref ctx 'sut/user)))) (test ("user id is set" ctx) (is (number? (user-id (db*) (assoc-ref ctx 'sut/user)))))) ``` 现在我们从 `ctx` 中获取用户名,而不是硬编码。要将名称添加到上下文中,我们只需修改外层套件的元数据。`sut` 代表被测试主体(subject under test)。 ``` (define-suite (user-set-correctly-tests) (test ("user is present" ctx) (is (user-exists? (db*) (assoc-ref ctx 'sut/user)))) (test ("user id is set" ctx) (is (number? (user-id (db*) (assoc-ref ctx 'sut/user)))))) (define-suite (user-tests) 'metadata `((fixtures ,create-user-fixture) (sut/user . "user")) (user-set-correctly-tests) (test ("user is not admin" _) (is (not (member "admins" (get-user-groups (db*) "user")))))) (define-suite (admin-tests) 'metadata `((fixtures ,create-admin-fixture) (sut/user . "admin")) (user-set-correctly-tests) (test ("user is admin" _) (is (member "admins" (get-user-groups (db*) "admin"))))) ``` 在某些情况下,重复完全可以接受,但对于你大量复用的场景,比如 API 兼容的库重新实现,复制粘贴和 monkeypatching 测试是通往地狱的保证。 当然,代码复用并不是上下文感知的唯一好处。通过可访问的执行上下文,测试可以控制其行为。例如,如果测试看到 `fast-run?` 设置为 `#t`,它可以跳过昂贵的计算和相关的断言。或者在我们的 `user-set-correctly-tests` 套件中,我们可以添加一个额外的断言,检查名为 `"admin"` 的用户对应的权限字段是否在数据库中初始化为正确的值。现在想象力是唯一的限制,而不是测试库 :) ### 过度解耦 让我们探讨动态变量、fixtures 和不必要耦合的情况,以及它有多大程度的改善或恶化,嘿嘿。 将测试与动态变量解耦很容易,我们只需从上下文中获取 db 或执行环境的任何其他部分: ``` (test ("db access example" ctx) (define db (assoc-ref ctx 'db)) (is (db-connection? db))) ``` 通过这个小小的改变,测试变得自包含,与 fixture 实现、动态变量以及任何其他东西解耦。依赖关系现在是显式的,测试通过 context 参数直接连接到测试运行器。 在这个更新之后,测试运行器需要构建一个合适的执行上下文,并用上下文作为参数调用 `test/body-procedure`。实现很简单,但这也会影响 fixtures 的工作方式。它们不再依赖于某个共享的动态作用域变量及其隐式的参数化,而是用自己的值丰富上下文,供其他 fixtures 和测试使用,并将其传递给堆栈中的下一个 fixture/测试。 ``` (define (db-connection-fixture f) (lambda (ctx) (let ((db (open-db-connection ...))) ;; 将 `(db . ,db) 对添加到上下文中,以便后续的 fixtures 和 ;; 测试可以访问 db (f (acons 'db db ctx)) (cleanup-db! db) (close-db-connection! db)))) (define (create-admin-fixture f) (lambda (ctx) (init-db-with-admin-user! (assoc-ref ctx 'db)) (f ctx))) (define (create-user-fixture f) (lambda (ctx) (init-db-with-basic-user! (assoc-ref ctx 'db)) (f ctx))) ``` 你可能已经见过这种模式:一系列可组合的函数以特定顺序相互包裹,以构建上下文,并在返回过程中处理返回值并丰富它。这类函数通常称为中间件(middlewares)。这种组合对顺序非常敏感,但实现极其简单。 ## 友谊的协同力量 酷,添加了上下文感知,消除了过度耦合,但我们能否做以前不可能的事情呢?我有一个例子,其中这两项改进相结合、协同作用,提供了相当不错的开发者体验。 我们有 `multiple-users-tests`,但还没有动它。让这个套件足够通用,既可以用于测试数据库连接,也可以用于测试 CSV 备份,如何? ``` ;; 原始代码 (define-suite (multiple-users-tests) 'metadata `((fixtures ,create-admin-fixture ,create-user-fixture)) (test "there are two users" (define users (get-users (db*))) (is (= 2 (length users)))) (test "admin and user are present" (define users (get-users (db*))) (define user-names (map user-name users)) (is (member "admin" user-names)) (is (member "user" user-names)))) ``` 首先,我们将让测试不知道用户来自哪个来源。它们只依赖于从上下文获取用户列表。

相似文章

基于Markdown的测试套件

Hacker News Top

作者解释了为EndBASIC的编译器和虚拟机切换到基于Markdown的测试套件的原因,目的是让这些测试作为LLM学习该语言独特特性的权威文档。

如何编写Elixir测试?

Lobsters Hottest

博客文章提供编写Elixir测试的指南,包括使用@subject模块属性、describe块,以及避免模块模拟而采用依赖注入。