Svelte-测试驱动开发-全-
Svelte 测试驱动开发(全)
原文:
zh.annas-archive.org/md5/91742a73e5f1fb991eeb4c88631dad0c译者:飞龙
前言
我对 Svelte 感到兴奋的是什么?它的简洁、优雅和实用主义设计理念。它在市场上的日益成功表明,其他程序员也有同样的感受。我并不感到惊讶。当我们为 JavaScript 生态系统编写软件时,我们有一个选择:我们可以接受并被其主要组成部分(React、Node.js、webpack、Babel 等)的复杂性所消耗,或者我们可以积极寻找那些旨在简化我们工作的边缘产品和流程。
不会让你感到惊讶的是,我把 测试驱动开发 (TDD) 明确地放在这个第二阵营中。因为没有它,你所拥有的开发工作流程主要就是追逐错误和在大脑中携带大量背景信息。这始终是我认为的 糟糕的 旧方法。
当我年轻时刚开始编程时,我还记得调试时的恼火经历——写一些代码,尝试运行,发现错误,然后挣扎数小时试图找出错误隐藏在我代码中的地方。这看起来像是编程的自然部分:花大量时间进行调试。而且这种情况一直持续到我的第一份工作,作为 C++ 桌面应用程序开发者。(不久我就发现了 TDD 以及它如何帮助我过上更简单、更安静、更平静的生活。)
然后还有当你计划实现下一个新功能的大致设计时所需的思维背景。你必须知道自己在哪里,已经做了什么,接下来是什么,并尽最大努力不偏离轨道。当你被调试和其他干扰所阻碍时,这很困难。
当然,你可以写一个待办事项列表或记日记,但为什么不写一些自动化测试呢?它们不仅能提醒你自己在哪里,还能检查错误。
那就是 TDD 背后的基本理念。
如果你喜欢 Svelte 因为它简化了你的生活,让你感觉像是在水中游泳而不是在泥潭中跋涉,我想你也会喜欢 TDD。这本书展示了如何以及为什么使用 TDD 来学习 Svelte。希望你喜欢它。感谢阅读!
这本书面向的对象
如果你是一名 Svelte 程序员,这本书就是为你准备的。我的目标是向你展示 TDD 如何提高你的工作效率。如果你已经对 TDD 比较了解,我希望你还能从比较你自己的流程和我的流程中学到很多东西。
如果你之前不了解 Svelte,但熟悉任何现代前端框架,例如 React,你应该能够跟上并随着学习逐步掌握。TDD 是一个解释新技术的绝佳平台,而且完全有可能你只需通过这本书就能学会 Svelte。
这本书涵盖的内容
第一章,为测试做准备,涵盖了 SvelteKit 包以及配置你的开发环境以使用 Vitest 和 Playwright 测试运行器进行有效的 TDD 工作。
第二章, 介绍红-绿-重构工作流程,展示了基本的 TDD 过程是如何工作的,并讨论了为什么它是有用的。它介绍了 Vitest 测试运行器,用于编写单元测试。
第三章, 将数据加载到路由中,展示了如何使用 TDD 将数据加载到 Svelte 页面组件中。它介绍了 Playwright 测试运行器,用于编写端到端测试。
第四章, 保存表单数据,展示了如何实现一个基本的 HTML 表单及其提交操作。
第五章, 验证表单数据,向在第四章第四章中构建的表单中添加表单验证规则。
第六章, 编辑表单数据,展示了如何通过修改表单以在编辑模式下工作来使用 TDD 来演进系统设计。
第七章, 整理测试套件,将重点转向查看更好的测试技术,从查看如何保持测试套件整洁和有序开始。
第八章, 创建匹配器以简化测试,解释了一种管理测试套件复杂性的高级技术:创建和使用期望匹配器函数。
第九章, 从框架中提取逻辑,讨论了如何通过将逻辑移出框架控制的模块来使你的应用程序设计更易于测试。
第十章, 测试驱动 API 端点,探讨了如何使用 TDD 来实现 API 调用。
第十一章, 用并排实现替换行为,展示了即使在面对复杂的重构练习时,TDD 也是如何有用的。
第十二章, 使用组件模拟来澄清测试,介绍了前端自动化测试中最复杂的一部分:组件模拟。
第十三章, 添加 Cucumber 测试,介绍了 Cucumber 测试框架,并展示了它如何应用于 SvelteKit 项目。
第十四章, 测试认证,展示了一种为认证库编写单元测试和端到端测试的方法。
第十五章, 测试驱动 Svelte 存储,简要介绍了如何有效地测试 Svelte 存储。
第十六章, 测试驱动服务工作者,展示了如何使用 SvelteKit 框架编写服务工作者的自动化测试。
为了充分利用这本书
阅读这本书有两种方法。
第一种方法是当你面临特定的测试挑战时将其作为参考。使用索引找到你想要的内容,然后转到那一页。
第二种,也是我推荐您从它开始的方法,是逐步跟随教程,在过程中构建自己的代码库。配套的 GitHub 仓库为每个章节(如Chapter01)都有一个目录,然后在该目录内,还有两个目录:
-
开始,这是章节的起点;如果您正在跟随,应该从这里开始。 -
完整,其中包含所有练习的完整解决方案。
您至少需要稍微熟悉branch、checkout、clone、commit、diff和merge命令。
查看 GitHub 仓库中的README.md文件以获取更多信息和工作代码库的说明。
如果您使用的是本书的数字版,我们建议您亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Svelte-with-Test-Driven-Development。如果代码有更新,它将在 GitHub 仓库中更新。
我们还提供了其他丰富的代码包,这些代码包来自我们的书籍和视频目录,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一份 PDF 文件,其中包含本书中使用的截图和图表的彩色图像。您可以从这里下载:packt.link/GD8Lg。
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“我们可以从一个测试开始,指定这个Birthday组件将如何使用其name属性。”
代码块设置如下:
import { describe, it, expect } from 'vitest';
import {
render,
screen
} from '@testing-library/svelte';
import Birthday from './Birthday.svelte';
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
describe('Birthday', () => {
it('displays the name of the person', () => {
render(Birthday, { name: 'Hercules' });
});
});
任何命令行输入或输出都应如下编写:
mkdir birthdays
cd birthdays
npm create svelte@latest
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“用户点击保存按钮。”
小贴士或重要注意事项
看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。
勘误表: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将非常感谢。请访问www.packtpub.com/support/errata并填写表格。
盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《使用测试驱动开发学习 Svelte》,我们很乐意听听您的想法!请选择www.amazon.in/review/create-review/error?asin=1837638330为此书评分并分享您的反馈。
您的评论对我们和科技社区都非常重要,它将帮助我们确保我们提供高质量的内容。
下载本书的免费 PDF 副本
感谢您购买本书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走?
您的电子书购买是否与您选择的设备不兼容?
不要担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取优惠:
- 扫描二维码或访问以下链接

packt.link/free-ebook/9781837638338
-
提交您的购买证明
-
就这些!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件。
第一部分:学习 TDD 周期
第一部分介绍了测试驱动开发(TDD)工作流程,并解释了您如何使用它来构建 Svelte 应用程序。
本部分包含以下章节:
-
第一章,为测试做准备
-
第二章,介绍红-绿-重构工作流程
-
第三章,将数据加载到路由中
-
第四章,保存表单数据
-
第五章,验证表单数据
-
第六章,编辑表单数据
第一章:测试设置
在您还是一个小学生的时候,您可能通过在纸上使用铅笔来学习写作。现在您长大了,您可能更喜欢钢笔。对于学习者来说,铅笔在纠正错误方面比钢笔有明显的优势,因为当您刚开始书写字母和单词时,您会犯很多错误。铅笔对小孩子来说也更安全——没有盖子或混乱的墨水需要担心。
但铅笔仍然是一种有效的书写工具,您可能仍然更喜欢铅笔而不是钢笔。铅笔是完成这项工作的完美工具。
测试驱动开发(TDD)是一种可以以类似方式为您服务的工具。这是作为开发者学习和成长的好方法。许多经验丰富的开发者更喜欢它,而不是任何替代方案。
在本章中,您将配置一个旨在帮助您充分利用 TDD 技术的工作环境。由于 TDD 要求您执行一系列小型重复性任务——编写测试、运行测试、尽早和经常提交,以及在测试代码和应用代码之间切换——因此,每个任务都应易于且快速完成。
因此,一个重要的个人修养是客观地评价您的开发工具。对于您使用的每一个工具,问自己这个问题:这个工具是否为我服务得很好?它是否易于使用且快速?
这可能是您的 集成开发环境(IDE),您的操作系统,您的源代码仓库,您的笔记程序,您的时间管理工具,等等。您在日常工作中使用的任何和所有东西。审视您的工具。丢弃那些对您不起作用的工具。
这是非常个人化的事情,很大程度上取决于经验和个性。而且,您的偏好也可能随着时间的推移而改变。
我经常使用非常简单、简单的键盘驱动工具,这些工具对我始终有效,无论我正在使用哪种编程语言,比如文本编辑器 Vim。它不提供关于 JavaScript 编程语言或 Svelte 框架的知识,但它使我非常有效地编辑文本。
但如果您关心学习 JavaScript 或程序设计,那么您可能更喜欢一个提供 JavaScript 自动完成建议和有帮助的项目辅助的 IDE。
本章将指导您设置一个新的 SvelteKit 项目,并突出您需要做出的所有个人选择,以及为了练习有效的 TDD 需要的额外功能。
它涵盖了以下主题:
-
创建一个新的 SvelteKit 项目
-
准备您的开发环境以进行频繁的测试运行
-
配置对 Svelte 组件测试的支持
-
您可能想要尝试的配置选项
到本章结束时,您将知道如何创建一个适合测试驱动功能构建的新 Svelte 项目。
技术要求
本章的代码可以在网上找到,地址为 github.com/PacktPublishing/Svelte-with-Test-Driven-Development/tree/main/Chapter01/Start。
你需要安装一个较新的 Node.js 版本。有关如何在你的平台上安装和更新 Node.js 的说明,请参阅 nodejs.org。
创建一个新的 SvelteKit 项目
在本节中,你将使用创建新 SvelteKit 项目的默认方法,该方法使用 npm create 命令。(作为参考,你还可以查看官方文档 kit.svelte.dev/docs/creating-a-project。)
我们正在构建的项目被称为 Birthdays,npm 包名为 birthdays。它将在 第二章,介绍红-绿-重构工作流程 中详细介绍。
SvelteKit 1.0
这些说明在撰写本书时是有效的,针对 SvelteKit 1.0。随着时间的推移,事情可能会得到改善,因此你可能会发现一些后续说明将变得不再必要或可能不再适用。请查看本书的 GitHub 仓库以获取最新说明。
现在,我们将专注于构建新项目的机制:
-
首先,在你的常规工作位置打开一个终端窗口(对我来说,这是我的 Mac 上的
~/work)。然后输入以下命令:mkdir birthdays cd birthdays npm create svelte@latest
如果你这是创建的第一个 Svelte 项目,npm 将显示以下消息:
Need to install the following packages:
create-svelte@2.1.0
Ok to proceed? (y)
-
对该问题回答
y。你会看到更多问题,我们将逐一解答:create-svelte version 2.1.0 Welcome to SvelteKit! ? Where should we create your project? (leave blank to use current directory) › -
由于你已经在
birthdays目录中,只需留空,然后按 Enter。接下来,你将被询问你想要使用哪个应用程序模板:? Which Svelte app template? › - Use arrow-keys. Return to submit. SvelteKit demo app ❯ Skeleton project - Barebones scaffolding for your new SvelteKit app Library skeleton project -
选择
骨架项目。接下来,你将被询问关于 TypeScript 的问题:? Add type checking with TypeScript? › - Use arrow-keys. Return to submit. Yes, using JavaScript with JSDoc comments Yes, using TypeScript syntax ❯ No -
对于这个问题,我选择了
No。这是因为这本书是关于测试技术,而不是类型技术。这并不是说这本书不适用于 TypeScript 项目——当然适用——只是类型不是这本书的主题。
如果你想要使用 TypeScript
如果你是一位经验丰富的 TypeScript 开发者,请随时选择该选项。本书中的代码示例不需要太多修改,除了需要提供的额外类型定义。
-
最后,你将被询问额外的包依赖项:
? Add ESLint for code linting? › No / Yes ? Add Prettier for code formatting? › No / Yes ? Add Playwright for browser testing? › No / Yes ✔ Add Vitest for unit testing? … No / Yes -
对所有这些问题回答
Yes。尽管我们在这本书中不会提到 ESLint,但它总是好的。我们还需要 Playwright 和 Vitest。
然后,你将看到所有选择的摘要,随后是一个 Next 步骤 列表:
Your project is ready!
✔ ESLint
https://github.com/sveltejs/eslint-plugin-svelte3
✔ Prettier
https://prettier.io/docs/en/options.xhtml
https://github.com/sveltejs/prettier-plugin-svelte#options
✔ Playwright
https://playwright.dev
✔ Vitest
https://vitest.dev
Install community-maintained integrations:
https://github.com/svelte-add/svelte-adders
Next steps:
1: npm install (or pnpm install, etc)
2: git init && git add -A && git commit -m "Initial commit" (optional)
3: npm run dev -- --open
我们将执行以下步骤,但在这样做之前,我们将运行一些额外的验证步骤。检查你的工作总是好的。
在终端中输入 npm install 并确认一切安装正确。然后,继续提交你的更改。(如果你已经 fork 了 GitHub 仓库,你不需要使用 git init 命令。)
提前频繁提交
经常检查你的工作是个好主意。当你学习 TDD 测试方法时,每次测试后都进行检查可能会有所帮助。这可能会显得有些多,但它将帮助你回溯,以防你遇到难题。
然后,运行 npm run dev – –open。它应该会打开你的网页浏览器并显示一个 "欢迎来到 SvelteKit" 的消息。
你可以关闭浏览器,并在终端中按 Ctrl + C 来停止 web 服务器。
接下来,让我们验证 Playwright 和 Vitest 的依赖项。
安装和运行 Playwright
虽然我们在这章中不会使用 Playwright,但安装它并验证它是否工作是个好主意。
首先在命令行中运行 npm test:
work/birthdays % npm test
> birthdays@0.0.1 test
> playwright test
Running 1 test using 1 worker
[WebServer]
[WebServer]
[WebServer] Generated an empty chunk: "hooks".
[WebServer]
✘ 1 test.js:3:1 › index page has expected h1 (7ms)
1) test.js:3:1 › index page has expected h1 =============================================
browserType.launch: Executable doesn't exist at /Users/daniel/Library/Caches/ms-playwright/chromium-1041/chrome-mac/Chromium.app/Contents/MacOS/Chromium
...
1 failed
test.js:3:1 › index page has expected h1 ============================================
如果你以前从未安装过 Playwright,你会看到前面提到的消息。
Playwright 有其自己的环境设置要做,例如在你的机器上安装 Chromium。你可以使用以下命令安装它:
npx playwright install
然后,再次尝试 npm test 应该会给出以下输出,显示包含的一个示例测试正在通过:
> birthdays@0.0.1 test
> playwright test
Running 1 test using 1 worker
[WebServer]
[WebServer]
[WebServer] Generated an empty chunk: "hooks".
[WebServer]
✓ 1 test.js:3:1 › index page has expected h1 (307ms)
1 passed (4s)
这个测试,index 页面有预期的 h1,是对你在浏览器中启动应用程序时看到的 "欢迎来到 SvelteKit" 消息的测试。
运行 Vitest
运行 npm run test:unit 是运行 Vitest 测试的默认方式。现在试试看:
work/birthdays % npm run test:unit
> birthdays@0.0.1 test:unit
> vitest
DEV v0.25.8 /Users/daniel/work/birthdays
✓ src/index.test.js (1)
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 15:56:18
Duration 737ms (transform 321ms, setup 0ms, collect 16ms, tests 2ms)
PASS Waiting for file changes...
press h to show help, press q to quit
这会自动将你置于监视模式,这意味着文件系统中的任何更改都会导致测试重新运行。按 q 退出此模式。我个人不使用监视模式,我们在这本书中也不会使用它。请参阅 创建 shell 别名 部分以了解为什么。
在下一节中,我们将使项目的操作更加便捷。
为频繁的单元测试准备你的开发环境
在本节中,我们将采取一些配置操作,这将使我们的测试驱动生活变得更加简单。
选择你的编辑器
让我们从你选择的代码编辑器开始。很可能这意味着在 IDE,如 Visual Studio Code,或纯文本编辑器,如 Vim 或 Emacs 之间做出选择。
IDE 通常有很多功能,其中之一是内置的测试运行器,它会为你运行测试并将测试输出集成到编辑器本身。另一方面,纯文本编辑器将需要你有一个单独的终端窗口来直接输入测试命令,就像你在上一节中所做的那样。
图 1**.1 展示了我的个人设置,使用 Vim 和 tmux 来分割窗口。屏幕的上半部分是我编辑源文件的地方,当我准备好运行测试时,我可以切换到下半部分并输入 test 命令。

图 1.1 – 使用 tmux 和 Vim
图 1**.2 展示了安装了 Vitest 扩展的 Visual Studio Code 中的相同项目。注意测试运行器有许多整洁的功能,例如能够过滤测试输出,以及通过测试通过行的数字旁边的绿色勾选标记。

图 1.2 – 使用 Visual Studio Code 运行测试
我认为从使用纯编辑器和终端设置中可以学到很多东西,但如果您对此感到不舒服,那么现在最好坚持使用您最喜欢的 IDE。
您想要确保的一件事是测试运行既简单又快捷。因此,如果您正在编写一个新的测试,您希望立即运行它并看到它失败。如果您正在使测试通过或重构测试,请确保您可以快速重新运行测试以检查您的进度。
创建 shell 别名
如果您选择使用终端来运行测试,那么您几乎肯定想要设置一个别名,以便更简单地运行 Vitest 单元测试。您会记得,您使用两个命令来运行测试:npm test 用于 Playwright 测试,以及 npm run test:unit 命令用于 Vitest 单元测试。
本书展示的测试风格遵循经典的 测试金字塔 测试方法,该方法指出我们应该有很多小的单元测试(在 Vitest 中)和很少的系统测试(在 Playwright 中)。
既然我们将更频繁地使用 Vitest,那么让 test 命令运行单元测试不是更合理吗?
我使用的解决方案是一个 shell 别名 v,它调用 Vitest。如果您想使用标准的监视模式,您需要设置 shell 别名为运行此命令:
npx vitest
然而,因为我不想使用监视模式,所以我将其设置为使用此命令:
npx vitest run
我建议您使用这个版本,至少在您阅读这本书的时候。我发现监视模式往往会静默地崩溃,尤其是在您处于设置项目的初期阶段。为了避免混淆,最好在准备好时再调用测试命令。
在我的 Mac 上,我的默认 shell 是 zsh,它在其配置的 shell 别名在 ~/.zshrc 文件中。您可以使用以下命令将该别名添加到文件中:
echo 'alias v="npx vitest run"' >> ~/.zshrc
source ~/.zshrc
现在,您只需输入 v 命令即可运行您的 Vitest 单元测试。您也可以使用此命令运行单个测试文件,如下所示:
v src/index.tests.js
这是一种方便的方法来运行您测试套件的一小部分。
将测试运行器更改为报告每个测试名称
回想一下,当我们运行我们的 Vitest 单元测试时,测试报告告诉我们运行的测试套件的文件名,以及一些摘要信息:
DEV v0.25.8 /Users/daniel/work/birthdays
✓ src/index.test.js (1)
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 15:56:18
Duration 737ms (transform 321ms, setup 0ms, collect 16ms, tests 2ms)
结果发现这还不够——我们还想看到测试名称,就像 Playwright 测试告诉我们通过测试的描述一样。
打开 vite.config.js 文件,并添加一个新的 reporter 属性,将其设置为 verbose,如下面的代码块所示:
const config = {
plugins: [sveltekit()],
test: {
...,
reporter: 'verbose'
}
};
小心
如果你之前在监视模式下运行测试运行器,此时你需要重新启动它,以及在任何修改配置的其他时刻。
现在,使用v命令在命令行运行测试将给出以下结果:
RUN v0.25.8 /Users/daniel/work/birthdays
✓ src/index.test.js (1)
✓ sum test (1)
✓ adds 1 + 2 to equal 3
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 11:02:05
Duration 905ms (transform 320ms, setup 1ms, collect 16ms, tests 2ms)
太好了!
观察测试失败
我们几乎完成了 Vitest 的配置,但在继续之前,让我们检查一下测试实际上是否测试了我们想要测试的内容。这是 TDD 中的一个重要概念:如果你从未见过失败的测试,你怎么知道它测试了正确的内容?
打开src/index.test.js并查看:
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});
对expect语句进行更改,就像这里显示的那样:
expect(2 + 2).toBe(3);
现在如果你运行测试,你应该会看到失败:
❯ src/index.test.js:5:17
3| describe('sum test', () => {
4| it('adds 1 + 2 to equal 3', () => {
5| expect(2 + 2).toBe(3);
| ^
6| });
7| });
- Expected "3"
+ Received "4"
太棒了 – 我们的测试运行器似乎一切正常。你可以继续撤销对测试的更改,并再次看到它变绿。这就是基本编辑器配置的全部内容。
测试文件位置 – 在src还是test?
在许多其他编程环境中,测试文件与应用程序源文件分开。一个名为tests或specs的单独目录用于存放所有可执行测试脚本。
这样做有几个优点。首先,它可以在构建可部署单元时避免将测试与应用程序代码打包在一起。然而,Svelte(以及 JavaScript)并不存在这个问题,因为只有由应用程序入口点引用的模块才会被打包。
第二,拥有一个单独的目录可以避免“每个模块一个测试文件”的思维模式。并非所有模块都需要单元测试:如果一个单元作为更大单元的一部分存在,我们通常会只为顶层单元编写测试,这些测试也将为低层单元提供覆盖率。相反,有时为单个模块拥有两个(或更多!)测试文件是有帮助的。
这尤其在使用会清除整个模块的组件模拟时成立。你可能需要一个模拟组件的测试文件,以及另一个没有模拟组件的测试文件。我们将在第十二章中探讨组件模拟,使用组件模拟来澄清测试。
当前 SvelteKit 的方法是将 Vitest 测试文件保存在src目录中。部分原因是避免与位于单独目录tests中的 Playwright 测试混淆,Playwright 测试确实位于单独的目录中。(我们将在第三章中看到 Playwright 测试,将数据加载到路由中)。
这本书将继续采用这种风格,但我鼓励你探索并采用你感觉最舒适的风格。
在下一节中,我们将添加对本书中将编写的各种测试的支持。
配置 Svelte 组件测试支持
Svelte 组件测试是一种测试 Svelte 组件的测试。为此,我们需要访问文档对象模型(DOM),它不是 Node.js 标准环境的一部分。我们还需要一些额外的包来编写针对 DOM 的单元测试期望。
安装 jsdom 和测试库助手
在终端中,运行以下命令来安装我们将用于单元测试的jsdom包和@testing-library包:
npm install --save-dev \
jsdom \
@testing-library/svelte \
@testing-library/jest-dom \
@testing-library/user-event
如果你正在使用 TypeScript,此时你可能希望添加包含类型定义的包。
接下来,创建一个名为src/vitest/registerMatchers.js的新文件,内容如下。它确保我们将使用的匹配器可以通过expect函数使用:
import matchers from '@testing-library/jest-dom/matchers';
import { expect } from 'vitest';
expect.extend(matchers);
然后,更新vite.config.js以添加一个新的environment属性,正确安装jsdom,以及一个setupFiles属性,确保之前定义的文件在测试套件加载前(和调用)被加载(和调用):
const config = {
plugins: [sveltekit()],
test: {
...,
reporter: 'verbose',
environment: 'jsdom',
setupFiles: ['./src/vitest/registerMatchers.js']
}
};
基本设置到此结束。现在让我们测试一下。
编写对 DOM 的测试
打开src/index.test.js文件,在describe块内添加以下测试定义。这个测试使用了jsdom包为我们创建的document对象,以及由@testing-library/jest-dom包提供的toHaveTextContent匹配器:
it('renders hello into the document', () => {
document.body.innerHTML =
'<h1>Hello, world!</h1>';
expect(document.body).toHaveTextContent(
'Hello, world!'
);
});
现在,如果你运行测试,你应该看到它通过。但,就像你第一次测试那样,确认测试实际上测试了它所说的内容非常重要。通过注释掉或删除测试的第一行来更改测试,然后重新运行测试运行器。
你应该看到以下输出:
FAIL src/index.test.js > sum test > renders hello into the document
Error: expect(element).toHaveTextContent()
Expected element to have text content:
Hello, world!
Received:
❯ src/index.test.js:9:25
7|
8| it('renders hello into the document', () => {
9| expect(document.body).toHaveTextContent(
| ^
10| 'Hello, world!'
11| );
这证明了测试正在工作。你可以继续撤销你做的破坏性更改。
编写第一个 Svelte 组件测试
接下来,让我们编写一个实际的 Svelte 组件并对其进行测试。创建一个名为src/Hello.svelte的新文件,内容如下:
<script>
export let name;
</script>
<p>Hello, {name}!</p>
然后,回到src/index.test.js文件,重构你的测试以使用这个新组件。为此,将document.outerHTML的调用替换为对render函数的调用,如下所示:
it('renders hello into the document', () => {
render(Hello, { name: 'world' });
expect(document.body).toHaveTextContent(
'Hello, world!'
);
});
这个render函数来自@testing-library/svelte包。现在导入它,以及一个Hello组件的导入,将其放置在文件顶部:
import { render } from '@testing-library/svelte';
import Hello from './Hello.svelte';
确认重构后的测试仍然通过。
然后,添加第三个测试,它验证组件中的name属性是否被用来验证输出:
it('renders hello, svelte', () => {
render(Hello, { name: 'Svelte' });
expect(document.body).toHaveTextContent(
'Hello, Svelte!'
);
});
运行测试并确保它通过。
现在,请先注释掉最后一个测试中的render调用。你可能认为测试会因为屏幕上没有渲染任何内容而失败。但让我们看看会发生什么:
Error: expect(element).toHaveTextContent()
Expected element to have text content:
Hello, Svelte!
Received:
Hello, world!
等一下,这是我们所期望的吗?这个测试从未打印出Hello, world!消息,那么为什么测试期望会捕捉到它?
结果表明,我们的测试共享同一个document对象,这显然不利于测试的独立性。想象一下,如果第二个测试也期望看到Hello, world!而不是Hello, Svelte!,它就会因为第一个测试的运行而通过。我们需要对此采取措施。
确保在每次测试运行后清除 DOM
我们想确保每个测试都得到其自己的干净 DOM 版本。我们可以通过使用cleanup函数来实现这一点。
创建一个名为src/vitest/cleanupDom.js的新文件:
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/svelte';
afterEach(cleanup);
然后,将其插入vite.config.js中的setupFiles属性:
const config = {
...,
test: {
...,
setupFiles: [
'./src/vitest/cleanupDom.js',
'./src/vitest/registerMatchers.js'
]
}
};
现在,如果你再次运行你的失败测试,你应该会看到Hello, world!消息不再出现。
在继续之前,取消注释render调用并检查你的测试是否回到了全绿色状态。
自动恢复模拟
我们还需要在vite.config.js中进行最后的配置。添加restoreMocks属性,如下所示:
const config = {
...,
test: {
...,
restoreMocks: true
}
};
这对于测试的独立性也很重要,在第十一章中,用并排实现替换行为,当我们开始使用vi.fn函数构建测试替身时,这一点将变得很重要。
这涵盖了本书其余部分所需的所有配置。下一节简要介绍了你可能想要考虑的一些可选配置。
可选配置
在本节中,我们将查看配置 Prettier 并在终端上设置更合适的制表符宽度。这些设置与本书中使用的打印设置相匹配。
配置 Prettier 的打印宽度
由于本书的物理页面限制,我已经将 Prettier 的printWidth设置设置为 54 个字符,并且所有代码示例都是使用该设置格式化的。
我也认为默认值,100,太高了。我喜欢短文本列,因为我觉得它们在各种环境中更容易分享和阅读——比如在移动设备上,垂直滚动比水平滚动要容易得多。
此外,当你与其他开发者配对并想要参考特定的行号时(假设你已经打开了行号),额外的垂直空间也很有用。
在.prettierrc中,你可以通过以下方式设置打印宽度:
{
"printWidth": 54,
...
}
你可能更喜欢60到80范围内的某个值。
在终端中减少制表符宽度
Svelte 社区更喜欢使用制表符而不是空格,因为制表符对屏幕阅读器更好。不幸的是,很多终端和 shell 程序默认的制表符宽度是八个字符,这对我来说太多了。
尽管每个终端都不相同,但我有一个坚定的建议,那就是将git config设置为使用less作为其分页器,制表符位置在1、3、5和7:
git config --global core.pager 'less -x1,3,5,7'
这使得git diff和git show更加可忍受,这两个命令我使用得非常频繁。
摘要
本章详细探讨了基础 SvelteKit 项目的各个部分,展示了如何添加 Playwright 和 Vitest,以及您编写 Svelte 组件测试所需的额外依赖项。
我们还探讨了您如何设置开发环境的一些方法,以帮助您提高工作效率。
您现在可以开始探索 TDD 实践了,下一章将从红-绿-重构周期 -> 工作流程开始。
第二章:介绍红绿重构工作流程
本章介绍了红-绿-重构工作流程,这是测试驱动开发(TDD)的核心。你将使用它来编写第一个 Svelte 组件,包括一个单元测试套件,该套件指定了组件的行为。
这种工作流程对你有益,因为它为软件实现提供了结构。这使你不太可能实现错误的东西。它还帮助你避免过度复杂化解决方案。
本章涵盖了以下主题:
-
理解红-绿-重构工作流程
-
前瞻性设计
-
编写失败的测试
-
让它通过
-
重构测试
-
为组件添加样式
到本章结束时,你将使用 TDD 编写你的第一块 Svelte 功能。
技术需求
本章的代码可以在网上找到,地址为github.com/PacktPublishing/Svelte-with-Test-Driven-Development/tree/main/Chapter02/Start。
理解红绿重构工作流程
红绿重构工作流程(或周期)是一个定义你如何编写软件的机制的过程。它具有持久的吸引力,因为它通过为他们的日常工作提供结构,帮助许多开发者提高了生产力。
你通过在工作流程中重复循环,直到软件完成来完成任务。红-绿-重构也适合成对或团队合作开发,因为它为你提供了一种组织讨论和决策的方式。
图 2**.1显示了工作流程。它有三个部分:
-
红:首先,你编写一个失败的测试。这听起来比实际要容易,因为你首先必须知道你打算建造什么。
-
绿:一旦你有一个失败的测试,你让它通过。你努力找到解决测试的最短路线。
-
重构:让它变得更好。退一步思考设计。即使你选择了最短的路线,你是否弄乱了?是否有任何抽象开始出现,可以被实现,或者现有的抽象需要调整?

图 2.1 – 红绿重构工作流程
这为你构建软件提供了结构。你从无到有,通过重复循环,测试一次,构建你的软件产品,直到它变得有用。(当然,你需要知道你的轨迹,但这将是第一步的一部分,正如我们很快将看到的。)
我们将在本章剩余的两部分中更详细地探讨红和绿步骤。首先,我们将编写一些代码作为示例,然后我们将讨论我们所做的。
第三部分,重构,在我们有更多代码可以操作时更容易解释,所以我们将重点放在 第四章,保存表单数据,和 第五章,验证表单数据。
现在,你已经了解了 Red-Green-Refactor 的理论。让我们试试看,好吗?
提前进行一些初步设计
如果你想象我们是一支即将开始新项目的开发团队,那么在没有项目概要的情况下开始项目是非常不可能的——你不能没有目的就随意编写测试。
因此,本节的目标有两件事:
-
在本书的第一部分,从高层次上讨论我们将要构建的内容
-
学习足够多的知识,以便我们可以编写第一个失败的测试
Birthdays 应用程序
我们将构建一个名为 Birthdays 的网络应用程序,用于管理人员名单及其生日。
图 2.2 展示了该应用程序的实际运行情况。主页是一个存储的生日列表。底部有一个表单,可以添加新的生日,这些生日以人员的姓名(仅一个文本字段)和他们的出生日期存储。

图 2.2 – Birthdays 应用程序
用户通过访问我们网站的 /birthdays URL 来访问这个应用程序。
在本章中,我们将专注于构建一个 Svelte 组件,该组件以列表的形式显示每个生日。它可以这样使用:
<Birthday name={name} dob={dob} />
我们可以从一个测试开始,这个测试指定了Birthday组件将如何使用其name属性。
初步设计和 TDD
工作流程中的 Red 部分要求编写一个失败的测试。好吧,但写什么测试呢?实际上,每次你开始 Red 循环时,你应该在思考你的轨迹。我们接下来需要什么?我们对我们设计了解到了什么?这就是你应该在每次测试之前进行的初步设计。
然而,技巧在于只做足够多的事情,以便确切地知道你要编写什么测试。你不需要把整个系统都规划出来。
这就是我们在这个阶段需要做的所有初步设计。我们知道我们可以从哪里开始——一个检查组件是否显示名称的检查。
编写一个失败的测试
在本节中,你将为 Vitest 测试运行器编写第一个单元测试。
创建一个名为 src/routes/birthdays/Birthday.test.js 的新文件,并从以下 import 语句开始:
import { describe, it, expect } from 'vitest';
import {
render,
screen
} from '@testing-library/svelte';
import Birthday from './Birthday.svelte';
describe 函数用于将测试分组为测试套件。每个测试文件(如 Birthday.test.js)至少有一个 describe 块。这些块也可以嵌套;在未来的章节中,我们将探讨一些场景,在这些场景中,你可能想要这样做。例如,在 第四章,保存表单数据,你将使用它们根据 HTML 表单中出现的单个表单字段来分组测试。
it 函数是定义单元测试的基本函数,它被这样命名是为了让测试描述读起来像规范。这个函数被设计成流畅的,意味着它的调用应该像普通英语一样阅读。这通常是我们在编写测试时追求的目标,因为它有助于它们像规范一样阅读。
expect 函数是我们用来检查我们的软件是否做了我们 期望 它做的事情。
render 和 screen 函数帮助我们操作 render 将组件结果输出到当前的 DOM 文档中,随后调用 screen 会给我们提供一系列定位函数,用于查找 DOM 元素。
最后一个导入是 Birthday 组件。这个组件目前还不存在。事实上,文件还不存在。这是故意的。注意我们已经做出了设计选择:文件应该存放的位置,它的名称,以及组件的名称。
单位级别的设计讨论
如果我们作为一个团队一起工作,我们会就所有这些决策进行讨论。尽管这看起来可能很显然,但 TDD 工作流程给了我们与队友自由讨论这些问题的空间。
命名事物很难。这就是为什么讨论命名非常有帮助。关于我自己,我学到的就是当我与他人讨论一个困难的变量名时,我们总能想出一个比我最初选择更好的名字。
与此相对的是作为一个独立开发者,你会在完成工作后进行工作审查,可能在一个漂亮的拉取请求中打包。想象一下,你完成了一切工作,后来队友告诉你:“我认为这个名字可以改进……” 这不是很令人沮丧吗?提前讨论意味着你可以避免之后的所有情绪困扰。
让我们继续编写测试。我们首先需要的是 describe 块和 it 测试描述。在 import 定义下方添加以下内容:
describe('Birthday', () => {
it('displays the name of the person', () => {
});
});
看看 describe 和 it 函数给出的描述是如何形成一个简单的英文句子的?Birthday 显示了 该人的 名字。
现在我们可以填写测试:
describe('Birthday', () => {
it('displays the name of the person', () => {
render(Birthday, { name: 'Hercules' });
});
});
render 调用将我们的 Birthday 组件与传递的 name 属性一起调用。它将 Birthday 组件挂载到 DOM 上,以便我们检查其结果。
接下来,是断言的时间。通过以下代码块中的 expect 函数完成测试:
describe('Birthday', () => {
it('displays the name of the person', () => {
render(Birthday, { name: 'Hercules' });
expect(
screen.queryByText('Hercules')
).toBeVisible();
});
});
在这个期望中,有几个重要的事情需要注意。
首先,注意定位器的使用,screen.queryByText。screen 对象有一系列这样的查询函数,所有这些函数都是为了在 DOM 中找到单个元素。随着本书的进展,我们将揭示常见的查询函数。queryByText 函数搜索提供的文本,如果找不到则返回 null。
queryBy 与 getBy 查询函数变体
如果您有使用 Testing Library 的经验,您会知道每个查询函数都有一个getBy和queryBy变体。当我使用 TDD 时,我在引入新元素的第一个测试中使用queryBy。这使得很清楚,我不期望元素此时存在。但是一旦这个测试变为绿色(并且通过),后续的测试就可以使用getBy,如果找不到元素,它会抛出一个异常。这有助于清楚地表明这个测试依赖于前面的测试来证明元素的存在。
另一个重要的事情是toBeVisible函数调用,它连接在expect函数调用之后。这检查我们在第一次调用中获得的内容是否在 DOM 文档中可见。
如果您熟悉 Testing Library,您可能知道这里有一个更功能上合适的匹配器我们可以使用。我们可以使用not.toBeNull,如下所示:
expect(
screen.queryByText('Hercules')
).not.toBeNull();
我认为这是更功能上合适的做法,因为queryByText查询函数如果找不到页面上的文本,将返回null,这正是我们真正感兴趣的。
我更喜欢使用toBeVisible的原因是它使测试更易于阅读,继续使用流畅、简洁的英语匹配语句的主题。当然,了解测试失败时的错误信息也很重要,我们马上就会看到。
红阶段的最后一步是观察测试失败。现在就运行它,无论是在您的 IDE 中还是在终端上。(如果您遵循了第一章中设置说明的指示,为测试设置,您将能够使用v shell 命令或npm run test:unit来运行测试。)
你应该看到以下输出:
FAIL src/routes/birthdays/Birthday.test.js [ src/routes/birthdays/Birthday.test.js ]
Error: Failed to load url ./Birthday.svelte (resolved id: ./Birthday.svelte). Does the file exist?
这实际上并不是一个测试失败!它告诉我们我们导入的文件不存在。这是创建文件的时候了。
请在src/routes/birthdays/Birthday.svelte位置创建一个空白文件。
然后,重新运行测试。会发生什么?
FAIL src/routes/birthdays/Birthday.test.js > Birthday > displays the name of the person
Error: expect(received).toBeVisible()
received value must be an HTMLElement or an SVGElement.
❯ src/routes/birthdays/Birthday.test.js:13:5
11| expect(
12| screen.queryByText('Hercules')
13| ).toBeVisible();
| ^
14| });
15| });
完美——我们得到了一个测试失败!注意这个神秘的错误信息,“接收到的值必须是 HTMLElement 或 SVGElement”。如果我们使用了not.toBeNull匹配器,我们会看到更不神秘的东西。然而,我认为这个测试足够简单,所以很清楚发生了什么——特别是由于这一行:
Error: expect(received).toBeVisible()
本节向您展示了如何使用基本的describe、it和expect函数编写一个失败的测试。您还看到了如何使用测试运行器来驱动一些管道工作,例如创建文件。
这就完成了循环中的红部分。接下来是绿部分。
让它通过
在本节中,我们将进行一个非常简单的更改,使测试通过,然后我们将用第二个测试重复这个循环。
要使这个测试通过,请将以下内容添加到src/routes/birthdays/Birthday.svelte文件中:
<script>
export let name;
</script>
<span><strong>{name}</strong></span>
现在重新运行你的测试,你应该会看到 Vitest 测试运行器的以下输出(你将看到来自第一章,设置测试环境的测试仍然列在这里):
✓ src/routes/birthdays/Birthday.test.js (1)
✓ Birthday (1)
✓ displays the name of the person
✓ src/index.test.js (3)
✓ sum test (3)
✓ adds 1 + 2 to equal 3
✓ renders hello into the document
✓ renders hello, svelte
Test Files 2 passed (2)
Tests 4 passed (4)
Start at 11:45:47
Duration 1.60s (transform 503ms, setup 306ms, collect 272ms, tests 63ms)
重复此过程
让我们继续添加dob属性的下一步。回到src/routes/birthdays/Birthday.test.js文件,添加此测试,就在原始测试下方,仍然在describe块内:
it('displays the date of birth', () => {
render(Birthday, { dob: '1994-02-02' });
expect(
screen.queryByText('1994-02-02')
).toBeVisible();
});
确保运行你的测试并观察它失败。然后,为了使其通过,请在src/routes/birthdays/Birthday.svelte中添加以下修改:
<script>
export let name;
export let dob;
</script>
<span><strong>{name}</strong></span>
<span>{dob}</span>
再次运行测试,你应该会看到现在两者都是绿色的。然而,有一些警告:
stderr | src/routes/birthdays/Birthday.test.js > Birthday > displays the name of the person
<Birthday> was created without expected prop 'dob'
stderr | src/routes/birthdays/Birthday.test.js > Birthday > displays the date of birth
<Birthday> was created without expected prop 'name'
✓ src/routes/birthdays/Birthday.test.js (2)
✓ Birthday (2)
✓ displays the name of the person
✓ displays the date of birth
我们将在下一节中修复这些警告,同时解决几个其他问题。
重构测试
在本节中,我们将查看在开始考虑下一个功能之前我们可以进行的重构工作。我们首先修复上一节中的测试警告,然后添加第三个测试作为完整性测试,最后添加一些样式。
重构和改变行为
重构的通常定义是任何不影响外部行为的内部更改。将 CSS 样式更改包括在内,或者确实删除警告的更改,这有点牵强。但我在项目早期发现,总是需要做出这样的小改动。关键点是,你的测试套件是绿色的,并且在整个过程中保持绿色。
清理警告
清理任何出现的警告非常重要。因为如果不这样做,测试运行器的输出就会充满噪音。我们希望输出尽可能简短,以便快速解析出现的问题。
在src/routes/birthdays/Birthday.test.js文件中,在describe块顶部添加一个名为exampleBirthday的新定义:
describe('Birthday', () => {
const exampleBirthday = {
name: 'Ares',
dob: '1996-03-03'
};
...
});
然后,更新第一个测试以包括此变量作为传递给组件的基本属性:
it('displays the name of the person', () => {
render(Birthday, {
...exampleBirthday,
name: 'Hercules'
});
...
});
现在更新第二个测试,如下所示:
it('displays the date of birth', () => {
render(Birthday, {
...exampleBirthday,
dob: '1994-02-02'
});
...
});
如果你现在运行测试,你应该会看到警告已经消失,测试仍然通过。
添加第三个测试以进行三角测量
如果你将Birthday.svelte文件更新为具有如下硬编码值,会发生什么?
<span><strong>Hercules</strong></span>
<span>1994-02-02</span>
好吧,试试看;你会发现你的测试仍然通过。
事实上,你的测试并不能防止硬编码。这突出了 TDD(测试驱动开发)的一个有趣方面。当我们根据测试构建软件时,我们总是旨在构建可能工作的最简单的东西。
为了超越硬编码的值,我们可以为每个单独的属性添加第二个测试。将此测试添加到测试套件中:
it('displays the name of another person', () => {
render(Birthday, {
...exampleBirthday,
name: 'Athena'
});
expect(
screen.queryByText('Athena')
).toBeVisible();
});
你会看到硬编码版本不再工作。使这两个测试都能正常工作的最简单方法就是简单地输出传入的name属性值。
本节已简要介绍了重构步骤。随着本书的进展,将会有更多的重构工作。
为组件添加样式
最后,你可以在Birthday.svelte文件中添加以下<style>块,这完成了Birthday组件,意味着你将准备好在页面上显示它,你将在下一章中这样做:
<script>
export let name;
export let dob;
</script>
<span><strong>{name}</strong></span>
<span>{dob}</span>
<style>
span {
display: inline-block;
width: 100px;
}
</style>
你可以避免为 CSS 编写单元测试,因为那是一种静态信息。单元测试专门关于行为:当这个或那个东西发生变化,或者传递了一个不同的name属性时会发生什么?
摘要
本章详细探讨了红-绿-重构工作流程中涉及的步骤。你已经看到了每一步都涉及了多少思考,以及 TDD 过程如何为个人工作和团队工作提供支架。
你还看到了如何使用 TDD 创建 Svelte 组件,并为 Vitest 测试运行器编写单元测试。
这种批判性思维的价值在于,它将帮助你从始至终规划你的预期工作,消除走错路或迷失方向的风险。如果你花时间练习这种工作流程,它很快就会变成一种本能。
在下一章中,我们将介绍 Playwright 测试,你将构建一个页面来容纳Birthday组件。
第三章:将数据加载到路由中
本章我们将要工作的/birthdays路由。其中一部分路由是确保路由有可用的数据。在本章中,你将看到如何测试驱动 SvelteKit 的load函数,以将数据拉入组件中。
你还将看到如何使用 Playwright 构建一个端到端测试,以证明这个系统的所有各种组件。
本章涵盖了以下内容:
-
使用 Playwright 指定端到端行为
-
决定使端到端测试通过的方法
-
测试驱动加载函数
-
测试驱动页面组件
到本章结束时,你将测试驱动一个可以在你的网络浏览器中查看的 SvelteKit 路由,并且你将了解 Playwright 端到端测试和 Vitest 单元测试之间的关键区别。
技术要求
本章的代码可以在网上找到,地址为github.com/PacktPublishing/Svelte-with-Test-Driven-Development/tree/main/Chapter03/Start。
使用 Playwright 指定端到端行为
在本节中,你将编写你的第一个 Playwright 测试,并了解其中的各种函数调用,你还将了解区分 Playwright 端到端测试和 Vitest 单元测试。
编写测试并观察其失败
我们将要编写的测试名为列出所有生日,它将执行以下步骤:
-
浏览到
/``birthdays位置。 -
寻找文本
Hercules和Athena,这将作为测试通过的证据。
一旦测试就位,我们将停下来思考如何让这个Hercules和Athena数据进入我们的系统。
创建一个名为tests/birthdays.test.js的新文件,并添加以下内容:
import { expect, test } from '@playwright/test';
test('lists all birthdays', async ({ page }) => {
await page.goto('/birthdays');
await expect(
page.getByText('Hercules')
).toBeVisible();
await expect(
page.getByText('Athena')
).toBeVisible();
});
你可以在这里看到一些与 Vitest 测试类似的东西,例如使用expect和toBeVisible匹配器。
然而,有些事情是不同的。首先,测试被标记为async,所有的函数调用(包括expect函数调用)都被标记为await。
这是必要的,因为 Playwright 正在驱动一个无头浏览器,这意味着它启动了一个在后台运行的真正浏览器进程,对你来说是不可见的。Playwright 没有机制来确定浏览器何时完成工作,除了耐心地等待并频繁检查浏览器状态。因此,其大部分内部逻辑都是由等待和超时驱动的:浏览器被给予一定的时间,通常几秒钟,来显示内容。
page.goto调用指示这个无头浏览器导航到/birthdays端点。Playwright 负责在后台启动一个真实的开发服务器,并确保任何相对 URL(例如/birthdays)都转换为指向此开发服务器的绝对 URL(如https://localhost:5173/birthdays)。
现在运行测试,使用 npm test 命令。你应该会看到几乎立即出现的失败:
1 birthdays.test.js:3:1 › lists all birthdays
✓ 2 test.js:3:1 › index page has expected h1 (618ms)
[WebServer] Error: Not found: /birthdays
测试完成后,让我们更详细地看看 Vitest 测试和 Playwright 测试之间的区别。
理解 Vitest 测试和 Playwright 测试之间的区别
Vitest 测试的工作方式和 Playwright 测试的工作方式之间有根本性的区别。两者都在 TDD 中扮演着它们的角色。
图 3.1 展示了每种类型的测试如何涵盖您的代码。Playwright 测试通常被称为端到端测试,它们是高级的,每个测试都锻炼大量的代码。Vitest 测试通常被称为单元测试。它们非常详细,只锻炼一小段代码。

图 3.1 – SvelteKit 项目中的端到端测试和单元测试
当开始构建新功能时,Playwright 测试通常是一个好的起点。甚至可能由不是开发者但仍然参与定义特性的项目利益相关者编写。在 第十三章,添加 Cucumber 测试 中,我们将看到如何使用纯英语语法而不是 JavaScript 代码来完成这项工作。
Playwright 测试通常针对浏览器 UI 编写。它们锻炼整个系统,包括网络浏览器和任何进程外资源,如数据库。当与 SvelteKit 应用程序一起工作时,Playwright 测试运行器启动 SvelteKit 网络服务器并执行所有 SvelteKit 运行时代码,以管理路由。
相反,Vitest 测试运行器不会加载 SvelteKit 网络服务器,也不会执行其任何代码。相反,它直接将您的 JavaScript 文件加载到与 Vitest 和您的测试套件相同的 Node 进程中。
虽然 Playwright 测试有助于让团队专注于需要构建的内容,但它们通常对软件的内部设计或整个系统的架构没有太多可说的。这就是 Vitest 单元测试发挥作用的地方。开发者可以使用它们来弄清楚系统的 如何。
单元测试有助于设计的方式有很多。例如,如果一个单元测试很难编写,那有时可能意味着应用程序设计过于复杂。以不同的方式分解单元可以使得单元测试变得更加简单。
Playwright 测试通常在具体细节上保持较低的要求,将详细内容留给单元测试。例如,在我们刚刚编写的测试中,我们关注的是系统所知道的生日列表,但请注意,我们只是通过查找人的名字来检查,而没有检查生日。我们将完整的生日检查留给 Birthday 组件的单元测试,该组件已在 第二章,介绍红-绿-重构工作流程 中编写。
这样,我们最终会有很多低级的 Vitest 单元测试和一些高级的 Playwright 测试。这正是 Mike Cohn 在《成功实施敏捷》一书中描述的经典测试自动化金字塔。它鼓励采用包含许多单元测试、一些服务测试以及少量 UI 测试的测试策略。
图 3.2 展示了测试自动化金字塔如何应用于 SvelteKit 项目。Playwright 端到端测试可以针对 UI 和特定 API 端点编写,而你的单元测试是为 Vitest 运行器编写的。

图 3.2 – 将测试金字塔应用于 SvelteKit 项目
以这种方式构建自动化测试的一个原因是单元测试创建和维护成本低,而 UI 测试在时间和精力投入方面成本高昂。
服务测试类似于 UI 测试,因为它们覆盖了整个系统流程,但避免了 UI。例如,它们可以直接调用 HTTP API 端点。这可能很有帮助,因为 UI 往往是系统中最脆弱的组件,而驱动 UI 需要一段时间,因为你需要等待屏幕上的变化被渲染。
注意
现代网络浏览器环境,以及现代测试运行器如 Playwright,在处理自动化 UI 测试方面已经变得越来越好。
经典测试自动化金字塔合理性的另一个原因是单元测试通常执行得非常快。你可以有很多单元测试,每个测试只执行代码表面的极小部分。当其中一个测试失败时,阅读测试描述或测试代码并找出应用程序代码中失败位置的速度非常快。
还值得记住的是,单元测试旨在记录编写代码时所做的所有技术设计决策,这种文档对于理解项目的历史至关重要。
最后,请记住,Vitest 单元测试并不测试 SvelteKit 服务器端运行环境。这意味着,例如,Vitest 单元测试可以测试你是否正确定义了 load 函数,但它不能测试路由是否正确连接。为此,你需要一个 Playwright 测试,它编译并运行你的组件和路由,就像它是一个真实的浏览器环境一样。
决定一种方法来使端到端测试通过
话虽如此,既然我们已经有了定义我们想要什么的 Playwright 测试,我们现在如何开始编写 Vitest 单元测试呢?
Playwright 测试寻找名称 Hercules 和 Athena。测试假设这两个人在系统中列出了他们的生日,并且 /birthdays 页面列出了他们。但我们是怎样首先将他们放入系统中的呢?
在真正的 TDD 风格中,我们可以推迟这个决定,并简单地将这些两个生日硬编码到系统中。毕竟,测试似乎并不关心数据是如何进入系统的,而只是关心它是如何呈现的。
我们可以稍后回来讨论如何添加生日。实际上,我们将在第八章中这样做,创建匹配器以简化测试。我们还可以利用第二章中的Birthday组件,介绍红-绿-重构工作流程,依次显示每个生日。
因此,我们需要做的是以下这些:
-
创建一个
load函数,返回赫拉克勒斯和雅典娜的硬编码生日数据。这需要在src/routes/birthdays/+page.server.js文件中存在。 -
创建一个
page组件,它从load获取数据并为每个给定的生日显示一个Birthday组件。这需要作为src/routes/birthdays/+page.svelte存在。
SvelteKit 负责将/birthdays路由与src/routes/birthdays目录中的文件匹配。在调用load后,它将结果传递到+page.svelte组件的data属性中。
这就涵盖了如何编写基本的 Playwright 端到端测试。我们已经讨论了 Playwright 端到端测试和 Vitest 单元测试之间的区别,并为本章的其余部分制定了一个计划。
下一个部分将介绍如何测试驱动我们的load函数的基本、硬编码版本。
测试驱动加载函数
现在我们已经决定实现一个load函数,该函数返回赫拉克勒斯和雅典娜的硬编码生日数据,实际的更改已经变得非常简单。
load函数是一个特殊的 SvelteKit 函数,当有请求到达指定的路由时将被调用。因此,当用户导航到/birthdays路由时,SvelteKit 会调用src/routes/birthdays/+page.server.js文件中的load函数,然后渲染src/routes/birthdays/+page.svelte文件中的组件。
按照以下步骤使用 TDD 创建load函数:
-
创建一个新的 Vitest 测试文件,命名为
src/routes/birthdays/page.server.test.js,并按照以下方式开始。我们正在从还不存在的+page.server.js文件中导入load函数。我们在测试中调用该函数并存储结果:import { describe, it, expect } from 'vitest'; import { load } from './+page.server.js'; describe('/birthdays - load', () => { it('returns a fixture of two items', () => { const result = load(); }); });
为加载函数命名 describe 块
我将describe块命名为/birthdays - load,这展示了可以用于路由load函数的标准命名模式。
-
使用以下期望完成测试:
it('returns a fixture of two items', () => { const result = load(); expect(result.birthdays).toEqual([ { name: 'Hercules', dob: '1994-02-02' }, { name: 'Athena', dob: '1989-01-01' } ]); });
每个测试一个期望
整个测试只包含对expect的单次调用。通常,在编写测试时,我发现尽可能只保留一个期望是有用的。这有助于保持测试描述和expect调用内容之间的紧密联系。
通常情况下(就像这个测试一样),你可以在单个期望中放入大量的检查。
toEqual 匹配器有一个特殊的 深度相等 机制,这意味着可以检查对象或数组的每一层的值,而不是它的身份。而且,我们可以使用约束函数,例如 objectContaining,我们将在 第六章,编辑 表单数据)中看到。
-
使用你的 Vitest 测试运行器运行测试。这将给出以下输出:
FAILsrc/routes/birthdays/page.server.test.js [ src/routes/birthdays/page.server.test.js ] Error: Failed to load url ./+page.server.js (resolved id: ./+page.server.js). Does the file exist? -
按照建议在
src/routes/birthdays/+page.server.js位置创建一个空文件,然后重新运行你的测试。你应该会看到以下内容:FAIL src/routes/birthdays/page.server.test.js > /birthdays - load > returns a fixture of two items TypeError: load is not a function ❯ src/routes/birthdays/page.server.test.js:6:18 4| describe('/birthdays - load', () => { 5| it('returns a fixture of two items', () => { 6| const result = load(); | ^ 7| expect(result.birthdays).toEqual([ 8| expect.objectContaining({ -
好的,太棒了:
load不是一个函数。那么,让我们创建一个基本的load函数,里面什么都没有。将以下内容添加到新文件中:export const load = () => ({}); -
重新运行你的测试。你会得到以下内容:
FAIL src/routes/birthdays/page.server.test.js > /birthdays - load > returns a fixture of two items AssertionError: expected [ { name: 'Hercules', …(2) }, …(1) ] to deeply equal [ { name: 'Hercules', …(1) }, …(1) ] ❯ src/routes/birthdays/page.server.test.js:14:28 12| it('returns a fixture of two items', () => { 13| const result = load(); 14| expect(result.birthdays).toEqual([ | ^ 15| { name: 'Hercules', dob: '1994-02-02' }, 16| { name: 'Athena', dob: '1989-01-01' } - Expected - 10 + Received + 1 - Array [ - Object { - "dob": "1994-02-02", - "name": "Hercules", - }, - Object { - "dob": "1989-01-01", - "name": "Athena", - }, - ]" + "undefined" -
为了解决这个问题,我们只需要填写硬编码的值。更新
src/routes/birthdays/+page.server.js中的代码,使其看起来如下:export const load = () => ({ birthdays: [ { name: 'Hercules', dob: '1994-02-02' }, { name: 'Athena', dob: '1989-01-01' } ] });
管道和硬编码的值
虽然这样做可能感觉有点没有意义,但价值在于将管道设置到位。我们在这里编写的测试将在我们填充 真实 实现时作为一个有用的回归测试,这个实现不仅仅是返回硬编码的数据。(我们将在 第四章,保存 表单数据)中改进这个实现)。
-
再次运行你的测试,你会看到测试成功:
✓ src/routes/birthdays/page.server.test.js (1) ✓ /birthdays - load (1) ✓ returns a fixture of two items
这就完成了一个工作的 load 函数。你现在已经涵盖了测试驱动路由的 load 函数的基础,以确保它符合 SvelteKit 的要求。
现在我们可以构建路由的 page 组件了。
测试驱动页面组件
是时候创建存在于路由中的 page 组件了。一如既往,我们将从一个测试开始:
-
创建
src/routes/birthdays/page.test.js文件,并添加以下导入。最后一个是为page组件本身。因为 SvelteKit 预期路由的组件存在于名为+page.svelte的文件中,所以我们不妨将组件命名为Page(毕竟它就是这个名字):import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import Page from './+page.svelte'; -
接下来,让我们编写测试。关键部分是
Page接收一个data属性,它需要与我们的load函数的结构相匹配。在实际运行环境中,SvelteKit 将调用load函数,然后使用data属性设置为load函数的结果来渲染+page.svelte中的组件:describe('/birthdays', () => { const birthdays = [ { name: 'Hercules', dob: '1994-02-02' }, { name: 'Athena', dob: '1989-01-01' } ]; it('displays all the birthdays passed to it', () => { render(Page, { data: { birthdays } }); expect( screen.queryByText('Hercules') ).toBeVisible(); expect( screen.queryByText('Athena') ).toBeVisible(); }); });
测试数据固定
尽管它们有相同的值,但这里的 birthdays 值集合与 load 函数中的硬编码值之间没有联系。load 函数最终会失去其 种子 数据。
-
如果你现在运行测试,你应该会看到缺少文件的常规失败:
FAIL src/routes/birthdays/page.test.js [ src/routes/birthdays/page.test.js ] Error: Failed to load url ./+page.svelte (resolved id: ./+page.svelte). Does the file exist? -
在
src/routes/birthdays/+page.svelte创建一个空文件,然后再次运行测试:FAIL src/routes/birthdays/page.test.js > /birthdays > displays all the birthdays passed to it Error: expect(received).toBeVisible() received value must be an HTMLElement or an SVGElement. -
是时候进行真正的实现了。复制以下代码,它使用
data属性来显示一个带有li的ol元素,每个生日都有一个。我们使用来自 第二章 介绍红-绿-重构工作流程 的Birthday组件来显示data.birthdays数组中的每个项目的生日:<script> import Birthday from './Birthday.svelte'; export let data; </script> <h1>Birthday list</h1> <ol> {#each data.birthdays as birthday} <li> <Birthday {...birthday} /> </li> {/each} </ol>
使用 HTML 列表进行可测试性
当渲染 数组 中的项目时,我们在这里所做的那样,始终使用 ol 元素(用于有序列表)或 ul 元素(用于无序列表)作为父容器,然后为列表中的每个项目使用 li 元素。使用列表元素可以增加你组件的可测试性,因为你可以使用定位函数来特别寻找 listitem 角色,我们将在 第六章 编辑表单数据 中看到。
注意,我们正在使用 Birthday 组件来使测试通过。但我们的测试并没有明确请求一个 Birthday 组件;期望看起来是这样的:
expect(
screen.queryByText('Hercules')
).toBeVisible();
你可能会认为使这个测试通过的最简单方法就是简单地打印出生日的名字。但那样会忽略我们测试的意图,我们的测试意图是显示一个 Birthday 组件的列表。
在 第十二章 使用组件模拟来明确测试 中,我们将探讨如何使用组件模拟来明确表示我们在这里想要使用 Birthday 组件。
实现完成后,你现在可以验证你通过测试。
-
运行 Vitest 测试运行器,你应该会看到测试现在通过了:
✓ src/routes/birthdays/page.test.js (1) ✓ /birthdays (1) ✓ displays all the birthdays passed to it -
现在,你也可以运行 Playwright 来查看你通过测试:
✓ 1 test.js:3:1 › index page has expected h1 (402ms) ✓ 2 birthdays.test.js:3:1 › lists all birthdays (430ms) 2 passed (4s) -
你可以在
src/routes/birthdays/+page.svelte文件中添加一些样式:<style> ol { list-style-type: none; padding-left: 0; } li { padding: 10px; margin: 5px; border: 1px solid #ccc; border-radius: 2px; } </style> -
最后,使用
npm run dev命令运行开发服务器。记下你应用程序的基本 URL,然后打开浏览器,加载/birthdaysURL 来检查你的工作。
计算加载路径
我们构建的路由最终会指向类似 https://localhost:5173/birthdays 的位置。但你的端口号可能不同:你需要运行 npm run dev 命令并查找带有 Local 标签的基本 URL。
本节向你展示了你如何在名为 +page.svelte 的文件中测试驱动一个 page 组件,SvelteKit 会在你浏览到已知路由时为你渲染该文件。
摘要
本章向你展示了如何使用 Playwright 编写端到端测试,并将其用作 Vitest 单元测试的脚手架。Playwright 测试检查所有单元是否协同工作,框架是否正常工作。Vitest 测试检查你是否满足了 SvelteKit 所要求的合同,例如 load 函数是否以正确的方式工作。
你也看到了如何使用 TDD 来延迟那些不是立即相关的决策,比如我们为什么直接硬编码样本数据而不是实现任何形式的持久化数据库来存储生日。
在下一章中,我们将通过实现一个 SvelteKit 表单操作来扩展相同的概念,使您能够向列表中添加新的生日。
第四章:保存表单数据
前一章介绍了 Playwright 和 SvelteKit 路由。我们 /birthdays 路由中的数据是硬编码的。在这一章中,我们将通过添加将新生日添加到系统中的功能来强制实现 load 函数的真正实现。
图 4**.1 显示了我们将要构建的新表单。它附加在 /``birthdays 路由的生日列表底部:

图 4.1 – 添加新生日的表单
本章涵盖了以下关键主题:
-
为数据输入添加 Playwright 测试
-
驱动 SvelteKit 表单
-
驱动 SvelteKit 表单操作
到本章结束时,你将很好地理解如何驱动 SvelteKit 表单。
技术要求
本章的代码可以在网上找到,地址为 github.com/PacktPublishing/Svelte-with-Test-Driven-Development/tree/main/Chapter04/Start。
为数据输入添加 Playwright 测试
将此测试添加到 tests/birthdays.test.js 中。它包括将新生日添加到系统所需的所有步骤:
test('saves a new birthday', async ({ page }) => {
await page.goto('/birthdays');
await page.getByLabel('Name').fill('Persephone');
await page
.getByLabel('Date of birth')
.fill('1985-01-01');
await page.getByRole('button').click();
await expect(
page.getByText('Persephone')
).toBeVisible();
});
在导航到 /birthdays 端点后,它使用 getByLabel 定位函数来查找一个带有 Name 标签的 input 字段。这是标准的 HTML 功能,使用 label 和 input 元素,我们将在下一节中看到。
我们使用 fill 函数将值输入到这个字段中,然后重复这个过程为 Date of birth 字段。然后,我们点击按钮(任何按钮!),最后检查 Persephone 文本是否出现在页面的某个位置。
这里需要区分的一个重要观点是,getByText 检查页面文本,而不是例如 input 字段的值。因此,我们不能只是填写 Name 字段,然后期待期望神奇地通过。
Playwright 测试的目的是展示以下步骤被执行:
-
用户填写姓名和出生日期。
-
用户按下 保存 按钮。
-
系统在其系统中记录生日。
-
浏览器刷新页面,新的生日作为加载的页面数据的一部分显示出来。
考虑到所有这些,我们将使用以下过程来使测试通过:
-
首先,我们将构建一个新的
BirthdayForm组件,该组件显示一个带有两个输入字段和一个按钮的基本 HTML 表单。 -
然后,我们将将其添加到上一章中构建的现有
+page.svelte文件中。 -
最后,我们将添加一个表单操作来添加这个生日,包括在服务器中引入一个新的数据结构来存储我们的生日。
这就涵盖了我们的前期设计,所有这些都包含在 Playwright 测试中。接下来,我们可以开始驱动表单。
驱动 SvelteKit 表单
在本节中,你将构建一个名为BirthdayForm的新组件,以及其测试套件。这个组件是一个 HTML 表单,包含两个文本字段:name和dob。每个input元素都有一个相应的label元素。还有一个名为保存的按钮用于提交表单。
SvelteKit 处理从客户端到服务器的表单数据提交。我们不会在我们的 Vitest 测试套件中测试这种行为,而是将其留给 Playwright 测试以确保所有部分正确对接。
按照以下步骤构建新的表单:
-
创建一个名为
src/routes/birthdays/BirthdayForm.test.js的新文件,并添加以下第一个测试。这个测试使用了queryByRole查询函数来在页面上查找具有form角色的元素:import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import BirthdayForm from './BirthdayForm.svelte'; describe('BirthdayForm', () => { it('displays a form', () => { render(BirthdayForm); expect(screen.queryByRole('form')).toBeVisible(); }); }); -
确保你运行测试并观察它失败。
-
然后,创建一个名为
src/routes/birthdays/BirthdayForm.svelte的新文件,并添加以下内容:<form /> -
如果你现在运行测试,你会看到测试仍然没有通过。那是因为
form角色只有在给表单命名后才会可用。按照以下方式更新实现:<form name="birthday" /> -
测试现在应该通过了。添加下一个测试,如下面的代码块所示。这个测试简单地检查我们是否通过
POST请求提交表单,这是向服务器提交新数据的常用机制:it('has a form method of POST', () => { render(BirthdayForm); expect(screen.getByRole('form').method).toEqual( 'post' ); }); -
通过添加
method属性使其通过,如下所示:<form method="post" name="birthday" /> -
然后,添加第三个测试,如下面的代码块所示:
it('displays a button to save the form', () => { render(BirthdayForm); expect( screen.queryByRole('button') ).toBeVisible(); }); -
要使其通过,请向表单中添加一个
type属性设置为submit的input元素。你也可以给它一个value属性为Save,这将用作按钮名称:<form method="post" name="birthday"> <input type="submit" value="Save" /> </form> -
对于下一个测试,我们将引入一个嵌套的
describe块,称为name field。我们可以在这里添加一个块以供分组,预期会有更多对这个字段的测试。我们将在第五章,验证 表单数据中添加一些:describe('name field', () => { it('displays a text field for the contact name', () => { render(BirthdayForm); const field = screen.queryByLabelText('Name', { selector: 'input[type=text]' }); expect(field).toBeVisible(); expect(field.name).toEqual('name'); }); });
这个测试使用了queryByLabelText函数。这与在 Playwright 测试中使用的page.getByLabel函数类似。
在这个测试中还有其他一些重要的内容:input[type=text]。从测试中看不太清楚,但这个测试的第一个预期检查以下所有内容:
-
存在一个带有
Name文本的label元素 -
存在一个带有
type属性设置为text的input元素 -
label元素与input元素相关联
这些检查的一部分来自选择器表达式本身。没有解释或对选择器语法的理解,很难知道这个预期的意图。
其中还有一个第二个预期检查,确保name属性已设置。这很重要,这样 SvelteKit 表单操作就可以正确地返回命名参数。我们将添加这个参数name,在下一个测试中,我们将添加另一个名为dob的参数。
在第八章,“创建匹配器以简化测试”中,我们将重构这些期望以提高其可读性。
让我们继续下一步:
-
要使测试通过,请继续添加
label和input元素,如下面的代码块所示:<form method="post" name="birthday"> <label> Name <input type="text" name="name" /> </label> <input type="submit" value="Save" /> </form> -
现在,我们可以用同样的方法对
出生日期字段重复同样的操作:describe('date of birth field', () => { it('displays a text field for the date of birth', () => { render(BirthdayForm); const field = screen.queryByLabelText( 'Date of birth', { selector: 'input[type=text]' } ); expect(field).toBeVisible(); expect(field.name).toEqual('dob'); }); }); -
要使那个测试通过,添加一个用于出生日期的字段:
<form method="post" name="birthday"> <label> Name <input type="text" name="name" /> </label> <label> Date of birth <input type="text" name="dob" /> </label> <input type="submit" value="Save" /> </form>
这完成了BirthdayForm组件。
将表单组件添加到页面组件中
接下来,我们将BirthdayForm添加到现有的/birthdays路由的页面组件中:
-
首先,在
src/routes/birthdays/page.test.js中添加这个测试,如下所示。我们通过检查具有form角色的 HTML 元素的存在来测试BirthdayForm:it('displays a form for adding new birthdays', () => { render(Page, { data: { birthdays } }); expect(screen.getByRole('form')).toBeVisible(); });
使用先前准备的工作来使测试通过
我们可以通过仅添加一个新的form元素来使这个测试通过,但鉴于我们已经在BirthdayForm中准备了form,使用它是有意义的。我们将在第十一章,“用并行实现替换行为”中看到如何使用组件模拟来使这个测试更具体。
-
要使这个测试通过,首先将新的
import语句插入到src/routes/birthdays/+page.svelte中:<script> import Birthday from './Birthday.svelte'; import BirthdayForm from './BirthdayForm.svelte'; export let data; </script> -
然后,添加对
BirthdayForm组件的引用,以及一个标题。由于标题将保持静态数据,我们不需要为它编写测试。我们的 Vitest 测试仅针对行为——当 props 改变或文档对象模型(DOM)事件触发时发生变化的事物:<ol> ... </ol> <h1>Add a new birthday</h1> <div> <BirthdayForm /> </div> -
当你在这里时,你还可以更新标签以确保
div具有与li元素相同的样式:<style> ... li, div { ... } </style>
你现在已经学会了如何测试驱动表单组件以及如何将其连接到你的page组件。这就是新的BirthdayForm组件的全部内容,如果你现在加载开发服务器并浏览到/birthdays URL,你应该会在页面上看到表单。
在下一节中,我们将连接保存按钮,以便将新的生日数据添加到系统中。
测试驱动 SvelteKit 表单操作
form操作是 SvelteKit 在表单提交时调用的东西。它在+page.server.js文件中定义为一个名为actions的对象。一般形式如下所示。现在不要添加这个;我们稍后会讨论它:
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
// ... do something with data here ...
}
};
这是我们现在要测试驱动的内容。有几个需要注意的点:
-
首先,Vitest 单元测试可以检查
form操作的行为,但它不会检查调用该操作的 SvelteKit 框架代码。你会记得我们用同样的方法对 HTML 表单进行了测试:我们没有测试submit操作,因为这种魔法是由 SvelteKit 管理的。为了测试框架集成,我们需要 Playwright 测试。 -
其次,如果你看一下前面的代码示例,表单操作有一个带有
formData函数的动作参数。这个函数返回一个FormData类型的项,这是一个内置的 DOM 类型。 -
如果我们要测试
form操作,我们需要一种方法来构建这些FormData对象。
我们要做的就是创建工厂方法来生成用于测试的示例对象。之后,我们将构建我们的表单操作。然而,为了做到这一点,我们需要用 真实 实现替换我们的硬编码加载函数。
构建 FormData 对象的工厂
创建一个名为 src/factories/formDataRequest.js 的新文件,并添加以下函数:
const createFormDataFromObject = (obj) => {
const formData = new FormData();
Object.keys(obj).forEach((k) =>
formData.append(k, obj[k])
);
return formData;
};
此函数接受一个纯 JavaScript 对象,并通过反复调用 append 方法将每个 obj 键值对转换为 FormData 对象。
接下来,添加 createFormDataRequest 函数,如下面的代码块所示。它返回一个与 SvelteKit 相同行为的 SvelteKit 请求对象:
export const createFormDataRequest = (obj) => ({
formData: () =>
new Promise((resolve) =>
resolve(createFormDataFromObject(obj))
)
});
你现在可以使用这个在 Vitest 测试的表单操作中。
为表单操作构建 Vitest 测试套件
打开 src/routes/birthdays/page.server.test.js 文件,并更新 load import 以导入 actions 对象:
import { load, actions } from './+page.server.js';
在那下面,添加一个新的 import 语句,用于你刚刚定义的 createFormDataRequest 工厂:
import {
createFormDataRequest
} from 'src/factories/formDataRequest.js';
然后,在文件的底部,在一个新的顶级 describe 块中,添加以下测试:
describe('/birthdays - default action', () => {
it('adds a new birthday into the list', async () => {
const request = createFormDataRequest({
name: 'Zeus',
dob: '2009-02-02'
});
await actions.default({ request });
expect(load().birthdays).toContainEqual(
expect.objectContaining({
name: 'Zeus',
dob: '2009-02-02'
})
);
});
});
此测试构建一个请求,用此调用我们的表单操作,然后使用 load 函数检查它是否成功返回。但这里有一个困难。因为前一章中的 load 函数有一个硬编码的实现,我们无法在那里添加任何新数据。
在我们可以使这个测试通过之前,我们需要将我们的硬编码 load 函数替换为一个版本,这样就可以使这个测试容易通过。
跳过测试作为 TDD 工作流程的一部分
有时,我们会编写一个 Red 测试并准备好将其变为 Green。但使其 Green 的方法涉及大量的重构。在这些情况下,最好通过标记新的 Red 测试为跳过来回退。然后你可以在 Green 的状态下安全地进行重构。一旦你的重构完成,取消跳过你的测试,你就可以回到 Red。现在,在所有重构工作完成后,使测试通过。
为什么要经历这个舞蹈?因为你有完全 Green 测试套件的安全感,可以告诉你你的重构是否正确完成。
首先,像这样跳过你刚刚添加的测试:
it.skip('adds a new birthday into the list', async () => {
...
});
重新运行所有测试以检查它们是否通过,除了跳过的测试:
✓ src/routes/birthdays/page.server.test.js (2)
✓ /birthdays - load (1)
✓ returns a fixture of two items
↓ /birthdays - default action (1) [skipped]
↓ adds a new birthday into the list [skipped]
...
Test Files 5 passed (5)
Tests 15 passed | 1 skipped (16)
现在在 src/routes/birthdays/+page.server.js 文件中,更新实现如下:
const db = [];
const addNew = (item) => db.push(item);
addNew({ name: 'Hercules', dob: '1994-02-02' });
addNew({ name: 'Athena', dob: '1989-01-01' });
export const load = () => ({
birthdays: Array.from(db)
});
这个新实现给我们提供了一个 addNew 函数,我们可以在最新的测试中使用它。
重新运行所有测试并检查它们是否通过。然后,你可以取消跳过最新的测试并重新运行它。你应该会得到一个失败,如下面的代码块所示:
FAIL src/routes/birthdays/page.server.test.js > /birthdays - default action > adds a new birthday into the list
TypeError: Cannot read properties of undefined (reading 'default')
❯ src/routes/birthdays/page.server.test.js:22:17
20| });
21|
22| await actions.default({ request });
好吧;我们可以添加一个空的 default 函数开始。将以下内容添加到 src/routes/birthdays/+page.server.js 文件的底部:
export const actions = {
default: async ({ request }) => {
}
};
如果你再次运行测试,你会从失败中看到所有管道似乎都正常;只是我们遗漏了添加生日的重要调用:
FAIL src/routes/birthdays/page.server.test.js > /birthdays - default action > adds a new birthday into the list
AssertionError: expected [ { name: 'Hercules', …(1) }, …(1) ] to deep equally contain ObjectContaining{ …(3) }
❯ src/routes/birthdays/page.server.test.js:24:28
22| await actions.default({ request });
23|
24| expect(load().birthdays).toContainEqual(
| ^
25| expect.objectContaining({
26| name: 'Zeus',
- Expected - 4
+ Received + 10
- ObjectContaining {
- "dob": "2009-02-02",
- "name": "Zeus",
- }"
...
最后,通过添加对已存在的 addNew 函数的调用使测试通过:
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
addNew({
name: data.get('name'),
dob: data.get('dob')
});
}
};
重新运行你的测试;现在所有测试都应该通过了。如果你运行 Playwright 测试,也应该发现它也通过了:
[WebServer]
✓ 1 test.js:3:1 › index page has expected h1 (499ms)
✓ 2 birthdays.test.js:3:1 › lists all birthday (507ms)
✓ 3 birthdays.test.js:13:1 › saves a new birthday (309ms)
3 passed (5s)
现在是启动开发服务器并真正尝试表单的好时机。
你现在已经学会了如何测试驱动 SvelteKit 表单操作,完成了全面测试整个路由所需工作的最后阶段。
摘要
本章介绍了如何使用 Playwright 端到端测试和 Vitest 单元测试来测试驱动 SvelteKit 表单和表单操作。
你已经看到了 Vitest 在测试单个 Svelte 组件的所有特殊性方面是有用的,但并不擅长测试 SvelteKit 的框架代码,例如处理 HTML 表单提交事件、构建服务器请求并调用你的表单操作的代码。为此,你需要一个 Playwright 测试。
在下一章中,你将通过添加一些服务器端表单验证来扩展这个表单。
第五章:验证表单数据
现在我们系统正在接受新的生日,我们需要验证传入的数据。在本章中,我们将看到如何测试驱动 SvelteKit 的fail函数,以便向用户返回有用的信息,使他们能够纠正任何错误。
图 5.1显示了服务器认为用户的出生日期无效后显示给用户的内容。注意无效的表单数据是如何被保留的,以便用户有机会进行更正:

图 5.1 – 输入无效日期时显示验证错误
本章涵盖了以下关键主题:
-
添加一个 Playwright 测试来验证表单错误
-
显示 SvelteKit 表单错误
-
在表单操作中验证数据
-
在测试之间清除数据存储
到本章结束时,你将很好地理解如何使用测试驱动的方法实现表单验证。
技术要求
本章的代码可以在网上找到,地址为github.com/PacktPublishing/Svelte-with-Test-Driven-Development/tree/main/Chapter05/Start。
添加一个 Playwright 测试来验证表单错误
在本节中,我们将编写另一个 Playwright 测试,并为所需的 Vitest 单元测试做一些前期规划。
这是下一个端到端测试,你现在可以将其添加到tests/birthdays.test.js中。它填写生日表单,就像上一章一样,但这次,invalid实际上就是单词invalid,它不是一个有效的出生日期:
test('does not save a birthday if there are validation errors', async ({
page
}) => {
await page.goto('/birthdays');
await page.getByLabel('Name').fill('Demeter');
await page
.getByLabel('Date of birth')
.fill('invalid');
await page.getByRole('button').click();
await expect(
page.getByText('Demeter')
).not.toBeVisible();
await expect(
page.getByText(
'Please provide a date of birth in the YYYY-MM-DD
format.'
)
).toBeVisible();
});
让我们思考一下错误信息:请提供 YYYY-MM-DD 格式的出生日期。为了简洁起见,我们不会完全进行测试驱动;相反,我们只会接受可以被内置的Date.parse函数解析的任何内容。结果证明,许多字符串都可以被这个函数解析。
除了这些,还需要什么才能使这个功能正常工作?我们的表单操作应该使用 SvelteKit 的fail函数来通知 SvelteKit 表单需要重新评估。我们使用422 – 不可处理的实体错误代码,这意味着请求数据无效。
fail函数还可以返回一个对象,该对象将传递回客户端。它作为form属性传递给我们的页面组件。该对象是一个我们控制的普通 JavaScript 对象。我们可以返回我们想要的任何内容:我们只需要调用 SvelteKit 的fail函数并传递该对象,它就会将其返回给客户端。
有效的返回对象
返回的对象只有在可以被序列化为字符串并在浏览器中重建时才是有效的。函数不能序列化,因此不能传递回。
我们可以包含一个返回错误信息的error属性。我们还可以返回name和dob属性,以便它们可以再次呈现给用户。
一个示例对象看起来像这样:
{
name: 'Demeter',
dob: 'invalid',
error: 'Please provide a date of birth in the YYYY-MM-DD
format.'
}
在剩余的部分,我们将首先更新 BirthdayForm 组件以使用这个新的 form 属性。然后,我们将更新表单操作以返回两个不同的验证错误:一个空的名字和一个无效的出生日期。
显示 SvelteKit 表单错误
在本节中,我们将添加测试和功能以支持将新的 form 属性传递给 BirthayForm 组件。
让我们从一个新的测试开始:
-
在
src/routes/birthdays/BirthdayForm.test.js文件中,添加一个新的嵌套describe块,其中包含一个测试,如下面的代码片段所示。它检查如果error属性设置在form属性上,那么那个错误必须在页面上某处显示:describe('validation errors', () => { it('displays a message', () => { render(BirthdayForm, { form: { error: 'An error' } }); expect( screen.queryByText('An error') ).toBeVisible(); }); }); -
在
src/routes/birthdays/BirthdayForm.svelte中使那通过,首先添加一个用于新form属性的export语句,然后添加一个包含错误文本的新p元素。你还可以在底部添加<style>元素,尽管这对于测试通过不是必需的:<script> export let form; </script> <p class="error">{form.error}</p> <form> ... </form> <style> .error { color: red; } </style> -
如果你现在运行测试,你会看到我们通过要求
form属性具有对象值而破坏了其他一些测试。但在这个组件的 创建 模式下,form属性应该保持未定义。按照以下代码块更新BirthdayForm组件:<script> export let form = undefined; </script> {#if form?.error} <p class="error">{form.error}</p> {/if} ... -
让我们在同一个
describe块中添加下一个测试。这个测试检查如果发生错误,我们将使用与传递的相同值重新填充name文本字段:describe('validation errors', () => { ... it('keeps the previous name value when an error occurs', () => { render(BirthdayForm, { form: { name: 'Hercules', error: 'Some awful error message' } }); expect( screen.queryByLabelText('Name') ).toHaveValue('Hercules'); }); }); -
为了使那通过,只需向
input字段添加一个value属性值:<input type="text" name="name" value={form?.name} /> -
现在为
dob字段重复那个操作:describe('validation errors', () => { ... it('keeps the previous dob value when an error occurs', () => { render(BirthdayForm, { form: { dob: '1994-01-01', error: 'Some awful error message' } }); expect( screen.queryByLabelText('Date of birth') ).toHaveValue('1994-01-01'); }); }); -
通过在
dob字段上添加value属性使那通过:<input type="text" name="dob" value={form?.dob} /> -
如果你现在运行应用程序,你会看到在 创建 模式下,当
form是undefined时,undefined字符串值现在出现在form上,因此value是undefined。浏览器将其转换为字符串,在文本框中给出undefined。为了修复这个问题,我们需要为字段指定一个初始值。在BirthdayForm测试套件中,找到name字段的describe块,并在其中添加一个第二个测试,如下面的代码块所示:describe('name field', () => { ... it('initially has a blank value', () => { render(BirthdayForm); expect( screen.getByLabelText('Name') ).toHaveValue(''); }); }); -
为了使那通过,更新
value属性,如下所示:<input type="text" name="name" value={form?.name || ''} /> -
然后,从下面的测试开始,对
date of birth field重复那个操作:describe('date a birth field', () => { ... it('initially has a blank value', () => { render(BirthdayForm); expect( screen.getByLabelText('Name') ).toHaveValue(''); }); }); -
最后,通过在
dob字段上设置value属性以同样的方式使那个测试通过:<input type="text" name="dob" value={form?.dob || ''} />
这就完成了 BirthdayForm 组件的更改。接下来,我们需要从页面组件获取 form 属性到 BirthdayForm。
通过页面组件传递表单数据
BirthdayForm 组件不是 根 路由组件:它作为 +page.svelte 中的组件的子组件实例化。
按照以下步骤确保 form 属性被 page 组件接收并传递给 BirthdayForm 组件:
-
在
src/routes/birthdays/page.test.js文件中,在测试套件的底部添加一个新的测试,如下面的代码片段所示。它检查如果发送了带有error属性的form属性,错误文本将显示在屏幕上:it('passes any form information to the BirthdayForm', () => { render(Page, { data: { birthdays }, form: { error: 'An error' } }); expect( screen.queryByText('An error') ).toBeVisible(); }); -
由于页面组件已经渲染了
BirthdayForm,因此使这个测试通过的最简单方法是传递form属性到BirthdayForm。在 第十二章,使用组件模拟来澄清测试,我们将看到如何使用组件模拟重写这个测试。不过,现在,在src/routes/birthdays/+page.svelte文件中,更新组件以声明form属性,然后直接将其传递给BirthdayForm:<script> ... export let form = undefined; </script> ... <BirthdayForm {form} />
你现在已经学会了如何使用自动化测试来驱动表单错误的显示。
在下一节中,我们将编写针对服务器端发生的表单操作的测试。
在表单操作中验证数据
现在我们已经为客户端的错误做好了准备,但我们还需要服务器代码实际执行验证检查。我们将添加两个检查:一个检查名称是否不为空,另一个检查日期是否可以解析为有效的 Date 对象。
这些检查需要四个单元测试:第一个测试确保我们在不添加生日的情况下提前退出;下一个测试检查 422 错误代码;然后一个测试检查错误信息文本;最后,一个测试检查原始数据是否被返回。(在 第八章,创建匹配器以简化测试,你将看到如何构建一个可以将这三个测试合并为一个单一测试的匹配器。)
beforeEach 函数
本节介绍了 beforeEach 函数,该函数用于在 describe 块内的每个测试之前运行设置代码。它是减少测试套件中重复的有用工具。你可以将其视为测试的 安排 阶段的一部分。
beforeAll、afterEach 和 afterAll 函数执行类似的工作,但使用频率较低。我们在 第一章,为测试做准备 中使用了 afterEach 函数进行初始化,我们将在 第八章,创建匹配器以简化测试 中使用 beforeAll。
让我们开始吧:
-
在
src/routes/birthdays/page.server.test.js文件中,为beforeEach函数添加一个新的导入。我们将使用这个函数来为整个测试集进行设置:import { describe, it, expect, beforeEach } from 'vitest';
然后添加一个嵌套的 'validation errors' describe 块,以及另一个嵌套的标题为 'when the name is not provided' 的 describe 块,如下所示。这包括第一个测试:
describe('/birthdays - default action', () => {
...
describe('validation errors', () => {
describe('when the name is not provided', () => {
let result;
beforeEach(async () => {
const request = createFormDataRequest({
name: '',
dob: '2009-02-02'
});
result = await actions.default({
request
});
});
it('does not save the birthday', () => {
expect(load().birthdays).not.toContainEqual(
expect.objectContaining({
name: '',
dob: '2009-02-02'
})
);
});
});
});
});
when… 上下文
当一组测试属于特定的起始场景时,例如前面场景所示,使用 when 风格命名 describe 上下文是常见的。它们通常包含一个包含所有测试的共同设置的 beforeEach 块。
有时候可能会有多个嵌套层级,但为了简单起见,最好只保留一个层级的when...上下文块。前面的例子显示了一个名为'validation errors'的外部块,但这只是为了组织,并不包含任何自己的设置。
-
然后,在
src/routes/birthdays/+page.server.js文件中,更新actions中的新name字段为空:export const actions = { default: async ({ request }) => { const data = await request.formData(); const name = data.get('name'); if (empty(name)) { return; } ... } }; -
为了使其工作,您需要一个
empty函数的定义,您可以像下面这样定义:const empty = (value) => value === undefined || value === null || value.trim() === '';
简化操作
您可能会观察到我们没有完全测试empty函数,例如,如果发送了一行空格会发生什么。为了避免在本章中编写更多的测试,我没有列出那些测试。如果这是一个真实的应用程序,我会将empty移动到自己的文件中,然后提供一大堆直接针对它的单元测试。
-
对于下一个测试,我们将编写一个测试来确保表单返回
422响应。将此测试添加到同一嵌套的describe块中:it('returns a 422', () => { expect(result.status).toEqual(422); }); -
为了使它通过,首先在
src/routes/birthdays/+page.server.js文件中添加以下import语句:import { fail } from '@sveltejs/kit'; -
然后更新保护子句以使用
fail返回一个值:if (empty(name)) { return fail(422); } -
现在,仍然在同一嵌套的
describe块中,添加一个测试来检查错误消息:it('returns a useful message', () => { expect(result.data.error).toEqual( 'Please provide a name.' ); }); -
通过将
return对象作为fail调用的第二个参数添加来使它通过:if (empty(name)) { return fail(422, { error: 'Please provide a name.' }); } -
然后添加此名称检查的最后一个测试,即我们继续传递
dob字段值:it('returns the data back', () => { expect(result.data).toContain({ dob: '2009-02-02' }); }); -
完成保护子句,如下面的代码片段所示。为此,您需要以与
name相同的方式提取dob字段:const name = data.get('name'); const dob = data.get('dob'); ... if (empty(name)) { return fail(422, { dob, error: 'Please provide a name.' }); } -
现在,让我们开始对无法解析的
date值进行第二个检查。这的行为与之前的检查完全相同,只是表单数据有不同的值:describe('/birthdays - default action', () => { ... describe('validation errors', () => { ... describe('when the date of birth is in the wrong format', () => { let result; beforeEach(async () => { const request = createFormDataRequest({ name: 'Hercules', dob: 'unknown' }); result = await actions.default({ request }); }); it('does not save the birthday', () => { expect(load().birthdays).not.toContainEqual( expect.objectContaining({ name: '', dob: '2009-02-02' }) ); }); }); }); }); -
为了使它通过,首先在
empty辅助函数旁边定义一个invalidDob辅助函数:const invalidDob = (dob) => isNaN(Date.parse(dob)); -
然后,更新表单操作以包含一个新的保护子句:
export const actions = { default: async ({ request }) => { ... if (invalidDob(dob)) { return; } } }; -
然后,从步骤 4开始重复动作,添加一个测试以确保返回
422响应:it('returns a 422', () => { expect(result.status).toEqual(422); }); -
为了使它通过,更新
return语句如下:if (invalidDob(dob)) { return fail(422); } -
接下来,添加一个测试来确保返回一个有用的消息:
it('returns a useful message', () => { expect(result.data.error).toEqual( 'Please provide a date of birth in the YYYY-MM-DD format.' ); }); -
更新保护子句以显示该消息:
if (invalidDob(dob)) { return fail(422, { error: 'Please provide a date of birth in the YYYY-MM- DD format.' }); } -
对于最终测试,我们检查是否返回了所有数据,包括无效日期。这样用户就有机会更正数据:
it('returns all data back, including the incorrect value', () => { expect(result.data).toContain({ name: 'Hercules', dob: 'unknown' }); }); -
通过将
name和dob属性传递给失败对象来使它通过。此时,所有测试都应通过:if (invalidDob(dob)) { return fail(422, { name, dob, error: 'Please provide a date of birth in the YYYY- MM-DD format.' }); } -
现在,作为最后的微小重构步骤,您可以更新对
addNew的调用,使其使用您在前面步骤中已经提取的表单数据值:addNew({ name, dob });
这样就完成了服务器端验证的测试驱动。您的 Vitest 测试和 Playwright 测试现在应该通过。您也可以通过运行开发服务器(使用npm run dev命令)并在浏览器中打开应用程序来尝试应用程序。
在本章的最后部分,我们将修复测试套件中悄悄出现的错误。
在测试之间清除数据存储
结果表明,我们的测试并不是独立的:一个测试中对db对象的更改也会影响其他测试。我们不得不在每次运行之间清除我们的测试数据库。我们可以通过创建一个清除数据库对象的clear函数来解决,我们将在每个测试之前使用beforeEach块来调用它。
我们需要的是一个可以直接在我们的测试中调用的clear函数。然而,如果你尝试将这个函数添加到+page.server.js文件中,当你运行 Playwright 测试时,SvelteKit 会发出警告:
Error: Invalid export 'clear' in /birthdays (valid exports are load, prerender, csr, ssr, actions, trailingSlash, or anything with a '_' prefix)
为什么这个错误只在 Playwright 测试中出现,而不是在 Vitest 测试中出现?你的 Vitest 测试不会运行通过 SvelteKit 服务器代码,因此框架没有机会检查无效的导出。只有当你通过 Playwright 运行测试时,你才会看到这样的运行时问题。
SvelteKit 只需要一个load导出和一个actions导出,绝对不需要其他任何东西。因此,我们需要将东西从动作中移出,放入它们自己的文件中:
-
创建一个新文件,
src/lib/server/birthdayRepository.js,内容如下:let db = []; export const addNew = (item) => db.push(item); export const getAll = () => Array.from(db); export const clear = () => (db = []); -
在
src/routes/birthdays/+page.server.js中,你现在可以像以下代码块所示导入它们。注意文件路径前使用$符号,这是用来创建一个相对于src文件夹的位置,这样我们就不需要在文件名前写../../:import { addNew, getAll } from '$lib/server/birthdayRepository.js'; -
然后删除
db和addNew函数,并将load函数更新如下。此时,除了跳过的测试外,所有测试都应该仍然通过:export const load = () => ({ birthdays: getAll() }); -
现在,你可以在
src/routes/birthdays/page.server.test.js中添加这个新的import语句:import as birthdayRepository from '$lib/server/birthdayRepository.js'; -
添加
beforeEach语句,如下面的代码块所示:describe('/birthdays - default action', () => { beforeEach(birthdayRepository.clear); ... }); -
最后,在
describe动作块中,你现在也可以用birthdayRepository.getAll替换load的使用,这使得测试更清晰地表明实际测试的内容:表单动作会将一个新的生日插入到birthdayRepository对象中:it('adds a new birthday into the list', async () => { ... expect(birthdayRepository.getAll()).toContainEqual( ... ); });
在最后一步,请注意不要替换load的所有出现。在第二个describe块中,load函数是受测试的函数。因此,我们保留那些测试保持原样。
这样就完成了提取仓库模块所需的所有工作。这样做使我们能够引入一个clear函数,可以用来保持我们的测试相互独立。beforeEach块确保每个测试都是从一张白纸开始的。
摘要
本章涉及编写的单元测试比之前的章节要多得多。有时,单元测试需要非常详细,尤其是在测试非常具体的返回值时。在第八章创建匹配器以简化测试中,我们将探讨减少所需测试数量的方法。
你也看到了为什么单元测试需要独立运行的重要性,以及如何使用beforeEach函数确保你的 SvelteKit 路由测试在每次测试之间清除数据。
在下一章中,你将学习如何扩展当前的BirthdayForm组件,使其能够处理编辑现有生日,除了添加新生日。
第六章:编辑表单数据
前两章展示了如何构建一个 HTML 表单来将新的生日添加到 Birthdays 应用程序中,以及如何为该表单添加服务器端验证。本章通过添加编辑现有生日信息的能力来总结表单实现。
做这件事将涉及添加 Svelte 组件状态来跟踪编辑表单是在列表模式还是编辑模式。
到目前为止,服务器一直在使用纯 JavaScript 数组存储数据。我们一直使用 TDD 来强制实施最简单的可能工作的实现。本章引入了一个更复杂的实现,它使用了一个 Map 对象,我们将作为 Refactor 步骤的一部分,在 Red-Green-Refactor 工作流程中进行。
本章将涵盖以下关键主题:
-
规划未来的路径
-
为编辑表单数据添加 Playwright 测试
-
逐步演化存储库以允许 ID 查找
-
更新表单操作以处理编辑
-
使用新的编辑模式更新列表页面
到本章结束时,你将看到如何使用 TDD 来演化系统设计,当你增加软件系统的功能时。
技术要求
该章节的代码可以在网上找到,地址为 github.com/PacktPublishing/Svelte-with-Test-Driven-Development/tree/main/Chapter06/Start。
规划未来的路径
在我们开始编写代码之前,让我们做一些初步设计,以便有一个大致的行动路线。
总体目标是允许系统中的每个生日条目都可以被修改。我们希望重用现有的 BirthdayForm 组件,以便它可以用于此目的。
图 6.1 展示了如何更新系统以支持这个新功能的示意图。每个列表项将有一个 Birthday 组件,将被切换到 BirthdayForm 组件:

图 6.1 – 编辑生日的原型
在编辑模式下,隐藏添加生日的表单并禁止编辑其他生日是有意义的,只是为了确保始终只有一个活动表单在显示。
剩下的一个问题是如何让后端表单操作知道我们正在编辑一个生日而不是添加一个?
做这件事的一个直接方法是给每个生日数据对象添加一个特殊的 id 属性。这是一个服务器可以用来识别每个单独对象的唯一值。id 将永远不会改变且不能被编辑,而其他数据项可以更改。用户永远不需要看到 id 值。它的目的是简单地启用现有数据项的修改。
我们可以使用标准的 JavaScript randomUUID 函数来生成一个唯一的字符串,为每个生日创建一个 id。
图 6**.2显示了各种 SvelteKit 组件和函数,以及实现此功能所需的重要数据,包括页面组件中的新编辑状态变量和用于选择编辑生日所用的id字段:

图 6.2 – 使用组件状态和查找表实现编辑行为
在继续之前,值得注意的是,我们当前的生日存储库以纯 JavaScript 数组的形式存储其birthday对象。这对于列出和添加新项目来说是不错的,但不是在不需要更新版本的情况下替换现有项目的理想数据结构。更好的数据结构是一个Map对象,它允许我们根据键轻松更新项目。由于我们已经意识到我们需要一个固定的id值来表示每个生日,我们已经有了一个很好的键选择。
这就完成了我们的初步设计。有了计划,现在是时候进行端到端测试了。
添加用于编辑表单数据的 Playwright 测试
在本节中,我们将构建我们系统的最新 Playwright 测试。因为这个测试相当长,我们将逐步构建它。在第七章“整理测试套件”中,我们将看看如何缩短这个测试脚本。
让我们按照以下步骤创建测试:
-
在
tests/birthday.test.js中,使用以下代码开始测试,该代码加载应用程序、/birthdays端点,然后完成表单以添加Ares的新生日。我们必须小心地找到名为保存的按钮。这是因为我们现在将在页面上有多个按钮:一个名为保存的按钮,然后是多个名为编辑的按钮:test('edits a birthday', async ({ page }) => { await page.goto('/birthdays'); // add a birthday using the form await page.getByLabel('Name').fill('Ares'); await page .getByLabel('Date of birth') .fill('1985-01-01'); await page .getByRole('button', { name: 'Save' }) .click(); }); -
接下来,添加以下命令以找到
Ares。这使用特殊的getByRole('listitem').filter(...)链式命令,该命令找到一个具有listitem角色的元素(意味着li元素),同时也包含'Ares'文本。然后我们在该列表项元素中找到编辑按钮:await page .getByRole('listitem') .filter({ hasText: 'Ares' }) .getByRole('button', { name: 'Edit' }) .click(); -
我们现在假设已经出现了一个新的表单,用于编辑
Ares的生日信息。使用以下代码继续测试,该代码将出生日期字段替换为另一个值,然后点击保存按钮:await page .getByLabel('Date of birth') .fill('1995-01-01'); await page .getByRole('button', { name: 'Save' }) .click(); -
使用几个期望来完成新的测试。我们检查原始出生日期不再出现,而新的出生日期确实出现:
// check that the original text doesn't appear await expect( page .getByRole('listitem') .filter({ hasText: 'Ares' }) ).not.toContainText('1985-01-01'); // check that the new text does appear await expect( page .getByRole('listitem') .filter({ hasText: 'Ares' }) ).toContainText('1995-01-01'); -
最后,需要修改之前的 Playwright 测试。每个测试都假设页面上只有一个按钮,即
find a button,并将它们更改为find a button named Save。找到所有看起来像这样的行:await page.getByRole('button').click();
并将它们更新如下:
await page.getByRole('button',
{ name: 'Save' }
).click();
这就完成了新的测试。您可以看到,我们已经在新的编辑按钮及其操作上做出了一些设计决策。
如果您现在使用npm test命令运行测试,您将看到新的测试超时等待编辑按钮出现:
Test timeout of 30000ms exceeded.
...
waiting for getByRole('listitem').filter({ hasText: 'Ares' }).getByRole('button', { name: 'Edit' })
在下一节中,我们将将其转换为关于应用程序代码的决定。
使存储库进化以允许 ID 查找
现在是时候更新我们的生日数据项,包括一个 id 字段。
让我们从一个新的测试开始,检查 id 是否存在。
-
首先将此测试添加到
src/routes/birthdays/page.server.test.js文件中,在名为/birthdays - default action的describe块内。它检查每个生日都有一个与其关联的唯一id字段:it('saves unique ids onto each new birthday', async () => { const request = createFormDataRequest({ name: 'Zeus', dob: '2009-02-02' }); await actions.default({ request }); await actions.default({ request }); expect(birthdayRepository.getAll()[0].id).not .toEqual(birthdayRepository.getAll()[1].id); }); -
在
src/lib/server/birthdayRepository.js中使其通过。首先添加import语句:import { randomUUID } from 'crypto'; -
然后更新
addNew函数的定义:export const addNew = (item) => db.push({ ...item, id: randomUUID() }); -
如果你现在运行测试,你会看到测试通过了,但我们测试套件中的另一个部分出现了新的失败。由于这些新的
id字段,返回两个项目的固定值测试现在出现了错误。我们可以通过使用expect.objectContaining约束函数来修复这个问题,这是一个有用的工具,可以用来表示,“我不在乎除了这些属性之外的一切”。它是减少测试脆弱性的有用工具。现在更新那个测试,如下面的代码块所示:it('returns a fixture of two items', () => { const result = load(); expect(result.birthdays).toEqual([ expect.objectContaining({ name: 'Hercules', dob: '1994-02-02' }), expect.objectContaining({ name: 'Athena', dob: '1989-01-01' }) ]); }); -
现在添加这个下一个测试,该测试检查如果我们发送一个带有
id属性的请求,那么我们应该选择更新与该id匹配的项目,而不是添加一个新的生日。注意storedId函数的使用,它提取出已保存到存储库中的id属性:const storedId = () => birthdayRepository.getAll()[0].id; it('updates an entry that shares the same id', async () => { let request = createFormDataRequest({ name: 'Zeus', dob: '2009-02-02' }); await actions.default({ request }); request = createFormDataRequest({ id: storedId(), name: 'Zeus Ex', dob: '2007-02-02' }); await actions.default({ request }); expect(birthdayRepository.getAll()).toHaveLength(1); expect(birthdayRepository.getAll()).toContainEqual({ id: storedId(), name: 'Zeus Ex', dob: '2007-02-02' }); }); -
现在是时候重构我们的
db值,使其成为一个Map对象,而不是数组,正如我们在上一节中讨论的那样。进行这个重构将使这个新测试变得简单。但我们不在 Red 上 重构。所以,首先跳过你刚刚编写的测试,并检查测试套件是否 Green。it.skip('updates an entry that shares the same id', async () => { ... }); -
在
src/lib/server/birthdayRepository.js中,将db、addNew、getAll和clear替换为使用Map对象的这个实现:const db = new Map(); export const addNew = (item) => { const id = randomUUID(); db.set(id, { ...item, id }); }; export const getAll = () => Array.from(db.values()); export const clear = () => db.clear(); -
在此更改后运行测试,并确保它们仍然是 Green。
有信心地进行重构
注意,你的单元测试的存在消除了当你完全替换内部数据结构时的任何改变恐惧。测试鼓励你做出任何必要的更改,而不用担心行为上的无意改变。
所有测试都应该通过——太棒了!
本节向您展示了另一个示例,说明我们可以如何使用 TDD 将复杂设计推迟到单元测试迫使我们的时候。您已经看到我们可以如何将一个重要变量从数组迁移到 Map 对象。
现在让我们继续构建编辑功能。
更新表单操作以处理编辑
在本节中,我们将继续更新存储库以处理更新生日以及添加新生日。我们将分三部分来处理这个问题:首先,替换 db 字段中的项目,其次,防止无效的 id 值,最后,确保在验证错误中返回 id 值,以便用户可以更正相同的生日。
替换存储库中的项目
让我们从上一节中写的测试开始:
-
取消
src/routes/birthdays/page.server.test.js文件中你最后写的测试的跳过。确保运行测试并观察它失败,确保你处于 红色 状态:it('updates an entry that shares the same id', async () => { ... }); -
为了使这个测试通过,首先在
src/lib/server/birthdayRepository.js中添加一个replace函数:export const replace = (id, item) => db.set(id, { ...item, id }); -
然后,将那个新函数导入到
src/routes/birthdays/+page.server.js:import { addNew, getAll, replace } from '$lib/server/birthdayRepository.js'; -
更新
actions常量,首先从请求中提取id,然后使用该id值来切换行为。如果id存在,则调用replace函数;否则,调用addNew函数:export const actions = { default: async ({ request }) => { const data = await request.formData(); const id = data.get('id'); ... if (id) { replace(id, { name, dob }); } else { addNew({ name, dob }); } } }; -
重新运行你的测试;你现在应该处于 绿色 状态。
接下来,让我们确保只接受有效的 id 值。
防止未知标识符
我们需要的最后一个验证是确保我们不会尝试更新库中不存在的项目。让我们从一个新的测试上下文开始:
-
仍然在
src/routes/birthdays/page.server.test.js中,向 验证错误 上下文中添加一个新的嵌套describe块,如下面的代码所示。我已经跳过了一些内容,并包括了 三个 测试,因为我们之前已经解决过这类测试,并且我们可以对同时解决它们有信心:describe('when the id is unknown', () => { let result; beforeEach(async () => { const request = createFormDataRequest({ id: 'unknown', name: 'Hercules', dob: '2009-01-02' }); result = await actions.default({ request }); }); it('does not save the birthday', () => { expect(load().birthdays).not.toContainEqual( expect.objectContaining({ name: 'Hercules', dob: 'unknown' }) ); }); it('returns a 422', () => { expect(result.status).toEqual(422); }); it('returns a useful message', () => { expect(result.data.error).toEqual( 'An unknown ID was provided.' ); }); }); -
要使这个通过,首先在
src/lib/server/birthdayRepository.js中添加一个新的has函数:export const has = (id) => db.has(id); -
然后将它导入到
src/routes/birthdays/+page.server.js:import { addNew, getAll, replace, has } from '$lib/server/birthdayRepository.js'; -
最后,通过添加一个新的防护子句来利用它。
if (id && !has(id)) { return fail(422, { error: 'An unknown ID was provided.' }); }
我们几乎完成了表单操作验证,但还有一件事需要做。
更新返回值以包含标识符
当发生验证错误时,例如当 name 字段为空时,我们需要确保 id 表单值包含在返回的错误值中。这确保了在网页浏览器中,可以重新打开正确的编辑表单,以便用户更正他们的编辑。
在我们开始代码更改之前,让我们讨论一下策略。这是应用程序代码最终将呈现的样子:
if (empty(name)) {
return fail(422, {
id,
dob,
error: 'Please provide a name.'
});
}
在你做出更改之前,考虑一下你将如何测试这个。我们已经有了一个检查返回值内容的测试,所以一个选择是回过头来编辑这个测试,如下所示:
beforeEach(async () => {
const request = createFormDataRequest({
id: '123',
name: 'Hercules'
});
});
但不要这样做。
我发现编辑之前的测试通常是一个坏主意。原因是它可能会产生测试,最终指定了永远不会发生的不合法场景。前面的例子确实是一个不合法的场景。这是因为系统中没有 id 值为 123 的生日。为了使其有效,我们需要新的测试设置说明,创建一个 id 值为 123 的生日,以确保 id 值是有效的。
但如果我们这样做,那么我们就没有对添加生日时的原始场景进行测试了!相反,让我们创建新的测试,覆盖可能发生的两种用例:编辑生日时无效的名称或无效的出生日期。
基于场景的测试
当你编写单元测试时,始终确保你的测试覆盖了有效场景。如果你遵循 TDD,那通常意味着总是添加新的测试而不是返回修改现有测试。
让我们从在validation errors上下文中添加一个新的嵌套上下文开始:
-
添加以下带有相关
beforeEach块的describe上下文,该块将生日添加到系统中:describe('when replacing an item', () => { beforeEach(async () => { let request = createFormDataRequest({ name: 'Zeus', dob: '2009-02-02' }); await actions.default({ request }); }); }); -
现在,将第一个测试添加到上下文中。它尝试编辑创建的生日,但名称为空。期望检查响应中返回的
id值是否相同:it('returns the id when an empty name is provided', async () => { const request = createFormDataRequest({ id: storedId(), name: '', dob: '1982-05-01' }); const result = await actions.default({ request }); expect(result.data).toContain({ id: storedId() }); }); -
为了使这一步通过,请在相关的保护子句中包含
id属性:if (empty(name)) { return fail(422, { id, dob, error: 'Please provide a name.' }); } -
接下来,添加一个针对无效出生日期的测试:
it('returns the id when an empty date of birth is provided', async () => { const request = createFormDataRequest({ id: storedId(), name: 'Hercules', dob: '' }); const result = await actions.default({ request }); expect(result.data).toContain({ id: storedId() }); }); -
为了使这一步通过,更新第二个保护子句,如下所示:
if (invalidDob(dob)) { return fail(422, { id, name, dob, error: 'Please provide a date of birth in the YYYY-MM-DD format.' }); }
这样就完成了表单操作的更改。
倾听你的测试
倾听你的测试非常重要。如果它们编写和更新起来很困难,那么这可能意味着测试可以改进或应用程序代码设计可以改进。
在第九章《从框架中提取逻辑》中,我们将验证移动到生日仓库中,这将给我们一个重新思考这些测试结构的机会。
本节涵盖了大量的更改:为替换项目添加仓库功能,更新表单操作以添加或替换项目,添加另一个保护子句以防止无效替换,并最终更新现有的保护子句以返回id值。
现在是时候更新页面组件以在编辑模式下显示BirthdayForm了。
更新列表页面以使用新的编辑模式
在本节中,你将更新页面,使其能够切换到特定生日的编辑模式。这依赖于有一个隐藏的表单字段用于id值。
测试隐藏字段
测试库没有提供一种简单的方法来查询隐藏输入字段,因为它通常关注的是用户可以看到的内容,而我们的id字段是故意设计成内部系统细节。
幸运的是,我们可以退回到标准的文档对象模型(DOM)表单 API 来解决这个问题。
为 SvelteKit 等框架编写单元测试的性质意味着有时你需要检查像这样的内部细节。
让我们从一个新的嵌套describe块中的新测试开始:
-
在
src/routes/birthdays/BirthdayForm.test.js文件中,并在describe块中的BirthdayForm根内部,添加这个新的嵌套describe块和测试:describe('id field', () => { it('contains a hidden field for the id if an id is given', () => { render(BirthdayForm, { form: { id: '123' } }); expect( document.forms.birthday.elements.id.value ).toEqual('123'); }); }); -
为了使这一步通过,更新
BirthdayForm组件(在src/routes/birthdays/BirthdayForm.svelte)以包含一个新的隐藏字段:<form method="post" name="birthday"> <input type="hidden" name="id" value={form?.id} /> </form> -
注意我们如何需要可选链(使用
form?)来确保我们的现有测试(没有form属性)继续工作。然而,这提出了一个问题:如果我们不是在编辑而是在创建,id字段的值是什么?我们需要另一个测试,你可以将其添加到同一个describe块中:it('does not include the id field if no id is present', () => { render(BirthdayForm); expect( document.forms.birthday.elements.id ).not.toBeDefined(); }); -
为了使这个测试通过,将可选链提升到条件中,该条件将
BirthdayForm组件中的隐藏input元素包装起来:<form method="post" name="birthday"> {#if form?.id} <input type="hidden" name="id" value={form.id} /> {/if} </form>
好了,这就是BirthdayForm组件本身的所有内容。现在关于页面组件呢?
在页面上添加切换模式
在本节中,你将引入一个名为editing的组件状态变量,使我们能够在创建和更新模式之间切换。
让我们从显示页面上列出的每个生日的编辑按钮开始:
-
在
src/routes/birthdays/page.test.js中添加以下测试。请记住,默认情况下,仓库有两个项目,因此这个测试允许我们测试两个都有一个编辑按钮:it('displays an Edit button for each birthday in the list', () => { render(Page, { data: { birthdays } }); expect( screen.queryAllByRole('button', { name: 'Edit' }) ).toHaveLength(2); }); -
为了使这个测试通过,在
/src/routes/birthdays/+page.svelte中,更新每个li元素以包含一个新的button元素:<ol> {#each data.birthdays as birthday} <li> <Birthday {...birthday} /> <button>Edit</button> </li> {/each} </ol> -
接下来,当我们点击那个按钮时会发生什么?让我们添加一组测试来检查使用
beforeEach函数(我们将用它来提取每个测试的公共设置)时的行为。第二个是为click函数,它将用于模拟 DOM 点击事件:import { describe, it, expect, beforeEach } from 'vitest'; import { click } from '@testing-library/user-event'; -
然后添加这个新的嵌套
describe块和测试。beforeEach函数用于提取测试的安排部分,以避免在后续的每个测试中重复它。此代码还使用了一个名为firstEditButton的辅助函数,使测试可读且简短:describe('when editing an existing birthday', () => { beforeEach(() => render(Page, { data: { birthdays } }) ); const firstEditButton = () => screen.queryAllByRole('button', { name: 'Edit' })[0]; it('hides the existing birthday information', async () => { await click(firstEditButton()); expect( screen.queryByText('Hercules') ).toBeNull(); }); }); -
为了使这个测试通过,首先在页面组件中引入一个新的组件状态变量
editing:<script> ... let editing = null; </script> -
当
editing到特定的birthday对象时,这个对象是由each构造函数给我们的。然后我们可以用条件包装原始的Birthday组件;如果editing等于当前的birthday对象,则不显示Birthday:<ol> {#each data.birthdays as birthday} <li> {#if editing !== birthday} <Birthday {...birthday} /> {/if} <button on:click={() => (editing = birthday)}> Edit</button> </li> {/each} </ol> -
接下来,我们还想隐藏添加页面的原始表单:
it('hides the birthday form for adding new birthdays', async () => { await click(firstEditButton()); expect( screen.queryByRole('heading', { name: 'Add a new birthday' }) ).toBeNull(); }); -
为了使这个测试通过,将页面组件的最后部分用
if包装起来:{#if !editing} <h1>Add a new birthday</h1> <div> <BirthdayForm {form} /> </div> {/if}
但是等等!我们现在正在定义一个静态元素的行为,我们之前从未测试过:标题。添加新生日文本是我们没有费心测试的东西。但现在它是我们测试套件的一个组成部分,我们当然应该有一个测试来证明它最初在那里?(否则,使最后一个测试变为绿色的最直接方法就是删除标题。)
事实上,现在就做吧。删除它并观察你的测试套件愉快地通过。为了将其恢复,我们需要一个失败的测试:
-
在测试套件的顶部添加这个新测试:
it('displays a heading for "Add a new birthday"', () => { render(Page, { data: { birthdays } }); expect( screen.queryByRole('heading', { name: 'Add a new birthday' }) ).toBeVisible(); }); -
观察测试失败,然后继续取消删除标题。
-
接下来是下一个测试。这次,让我们检查是否显示了
BirthdayForm。我们可以通过查找包含现有名称的Name字段来完成(在这种情况下,是Hercules):it('shows the birthday form for editing', async () => { await click(firstEditButton()); expect( screen.getByLabelText('Name') ).toHaveValue('Hercules'); }); -
为了使这个测试通过,用一个新的
:else块充实if条件块。注意这里的执行顺序发生了变化:如果editing等于birthday,则显示BirthdayForm;否则,显示Birthday:<ol> {#each data.birthdays as birthday} <li> {#if editing == birthday} <BirthdayForm form={editing} /> {:else} <Birthday {...birthday} /> {/if} ... </li> {/each} </ol> -
现在我们页面上有了保存按钮,难道我们不应该隐藏所有的编辑按钮吗?是的,让我们这么做:
it('hides all the Edit buttons', async () => { await click(firstEditButton()); expect( screen.queryByRole('button', { name: 'Edit' }) ).toBeNull(); }); -
要使它通过,在按钮周围引入另一个
if块:{#if !editing} <button on:click={() => (editing = birthday)}> Edit</button> {/if} -
还有一个最后的测试。这是一个重要的测试。它检查如果 SvelteKit 返回一个带有
id值的form对象,那么我们需要立即开始对该生日进行编辑模式。由于id值在这里很重要,这个测试包括它自己的data和form属性:it('opens the form in editing mode if a form id is passed in', () => { render(Page, { data: { birthdays: [ { id: '123', name: 'Hercules', dob: '1994-02-02' } ] }, form: { id: '123', name: 'Hercules', dob: 'bad dob', error: 'An error' } }); expect( screen.queryByRole('heading', { name: 'Add a new birthday' }) ).toBeNull(); });
使用工厂方法缩短测试
在第七章,整理测试套件中,您将创建一个用于生日的工厂方法,这将缩短这个测试。
-
测试暗示
editing的初始值取决于form。所以,现在更新它,使其看起来像这样:let editing = form?.id ? form : null; -
然后,因为我们正在处理不同的对象,我们不能再使用基于对象身份的相等性来匹配当前正在编辑的生日。所以,更新第一个
if,使其像以下代码所示,检查id而不是整个对象本身:{#if editing?.id === birthday.id} ... {/if} -
现在,由于对
id字段的这种新依赖,您会发现其他测试正在中断。更新birthdays数组,包括像这样的id值:const birthdays = [ { id: '123', name: 'Hercules', dob: '1994-02-02' }, { id: '234', name: 'Athena', dob: '1989-01-01' } ];
从这一点开始,您的测试应该通过,包括 Playwright 测试。
图 6**.3显示了如果您启动开发服务器(使用npm run dev命令)并尝试用无效日期替换现有的生日,应用程序看起来会是什么样子:

图 6.3 – 编辑生日时的验证错误
本节向您展示了如何使用 Svelte 组件状态在表单的添加和编辑模式之间切换,以及如何对这两种修改进行测试驱动开发,既在表单组件中也在页面组件中。
摘要
本章演示了在放置了大量代码之后,TDD 过程是如何工作的。此外,您还看到了我们如何使用与第二章,介绍红-绿-重构工作流程中学习到的相同的红-绿-重构工作流程来构建功能:首先,通过重构存储实现,然后通过引入 Svelte 组件状态。
在下一章中,我们将停下来看看我们可以简化当前代码库的一些方法。
第二部分:重构测试和应用代码
现在您已经了解了并实践了测试驱动开发的工作流程,是时候关注那些将保持您的自动化测试和应用代码整洁有序的实践和策略了。本部分中的章节将为您提供创建优雅且可维护的自动化测试套件的指导。
这一部分包含以下章节:
-
第七章,整理测试套件
-
第八章,创建匹配器以简化测试
-
第九章, 从框架中提取逻辑
-
第十章, 测试驱动 API 端点
-
第十一章, 用并排实现替换行为
-
第十二章, 使用组件模拟来澄清测试
-
第十三章, 添加 Cucumber 测试
第七章:整理测试套件
你在处理测试套件时是否曾经感到沮丧?除非你积极维护它们,否则它们很容易变得杂乱无章。在本章中,我们将探讨一些保持测试套件整洁的方法。
你用于整理测试套件的技巧与你在应用程序代码中使用的技巧不同。应用程序代码需要构建抽象和封装细节,具有深层连接的对象。然而,测试受益于保持简洁,每个测试语句都有明确的效果。
另一种思考方式是,正常程序流程可以通过代码中的许多不同路径,但测试套件只有一条流程——它们是从上到下运行的脚本。其中缺少控制逻辑,如条件表达式和循环结构。
在 Playwright 测试中使用页面对象模型
本章涵盖了以下技巧:
-
在 Playwright 测试中使用页面对象模型
-
提取动作辅助函数
-
提取用于创建数据对象的工厂方法
到本章结束时,你将学会一系列策略来减少测试套件的大小。
技术要求
控制测试套件复杂性的主要机制是抽象函数,以隐藏细节。
代码示例
页面对象模型只是一个简单的 JavaScript 类,它将导航页面的机械动作(定位字段、点击按钮或填写文本字段)组合成描述应用程序中高级操作的方法(完成生日表单)。
在本节中,你将构建一个名为 BirthdayListPage 的页面对象模型,这将允许你更简单地重写现有的 Playwright 测试。
让我们从添加新的类开始:
-
创建一个名为
tests/BirthdayListPage.js的新文件,并给它以下内容。它创建了一个基本类,以及一个名为goto的单个方法,用于导航到/birthdays应用程序 URL:export class BirthdayListPage { constructor(page) { this.page = page; } async goto() { await this.page.goto('/birthdays'); } } -
我们已经在测试中使用了这个类。在
tests/birthdays.test.js文件顶部添加以下导入:import { BirthdayListPage } from './BirthdayListPage.js'; -
现在,更新所有测试以使用此类,通过将直接调用
page.goto替换为通过BirthdayListPage对象的间接调用。例如,考虑以下现有测试:test('edits a birthday', async ({ page }) => { await page.goto('/birthdays'); ... });
它应该修改为如下所示:
test('edits a birthday', async ({ page }) => {
const birthdayListPage = new BirthdayListPage(page);
await birthdayListPage.goto();
...
});
-
现在,我们将继续为每个单独的文件创建辅助函数。看看以下来自编辑测试的原始代码:
// add a birthday using the form await page.getByLabel('Name').fill('Ares'); await page .getByLabel('Date of birth') .fill('1985-01-01'); await page .getByRole('button', { name: 'Save' }) .click();
从这里,我们可以提取名为 nameField、dateOfBirthField 和 saveButton 的辅助函数。现在,将它们添加到 BirthdayListPage 中,如下所示:
export class BirthdayListPage {
...
dateOfBirthField = () =>
this.page.getByLabel('Date of birth');
nameField = () => this.page.getByLabel('Name');
saveButton = () =>
this.page.getByRole('button', { name: 'Save' });
}
-
仍然在
BirthdayLastPage中,你现在可以将这些辅助方法合并成一个执行整个操作的单一辅助方法,名为saveNameAndDateOfBirth:export class BirthdayListPage { ... saveNameAndDateOfBirth = async (name, dob) => { await this.nameField().fill(name); await this.dateOfBirthField().fill(dob); await this.saveButton().click(); }; -
原始测试中 步骤 4 的代码部分,包括解释性注释,现在可以通过一个函数调用完成。注释不再必要,因为方法名基本上表达了相同的意思。现在按照以下方式更新测试:
test('edits a birthday', async ({ page }) => { const birthdayListPage = new BirthdayListPage(page); await birthdayListPage.goto(); await birthdayListPage.saveNameAndDateOfBirth( 'Ares', '1985-01-01' ); ... );
这样就完成了本节的测试。接下来是下一部分,看起来如下:
// find the Edit button for that person
await page
.getByRole('listitem')
.filter({ hasText: 'Ares' })
.getByRole('button', { name: 'Edit' })
.click();
对于本节,我们可以重复之前的步骤来提取一个内部辅助方法以定位所需的字段,然后提取一个外部辅助方法来执行操作。首先在 BirthdayListPage 页面对象模型中添加一个新的辅助方法,命名为 entryFor,用于查找某人的姓名条目:
entryFor = (name) =>
this.page
.getByRole('listitem')
.filter({ hasText: name });
-
现在,使用
entryFor方法构建另一个辅助方法,beginEditingFor,它将点击该生日页面的 编辑 按钮:beginEditingFor = (name) => this.entryFor(name) .getByRole('button', { name: 'Edit' }) .click(); -
是时候移除 步骤 6 中显示的原始代码,通过在页面对象模型中调用这个新辅助方法来执行单一调用。按照以下代码块中的更新进行操作,再次删除注释并将六行代码缩减为一行:
test('edits a birthday', async ({ page }) => { const birthdayListPage = new BirthdayListPage(page); await birthdayListPage.goto(); await birthdayListPage.saveNameAndDateOfBirth( 'Ares', '1985-01-01' ); await birthdayListPage.beginEditingFor('Ares'); ... }); -
测试中还有一个最终的操作可以更新,即开始编辑后修改表单值。我们不需要为此操作创建任何新的辅助方法。我们只需要重用
saveNameAndDateOfBirth辅助方法。现在按照以下方式执行此操作:test('edits a birthday', async ({ page }) => { ... await birthdayListPage.beginEditingFor('Ares'); await birthdayListPage.saveNameAndDateOfBirth( 'Ares', '1995-01-01' ); ... }); -
最后要做的更改是更改期望。它们可以更新为使用
entryFor辅助方法。由于这是本节中的最后一个更改,列表显示了完整的测试。按照以下方式更改期望:test('edits a birthday', async ({ page }) => { const birthdayListPage = new BirthdayListPage(page); await birthdayListPage.goto(); await birthdayListPage.saveNameAndDateOfBirth( 'Ares', '1985-01-01' ); await birthdayListPage.beginEditingFor('Ares'); await birthdayListPage.saveNameAndDateOfBirth( 'Ares', '1995-01-01' ); await expect( birthdayListPage.entryFor('Ares') ).not.toContainText('1985-01-01'); await expect( birthdayListPage.entryFor('Ares') ).toContainText('1995-01-01'); });
引入 BirthdayListPage 页面对象模型的好处是显而易见的:测试更易读,测试数据更突出(两个出生日期的变化现在更明显),并且任何未来的更改都将更快完成,因为测试更短。
继续进行剩余的测试
仓库中的其他 Playwright 测试也可以使用完全相同的辅助方法重写。这些更改在本书中没有展示,但在配套仓库中可用。
在本节中,你看到了如何创建 Playwright 页面对象模型。接下来,我们将对 Vitest 单元测试做类似的事情。
提取操作辅助方法
本节介绍了如何使用辅助方法简化测试的 执行 阶段。
理解 Arrange-Act-Assert 模式
Arrange-Act-Assert 模式是描述单元测试编写顺序的标准方式。
它们从 安排 阶段开始,这是测试结构准备工作的阶段。任何输入数据都会被构建,并且会调用任何准备方法,使系统进入正确的状态。
然后,我们有 Act 阶段,它调用正在检查的操作。最后,测试以 Assert 阶段结束,该阶段可以是一个或多个期望(或 断言),以验证操作是否按预期执行。
这三个阶段各自受益于不同的策略来消除重复。
Act 阶段是受益于消除重复最少的阶段。这是因为您将要编写的绝大多数单元测试——以及本书中的所有单元测试——都有一个由单个方法调用触发的操作。很少会遇到需要超过单个指令的操作来观察的动作。
由于这个原因,我倾向于确保方法调用直接在单元测试中调用,而不是与任何 Arrange 阶段的语句混合。话虽如此,有些情况下构建一个 Act 辅助函数是有帮助的。通过 actions.default 导入调用 SvelteKit 表单操作的调用就是这些情况之一。
有几个原因。首先,actions.default 的名称在单元测试套件的上下文中不够描述性。其次,表单操作的参数并不简单——它使用表单 API 的 request 对象来包装表单数据,然后将其打包成一个类似 SvelteKit RequestEvent 的对象。这需要在每个测试中完成。我们关心的是表单数据中的值,而不是围绕它的管道。
在 src/routes/birthdays/page.server.test.js 文件中,您将看到以下模式被重复多次:
const request = createFormDataRequest({
// ... the form data object ...
});
await actions.default({ request });
您可以将请求语句视为设置的一部分。每次调用表单操作时都需要它。没有进行表单数据操作就无法调用表单操作。
因此,让我们创建一个包装此行为的辅助函数。我们将称之为 performFormAction,这样在测试中就可以清楚地了解正在发生什么。
让我们开始:
-
在
src/routes/birthdays/page.server.test.js中,将以下函数定义添加到/birthdays - defaultaction上下文的顶部:const performFormAction = (formData) => actions.default({ request: createFormDataRequest(formData) }); -
在每个测试中,寻找前面代码片段中描述的模式——调用
createFormDataRequest然后导入的actions.default函数——并将每个实例替换为对performFormAction的调用。以下是从测试中的一个示例:it('adds a new birthday into the list', async () => { await performFormAction({ name: 'Zeus', dob: '2009-02-02' }); expect(birthdayStore.getAll()).toContainEqual( expect.objectContaining({ name: 'Zeus', dob: '2009-02-02' }); }); });
确保您在测试套件中对所有这些更改重新运行所有测试。在下一节中,我们将继续查看简化测试套件的 Arrange 部分。
提取用于创建数据对象的工厂方法
是时候使用名为 createBirthday 的工厂方法简化测试的 Arrange 阶段了。
上一节提到,每个 安排-行动-断言 阶段都需要不同的处理以简化。安排 阶段的一个关键方法是使用工厂。您已经在 第四章,保存表单数据 中创建了一个这样的工厂。那就是您在上一节中使用的 createFormDataRequest 方法。
使用测试工厂来隐藏无关数据
工厂方法帮助您以尽可能少的代码生成支持对象。它们这样做的一种方式是为对象属性设置默认值,这样您就不需要指定它们。然后您可以自由地在每个单独的测试中覆盖这些默认值。
隐藏必要但无关的数据是保持单元测试简洁和清晰的关键方法。
我们的生日对象结构非常简单,只有三个字段——name、dob 和 id。在这三个中,name 和 dob 频繁设置,而 id 字段很少设置。此外,每个单独的字段都有独特的数据形状——名称看起来与日期非常不同,与 通用唯一 标识符(UUID)也非常不同。
考虑到这一点,即将到来的 createBirthday 辅助函数需要 name 和 dob 两个参数,但将 id 作为可以有时指定的额外字段。这些 name 和 dob 值作为位置参数给出——这意味着它们通过位置而不是通过名称来识别——因为很明显哪个是哪个。这节省了页面上的空间。
这可能看起来并不重要,但当你编写可维护的软件时,每个单词都必须证明其价值。
下面是一个示例,注意 id 的指定方式不同,因为很少使用:
createBirthday('Zeus Ex', '2007-02-02', { id: storedId() })
让我们开始:
-
创建一个名为
src/factories/birthday.js的新文件,并给它以下内容:export const createBirthday = ( name, dob, extra = {} ) => ({ name, dob, ...extra }); -
打开
src/routes/birthdays/Birthday.test.js文件并导入新的辅助函数:import { createBirthday } from src/factories/birthday.js'; -
接下来,找到所有使用
exampleBirthday对象的render调用。它们看起来像这样:render(Birthday, { ...exampleBirthday, name: 'Hercules' });
将它们更新为使用新的辅助函数,如下所示:
render(
Birthday,
createBirthday('Hercules', '1996-03-03')
);
-
然后,在
src/routes/birthdays/page.server.test.js中添加createBirthday导入:import { createBirthday } from 'src/factories/birthday.js'; -
找到上一节中更新的所有对
performFormAction的调用。它们看起来像这样:await performFormAction({ name: 'Zeus', dob: '2009-02-02' });
将它们更新为使用 createBirthday 辅助函数,如下所示:
await performFormAction(
createBirthday('Zeus', '2009-02-02')
);
有几个测试中,前面的更改并不直接。在 保存每个新生日唯一的 ID 测试中,您可以将创建的生日保存到 request 对象中,然后将其传递给 performFormAction 两次,如下面的代码所示:
it('saves unique ids onto each new birthday', async () => {
const request = createBirthday(
'Zeus',
'2009-02-02'
);
await performFormAction(request);
await performFormAction(request);
expect(birthdayStore.getAll()[0].id).not.toEqual(
birthdayStore.getAll()[1].id
);
});
更新具有相同 ID 的条目 测试需要一个特定的 id 在第二次调用中传递。注意工厂方法是如何构建的,以便需要命名不常见的信息,例如 id 字段:
it('updates an entry that shares the same id', async () => {
await performFormAction(
createBirthday('Zeus', '2009-02-02')
);
await performFormAction(
createBirthday('Zeus Ex', '2007-02-02', { id:
storedId() })
);
expect(birthdayStore.getAll()).toHaveLength(1);
expect(birthdayStore.getAll()).toContainEqual({
id,
name: 'Zeus Ex',
dob: '2007-02-02'
});
});
-
对于最终的测试套件,在
src/routes/birthdays/page.test.js中,可以将birthdays数组更新为使用两个createBirthday调用,如下所示:const birthdays = [ createBirthday('Hercules', '1994-02-02', { id: '123' }), createBirthday('Athena', '1989-01-01', { id: '234' }) ]; -
最后,更新这里显示的测试,使其直接在
render调用中使用createBirthday助手,用于birthdays属性值和form属性值:it('displays all the birthdays passed to it', () => { render(Page, { data: { birthdays: [ createBirthday('Hercules', '1994-02-02', { id: '123' }) ] }, form: { ...createBirthday('Hercules', 'bad dob', { id: '123' }), error: 'An error' } }); });
使用 createBirthday 的过程到此结束。请确保重新运行你的测试,以确保一切仍然正常。
你现在已经学会了如何使用测试工厂方法来简化并使你的单元测试更加清晰。
摘要
本章向你展示了三种缩短测试套件的技术:Playwright 端到端测试的页面对象模型、Vitest 单元测试的动作助手和工厂方法。保持测试套件清晰和有意义是保持它们易于维护的关键。
在下一章中,我们将探讨一种更复杂的方式来减少单元测试代码——编写你自己的自定义匹配器。
第八章:创建匹配器以简化测试
本章介绍了另一种简化测试的方法:构建自定义匹配器。大多数时候,坚持使用内置匹配器是有意义的。例如,toEqual 匹配器与 expect.objectContaining 和 expect.arrayContaining 函数的强大组合使得构建表达式的期望变得容易。
但有时构建一个可以将多个不同的检查合并到一个单独的检查中的匹配器是有意义的。这不仅缩短了测试,还可以使它们更容易阅读。
在 第五章 验证表单数据 中,每个表单验证规则都通过一个包含四个测试的 describe 上下文进行测试,如下所示:
describe('when the date of birth is in the wrong format')
it('does not save the birthday', ...)
it('returns a 422', ...)
it('returns a useful message', ...)
it('returns the other data back including the incorrect
value', ...)
});
由于所有验证规则都具有相同的格式,因此似乎是一个很好的候选者来抽象一些共享代码。我们将创建的匹配器将把这三个测试中的三个合并到一个自定义匹配器中——toBeUnprocessableEntity 匹配器——这样就可以用一个测试来替换它们:
it('returns a complete error response', async () => {
const result = await performFormAction(
createBirthday('Hercules', 'unknown')
);
expect(result).toBeUnprocessableEntity({
error:
'Please provide a date of birth in the YYYY-MM-DD
format.',
name: 'Hercules',
dob: 'unknown'
});
});
最后还有一个重要的一点:自定义匹配器需要它自己的单元测试集。这样你就可以确保匹配器做了正确的事情:当它应该通过时通过,当它应该失败时失败。就像你想要确保你的应用程序代码做正确的事情一样。
测试测试代码
我的一般规则是这样的:如果你的代码包含任何类型的控制结构或分支逻辑,例如 if 语句或循环结构,那么它需要测试。
本章涵盖了以下内容:
-
测试期望的通过或失败
-
在失败消息中提供额外信息
-
实现否定匹配器
-
更新现有测试以使用匹配器
到本章结束时,你将了解如何构建一个匹配器以及如何为其编写测试。
技术要求
本章的代码可以在网上找到,地址为 github.com/PacktPublishing/Svelte-with-Test-Driven-Development/tree/main/Chapter08/Start。
测试期望的通过或失败
在本节中,你将构建 toBeUnprocessableEntity 匹配器的基本功能,确保它能够正确地通过或失败你的测试。但首先,我们将查看匹配器的结构,然后是单元测试匹配器的方法。
理解匹配器结构
让我们看看匹配器的基本结构以及我们如何测试它:
export function toTestSomething(received, expected) {
const pass = ...
const message = () => "..."
return {
pass,
message
};
}
received 参数值是传递给 expect 调用的对象。expected 值是传递给匹配器的值。因此,在引言中的示例中,结果是接收到的对象,包含 error、name 和 dob 属性的对象是期望的对象。
return 值有两个重要的属性:pass 和 message。如果匹配器通过了检查,pass 布尔值应该是 true,否则为 false。然而,对于否定匹配器,情况正好相反:pass 的 true 值意味着期望失败。
message 属性是一个返回字符串的函数。这个字符串是测试运行器在测试失败时显示的内容。字符串的内容应该足够让开发者定位到发生的错误。这个属性本身被定义为函数,以便它可以延迟评估:如果测试通过,运行此代码就没有意义。
与本书中的其他代码示例不同,匹配器函数将使用标准的 function 关键字定义。这意味着它能够访问 this 绑定变量。
Vitest 为 this 提供了一些有用的实用函数,匹配器可以使用。我们将在本章中使用其中几个:this.equals 和 this.utils.diff。另一个有用的属性是 this.isNot,如果匹配器以否定形式调用,则该属性为 true。
测试匹配器
测试匹配器有几种方法。一种方法是与任何其他函数一样测试函数返回值。这种方法的问题是需要设置 this 变量,而这并不简单。
另一种方法,也是我们将在本章中使用的方法,是使用 toThrowError 匹配器来包装要测试的匹配器,如下所示:
expect(() =>
expect(response).toBeUnprocessableEntity()
).toThrowError(
'Expected 422 status code but got 500'
);
toThrowError 匹配器接受一个函数作为参数来期望;然后在 try 块中执行该函数。捕获的 Error 对象然后会检查其 message 值是否与期望值匹配。
为了使这种方法有效,我们需要确保 toBeUnprocessableEntity 已注册到 Vitest 测试运行器。我们可以通过在测试套件开始时运行一次的 beforeAll 函数来实现这一点。
拥有所有这些知识,我们就可以开始编写匹配器了。
编写 toBeUnprocessableEntity 匹配器
让我们开始编写测试套件:
-
创建一个名为
src/matchers/toBeUnprocessableEntity.test.js的新文件,并从以下导入开始。它们包括所有我们将要使用的 Vitest 导入。我们还将导入failSvelteKit 函数,我们将在测试中使用它:import { describe, it, expect, beforeAll } from 'vitest'; import { fail } from '@sveltejs/kit'; import { toBeUnprocessableEntity } from './toBeUnprocessableEntity.js'; -
接下来,在开始处创建一个新的
describe块和一个beforeAll块。这确保了新匹配器在我们运行测试之前被注册。这只需要做一次:describe('toBeUnprocessableEntity', () => { beforeAll(() => { expect.extend({ toBeUnprocessableEntity }); }); }); -
我们的第一项测试将导致期望失败。添加以下测试代码,它使用
500错误代码而不是422创建一个失败原因,然后使用toThrowError匹配器来检查期望是否失败:it('throws if the status is not 422', () => { const response = fail(500); expect(() => expect(response).toBeUnprocessableEntity() ).toThrowError(); }); -
要使该测试通过,我们只需要构建一个返回
pass值为false的匹配器。创建一个名为src/matchers/toBeUnprocessableEntity.js的新文件,并包含以下内容。如前所述,这使用function关键字语法,以便我们可以访问附加到this变量的 Vitest 匹配器实用函数:export function toBeUnprocessableEntity( received, expected ) { return { pass: false }; } -
在第一个测试通过后,添加第二个。这个测试检查的是相反的情况——即如果响应有
422错误代码,匹配器不会抛出异常:it('does not throw if the status is 422', () => { const response = fail(422); expect(() => expect(response).toBeUnprocessableEntity() ).not.toThrowError(); }); -
要使测试通过,将原始代码包裹在条件中,使其成为一个守卫子句,如果该条件不满足,则返回
pass值为true,如下所示:export function toBeUnprocessableEntity( received, expected ) { if (received.status !== 422) { return { pass: false }; } return { pass: true }; } -
如果发生失败,我们希望测试运行器显示有关期望失败原因的有用消息。为此,你可以向
toThrowError匹配器传递一个字符串值,该值定义了错误消息。这就是 Vitest 测试运行器将在屏幕上显示的内容。添加以下测试:it('returns a message that includes the actual error code', () => { const response = fail(500); expect(() => expect(response).toBeUnprocessableEntity() ).toThrowError( 'Expected 422 status code but got 500' ); }); -
使其通过涉及返回
message属性。该属性的值是一个仅在测试失败时调用的函数。这是一种懒加载的形式,允许测试运行器避免进行不必要的操作。更新守卫子句以包含message属性,如下所示:if (received.status !== 422) { return { pass: false, message: () => `Expected 422 status code but got ${received.status}` }; } -
接下来,我们还需要检查
response体。在我们的应用程序代码中,任何422结果都会返回一个包含原始表单值的error属性。我们希望匹配器在实际响应与预期值不匹配时使测试失败:it('throws error if the provided object does not match', () => { const response = fail(422, { a: 'b' }); expect(() => expect(response).toBeUnprocessableEntity({ c: 'd' }) ).toThrowError(); }); -
要使测试通过,我们只需要添加一个非常简单的第二个守卫子句。如果向匹配器传递了一个参数,那么测试就会失败。这个实现甚至离正确实现还差得远,但它足以使测试通过。我们还需要通过更多测试来验证。但就目前而言,你可以从以下代码开始:
export function toBeUnprocessableEntity( received, expected ) { if (received.status !== 422) { ... } if (expected) { return { pass: false }; } ... } -
下一个测试非常相似,但这次,两个响应体确实匹配。这种情况不应该导致失败错误:
it('does not throw error if the provided object does match', () => { const response = fail(422, { a: 'b' }); expect(() => expect(response).toBeUnprocessableEntity({ a: 'b' }) ).not.toThrowError(); }); -
将第二个守卫子句更新为使用
this.equals函数对received.data值和expected参数进行深度相等性检查。这足以使测试通过:export function toBeUnprocessableEntity( received, expected ) { if (received.status !== 422) { ... } if (!this.equals(received.data, expected)) { return { pass: false }; } ... }; -
最终测试是一个检查部分对象是否匹配的测试:
it('does not throw error if the provide object is a partial match', () => { const response = fail(422, { a: 'b', c: 'd' }); expect(() => expect(response).toBeUnprocessableEntity({ a: 'b' }) ).not.toThrowError(); }); -
要使该测试通过,我们将使用
expect.objectContaining约束函数,该函数可以传递到this.equals的调用中。首先,在测试文件顶部导入expect对象:import { expect } from 'vitest'; -
然后,更新守卫类,将
expected值包裹在expect.objectContaining的调用中,如下所示:if ( !this.equals( received.data, expect.objectContaining(expected) ) ) { ... } -
最后,如果你现在运行测试,你会发现第一个测试失败了,因为
expected的值是undefined,而expect.objectContaining不喜欢这个值。要修复这个问题,为expected参数设置一个默认值,如下所示:export function toBeUnprocessableEntity( received, expected = {} ) { ... }
你现在已经看到了如何测试驱动匹配器函数。下一节将重点介绍在发生失败时显示的错误消息的改进。
在错误消息中提供额外信息
本节改进了在测试失败时向开发者展示的详细信息。额外信息的目的是帮助定位应用程序代码中的问题,以便开发者不会对出了什么问题感到困惑。
让我们开始:
-
添加下一个测试,该测试检查当响应体不匹配时是否显示基本消息:
it('returns a message if the provided object does not match', () => { const response = fail(422, { a: 'b' }); expect(() => expect(response).toBeUnprocessableEntity({ c: 'd' }) ).toThrowError(/Response body was not equal/); }); -
要实现这个跳过,请将
message属性添加到第二个保护子句的return值。我们将在下一个测试中进一步说明这一点:if (!this.equals(...)) { return { pass: false, message: () => 'Response body was not equal' }; } -
Vitest 包含一个内置的
diff辅助对象,它将打印出彩色的差异。颜色是通过 ANSI 颜色代码添加到文本字符串中的,终端将解析并使用这些代码来切换颜色。这些代码的存在意味着在toThrowError匹配器中检查文本内容并不简单。以下测试展示了以更简单的方式检查相同内容的实用方法,通过检查c和a属性是否出现在输出中:it('includes a diff if the provided object does not match', () => { const response = fail(422, { a: 'b' }); expect(() => expect(response).toBeUnprocessableEntity({ c: 'd' }) ).toThrowError('c:'); expect(() => expect(response).toBeUnprocessableEntity({ c: 'd' }) ).toThrowError('a:'); }); -
要实现这个跳过,我们将把差异追加到我们正在打印的
message的末尾。首先,从 Node.js 的os模块中导入EOL常量,它提供了当前平台的行结束符:import { EOL } from 'os'; -
在匹配器代码中,更新第二个保护子句的
message属性,使用this.utils.diff函数来打印expected和received.data对象的差异:return { pass: false, message: () => `Response body was not equal:` + EOL + this.utils.diff(expected, received.data) };
这样就完成了详细错误信息的显示。我们将在下一节中完成我们的匹配器,确保它在被否定使用时工作良好。
实现否定匹配器
否定匹配器是一项棘手的工作,主要是因为否定匹配器可能有令人困惑的含义。例如,以下期望意味着什么?
expect(result).not.toBeUnprocessableEntity({
error: 'An unknown ID was provided.'
});
据推测,如果响应是422且响应体与提供的对象匹配,它应该失败。但是,如果响应是500或200响应,它也应该失败吗?如果这是预期的,那么写这个就足够了吗?
expect(result).not.toBeUnprocessableEntity();
我发现,在编写针对特定领域思想的匹配器时,最好避免使用否定匹配器,或者至少限制其使用。然而,为了展示如何实现,让我们继续使用匹配器。
当我们否定匹配器时,Vitest 测试运行器会在匹配器返回pass值为true时失败测试。我们恰好有一个场景会发生这种情况,因为所有保护子句都返回pass值为false。因此,所有这些剩余的测试只是在这种情况下检查message属性。
让我们从创建一个嵌套的describe块开始:
-
添加一个名为
not的嵌套describe块,并添加第一个测试:describe('not', () => { it('returns a message if the status is 422 with the same body', () => { const response = fail(422, { a: 'b' }); expect(() => expect(response).not.toBeUnprocessableEntity({ a: 'b' }) ).toThrowError( /Expected non-422 status code but got 422/ ); }); }); -
要实现这个跳过,请转到匹配器的底部,并将基本的
message属性值添加到函数中的最后一个return值,如下所示:return { pass: true, message: () => 'Expected non-422 status code but got 422' }; -
我们可以通过确保实际
response体在消息中返回来改进这一点。添加下一个测试:it('includes with the received response body in the message', () => { const response = fail(422, { a: 'b' }); expect(() => expect(response).not.toBeUnprocessableEntity({ a: 'b' }) ).toThrowError(/"a": "b"/); }); -
为了使其通过,您可以使用
this.utils.stringify实用函数,它为您做了所有艰苦的工作:return { pass: true, message: () => `Expected non-422 status code but got 422 with body:` + EOL + this.utils.stringify(received.data) }; -
最后,我们需要注意没有传递预期对象的情况。当这种情况发生时,实际的身体对于开发者来说并不相关,因为通过从期望中省略它,他们已经表示他们对此不感兴趣:
it('returns a negated message for a non-422 status with no body', () => { const response = fail(422); expect(() => expect(response).not.toBeUnprocessableEntity() ).toThrowError( 'Expected non-422 status code but got 422' ); }); -
为了使其通过,添加一个第三条守卫子句,如下所示:
if (!received.data) { return { pass: true, message: () => 'Expected non-422 status code but got 422' }; }
你现在已经测试驱动了一个完整的匹配器,它具有有用的错误消息和对否定形式的支持。接下来,是时候在我们的现有测试套件中使用它了。
更新现有测试以使用匹配器
在本节的最后,我们将使用我们刚刚构建的匹配器来简化表单验证错误测试套件。
让我们开始吧:
-
首先,通过在
src/vitest/registerMatchers.js文件中添加一个import语句和调用expect.extend来为我们的测试运行注册匹配器:... import { toBeUnprocessableEntity } from './src/matchers/toBeUnprocessableEntity.js'; ... expect.extend({ toBeUnprocessableEntity }); -
然后,在
src/routes/birthdays/page.server.test.js中,找到具有描述当名称未提供时的嵌套describe块。它包含四个测试。保留第一个测试,并用以下测试替换最后三个测试:describe('when the name is not provided', () => { ... it('does not save the birthday', ...); it('returns a complete error response', () => { expect(result).toBeUnprocessableEntity({ error: 'Please provide a name.', dob: '2009-02-02' }); }); }); -
然后,对具有描述
当出生日期格式错误时的嵌套describe块执行相同的操作,用显示的测试替换最后三个测试:describe('when the date of birth is in the wrong format', () => { ... it('does not save the birthday', ...); it('returns a complete error response', () => { expect(result).toBeUnprocessableEntity({ error: 'Please provide a date of birth in the YYYY- MM-DD format.', name: 'Hercules', dob: 'unknown' }); }); }); -
用
当 id 是未知上下文完全做同样的事情:describe('when the id is unknown', () => { ... it('does not save the birthday', ...); it('returns a complete error message', () => { expect(result).toBeUnprocessableEntity({ error: 'An unknown ID was provided.' }); }); }); -
接下来,有一些特定的测试是为了确保返回
id。更新它们的期望,如下所示:it('returns the id when an empty name is provided', async () => { ... expect(result).toBeUnprocessableEntity({ id: storedId() }); }); ... it('returns the id when an empty date of birth is provided', async () => { ... expect(result).toBeUnprocessableEntity({ id: storedId() }); });
这样就完成了更改。请确保运行所有测试并检查是否一切通过。退后一步,看看您的测试变得多么清晰和简单。
摘要
本章向您展示了如何构建一个自定义匹配器以简化您的测试期望。它还讨论了测试驱动匹配器代码的重要性。
您的单元测试文件是您软件的规范。这些文件必须清晰简洁,以便规范清晰。有时,编写自定义匹配器可以帮助您实现这种清晰性。
为什么我们要测试驱动匹配器的实现?因为几乎所有的匹配器都有分支逻辑——有时会通过,有时会失败——你想要确保在正确的时间使用正确的分支。
在下一章中,我们将切换回重构我们的应用程序代码,目的是提高其可测试性。
第九章:从框架中提取逻辑
可维护软件的一个重要属性是其可测试性。这是指应用程序的所有部分都应该易于测试。更具体地说,应用程序代码的设计应该使其容易编写自动化的单元测试。
在本章中,我们将探讨一种提高可测试性的技术:将领域逻辑从框架中移出并放入纯 JavaScript。纯 JavaScript 代码更容易测试,因为没有复杂的框架对象与你的代码交互。
下面的图示展示了如何以这种方式思考 SvelteKit 代码库。

图 9.1 – 将应用程序代码保留在框架组件之外以帮助可测试性
在第七章“整理测试套件”中,我们朝着将生日数据项的存储移入birthdayRepository模块迈出了一步。我们将继续这个过程,通过将数据验证从 SvelteKit 表单操作中移出并放入birthdayRepository。这意味着我们可以测试复杂的验证规则,而无需设置复杂的 SvelteKit 表单请求对象,也无需测试表单响应对象。
由于仓库没有异步行为,移动的测试不再需要充斥着async和await关键字。
本章涵盖了以下主题:
-
使用测试待办事项列表迁移测试
-
从表单操作迁移测试
-
在仓库中复制表单验证行为
-
提取常用方法
到本章结束时,你将使用测试驱动的方法将领域逻辑从框架中移出。
技术要求
该章节的代码可以在网上找到,链接为github.com/PacktPublishing/Svelte-with-Test-Driven-Development/tree/main/Chapter09/Start。
使用测试待办事项列表迁移测试
当前任务是将我们在第五章“验证表单数据”中完成的 SvelteKit 表单操作中的验证逻辑移出,并放入birthdayRepository模块。在这一节中,我们将使用一种新技术来规划这个任务。
你可能还记得birthdayRepository模块已经存在,但没有测试。在我们重构时提取模块时,我们经常会遇到这种情况。通常,不测试这些模块是完全可以接受的。问题在于当我们想要修改这些模块的行为时:我们在哪里添加测试?
对于这个问题,没有明确的答案,但在这个情况下,我们将利用这个机会在birthdayRepository模块中复制所有来自表单操作的测试,同时也会添加一些新的测试。
删除原始位置的测试
我们不会删除原始测试,但你应该考虑这样做,以免过度测试。(比过度测试更糟糕的是不足测试!)
it 测试函数有一个特殊的修饰符,可以用于规划测试套件:it.todo 修饰符。作为一种前置规划,当你已经有一个具体的测试套件中所需测试的明确想法时,它非常有用。
birthdayRepository 模块导出了两个我们感兴趣要测试的函数:addNew 和 replace 函数。我们将把现有的表单操作测试在这两个新函数之间分配。验证错误的测试需要为两个函数重复。这样,每个函数都将被完全指定。
继续创建 src/lib/server/birthdayRepository.test.js 文件,并包含以下内容。这会放置所有导入,并通过调用 clear 来初始化 birthdayRepository 测试套件,以便每个测试都从一个空白状态开始。
还有一个 storedId 的定义——我们可以使用与表单操作测试中相同的技巧来验证数据。我们还添加了一个检查初始状态的空测试:
import { describe, it, expect, beforeEach } from 'vitest';
import {
createBirthday
} from 'src/factories/birthday.js';
import {
addNew, clear, getAll, replace
} from './birthdayRepository.js';
describe('birthdayRepository', () => {
beforeEach(clear);
const storedId = () => getAll()[0].id;
it.todo('is initially empty');
});
现在,为 addNew 函数添加一个嵌套的 describe 块。这将从 src/routes/birthdays/page.server.test.js 文件中获取原始测试描述,并将它们转换为更合适的形式:
describe('addNew', () => {
it.todo('adds a new birthday into the list');
it.todo('saves unique ids onto each new birthday');
it.todo('returns the added birthday with its id');
describe('validation errors', () => {
describe.todo('when the name is not provided');
describe.todo(
'when the date of birth in the right format'
);
});
});
是时候查看 replace 函数的测试了。你会看到验证错误是从 addNew 上下文中重复的。这突出了将单个 entrypoint(表单操作)拆分为两个(addNew 和 replace 函数)时的一个差异:
describe('replace', () => {
it.todo('updates an entry that shares the same id');
it.todo('returns the updated birthday');
describe('validation errors', () => {
describe.todo('when the name is not provided');
describe.todo(
'when the date of birth in the right format'
);
it.todo(
'returns the id when an empty date of birth is
provided'
);
});
});
计划已完成。本章剩余的部分将完成测试套件,从 happy path 测试开始,这些测试已经通过,然后回到完成验证错误。
从表单操作移植测试
在本节中,我们将编写针对 birthdayRepository 模块中已存在的行为的测试,同时确保函数为表单操作返回可重用的值。
src/lib/server/birthdayRepository.js 文件已经包含了你在 第六章,编辑表单数据 中最后修改的工作代码。这里是一个提醒:
import { randomUUID } from 'crypto';
const db = new Map();
export const addNew = (item) => {
const id = randomUUID();
db.set(id, { ...item, id });
};
export const getAll = () => Array.from(db.values());
export const clear = () => db.clear();
export const replace = (id, item) =>
db.set(id, { ...item, id });
export const has = (id) => db.has(id);
大部分功能都是通过表单操作进行测试的。我们需要添加测试,然后再次检查它们是否工作。
总是要求有一个失败的测试
在移植测试时,你会编写已经通过的测试。你跳过 Red 步骤,直接进入 Green。然而,仍然重要的是要验证你的测试检查的是正确的东西,而要做到这一点,你可以删除或注释掉正在测试的应用程序代码,这样你就可以看到测试失败。
现在让我们开始吧:
-
在
src/lib/server/birthdayRepository.test.js中,从第一个测试中移除.todo修饰符,并添加以下测试内容:it('is initially empty', () => { expect(getAll()).toHaveLength(0); }); -
你会看到测试已经通过了。通过将
getAll实现替换为null返回值来验证它是否测试了正确的内容,然后重新运行测试以检查它是否为红色。这样做的一个简单方法是注释掉该行的其余部分,如下所示:export const getAll = () => null; //Array.from(db.values()); -
在检查测试失败后,恢复原始实现并验证它是否为绿色。
-
填写下一个测试用例的正文,如下所示。由于以下几个原因,这比我们正在迁移的表单操作测试要简单。首先,不再有任何异步行为,其次,我们使用单个
createBirthday工厂方法,避免了更复杂的performFormAction辅助方法:describe('addNew', () => { it('adds a new birthday into the list', () => { addNew(createBirthday('Zeus', '2009-02-02')); expect(getAll()).toContainEqual( expect.objectContaining({ name: 'Zeus', dob: '2009-02-02' }) ); }); }); -
虽然这已经通过了,但检查它是否为红色是很重要的。你可以通过使用相同的注释掉相关代码行的技术来完成,如下所示:
export const addNew = (item) => { const id = randomUUID(); //db.set(id, { ...item, id }); }; -
在验证测试失败后,撤销注释的代码,并将测试恢复为绿色。
-
接下来,填写第三个测试:
it('saves unique ids onto each new birthday', () => { const birthday = createBirthday( 'Zeus', '2009-02-02' ); addNew(birthday); addNew(birthday); expect(getAll()[0].id).not.toEqual( getAll()[1].id ); }); -
为了验证这一点,你可以注释掉
id值:export const addNew = (item) => { const id = null; //randomUUID(); db.set(id, { ...item, id }); }; -
现在,我们来测试新的行为,检查对象是否与其新的
id属性一起返回。这使用了在测试套件顶部定义的storedId函数:it('returns the added birthday with its id', () => { expect( addNew(createBirthday('Zeus', '2009-02-02')) ).toEqual({ id: storedId(), name: 'Zeus', dob: '2009-02-02' }); }); -
为了使其通过,引入一个名为
itemWithId的变量并返回它:export const addNew = (item) => { ... const itemWithId = { ...item, id }; db.set(id, itemWithId); return itemWithId; }; -
现在,让我们继续进行
replace测试。我们将从迁移beforeEach块和storedId方法开始:describe('replace', () => { beforeEach(() => addNew(createBirthday('Hercules', '1991-05-06')) ); const storedId = () => getAll()[0].id; ... }); -
然后,我们将继续进行
replace函数的第一个测试。你可以通过给它一个null实现的方式来验证,就像你验证getAll的第一个测试一样:it('updates an entry that shares the same id', () => { replace( storedId(), createBirthday('Zeus Ex', '2007-02-02') ); expect(getAll()).toHaveLength(1); expect(getAll()).toContainEqual({ id: storedId(), name: 'Zeus Ex', dob: '2007-02-02' }); }); -
本节中的最后一个测试确保我们返回了更新后的项目:
it('returns the updated birthday', () => { expect( replace( storedId(), createBirthday('Zeus Ex', '2007-02-02') ) ).toEqual({ id: storedId(), name: 'Zeus Ex', dob: '2007-02-02' }); }); -
为了使其通过,在
addNew函数中做出完全相同的更改:export const replace = (id, item) => { ... const itemWithId = { ...item, id }; db.set(id, itemWithId); return itemWithId; };
这就是证明现有行为的所有测试。在下一节中,我们除了需要迁移测试用例外,还需要迁移实现。
在仓库中复制表单验证行为
在本节中,我们将继续从src/routes/birthdays/page.server.test.js文件迁移测试,但现在我们将复制表单操作中的验证行为。
重复作为设计信号
以下步骤包含相当多的重复。首先,测试与你在表单操作中已经编写的测试非常相似。其次,对addNew和replace函数都进行了相同的检查。
这种强制重复(让你感受到重复工作的痛苦)可以帮助你找出你想要提取的(如果有的话)共享逻辑。
让我们开始:
-
首先,填写嵌套的
validation errorsdescribe块及其中的测试。我选择一次性复制两个测试,因为这些测试非常简单,而且我们已经对实现的结果有了很好的了解:describe('addNew', () => { ... describe('validation errors', () => { describe('when the name is not provided', () => { let result; beforeEach(() => { result = addNew( createBirthday('', '1991-05-06') ); }); it('does not save the birthday', () => { expect(getAll()).toHaveLength(0); }); it('returns an error', () => { expect(result).toEqual({ error: 'Please provide a name.' }); }); }); }); }); }); -
要在
src/lib/server/birthdayRepository.js中实现这个通过,首先在文件底部定义empty函数:const empty = (value) => value === undefined || value === null || value.trim() === ''; -
然后,更新
addNew方法,包括使用empty函数的保护类。在此更改之后,两个测试都应该通过:export const addNew = (item) => { if (empty(item.name)) { return { error: 'Please provide a name.' }; } ... }; -
现在,我们将继续到下一个嵌套的
describe块,用于检查出生日期格式:describe('when the date of birth is in the wrong format', () => { let result; beforeEach(() => { result = addNew( createBirthday('Hercules', 'unknown') ); }); it('does not save the birthday', () => { expect(getAll()).toHaveLength(0); }); it('returns an error', () => { expect(result).toEqual({ error: 'Please provide a date of birth in the YYYY- MM-DD format.' }); }); }); -
要实现这个通过,首先从表单操作中复制
invalidDob辅助函数:const invalidDob = (dob) => isNaN(Date.parse(dob)); -
然后,添加保护子句,这将使两个测试都通过:
if (invalidDob(item.dob)) { return { error: 'Please provide a date of birth in the YYYY-MM- DD format.' }; } -
现在,让我们重复对
replace函数的验证检查。在原始表单操作测试中,这并不是必需的,因为我们通过在创建生日的原始操作之上构建来实现了编辑表单功能,所以验证已经存在。但现在,这两个操作是分开的。填写新的嵌套describe块:describe('replace', () => { ... describe('validation errors', () => { describe('when the name is not provided', () => { let result; beforeEach(() => { result = replace( storedId(), createBirthday('', '1991-05-06') ); }); it('does not update the birthday', () => { expect(getAll()[0].name).toEqual( 'Hercules' ); }); it('returns an error', () => { expect(result).toEqual({ error: 'Please provide a name.' }); }); }); }); }); -
要实现这个通过,首先从
addNew复制相同的保护子句。我们稍后会移除这个重复:export const replace = (id, item) => { if (empty(item.name)) { return { error: 'Please provide a name.' }; } ... }; -
现在,继续到出生日期:
describe('when the date of birth is in the wrong format', () => { let result; beforeEach(() => { result = replace( storedId(), createBirthday('Hercules', 'unknown') ); }); it('does not update the birthday', () => { expect(getAll()[0].dob).toEqual( '1991-05-06' ); }); it('returns an error', () => { expect(result).toEqual({ error: 'Please provide a date of birth in the YYYY- MM-DD format.' }); }); }); -
通过添加第二个保护子句来实现这个通过,重复步骤 6,但这次在
replace函数中添加代码:if (invalidDob(item.dob)) { return { error: 'Please provide a date of birth in the YYYY-MM- DD format.' }; } -
然后,进行最后的测试。由于只有一个测试,所以不需要
describe块:it('requires an id of a birthday that exists in the store', () => { expect( replace( '234', createBirthday('Hercules', '2009-01-02') ) ).toEqual({ error: 'An unknown ID was provided.' }); }); -
这次,将保护子句放在最上面。这个保护子句似乎应该优先于姓名和出生日期检查——注意,例如,参数在参数列表中的位置:
export const replace = (id, item) => { if (!has(id)) return { error: 'An unknown ID was provided.' }; ... }
这就完成了所有新的功能。birthdayRepository的所有行为都已完成。接下来,我们将停下来重构以消除重复。
提取公共方法
在本节中,我们将从addNew和replace函数中提取重复的验证子句,将它们移动到共享的validate函数中。
让我们从validate函数开始:
-
在
addNew和replace的定义下方,添加以下名为validate的函数。这个函数包含了原始函数中出现的两个保护子句。为了简化,item参数已经被解构为name和dob参数:const validate = ({ name, dob }) => { if (empty(name)) { return { error: 'Please provide a name.' }; } if (invalidDob(dob)) { return { error: 'Please provide a date of birth in the YYYY- MM-DD format.' }; } }; -
然后,更新
addNew,用对validate的调用替换其保护子句。结果存储在validationResult中,如果有值则可以返回:export const addNew = (item) => { const validationResult = validate(item); if (validationResult) { return validationResult; } const id = randomUUID(); const itemWithId = { ...item, id }; db.set(id, itemWithId); return itemWithId; }; -
接下来,对
replace函数做同样的处理:export const replace = (id, item) => { if (!has(id)) return { error: 'An unknown ID was provided.' }; const validationResult = validate(item); if (validationResult) { return validationResult; } const itemWithId = { ...item, id }; db.set(id, itemWithId); return itemWithId; }; -
现在,我们是否应该提取每个函数的最后部分?添加
set函数:const set = (id, item) => { const itemWithId = { ...item, id }; db.set(id, itemWithId); return itemWithId; }; -
然后,在
addNew中使用它:export const addNew = (item) => { const validationResult = validate(item); if (validationResult) { return validationResult; } return set(randomUUID(), item); }; -
最后,在
replace函数的末尾添加相同的调用:export const replace = (id, item) => { if (!has(id)) return { error: 'An unknown ID was provided.' }; const validationResult = validate(item); if (validationResult) { return validationResult; } return set(id, item); };
这就完成了birthdayRepository的实现——一个简单但令人满意的重构。
摘要
本章介绍了将现有行为从一个地方迁移到另一个地方的技术。特别是,它展示了将业务逻辑从框架对象(如表单操作)移入普通 JavaScript 对象是多么有价值。这样做可以使测试更简单。
在这种情况下,我们的测试不再异步,也不再需要使用复杂的performFormAction辅助函数。
另一个好处是birthdayRepository及其验证可以在其他地方重用。这正是我们在下一章将要做的,当时我们将介绍与存储库交互的新 API 端点。
第十章:测试驱动 API 端点
SvelteKit 使得创建 API 端点变得轻而易举。本章将探讨如何使用测试来驱动和证明你的 API 端点。
在前面的章节中,你看到了我们如何将业务逻辑从 SvelteKit 推送到纯 JavaScript。我们可以利用提取的 birthdayRepository 对象在新 API 端点中使用。现在我们将添加用于创建、更新和获取生日的端点,使用存储库中的 addNew、replace 和 getAll 函数。
图 10.1 展示了我们的系统设计是如何形成的。在本章中我们将创建的端点非常轻量级,这得益于完全指定的 birthdayRepository 对象:

图 10.1 – SvelteKit 组件流入内部系统
本章我们将涵盖以下关键主题:
-
使用 Playwright 创建服务测试
-
添加用于检索数据的 API 端点
-
添加用于保存数据的 API 端点
-
添加用于更新数据的 API 端点
到本章结束时,你将学会如何使用测试驱动开发来实现 API 端点。
技术要求
本章的代码可以在网上找到,链接为 github.com/PacktPublishing/Svelte-with-Test-Driven-Development/tree/main/Chapter10/Start。
使用 Playwright 创建服务测试
你已经看到了如何使用 Playwright 创建端到端测试,通过网页浏览器界面驱动应用程序。它也可以直接驱动 API 端点,这正是本节你要学习的内容。编写前置 Playwright API 测试的好处是我们可以非常仔细地规划我们的 API 将如何呈现。
我们将编写一个名为 creating and reading a birthday 的单个测试。它将做两件事:首先,创建一个向 /api/birthdays 发送 POST 请求的请求,以创建一个生日。然后我们将调用 GET /api/birthdays 并检查之前创建的生日是否在响应中返回:
-
创建一个名为
tests/api/birthdays.test.js的新文件,内容如下,这是测试的一半:import { expect, test } from '@playwright/test'; test('creating and reading a birthday', async ({ request }) => { const newBirthday = await request.post( '/api/birthdays', { data: { name: 'Nyx', dob: '1993-02-04' } } ); expect(newBirthday.ok()).toBeTruthy(); });
上述代码片段使用了测试的请求参数。它有一个 request.post 方法,我们调用它来发送 API 请求。data 属性是我们想要发送的 JSON 对象。Playwright 会负责将其转换为 HTTP 请求和响应。请求看起来是这样的:
POST http://localhost:1234/api/birthdays
{
"name": "Nyx",
"dob": "1993-02-04"
}
返回的对象,我们称之为 newBirthday,有一个 ok 方法,我们调用它来确定端点是否返回了 200 响应。
将断言混合到测试操作中
在前面的章节中,我们所有的测试都按照 安排-行动-断言 的格式进行结构化。当涉及到 Playwright 测试时,偶尔会有重复的 行动-断言 循环,最终可能变成 安排-行动-断言-行动-断言… 的样子。
我不会在我的单元测试中这样做,但在端到端和服务测试中,有时这样做是有意义的,因为它们有助于测试模拟 用户场景。测试遵循典型用户会采取的步骤。测试中散布的断言充当检查点,以检查测试是否在正确的路径上。
-
接下来,通过调用
/api/birthdays端点的GET请求来完成测试。这里值得注意的是,使用expect.anything()来表示 我期望返回一个 ID,但我并不关心它是什么:test('creating and reading a birthday', async ({ request }) => { ... const birthdays = await request.get( '/api/birthdays' ); expect(birthdays.ok()).toBeTruthy(); expect(await birthdays.json()).toEqual({ birthdays: expect.arrayContaining([ { name: 'Nyx', dob: '1993-02-04', id: expect.anything() } ]) }); });
这样就完成了测试;请运行测试并检查它目前是否失败(因为端点尚不存在)。
避免在 Playwright 测试中数据冲突
之前的例子在测试示例中使用了 Nyx 这个名字。这个名字之前没有被使用过,但如果你使用了之前的名字之一,比如 Hercules 或 Athena,你可能会看到其他测试失败。
当前 Playwright 测试套件在单个测试运行之间不会清除测试数据库。在这里,我们通过确保我们的每个测试都使用独立的数据来解决此问题。
另一种方法是在每次测试或测试套件之前始终清理数据库,类似于 Vitest 单元测试的编写方式。
然而,第一种方法有一个优点:你可以将 Playwright 指向已经用你无法控制的数据预置的已部署环境,并仍然期望测试通过。
-
添加第二个测试,使用
PUTHTTP 动词更新生日。请求看起来像这样:PUT http://localhost:1234/api/birthday/abc123 { "name": "Nyx", "dob": "1992-01-02" }
从以下给出的测试的第一部分开始。注意,这是如何提取返回的 id 字段,以便我们可以在测试的第二部分中使用它:
test('updating a birthday', async ({ request }) => {
const newBirthday = await request.post(
'/api/birthdays',
{
data: {
name: 'Nyx',
dob: '1993-02-04'
}
}
);
expect(newBirthday.ok()).toBeTruthy();
const { id } = await newBirthday.json();
});
-
然后添加测试的第二部分,它使用
id执行PUT请求并检查结果:test('updating a birthday', async ({ request }) => { ... const birthdays = await request.put( `/api/birthday/${id}`, { data: { name: 'Nyxx', dob: '1992-01-03' } } ); expect(birthdays.ok()).toBeTruthy(); const updatedBirthdays = await request.get( '/api/birthdays' ); expect(await updatedBirthdays.json()).toEqual({ birthdays: expect.arrayContaining([ { name: 'Nyxx', dob: '1992-01-03', id } ]) }); });
这个最后的期望并没有使用 expect.anything。相反,它检查 id 是否保持不变。
这样就完成了两个 Playwright 测试,涵盖了三个新的端点。在下一节中,我们将使用 GET 请求从系统中检索所有生日。
添加用于检索数据的 API 端点
现在我们进入有趣的部分:介绍 API 的新功能。在 SvelteKit 应用程序中,一个 GET 请求可以通过在路由内部创建一个名为 GET 的函数来非常直接地指定。
虽然我们不会在我们的端点中使用它,但这个函数有一个包含 route 参数的 params 参数。你将在本章后面实现 PUT 请求的 添加用于更新数据的 API 端点 部分时看到这一点。
来自端点的有效响应必须是正确的响应对象。我们可以使用 SvelteKit 的 JSON 辅助工具来定义有效的 JSON 响应。错误响应通过抛出一个由调用 SvelteKit 的错误辅助工具构建的异常来处理。我们现在将使用这两个工具。
在 Node 版本低于 18 的版本中,json 辅助工具
调用json函数可能会失败,并出现Response is not defined错误。如果您在实现以下测试时遇到此错误,您可以安装node-fetch包(github.com/node-fetch/node-fetch),并确保它作为您的 Vitest 设置文件的一部分加载。
让我们先定义一个新的测试套件:
-
创建一个新的测试文件,
src/routes/api/birthdays/server.test.js,它以以下import语句开始。导入birthdayRepository模块,以便在调用GET函数之前填充存储库:import { describe, it, expect, beforeEach } from 'vitest'; import { createBirthday } from 'src/factories/birthday.js'; import as birthdayRepository from '$lib/server/birthdayRepository.js'; import { GET } from './+server.js'; -
接下来定义以下辅助方法,
bodyOfResponse,我们将在第一个测试中使用它来从 HTTP 响应中提取数据。这可以放在导入下面:const bodyOfResponse = (response) => response.json(); -
然后,创建一个新的
describe块,包含以下测试。它创建两个生日,并检查它们是否被返回:describe('GET', () => { it('returns all the birthdays from the store', async () => { birthdayRepository.addNew( createBirthday('Hercules', '2010-04-05') ); birthdayRepository.addNew( createBirthday('Ares', '2008-03-02') ); const { birthdays } = await bodyOfResponse(GET()); expect(birthdays).toEqual([ expect.objectContaining( createBirthday('Hercules', '2010-04-05') ), expect.objectContaining( createBirthday('Ares', '2008-03-02') ) ]); }); });
测试数组响应
我发现,在测试数组对象时,使用列表中的两个(有时是三个)项目而不是只有一个项目总是最好的。这样,可以清楚地看出测试是在操作项目列表,而不仅仅是单个项目。
-
为了使该测试通过,创建一个名为
src/routes/api/birthdays/+server.js的新文件,并包含以下内容。实现方式如下:它直接使用getAll函数将请求传递到存储库,然后通过调用 SvelteKit 的json函数包装响应:import { getAll } from '$lib/server/birthdayRepository.js'; import { json } from '@sveltejs/kit'; export const GET = () => json({ birthdays: getAll() });
这完成了GET请求。接下来,我们将处理POST和PUT请求。
添加用于保存数据的 API 端点
在本节中,我们将首先添加用于保存数据的POST请求处理函数,然后继续在它自己的路由文件中添加PUT函数,用于更新数据。
让我们从定义一个测试辅助函数开始:
-
创建一个新文件,
src/factories/request.js,包含以下内容。这将由POST和PUT函数共同使用,以读取请求的数据。SvelteKit 将传递一个Request参数。我们只需要提供这个参数的json方法,如下所示:export const createRequest = (json) => ({ json: () => Promise.resolve(json) });
提供协作对象的最低版本
SvelteKit 将Request对象传递到我们的请求处理函数中。但对我们来说,单元测试不需要完整的Request对象,我们只需要一个实现我们使用的接口部分的简单对象。我们的应用程序代码除了用于以 JSON 格式读取请求体之外,不使用任何其他方法,因此我们只需要这个方法。
-
现在转到
src/api/birthdays/server.test.js,并添加一个import语句用于createRequest方法。同时,更新+server.js的导入以包括POST请求处理函数:import { createRequest } from 'src/factories/request.js'; import { GET, POST } from './+server.js'; -
接下来,在
GET函数的describe块下方添加一个新的describe块,并包含以下测试:describe('POST', () => { beforeEach(birthdayRepository.clear); it('saves the birthday in the store', async () => { await POST({ request: createRequest( createBirthday('Hercules', '2009-03-01') ) }); expect(birthdayRepository.getAll()).toHaveLength(1 ); expect(birthdayRepository.getAll()[0]).toContain( createBirthday('Hercules', '2009-03-01') ); }); }); -
为了使该测试通过,首先更新
src/api/birthdays/+server.js中的导入以包括addNew函数:import { addNew, getAll } from '$lib/server/birthdayRepository.js'; -
继续添加对
POST的定义,如下面的代码块所示。在此更改后,你的测试应该通过:export const POST = async ({ request }) => { const { name, dob } = await request.json(); addNew({ name, dob }); }; -
现在我们已经将数据保存到仓库中,是时候检查 HTTP 响应了。我们希望任何 API 调用者都能收到相同的数据。以下测试使用了
toContain匹配器而不是toEqual。这是因为我们已经知道响应将包含一个与本次测试无关的id字段:it('returns a json response with the data', async () => { const response = await POST({ request: createRequest( createBirthday('Hercules', '2009-03-01') ) }); expect(await bodyOfResponse(response)).toContain( createBirthday('Hercules', '2009-03-01') ); });
测试 id 字段的存在
为什么 id 字段没有被包含在本次测试的期望中?因为它与其他数据有不同的生命周期。这个测试验证了用户提供的信息已被存储。但 id 字段是自动生成的,因此它属于另一个测试。我在这里省略了它,因为它已经在仓库和 Playwright API 测试中测试过了,但你可能觉得包括这个测试会更舒服。
-
为了使测试通过,向函数中添加一个使用
json函数导入的return值:export const POST = async ({ request }) => { const { name, dob } = await request.json(); const result = addNew({ name, dob }); return json(result); }; -
POST请求处理函数的最终测试是检查如果数据无效,函数是否会抛出异常。抛出错误可能会使测试复杂化:在这里,测试使用对expect.hasAssertions的调用确保如果调用 不 抛出错误,Vitest 将失败测试:it('throws an error if the data is invalid', async () => { expect.hasAssertions(); try { await POST({ request: createRequest( createBirthday('Ares', '') ) }); } catch (error) { expect(error.status).toEqual(422); expect(error.body).toEqual({ message: 'Please provide a date of birth in the YYYY- MM-DD format.' }); } });
现在尝试运行这个测试:你会看到 expected any number of assertions, but got none 的失败。这是测试顶部 hasAssertions 语句的功劳。
理解 hasAssertions 辅助函数
尝试从测试中删除 expect.hasAssertions 行。你会注意到测试已经通过了。这是因为期望从未被满足。这就是 hasAssertions 调用的目的:它在依赖于在 catch 块中有期望的测试中很有用,这些期望直到你在应用程序代码中实现抛出异常的行为才会被调用。
-
为了使其通过,首先更新
src/api/birthdays/+server.js中的import语句以包含error辅助函数:import { json, error } from '@sveltejs/kit'; -
然后更新
POST函数,将仓库中的任何错误重新打包成一个error对象,然后抛出:export const POST = async ({ request }) => { const { name, dob } = await request.json(); const result = addNew({ name, dob }); if (result.error) throw error(422, result.error); return json(result); };
这样就完成了插入新生日信息的 POST 请求处理函数;下一节通过添加更新现有生日的 PUT 请求处理函数来完成本章。
添加用于更新数据的 API 端点
最后一步是添加一个用于处理更新的 PUT 请求处理函数。类似于 POST 函数,我们必须使用 createRequest 辅助函数提供请求体。
我们的 PUT 请求的形式意味着 id 作为参数传递到 URL 中,如下所示:
这里是一个关于如何传递 URL 的提醒:
PUT http://localhost:1234/api/birthday/abc123
为了让 SvelteKit 将 abc123 值注册为参数,我们需要在 src/routes/api/birthday/[id] 创建一个新的路由目录。SvelteKit 将知道 abc123 匹配目录路径中的 [id] 部分。该目录本身将包含 +server.js 文件及其测试。
让我们从测试开始:
-
创建一个名为
src/routes/api/birthday/[id]/server.test.js的新文件,并添加以下导入。这包括了之前用于GET和POST函数的所有内容,以及PUT函数的导入:import { describe, it, expect, beforeEach } from 'vitest'; import as birthdayRepository from '$lib/server/birthdayRepository.js'; import { createBirthday } from 'src/factories/birthday.js'; import { createRequest } from 'src/factories/request.js'; import { PUT } from './+server.js'; -
然后,创建这个新的
describe块。它包含一个beforeEach块,不仅清除仓库,还插入一个新项目,以便编辑:describe('PUT', () => { beforeEach(() => { birthdayRepository.clear(); birthdayRepository.addNew( createBirthday('Hercules', '2009-03-01') ); }); }); -
在我们到达第一个测试之前,添加以下辅助函数:
const storedId = () => birthdayRepository.getAll()[0].id; -
现在添加第一个测试,如下所示。它通过向处理程序传递一个新的
params对象来模仿 SvelteKit 的调用语义:it('updates the birthday in the store', async () => { await PUT({ request: createRequest( createBirthday('Hercules', '1999-03-01') ), params: { id: storedId() } }); expect(birthdayRepository.getAll()).toHaveLength(1); expect(birthdayRepository.getAll()[0]).toContain( createBirthday('Hercules', '1999-03-01') ); }); -
为了使其通过,创建一个名为
src/routes/api/birthday/[id]/+server.js的新文件,并包含以下内容。这应该会立即使测试通过:import { replace } from '$lib/server/birthdayRepository.js'; export const PUT = async ({ request, params: { id } }) => { const { name, dob } = await request.json(); const result = replace(id, { name, dob }); }; -
对于下一个测试,我们将从重复
GET测试中的一个辅助函数开始。添加bodyOfResponse函数,如下所示:const bodyOfResponse = (response) => response.json(); -
然后,添加下一个测试,该测试使用此函数来检查响应:
it('returns a json response with the data', async () => { const response = await PUT({ request: createRequest( createBirthday('Hercules', '1999-03-01') ), params: { id: storedId() } }); expect(await bodyOfResponse(response)).toContain( createBirthday('Hercules', '1999-03-01', { id: storedId() }) ); }); -
为了使其通过,首先在应用程序代码中添加对
json的导入:import { json } from '@sveltejs/kit'; -
然后,更新
PUT函数以返回仓库操作的结果,就像我们处理POST请求处理函数一样:export const PUT = async ({ request, params: { id } }) => { const { name, dob } = await request.json(); const result = replace(id, { name, dob }); return json(result); }; -
最后一个测试重复了最后一个
POST测试的机制,检查数据无效时会发生什么:it('throws an error if the data is invalid', async () => { expect.hasAssertions(); try { await PUT({ request: createRequest( createBirthday('Hercules', '') ), params: { id: storedId() } }); } catch (error) { expect(error.status).toEqual(422); expect(error.body).toEqual({ message: 'Please provide a date of birth in the YYYY- MM-DD format.' }); } }); -
为了使其通过,首先更新应用程序代码中的
import语句以包含error函数:import { json, error } from '@sveltejs/kit'; -
最后,在
PUT函数中包含守卫子句,如下所示:export const PUT = async ({ request, params: { id } }) => { const { name, dob } = await request.json(); const result = replace(id, { name, dob }); if (result.error) throw error(422, result.error); return json(result); };
就这样。如果你现在运行 Vitest 和 Playwright 测试,你应该会发现它们都通过了。
摘要
本章展示了如何快速处理 API 端点。Playwright 测试可以用来指定 API 应该如何表现,单元测试可以用来驱动设计并确保我们最终得到最小化的实现。
下一章完成了 API 故事弧:我们将更新表单操作以使用新的 API 端点,而不是直接访问仓库。
第十一章:用并行实现替换行为
在前两个章节中,您构建了一个完整的仓库和访问它的 API。现在,是时候通过更新 SvelteKit 加载器和表单操作来使用 API 而不是仓库来完成故事弧了。
值得指出的是,这不是一个必要的步骤:保持 SvelteKit 服务器组件直接指向仓库是完全可接受的。
但将现有代码重写以指向新的 API 端点将向您介绍两个想法:首先,是并行实现的概念,这是一种使用测试来替换现有代码的同时确保测试套件保持绿色的方法。第二个是使用测试替身来保护单元测试不受 SvelteKit 的影响。测试替身取代了框架代码的位置,避免了向 API 发起真正的网络调用——这本来就不可能工作,因为 API 没有在我们的 Vitest 测试套件中运行。
图 11.1显示了我们的代码库的两个视图。左侧显示了我们的应用程序代码的新架构将如何看起来,其中 SvelteKit 路由加载器和表单操作指向 API 端点。右侧显示了路由加载器和表单操作的单元测试将如何看待世界。在这个设置中,根本不会访问 API 端点。

图 11.1 – 使用测试替身将 SvelteKit 行为插入单元测试套件
在更新我们的 SvelteKit 组件后,我们将通过更新 Playwright 测试以使用新的 API 并停止在测试环境中(Playwright 和 Vitest)显示数据库种子数据来结束整个工作。
本章涵盖了以下关键主题:
-
更新路由加载器以使用 API
-
更新页面表单操作以使用 API
-
使用服务器钩子来生成样本数据
到本章结束时,您将看到如何使用间谍(一种测试替身的形式)以及构建并行实现的过程。
技术要求
本章的代码可以在网上找到,地址为github.com/PacktPublishing/Svelte-with-Test-Driven-Development/tree/main/Chapter11/Start。
更新路由加载器以使用 API
在本节中,您将使用 SvelteKit 的 fetch 函数引入对GET /api/birthdays端点的调用。这将涉及使用间谍。
什么是测试间谍?
间谍是一个记录每次被调用以及调用时传递的参数的函数。然后可以在之后检查以验证它是否以正确的参数被调用。间谍几乎总是也是一个存根,这意味着它完全避免了调用真实函数,而是返回一个硬编码的——存根——值。间谍充当真实函数的替代品。
在 Vitest 中,通过调用vi.fn函数来创建间谍。
当我们使用测试间谍时,你至少会有一个测试来检查传递给间谍的参数。然后你至少会有一个针对间谍返回的每个存根返回值的额外测试。
我们将使用一个间谍来模拟通过GET /api/birthdays端点检索生日。
理解 SvelteKit 的 fetch 函数
SvelteKit 为用户定义的load函数和表单操作提供了一个fetch参数。此参数的值是一个具有与浏览器提供的标准 Fetch API 相同语义的函数。区别在于机制:SvelteKit 的fetch函数能够绕过对服务器的调用,以便它们不会引起 HTTP 请求,而是直接输入与指定路由匹配的GET函数。
让我们从一个新的工厂方法定义开始:
-
创建一个名为
src/factories/fetch.js的新文件,并添加以下定义。我们将使用它来构建测试间谍将返回的存根Response对象:export const fetchResponseOk = (response = {}) => ({ status: 'ok', json: () => Promise.resolve(response) }); -
现在打开
src/routes/birthdays/page.server.test.js中的测试文件,并添加一个import语句到该函数:import { fetchResponseOk } from 'src/factories/fetch.js'; -
接下来,更新 Vitest 的
import语句以包含对vi的导入,如下所示:import { describe, it, expect, beforeEach, vi } from 'vitest'; -
在
describe块底部添加以下测试。除了通过调用vi.fn()创建测试间谍外,它还使用mockResolvedValue函数指定间谍应返回一个包装在Promise对象中的值。测试以toBeCalledWith匹配器结束,以验证间谍以正确的方式被调用:describe('/birthdays - load', () => { ... it('calls fetch with /api/birthdays', async () => { const fetch = vi.fn(); fetch.mockResolvedValue(fetchResponseOk()); const result = await load({ fetch }); expect(fetch).toBeCalledWith('/api/birthdays'); }); }); -
为了使其通过,我们将从并行实现开始。这意味着之前的实现仍然与新的实现并存。在
src/routes/birthdays/+page.server.js文件中,修改load函数,使其接受一个fetch参数,并将其作为第一件事调用,如下所示:export const load = ({ fetch }) => { fetch('/api/birthdays'); return { birthdays: getAll() } };
这将导致原始测试用例因缺少fetch的值而失败。我们即将删除此测试,但暂时让我们通过修复测试来自我娱乐。
-
更新测试用例以使用一个非常简单的存根值
fetch,如下所示。这突出了这样一个事实:由vi.fn创建的 Vitest 间谍并不总是必要的。如果你没有检查间谍,那么一个普通的存根就足够了:describe('/birthdays - load', () => { it('returns a fixture of two items', () => { const result = load({ fetch: () => {} }); ... }); }); -
继续编写第二个测试,该测试检查
load函数返回从fetch返回的任何数据。这次,我们需要向fetchResponseOk传递一个实际值,并将其与return值进行比较:describe('/birthdays - load', () => { ... it('returns the response body', async () => { const birthdays = [ createBirthday('Hercules', '1994-02-02'), createBirthday('Athena', '1989-01-01') ]; const fetch = vi.fn(); fetch.mockResolvedValue( fetchResponseOk({ birthdays }) ); const result = await load({ fetch }); expect(result).toEqual({ birthdays }); }); }); -
然后更新
load函数以返回此值:export const load = async ({ fetch }) => { const result = await fetch('/api/birthdays'); return result.json(); }; -
你可能已经注意到这与我们最初编写的测试直接冲突。当我们构建并排实现时,最后一步通常是替换返回值。当发生这种情况时,就是删除原始测试的时候了。所以,请删除原始测试——唯一一个标题为 返回两个项目的固定装置 的测试——因为它不再适用。
-
最后,从
+``page.server.js文件中删除getAll导入:import { addNew, replace } from '$lib/server/birthdayRepository.js';
现在,你已经看到了如何使用 vi.fn 创建一个用于测试 fetch 调用的测试间谍和一个基本的并排实现。下一节将重复相同的流程来测试页面表单操作,但这次实现更复杂。
更新页面表单操作以使用 API
在本节中,我们将更新页面表单操作,使用 API 上的新 POST 和 PUT 方法,而不是存储库的 addNew 和 replace 函数。
这将使用与上一节相同的并排技术来完成,但这次更复杂。我们仍然需要测试来验证间谍是否以正确的参数被调用,并且返回值被给出。但现在我们还需要验证错误是否被转换为 SvelteKit 表单失败,因为表单操作对错误有单独的处理。
这里另一个重要的变化是,我们将使用两个测试来检查传递给 fetch 调用的参数。当面对有 团块 参数具有不同意义的复杂参数时,这是一种强大的技术。
在 fetch 的情况下,URL 和 HTTP 动词是一个 团块:我们可以有一个单独的测试来验证,例如,我们是否调用了 POST /api/birthdays 端点。但还有 body 属性,由于这不是静态数据——它根据表单操作输入参数而变化——因此为它提供一个单独的测试似乎是合理的。
最后,由于 API 端点正在执行数据验证,我们不再需要该功能。因此,我们将通过删除该实现及其相关测试来完成。
让我们开始更新现有的 describe 块,添加一些新的设置:
-
找到名为
/birthdays - default action的describe块,并添加以下三件设置:一个可以在所有测试中访问的fetch变量;一个新的beforeEach块来设置fetch响应;以及一个更新的performFormAction方法,该方法传递fetch参数:describe('/birthdays - default action', () => { const fetch = vi.fn(); beforeEach(() => { fetch.mockResolvedValue(fetchResponseOk()); }); const performFormAction = (formData) => actions.default({ request: createFormDataRequest(formData), fetch }); ... }); -
然后,添加这个新的嵌套
describe块及其单个测试,位于所有现有测试的下方。它检查fetch间谍是否以正确的 URL 和POST的method被调用:describe('when adding a new birthday', () => { it('requests data from POST /api/birthdays', async () => { await performFormAction( createBirthday('Zeus', '2009-02-02') ); expect(fetch).toBeCalledWith( '/api/birthdays', expect.objectContaining({ method: 'POST' }) ); }); }); -
要使其通过,首先将
fetch参数添加到表单操作中:export const actions = { default: async ({ request, fetch }) => { ... } -
然后,开始进行并排实现。找到对
addNew的调用,并在其下方添加一个新的fetch调用,如下所示:let result; if (id) { ... } else { result = addNew({ name, dob }); await fetch('/api/birthdays', { method: 'POST' }); } -
对于下一个测试,我们有一个非常相似的测试,它仍然在测试
fetch间谍的参数,但这次检查的是它的可变部分:body属性。现在添加这个测试:it('sends the birthday as the request body', async () => { await performFormAction( createBirthday('Zeus', '2009-02-02') ); expect(fetch).toBeCalledWith( expect.anything(), expect.objectContaining({ body: JSON.stringify({ name: 'Zeus', dob: '2009-02-02' }) }) ); });
上述代码示例使用了名为 expect.anything 的辅助函数,您在 第十章,测试驱动 API 端点 中也看到了它。由于我们已经有了一个检查第一个参数值的先前测试,我们可以通过在这里进行检查来避免重复,同时放松期望,使测试彼此独立。
使用此辅助工具还有助于提高测试的可读性,因为它将读者的注意力集中在具体感兴趣的部分:第二个参数,而不是第一个。
-
为了使它通过,将
fetch调用从 步骤 4 更新为包括body属性:await fetch('/api/birthdays', { method: 'POST', body: JSON.stringify({ name, dob }) }); -
现在让我们处理错误情况。为此,我们需要一个用于错误响应的新工厂。在
src/factories/fetch.js中,添加以下fetchResponseError的定义:export const fetchResponseError = (errorMessage) => ({ status: 'error', json: () => Promise.resolve({ message: errorMessage }) }); -
然后将它导入到您的测试套件中:
import { fetchResponseOk, fetchResponseError } from 'src/factories/fetch.js'; -
我们已经准备好进行下一个测试。这个测试检查当发生错误时会发生什么。由于我们的间谍没有真正的逻辑,我们不在乎错误的 具体细节。我们只需要它触发与真实代码相同的操作。这意味着有一个非
ok状态消息,就像fetchResponseError工厂给我们的一样。为了清楚地表明这不是真实逻辑,我使用了一个error message字符串而不是真正的错误消息:it('returns a 422 if the POST request returns an error', async () => { fetch.mockResolvedValue( fetchResponseError('error message') ); const result = await performFormAction( createBirthday('Zeus', '2009-02-02') ); expect(result).toBeUnprocessableEntity({ error: 'error message', name: 'Zeus', dob: '2009-02-02' }); }); -
为了使这个通过,首先将
fetch调用的响应保存到表单操作中:let result; let response; if (id) { ... } else { result = addNew({ name, dob }); response = await fetch('/api/birthdays', { method: 'POST', body: JSON.stringify({ name, dob }) }); } -
然后,在原始
result值的现有错误子句之后添加以下返回子句。这是并行实现的一个技巧。这确保了原始实现不会在我们这里失败:if (!response.ok) { const { message } = await response.json(); return fail(422, { id, name, dob, error: message }); } -
好吧,让我们对
replace调用也做同样的事情。添加以下新的嵌套describe块,包含一个测试:describe('when replacing an existing birthday', () => { it('requests data from PUT /api/birthday/{id}', async () => { await performFormAction( createBirthday('Zeus', '2009-02-02', { id: '123' }) ); expect(fetch).toBeCalledWith( '/api/birthday/123', expect.objectContaining({ method: 'PUT' }) ); }); }); -
在应用程序代码中,找到对
replace的调用,并在其下方添加一个新的fetch调用。之后,测试应该通过:if (id) { result = replace(id, { name, dob }); await fetch(`/api/birthday/${id}`, { method: 'PUT' }); } else { ... } -
接下来,我们将测试
PUT请求的主体。因为我们实际上并没有调用到存储库,所以不再重要该项是否存在。一切都取决于测试双工设置:it('sends the birthday as the request body', async () => { await performFormAction( createBirthday('Zeus', '2009-02-02', { id: '123' }) ); expect(fetch).toBeCalledWith( expect.anything(), expect.objectContaining({ body: JSON.stringify({ name: 'Zeus', dob: '2009-02-02' }) }) ); }); -
为了使它通过,将
body属性添加到fetch调用中:await fetch(`/api/birthday/${id}`, { method: 'PUT', body: JSON.stringify({ name, dob }) }); -
对于最后的测试,重复用于
POST请求的相同过程。我们使用mockResolvedValue与fetchResponseError工厂结合使用,以使间谍触发我们的错误流程:it('returns a 422 if the POST request returns an error', async () => { fetch.mockResolvedValue( fetchResponseError('error message') ); const result = await performFormAction( createBirthday('Zeus', '2009-02-02', { id: '123' }) ); expect(result).toBeUnprocessableEntity({ error: 'error message', name: 'Zeus', dob: '2009-02-02', id: '123' }); }); -
为了使它通过,只需将结果保存在
response变量中。然后代码将依赖于与 步骤 11 相同的返回子句:if (id) { result = replace(id, { name, dob }); response = await fetch('/api/birthdays', { method: 'POST', body: JSON.stringify({ name, dob }) }); } ... -
现在是令人满意的部分。您可以继续删除原始实现,首先删除测试,然后删除代码本身。删除所有这些测试:
-
将新的生日添加到列表中 -
将唯一的 ids 保存到每个新的生日 -
更新具有相同 id 的条目 -
当名称未提供时... -
当出生日期格式错误时... -
当 id 未知时... -
当提供空名称时返回 id -
当提供空出生日期时返回 id
-
使用在线代码仓库
这是一大堆代码更改。你可以使用在线仓库在过程中交叉检查你的更改。
-
你可以删除对
birthayRepository的导入,因为你不再使用它,以及storedId方法。如果你现在运行测试,你应该会发现它们仍然通过。 -
现在就去删除实现中所有引用生日仓库的部分:
-
对
addNew和replace的调用 -
result变量和错误处理 -
replace函数的import语句(尽管addNew函数仍然需要;我们将在下一节中删除它)
-
这就完成了页面表单操作的新版本。但在完成之前,我们需要处理我们的种子数据。
使用服务器钩子来生成样本数据
在本书的前几章中,我们在src/routes/birthdays/+page.server.js文件中的/birthdays路由中添加了种子数据。在顶部,有两个对addNew的调用以创建两个虚假的生日。我们在 Playwright 测试中依赖这些数据。现在是时候清理了。
在开发环境中创建重复数据
如果你编辑文件时一直在运行开发服务器,你将注意到,随着 SvelteKit 重新加载你的文件,虚假的生日被反复创建,导致系统中出现了许多生日对象。这是因为路由的+page.server.js文件顶部的那些addNew调用。现在我们将修复由我们的种子数据引起的另一个问题。
首先,我们将更新 Playwright 测试,通过 API 创建所有测试数据。然后我们将从我们的系统中删除硬编码的种子数据。最后,当加载开发环境时,我们将重新引入种子数据。
这意味着种子数据可以通过npm run dev命令使用,但在运行自动化测试或以生产模式启动时将不可用。
让我们从 Playwright 测试开始:
-
将以下内容添加到
tests/birthdays.test.js的顶部,这是一个新函数,用于向仓库发送POST /api/birthdays请求以插入生日:const addBirthday = async (request, { name, dob }) => { await request.post('/api/birthdays', { data: { name, dob } }); }; -
然后,更新
列出所有生日测试,使其以两个对addBirthday的调用开始,如下所示:test('lists all birthday', async ({ page, request }) => { await addBirthday(request, { name: 'Hercules', dob: '1995-02-03' }); await addBirthday(request, { name: 'Athena', dob: '1995-02-03' }); ... }); -
接下来,以相同的方式更新
编辑生日测试:test('edits a birthday', async ({ page, request }) => { await addBirthday(request, { name: 'Ares', dob: '1985-01-01' }); ... }); -
在运行 Playwright 测试之前,我们需要删除种子数据。在
src/routes/birthdays/+page.server.js中,删除对addNew的两个调用和addNew导入语句。 -
运行 Playwright 测试并验证它们是否通过。
-
剩下的就是添加一个服务器钩子来处理这些数据,这样当你在开发模式下运行服务器时,你就能得到一些数据。创建一个名为
src/hooks.server.js的新文件,内容如下。SvelteKit 将在启动网络服务器时自动加载此文件:import { addNew } from '$lib/server/birthdayRepository.js'; if (import.meta.env.MODE === 'development') { addNew({ name: 'Hercules', dob: '1994-02-02' }); addNew({ name: 'Athena', dob: '1989-01-01' }); }
这样就完成了对硬编码种子数据的移除。
摘要
本章向您介绍了测试替身(test double)的概念,它可以用来屏蔽不想要的框架行为。在我们的例子中,那就是在运行时会被神奇地连接到 API 端点的fetch调用。但由于我们在 Vitest 单元测试中无法访问 SvelteKit 运行时环境,所以我们对其进行了存根化处理。
您还学习了如何使用并排实现来保持您的测试套件处于绿色状态,同时您有系统地替换函数的内部结构。
下一章将继续探讨测试替身主题,详细介绍了组件模拟。
第十二章:使用组件模拟来澄清测试
前一章介绍了测试替身(test double)的概念,并展示了如何使用vi.fn在 Vitest 测试套件中替换掉不希望的行为。同样的技术也可以用于 Svelte 组件,但稍微复杂一些。
假设你正在为一个名为Parent的组件编写单元测试,而这个组件本身渲染了另一个开发者定义的组件,名为Child。默认情况下,当你的测试渲染Parent时,Child也会被渲染。但使用组件模拟可以阻止这种情况发生。它会用测试替身替换掉真实的Child。
你可能想要这样做的原因有很多:
-
Child组件已经有了自己的单元测试套件,你不想重复测试(这是过度测试的一种形式,在避免组件模拟部分有详细描述) -
Child组件在挂载时有一些行为,例如通过 Fetch API 获取数据,你更希望避免在测试中运行这些行为 -
Child组件来自第三方库,验证它渲染的属性比验证第三方组件本身的行为更重要
使用组件模拟的缺点是它们很复杂,如果你不小心,它们可能会成为负担。
使用测试替身保持安全
使用组件模拟以及测试替身的一般规则是避免在它们中构建任何控制逻辑(if语句和循环)。相反,当你使用mockReturnValue或mockResolvedValue来指定返回的值时,始终优先返回固定值。
确保这一点的一个简单方法是为每个单元测试获取其自己的测试替身实例。换句话说,避免在beforeEach块中设置测试替身并在所有测试中重复使用它。
如果你发现难以保持测试替身简单,这可能意味着应用程序代码设计过于复杂。尝试重新配置待测试的对象,例如将其拆分为多个单独的对象。
本章涵盖了以下主题:
-
在可能的情况下避免使用组件模拟
-
使用手工制作的组件存根
-
使用组件模拟库
到本章结束时,你将能够自信地使用组件模拟技术,并知道何时使用它们。
技术要求
本章的代码可以在网上找到,地址为github.com/PacktPublishing/Svelte-with-Test-Driven-Development/tree/main/Chapter12/Start。
避免使用组件模拟
本节介绍了如何在不需要使用组件模拟的情况下构建应用程序。当然,我们到目前为止构建的应用程序没有使用任何组件模拟,所以你已经知道这是可能的。
我们构建的 SvelteKit 应用程序有一个页面路由组件,它渲染一个 Birthday 组件列表和一个 BirthdayForm 组件。这两个组件都有自己的测试套件,因此它们当然适合使用组件模拟。页面路由组件只需检查它是否以正确的方式渲染了 Birthday 和 BirthdayForm,并避免测试任何生日本身。
但这样做并没有多大意义。这两个组件在挂载时都没有任何行为,所以让它们渲染没有问题。
不使用组件模拟的最大风险是 Birthday 组件测试套件在页面路由测试套件中的重复。
图 12.1 显示了如何在不过度测试的情况下开发 Parent 和 Child 组件的测试套件。Parent 测试套件只需要测试一个到 Child 的单一流程,以证明连接。如果有任何有趣的数据回流从 Child 返回到 Parent(例如组件事件绑定),那么它们也应该被测试。

图 12.1 – 组件层次结构中两个级别的重复测试导致的过度测试
幸运的是,TDD 有几条规则可以避免过度测试的问题。
使用 TDD 避免过度测试
想象一下,你首先使用自下而上的方法构建你的应用程序,这正是本书所采用的方法。这意味着你在编写 Parent 组件及其测试套件之前先编写 Child 组件及其测试套件。
当你开始测试驱动 Parent 组件时,你会编写一个测试来引入 Child 组件。(测试描述可能类似于 显示生日信息。)回想一下 TDD 规则中的 进行可能的最简单更改。由于你已经有现成的 Child 组件,那么最简单的更改就是直接引入 Child 组件。
然后,红 测试的规则开始发挥作用,这是避免过度测试的关键规则。除非你看到测试失败,否则你不能进行测试。但是,如果你的第一个测试引入了 Child 组件,你突然会免费获得 Child 的所有行为。所以,如果你遵循 TDD,你不可能为 Child 的所有行为编写 红 测试,因为它已经通过了。
现在想象一下,你开始构建 Parent 组件,并在某个时刻产生了提取 Child 组件的冲动(你可能将其视为自顶向下的方法)。你如何进行提取 Child?如果你非常严格,你可能会从重写 Child 组件的测试开始,就像我们在 第九章 中所做的那样,从框架中提取逻辑,当时我们提取了生日存储库。但通常,你不会就此停止:你还会想回去删除 Parent 中的那些额外测试。
你可以将这视为 TDD 循环中的 重构 步骤。实际上,我们在前一章中就是这样做的,当我们改变系统功能时,我们最终删除了一大堆测试。
再次强调本节的内容:你并不总是需要使用模拟。如果你只有一个测试来证明 Parent 和 Child 之间的连接,这通常就足够了。此外,使用 TDD 可以自然地引导你采用这种方法。
使用手工组件存根
在本节中,我们将探讨一种简单但有效的方法来模拟组件,即通过构建手工组件存根。这并不像使用组件模拟库那样聪明,但它更简单,更容易理解。通常,最简单的方法是最好的选择。
为了回顾我们试图做什么:我们有一个我们想要避免渲染的子组件,可能是因为它有挂载行为或者它是一个复杂的第三方组件。
手工组件存根依赖于 Vitest 的 vi.mock 函数以及一个特殊的 __mocks__ 目录。你创建一个与你的组件同名但位于同一级别的 __mocks__ 目录中的存根组件。然后,你通过在测试文件顶部放置 vi.mock 语句来指示 Vitest 使用模拟。这意味着整个测试套件都将使用模拟。
我们可以通过使用页面路由组件测试套件来演示这一点,为 Birthday 和 BirthdayForm 组件构建模拟。这些组件在挂载时没有副作用,它们也不是第三方组件,但它们有自己的测试套件。因此,尽管感觉没有必要进行这种更改,但这样做并不危险。
由于这项工作不是必需的,我们将在名为 page.mocks.test.js 的测试文件中构建一个示例测试套件。虽然这并不是我在现实世界中会这样做的方式,但它确实突出了你如何在同一个代码库中既有模拟又有非模拟的单元测试。
我们将从如何使用存根组件验证 props 开始,然后检查实例的顺序,接着处理复杂的 prop 验证,最后看看如何分发组件事件。
在组件存根中渲染所有 props
在前一章中,你看到了验证传递给测试替代的 props 的重要性。组件存根也不例外。我们这样做的方式是确保组件存根渲染所有 props,然后使用标准的 DOM 匹配器来验证它们的存在。
让我们首先创建一个 Birthday 组件的手工组件存根:
-
创建一个名为
src/routes/birthdays/__mocks__的新目录。这个特殊的名称会被 Vitest 自动识别为存放你的模拟文件的位置。 -
创建一个名为
src/routes/birthdays/__mocks__/Birthday.svelte的新文件,内容如下。它什么也不做,只是渲染传入的所有 props:<script> export let name; export let dob; </script> <div> {name} {dob} </div> -
现在创建一个名为
src/routes/birthdays/page.mock.test.js的新测试文件,并从常用的import语句开始。注意vi是如何被包含在内的;我们将在下一步中使用它:import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import { click } from '@testing-library/user-event'; import { createBirthday } from 'src/factories/birthday.js'; import Page from './+page.svelte'; -
接下来,在
import语句下方,添加对vi.mock的调用。提供的路径必须与实际的Birthday组件路径匹配。Vitest 将拾取模拟并加载该组件:vi.mock('./Birthday.svelte'); -
现在,开始一个新的
describe块,以及一个示例birthdays数组。我们所有的测试都将使用这个:describe('/birthdays', () => { const birthdays = [ createBirthday('Hercules', '1994-02-02', { id: '123' }), createBirthday('Athena', '1989-01-01', { id: '234' }) ]; }); -
进行第一次测试的时间。这将检查给定之前定义的两个生日,每个正确的信息片段都显示在屏幕上:
it('displays a Birthday component for each birthday', () => { render(Page, { data: { birthdays } }); expect( screen.queryByText(/Hercules/) ).toBeVisible(); expect( screen.queryByText(/1994-02-02/) ).toBeVisible(); expect( screen.queryByText(/Athena/) ).toBeVisible(); expect( screen.queryByText(/1989-01-01/) ).toBeVisible(); });
测试组件列表
此测试检查两个生日,而不是一个:因为我们感兴趣的是列表行为——每个条目一个Birthday组件——所以测试确实给出了生日列表的Birthday组件列表很重要。单个生日不足以证明列表。
-
由于我们的实现已经存在,此测试应该已经通过。但证明测试有效很重要,这次我们还想验证模拟已被拾取。所以,首先删除实际的
Birthday组件的内容(不是模拟)。测试应该仍然通过。(确保您使用v src/routes/birthdays/page.mocks.test.js命令运行测试,否则您将看到来自其他测试套件的全部失败。)这应该让您相信模拟已被拾取。 -
撤销对
Birthday组件的更改,现在让我们进行一个更改以使测试失败。在页面路由组件src/routes/birthday/+page.svelte中,注释掉Birthday组件的渲染,如下所示:<!--Birthday name={birthday.name} dob={birthday.dob} /--> -
使用
v src/routes/birthdays/page.mocks.test.js运行测试,并验证测试现在失败。然后,撤销您的更改。
您现在已经了解了组件存根的基本用法。
检查组件实例的排序
有时候,当我们处理组件列表时,我们想检查实例的排序。我们可以使用data-testid属性来获取每个特定实例以进行检查。
有一个普遍的建议是避免在测试中使用data-testid。这是一个好建议,但您的组件存根是测试套件的一部分,而不是应用程序代码,所以在这里使用它们是安全的。
何时使用列表排序测试
本节中的测试并不非常符合 TDD(测试驱动开发)的风格;它证明了我认为的默认列表排序。如果您已经有了一个检查数据是否列出的测试,就像前一个节中的步骤 6中的那样,那么使那个测试通过的最简单方法就是实现默认排序。所以,编写一个像您即将看到的测试一样,可能会默认通过,因此是一个无效的测试。
让我们开始:
-
更新组件存根以添加一个
data-testid属性,如下所示:<script> export let name; export let dob; </script> <div data-testid="Birthday"> {name} {dob} </div> -
现在,编写一个测试来证明排序。它使用
queryAllByTestId来返回一个匹配特定data-testid属性值的元素列表,按照它们在文档中的顺序:it('displays the Birthdays in the same order as the props passed in', () => { render(Page, { data: { birthdays } }); const birthdayEls = screen.queryAllByTestId('Birthday'); expect(birthdayEls[0]).toHaveTextContent( /Hercules/ ); expect(birthdayEls[1]).toHaveTextContent( /Athena/ ); }); -
这将通过,但请确保你通过使用之前提到的注释技巧来验证它不会通过。
还值得指出的是,还有一种编写排序测试的方法,不涉及使用data-testid属性。你可以获取页面上的ol元素,然后将每个li元素映射到它们的文本内容,并检查它是否按照你预期的顺序是一个数组。
处理复杂属性
有时候,你的组件的属性是对象或数组,如果你要在组件存根中渲染它们,你会在存根中结束于一大堆代码。有一种更短的方法来输出属性值,那就是使用JSON.stringify函数。
现在我们为BirthdayForm组件做这件事:
-
创建一个名为
src/routes/birthdays/__mocks__/BirthdayForm.svelte的新文件,内容如下:<script> export let form; </script> <div> Editing {JSON.stringify(form)} </div> -
在你的测试套件中,添加一个新的
vi.mock调用,以获取这个组件,紧邻之前的vi.mock调用:vi.mock('./BirthdayForm.svelte'); -
接下来,在测试套件的顶部添加以下辅助函数:
const firstEditButton = () => screen.queryAllByRole('button', { name: 'Edit' })[0]; -
然后,添加下一个测试,如图所示;它也调用了
JSON.stringify。这应该会通过,但请确保在完成之前进行验证:it('passes the currently edited birthday to the BirthdayForm component', async () => { render(Page, { data: { birthdays } }); await click(firstEditButton()); expect( screen.queryByText( `Editing ${JSON.stringify(birthdays[0])}` ) ).toBeInTheDocument(); });
你会注意到组件存根和测试套件之间的耦合。我发现,只要它是你用来检查复杂属性的唯一模式,并且开发者之间使用一致,那么利用JSON.stringify的技术通常是可行的。
派发组件事件
我们将要探讨的最后一个组件存根技术是派发组件事件的机制。正如上一节所述,这是尴尬的,因为在没有在存根本身中定义派发对象的情况下,不可能在手工制作的存根上引发组件事件。
处理这个问题的一种方法是将一个按钮放入存根中并使用它来派发事件:
警告
以下示例在我们应用程序的上下文中并没有太多意义:实际的BirthdayForm组件没有这个取消行为,而且如果这个功能确实存在,可能更有意义的是在页面路由中放置取消按钮,避免需要组件事件。
-
更新
BirthdayForm组件存根以包含一个派发cancel事件的button元素:<script> import { createEventDispatcher } from 'svelte'; const dispatcher = createEventDispatcher(); export let form; </script> <div data-testid="BirthdayForm"> Editing {JSON.stringify(form)} <button on:click={() => dispatcher('cancel')} /> </div> -
现在,你可以添加一个测试来检查当派发
cancel事件时会发生什么:it('cancels editing', async () => { render(Page, { data: { birthdays } }); await click(firstEditButton()); const button = screen .getByTestId('BirthdayForm') .querySelector('button'); await click(button); expect( screen.queryByText( `Editing ${JSON.stringify(birthdays[0])}` ) ).not.toBeInTheDocument(); }); -
为了使这个测试通过,更新
page组件以响应cancel事件:<BirthdayForm form={editing} on:cancel={() => (editing = null)} />
记住,真正的 BirthdayForm 组件没有这种行为,这暴露了模拟组件的一个大问题:保持模拟与实际实现一致是具有挑战性的。
避免手工制作模拟并使用库是处理此问题的方法之一,我们将在下一节中看到。
使用组件模拟库
在上一章中,你看到了如何使用vi.fn来监视函数。svelte-component-double npm 包可以以类似的方式使用,实现你刚刚学到的手工制作模拟的相同效果。
该包包括toBeRendered和toBeRenderedWithProps等匹配器,用于检查组件是否确实以你想要的方式渲染。
让我们将这个分成几个部分:安装库和编写测试。
安装库
该库需要一些设置来将相关的匹配器放置到位:
-
运行以下命令来安装包:
npm install --save-dev svelte-component-double -
然后,创建一个名为
src/vitest/registerSvelteComponentDouble.js的新文件,内容如下。它注册了匹配器,并使我们能够全局访问componentDouble函数,这不是必需的,但使模拟设置更容易:import { expect } from 'vitest'; import as matchers from 'svelte-component-double/vitest'; expect.extend(matchers); import { componentDouble } from 'svelte-component-double'; globalThis.componentDouble = componentDouble; -
然后,更新你的
vite.config.js文件,包括新的设置文件:setupFiles: [ ..., './src/vitest/registerSvelteComponentDouble.js' ],
你现在可以使用库进行测试了。
使用componentDouble函数编写测试
现在,我们将重写模拟测试套件,使用库而不是手工制作的组件占位符:
-
首先,重新定义对
vi.mock的两个调用,如下所示。每个componentDouble调用都得到一个字符串标识符。这出现在你的 DOM 输出中,并在期望失败时使用:vi.mock('./Birthday.svelte', async () => ({ default: componentDouble('Birthday') })); vi.mock('./BirthdayForm.svelte', async () => ({ default: componentDouble('BirthdayForm') })); -
你还需要在文件顶部添加两个
import语句,以便你可以访问模拟对象。虽然看起来你正在导入实际的组件,但实际上你会得到组件占位符:import Birthday from './Birthday.svelte'; import BirthdayForm from './BirthdayForm.svelte'; -
之间也很重要重置组件占位符。这是因为
vi.mock在每个测试套件中只会生成一个占位符。在describe块顶部添加以下两个调用到beforeEach:describe('/birthdays', () => { beforeEach(Birthday.reset); beforeEach(BirthdayForm.reset); ... }); -
你还需要更新
import语句,以引入beforeEach函数:import { ..., beforeEach } from 'vitest'; -
现在进行第一次测试。这次测试使用的是
toBeRendered匹配器,它检查组件是否出现在文档的某个位置。更新第一个测试,使其看起来如下所示:it('displays a Birthday component for each birthday, () => { render(Page, { data: { birthdays } }); expect(Birthday).toBeRendered(); }); -
要运行此测试,将其标记为
it.only,然后运行 Vitest 测试套件。你应该看到它通过;你可以通过以下方式验证它,即在页面路由组件中注释掉Birthday组件实例。这次,你会看到以下失败信息打印出来:Error: Expected "Birthday" component double to be rendered but it was not -
撤销该更改,以便测试再次通过。
-
我们知道显示的不仅仅是
Birthday组件一个,而是两个。我们可以使用toBeRenderedWithProps匹配器来检查组件的单独实例。更新相同的测试,使用该匹配器,如下所示:it('displays a Birthday component for each birthday', () => { render(Page, { data: { birthdays } }); expect(Birthday).toBeRenderedWithProps({ name: 'Hercules', dob: '1994-02-02' }); expect(Birthday).toBeRenderedWithProps({ name: 'Athena', dob: '1989-01-01' }); });
注意到toBeRenderedWithProps的调用不需要指定完整的属性集。如果给定的子集匹配,则期望通过。这意味着我们可以避免检查id字段,这对于测试目的来说是不必要的细节。
-
如果你注释掉组件中
Birthday的渲染,你会看到这个匹配器失败的样子:Error: Expected "Birthday" component double to have been rendered once with props but it was not Expected: Object { "dob": "1994-02-02", "name": "Hercules", } Received: -
本测试套件的第二个测试检查组件的顺序。我们可以通过使用存在于双实例上的
propsOfAllInstances函数来实现这一点。更新测试如下:it('displays the Birthdays in the same order as the props passed in', () => { render(Page, { data: { birthdays } }); expect(Birthday.propsOfAllInstances()).toEqual([ expect.objectContaining({ name: 'Hercules' }), expect.objectContaining({ name: 'Athena' }) ]); }); -
对于
BirthdayForm的第一个测试不再需要任何JSON.stringify魔法。我们只需直接测试对象属性即可。更新测试如下:it('passes the currently edited birthday to the BirthdayForm component', async () => { render(Page, { data: { birthdays } }); await click(firstEditButton()); expect(BirthdayForm).toBeRenderedWithProps({ form: birthdays[0] }); }); -
最后,最后一个测试可以利用
dispatch双函数将事件分发给父组件。注意这比手动编写的模拟要简单得多:it('cancels editing', async () => { render(Page, { data: { birthdays } }); await click(firstEditButton()); await BirthdayForm.dispatch('cancel'); expect(BirthdayForm).not.toBeRenderedWithProps({ form: birthdays[0] }); });
最后这两个测试展示了使用组件模拟库而不是手动编写的模拟可以多么简单。这完成了本节的内容。你现在已经发现了使用svelte-component-double库简化测试套件的全部内容。
摘要
本章详细探讨了组件模拟。我们首先探讨了在大多数情况下通常可以避免使用组件模拟,这很重要,因为组件模拟是测试套件复杂性的主要原因之一。
然后,你看到了如何使用利用 Vitest 的vi.mock函数和特别命名的__mocks__目录中的组件存根的手动编写的模拟。你也看到了它们如何迅速变得复杂。
最后,我们探讨了使用svelte-component-double库来避免使用手动编写的模拟。这提供了一些简单的匹配器和一些辅助函数,以帮助你编写测试。
这完成了本书中所有单元测试主题。下一章将增加一种测试技术:使用 Cucumber.js 为团队编写行为驱动开发(BDD)风格的测试。
第十三章:添加 Cucumber 测试
到目前为止,你已经看到了两种类型的自动化测试:Vitest 单元测试和 Playwright 端到端测试。本章添加了第三种测试类型:Cucumber(cucumber.io)。
就像 Playwright 一样,Cucumber 也有自己的测试运行器,通常设置为以与 Playwright 相同的方式驱动应用程序。区别在于 Cucumber 测试不是用 JavaScript 代码编写的。
Cucumber 测试包含在特性文件中,这些文件包含以特殊语法格式化的测试,这种语法称为Gherkin。这些测试,被称为特性,并组织成场景,读起来就像普通的英语。这有几个优点。
首先,它们可以被整个团队编写和理解,而不仅仅是开发者。这意味着你可以在开发团队之外扩展测试驱动实践。
其次,代码的缺失鼓励你编写专注于用户行为的测试,而不是软件的技术细节。这反过来又鼓励你为用户构建正确的东西。
Cucumber 如何将特性转换为可执行代码?好吧,看看特性文件中的一个示例行(或步骤):
When I navigate to the "/birthdays" page
黄瓜(Cucumber)执行这一步并寻找一个匹配的 JavaScript 定义的步骤定义。在这个例子中,步骤定义看起来是这样的:
When(
'I navigate to the {string} page',
async function (url) { ... }
);
注意步骤定义有一个相关的代码块。一旦 Cucumber 找到一个匹配的步骤定义,它就会执行这个代码块,并传递任何解析后的参数。在这种情况下,url参数将以/birthdays字符串的形式提供。Cucumber 还支持其他数据类型,例如int和bigdecimal(github.com/cucumber/cucumber-expressions#parameter-types)。
在本章中,我们将涵盖以下关键主题:
-
创建特性文件
-
设置 Playwright 世界对象
-
实现步骤定义
到本章结束时,你将自信地为你的应用程序添加 Cucumber 测试。
技术要求
本章的代码可以在网上找到,地址为github.com/PacktPublishing/Svelte-with-Test-Driven-Development/tree/main/Chapter13/Start。
创建特性文件
我们首先编写一个示例 Cucumber 特性文件。
用于编写特性的 Gherkin 语法以三个词Given、When和Then为特征。这些与所有良好单元测试的安排、行动和断言部分相对应,因此应该感觉熟悉。
让我们直接从特性文件开始,看看执行测试时会发生什么:
-
首先,添加一个新的目录
features,并在其中创建一个名为features/birthdays.feature的新文件,内容如下。它描述了一个用户场景,其中生日应用程序已经支持编辑生日。如下所示:Feature: Editing a birthday Scenario: Correcting the year of birth Given An existing birthday for "Hercules" on "1992-03-04" When I navigate to the "/birthdays" page And I edit the birthday for "Hercules" to be "1994-04-06" Then the birthday for "Hercules" should show "1994-04-06" And the text "1992-03-04" should not appear on the page -
使用以下命令安装 Cucumber:
npm install --save-dev @cucumber/cucumber -
然后,使用
npx @cucumber/cucumber运行测试。你应该会看到一个输出,开始如下:Failures: 1) Scenario: Correcting the year of birth # features/birthdays.feature:2 ? Given An existing birthday for "Hercules" on "1992-03-04" Undefined. Implement with the following snippet: Given('An existing birthday for {string} on {string}', function (string, string2) { // Write code here that turns the phrase above into concrete actions return 'pending'; });
正如所有好的测试运行器应该做的那样,Cucumber 正在告诉我们下一个任务:定义步骤定义。但在我们到达那里之前,我们需要引入 Playwright API。
设置 Playwright 世界对象
Cucumber 如何执行你的测试?就像 Playwright 测试一样,我们需要一个运行中的应用服务器和一个运行的浏览器来驱动 用户界面(UI)。在本节中,我们将编写所有准备测试执行的代码。
Cucumber.js 使用每个步骤中的 this 变量的概念。我们也在特殊的 Before 和 After 钩子中获得了对它的访问,这些钩子在每次场景前后运行。
世界对象应包含允许你驱动 UI 的函数(和状态)。由于你已经学习和使用了 Playwright API 来定位页面上的对象,如果我们能够使用相同的 API 那将是极好的。实际上,我们确实可以这样做。我们还可以使用我们熟悉的相同 expect API,我们将在下一节开始编写步骤定义时这样做。
这里我们要做的是:我们将构建一个名为 PlaywrightWorld 的世界级类,它具有以下功能:
-
launchServer和killServer用于启动和停止服务器 -
launchBrowser和closeBrowser用于打开和关闭无头网络浏览器,并在我们的世界对象上公开 Playwright 页面和requestAPI
然后,我们将使用 Before 和 After 钩子来启动和停止服务器和浏览器。
在我们开始之前的一个注意事项:我们的代码文件将使用 mjs 扩展名而不是 js,以向 Cucumber.js 表明这些文件使用 ECMAScript 模块语法。
让我们开始吧:
-
首先,创建一个新文件,
features/support/world.mjs,包含以下导入定义。我们稍后会添加更多,但这些都是启动服务器所需的:import * as childProcess from 'child_process'; import { setWorldConstructor } from '@cucumber/cucumber'; -
现在,定义
removeAnsiColorCodes函数。这对于会返回stdout流数据中颜色代码的执行环境(主要是 Windows)来说很重要:const removeAnsiColorCodes = (string) => string.replace(/\x1b\[[0-9;]+m/g, ''); -
我们准备好定义
PlaywrightWorld类,从单个方法launchServer开始。该方法以调用setWorldConstructor结尾,使其成为指定的世界类:class PlaywrightWorld { async launchServer() { console.log('launching server'); this.serverProcess = childProcess.spawn( config.webServer.command, [], { shell: true, env: config.webServer.env } ); this.baseUrl = await new Promise((resolve) => { this.serverProcess.stdout.on('data', (data) => { let text = removeAnsiColorCodes(String(data)); let match = text.match( /http[s]?:\/\/[a-z]+:[0-9]+\// ); if (match) { resolve(match[0]); } }); console.log(`started at ${this.baseUrl}`); } } setWorldConstructor(PlaywrightWorld);
我们 launchServer 函数中的代码非常简陋,但它完成了工作。它读取 Playwright 配置文件,并提取 config.webServer.command 的值,在我的项目中是这样的:
npm run build && npm run preview
使用 Playwright 配置启动网络服务器
因为这是一个 shell 命令,所以当我们调用 Node 的 childProcess.spawn 函数时,必须使用 detached 和 shell 属性。
设置env意味着 Cucumber 接收到的任何环境变量也会传递给这个新的 shell。一旦服务器启动,我们就读取stdout数据流,直到我们看到包含 HTTP URL 的行。这是运行中 Web 服务器的 URL,因此我们解析该值并将其作为resolve的参数返回。
使用Promise对象意味着线程将等待直到检索到值,然后将世界的baseUrl属性设置为该值。
-
添加以下错误处理逻辑,它简单地记录出出现在
stderr数据流上的任何非空消息:class PlaywrightWorld { async launchServer() { console.log('launching server'); this.serverProcess = childProcess.spawn(...); this.serverProcess.stderr.on('data', (data) => { const trimmed = String(data).trim(); if (trimmed !== '') { console.log(trimmed); } }); ... console.log(`started at ${this.baseUrl}`); } -
现在,让我们继续到
killServer,首先添加一个新的包,tree-kill-promise,这将使我们能够轻松关闭服务器进程:npm install --save-dev tree-kill-promise -
然后将它作为导入添加到同一个世界文件顶部:
import kill from 'tree-kill-promise'; -
定义
killServer方法,如下所示:class PlaywrightWorld { ... killServer() { await kill(this.serverProcess.pid); } } -
是时候启动浏览器了。首先在文件顶部引入以下 Playwright
import语句:import { chromium, request } from '@playwright/test'; -
然后定义
launchBrowser和closeBrowser函数,如下面的代码所示。关键部分是我们最终得到了request和page对象,这些对象与我们的 Playwright 端到端测试中的对象完全相同:class PlaywrightWorld { ... async launchBrowser() { this.browser = await chromium.launch(); this.context = await this.browser.newContext({ baseURL: this.baseUrl }); this.request = await request.newContext({ baseURL: `${this.baseUrl}api/` }); this.page = await this.context.newPage(); } async closeBrowser() { await this.browser.close(); } } -
有了完整的世界对象,现在是时候添加钩子了。添加一个名为
features/support/hooks.mjs的新文件,并给它以下内容:import { Before, After } from '@cucumber/cucumber'; Before(async function () { await this.launchServer(); await this.launchBrowser(); }); After(async function () { await this.killServer(); this.closeBrowser(); });
所有的设置都完成了。唯一剩下的事情是步骤定义。
实现步骤定义
最后一部分是匹配功能步骤与其实现的Given、When和Then函数。
在进行过程中检查你的工作
在本节中,我们将快速浏览定义,但请确保在实现每个函数后通过运行 Cucumber(使用npx @cucumber/cucumber命令)来验证每个步骤是否正常工作。
让我们开始吧!
-
创建另一个新目录,
features/support,并创建一个名为features/support/steps.mjs的文件,它以以下导入开始:import { Given, When, Then } from '@cucumber/cucumber'; -
然后实现我们的功能文件中的第一个
Given步骤。这个步骤使用 Playwright 的this.request.post函数调用 API。注意failOnStatusCode的使用,它确保如果未收到200 OK响应,Cucumber 将失败测试:Given( 'An existing birthday for {string} on {string}', async function (name, dob) { await this.request.post('birthdays', { data: { name, dob }, failOnStatusCode: true }); } ); -
现在我们进入
When步骤。这里有两大步骤;可以说,其中之一可以是Given,但我认为它们作为一个组工作得很好,因为它们都是用户操作。第一个步骤简单地调用this.page.goto,你之前也见过:When( 'I navigate to the {string} page', async function (url) { await this.page.goto(url); } ); -
对于剩余的步骤,我们将使用我们在第七章中定义的自己的
BirthdayListPage页面模型对象,整理测试套件。首先在文件顶部导入它:import { BirthdayListPage } from '../../tests/BirthdayListPage.js'; -
然后实现下一个
When步骤定义,它使用我们经过考验的beginEditingFor、dateOfBirthField和saveButton函数:When( 'I edit the birthday for {string} to be {string}', async function (name, dob) { const birthdayListPage = new BirthdayListPage( this.page ); await birthdayListPage.beginEditingFor(name); await birthdayListPage .dateOfBirthField() .fill(dob); await birthdayListPage.saveButton().click(); } ); -
是时候编写
Then步骤定义了。这些是包含期望的步骤定义。首先,在文件顶部添加一个import语句用于expect:import { expect } from '@playwright/test'; -
实现以下代码块中的第一个
Then子句。这会检查新生日是否显示在页面上:Then( 'the birthday for {string} should show {string}', async function (name, dob) { const birthdayListPage = new BirthdayListPage( this.page ); await expect( birthdayListPage.entryFor(name) ).toContainText(dob); } ); -
使用最后的
Then子句完成步骤定义:Then( 'the text {string} should not appear on the page', async function (text) { await expect( this.page.getByText(text) ).not.toBeVisible(); } ); -
现在运行测试,你应该会看到所有测试都通过:
work/birthdays % npx @cucumber/cucumber launching server started at http://localhost:4173/ ....... 1 scenario (1 passed) 5 steps (5 passed)
你现在已经看到了如何使用 Gherkin 语法编写功能文件,以及如何编写使用与您用于端到端测试相同的 Playwright API 的步骤定义。你还看到了标准 expect 语法可以用于编写适用于所有三种测试类型的断言。
摘要
本章介绍了如何使用 Cucumber 测试运行器执行 Gherkin 功能文件。Gherkin 的纯英文语法使得这项技术对于将自动化测试引入更广泛的产品开发团队变得非常重要。
功能文件由步骤定义支持。这些步骤定义是通过使用 Given、When 和 Then 函数实现的,这些函数将 Gherkin 步骤描述映射到 JavaScript 代码。
你已经看到了如何使用步骤定义重复使用现有的 Playwright API 代码来管理浏览器交互。
这完成了我们对自动化测试技术的探讨。在 第三部分 中,我们将探讨如何为 SvelteKit 特定功能编写单元测试,从测试认证的策略章节开始。
第三部分:测试 SvelteKit 功能
本部分简要介绍了需要仔细测试的一些特定功能。这些章节的顺序与前面部分不同。相反,它们是关于你如何进行测试的讨论。包含的代码示例仅关注前面章节中没有涉及的新颖部分。你始终可以参考在线存储库以获取完整的实现。
本部分包含以下章节:
-
第十四章,测试认证
-
第十五章,测试 Svelte 存储
-
第十六章,测试服务工作者
第十四章:测试认证
许多网络应用程序将涉及用户认证。本章展示了如何为这种功能编写测试。这些测试涵盖了登录、注销,并确保你的应用程序仅对已登录用户可访问。
本章不是教程,只包含少量关于实现认证所需的应用程序代码的细节。本书仓库使用 Auth.js 库,但相同的测试技术无论采用何种实现方法都适用。
本章涵盖了以下关键主题:
-
使用 Playwright 测试认证
-
使用 Vitest 测试认证
到本章结束时,你将看到如何编写涵盖所有认证方面的测试。
技术要求
本章的代码可以在网上找到,地址为 github.com/PacktPublishing/Svelte-with-Test-Driven-Development/tree/main/Chapter14/Complete。
如果你想运行这个示例中的代码,你需要创建一个包含一些环境变量的 .env 文件。有一个名为 .env.example 的示例文件,你可以复制并保存为 .env,这应该可以工作,但如果你想尝试 GitHub OAuth 集成,你需要在 GitHub 内进行一些配置。
你可以在仓库的 README.md 文件中找到更详细的信息。
使用 Playwright 测试认证
本节详细说明了使用 Playwright 测试进行有效测试所需的基础工作。首先,我们看看如何为端到端测试提供硬编码的认证凭据。然后我们使用这些凭据来验证用户能否登录和注销应用程序。最后,我们将更新现有的测试,以确保在尝试测试应用程序功能之前用户已经登录。
Cucumber 呢?
如果你使用 Cucumber 测试与 Playwright,那么这里展示的相同技术也适用于你。
为开发和测试模式创建一个认证配置文件
使用 OAuth 认证策略将认证责任委托给第三方提供者,如 Google 或 GitHub,这在很大程度上是典型的。然而,当涉及到编写端到端测试时,仅为了测试目的维护这些第三方用户账户是不切实际的。一方面,账户密码会过期并需要定期重置。这类工作需要被记录、跟踪和安排。
另一种解决方案是提供一个特殊的硬编码凭据,当应用程序服务器以特定测试模式运行时,可以使用该凭据登录。然后你的 Playwright 测试可以使用此凭据进行登录。
我们的解决方案如下:
-
Playwright 使用一个额外的环境变量
VITE_ALLOW_CREDENTIALS启动服务器,该变量设置为true。 -
Auth.js初始化代码寻找这个凭证,如果找到,将通过其凭证机制启用登录。有一个单一的用户api,与之关联的密码为空。 -
这个用户可以由 API 测试和您的应用程序开发模式使用。
为了确保 Playwright 以正确的环境变量启动,playwright.config.js 文件需要进行如下更改:
webServer: {
command:
'npm run build && npm run preview',
port: 4173,
env: {
PATH: process.env.PATH,
VITE_ALLOW_CREDENTIALS: true
}
},
然后,应用程序有一个名为 src/authProviders.js 的文件,该文件检查凭证,如下面的代码片段所示。这个样本是针对 Auth.js 的,但其他身份验证库也可以类似地初始化。关键点是预期的 authProviders 是一个可能包含也可能不包含特殊凭证提供程序的数组,这取决于环境变量的值:
import GitHubProvider from '@auth/core/providers/github';
import CredentialsProvider from '@auth/core/providers/credentials';
import {
GITHUB_ID,
GITHUB_SECRET
} from '$env/static/private';
const allowCredentials =
import.meta.env.VITE_ALLOW_CREDENTIALS === 'true';
const GitHub = GitHubProvider({
clientId: GITHUB_ID,
clientSecret: GITHUB_SECRET
});
const credentials = CredentialsProvider({
credentials: {
username: { label: 'Username', type: 'text' }
},
async authorize({ username }, req) {
if (username === 'api')
return { id: '1', name: 'api' };
}
});
const devAuthProviders = {
GitHub,
credentials
};
const prodAuthProviders = { GitHub };
export const authProviders = allowCredentials
? devAuthProviders
: prodAuthProviders;
应用程序准备就绪后,即可在我们的测试中使用。
编写登录测试
拥有一个成功登录流程的测试和一个失败的测试非常重要。拥有这些测试确保了您对这些页面的自动化测试覆盖率。
这里是一个成功登录测试的示例。它可以在 tests/login.test.js 仓库文件中找到。它导航到常规的 /birthdays 路由,然后寻找名为 api 用户的按钮,并再次点击该按钮(这次,它起到提交登录信息的作用)。然后检查用户是否被重定向到主页并可以看到 生日 列表 标题:
import { expect, test } from '@playwright/test';
test('logs in and returns to the application', async ({
page
}) => {
await page.goto('/birthdays');
await page.waitForLoadState('networkidle');
await page
.getByRole('button', { name: /Sign in with
credentials/i })
.click();
await page.getByRole('textbox').fill('api');
await page
.getByRole('button', { name: /Sign in with
credentials/i })
.click();
await expect(
page.getByText('Birthday list')
).toBeVisible();
});
注意使用 page.waitForLoadState Playwright 函数。这是必要的,以确保所有相关的 Auth.js 代码都已运行,并最终渲染登录按钮。
接下来是测试失败的登录。为此,我们可以提供除 api 之外任何用户名,因此这个测试提供了 未知用户 文本,为数据提供了一些有用的上下文:
test('does not log in if log in fails', async ({
page
}) => {
await page.goto('/birthdays');
await expect(
page.getByText('Please login')
).toBeVisible();
await page.waitForLoadState('networkidle');
await page
.getByRole('button', { name: /Sign in with
credentials/i })
.click();
await page
.getByRole('textbox')
.fill('unknown user');
await page
.getByRole('button', { name: /Sign in with
credentials/i })
.click();
await expect(
page.getByText(
'Sign in failed. Check the details you provided are
correct.'
)
).toBeVisible();
});
这涵盖了新登录功能的测试。接下来,我们需要更新现有测试以确保它们继续工作。
更新现有测试以验证用户身份
我们在 tests/birthdays.test.js 中现有的测试需要更新,以便每个测试都以登录用户开始。我们可以使用 beforeEach 块来完成此操作,它具有原始测试不需要修改的优点。
Auth.js 提供了一个整洁的 API 类型的端点,我们可以直接调用。这意味着我们不需要为每个测试导航通过网页表单,这减少了每个测试需要完成的工作量。
login 函数在下面的代码片段中定义。它模仿了点击 api 的 username 字段值的行为。发送此请求时,还重要地发送 origin 标头;否则,它将被拒绝:
const login = async ({ context, baseURL }) => {
const response = await context.request.get(
'/auth/csrf'
);
const { csrfToken } = await response.json();
const response2 = await context.request.post(
'/auth/callback/credentials',
{
form: {
username: 'api',
csrfToken
},
headers: {
origin: baseURL
}
}
);
};
然后,可以通过将其发送到 test.beforeEach 来为 tests/birthday.test.js 中的每个测试触发它,如下所示:
test.beforeEach(login);
这就完成了 Playwright 测试。你会注意到我省略了任何检查当你未认证访问/birthday路由时会发生什么的测试。我们将在下一节的 Vitest 测试中涵盖这一点。
使用 Vitest 测试认证
现在我们深入一层,具体来看。我们的测试将集中在/birthdays路由以及它如何根据认证数据呈现。
Auth.js 库利用 SvelteKit 的会话机制将认证信息传递到组件中,所以我们通过parent.session对象和locals.getSession函数来实现这一点。我们只需要使用测试双胞胎来模拟我们想要的响应。
我们首先定义一个会话工厂,它可以用来设置这些会话测试双胞胎。然后我们将更新页面加载测试以包含新的认证功能,最后,我们将更新表单操作测试。
定义会话工厂
这里是src/factories/session.js的定义,它定义了四个导出,这些导出在随后的测试中使用:
import { vi } from 'vitest';
const validSession = { user: 'api ' };
export const loggedInSession = () => ({
session: validSession
});
export const loggedOutSession = () => ({
session: null
});
export const loggedInLocalsSession = () => ({
getSession: vi.fn().mockResolvedValue(validSession)
});
export const loggedOutLocalsSession = () => ({
getSession: vi.fn().mockResolvedValue(null)
});
可以使用loggedInSession对象作为传递给页面加载的parent属性。Auth.js 认证过程将在你的路由加载之前运行,并将其合并到这个parent值中。所以,loggedInSession只是一个占位符对象:在我们的测试上下文中,任何值都构成一个有效的、已登录的用户。
loggedOutSession对象类似:这次,session的null值表示用户未认证。
loggedInLocalsSession和loggedOutLocalsSession值将用于替换传递给表单操作的 SvelteKit 的locals属性。这个属性是一组函数,表单操作可以使用这些函数。
接下来,我们将看到如何使用这些测试。
更新现有的页面加载函数测试
现在,让我们更新页面加载函数,使其具有必要的加载函数。我们还将编写一个测试来确保如果用户未认证,页面不会加载任何数据。
在src/routes/birthdays/page.server.test.js中需要一个新的导入,如下面的代码块所示。这些是两个工厂,将用于为传递给load函数的parent属性提供值:
import {
loggedInSession,
loggedOutSession,
} from 'src/factories/session.js';
然后,describe块被更新以创建一个默认为已登录用户的parent变量:
describe('/birthdays - load', () => {
const parent = vi.fn();
beforeEach(() => {
parent.mockResolvedValue(loggedInSession());
});
...
});
每个测试也需要更新,以便将这个新的parent属性传递给load函数:
it('calls fetch with /api/birthdays', async () => {
const fetch = vi.fn();
fetch.mockResolvedValue(fetchResponseOk());
const result = await load({ fetch, parent });
expect(fetch).toBeCalledWith('/api/birthdays');
});
你还想要添加一个测试来检查端点在没有正确认证的情况下是否无法工作。在下面的示例中,我们用loggedOutSession覆盖了默认的parent属性,然后测试返回的页面有一个303状态,这意味着浏览器正在被重定向。它还检查被重定向到的页面是/login路由:
it('redirects if the request is not authorised', async () => {
parent.mockResolvedValue(loggedOutSession());
expect.hasAssertions();
try {
await load({ parent });
} catch (error) {
expect(error.status).toEqual(303);
expect(error.location).toEqual('/login');
}
});
这就完成了load函数的测试。
更新现有的表单操作测试
对于表单动作测试,import 语句被更新为剩余的两个工厂函数:
import {
loggedInSession,
loggedOutSession,
loggedInLocalsSession,
loggedOutLocalsSession
} from 'src/factories/session.js';
然后,describe 块被更新以包含在 beforeEach 中设置的 locals 变量。注意这次变量被定义为 let,因为 loggedInLocalsSession 工厂负责设置 vi.fn 间谍函数:
describe('/birthdays - default action', () => {
const fetch = vi.fn();
let locals;
beforeEach(() => {
fetch.mockResolvedValue(fetchResponseOk());
locals = loggedInLocalsSession();
});
...
});
然后 performFormAction 被更新以包含 locals 属性。由于这是我们测试调用来触发表单动作的函数,因此测试本身不需要更改:
const performFormAction = (formData) =>
actions.default({
request: createFormDataRequest(formData),
fetch,
locals
});
最后,我们需要一个测试来检查当用户未认证时提交表单会发生什么。在这种情况下,我们返回 300 状态码而不是重定向,但您可以选择回到上一个表单页面,就像处理验证错误时一样。这有助于用户会话已过期的场景。以下是更简单的版本:
describe('when not authorised', () => {
beforeEach(() => {
locals = loggedOutLocalsSession();
});
it('returns a failure', async () => {
const result = await performFormAction({});
expect(result.status).toEqual(300);
});
});
这完成了支持我们的认证实现所需的 Vitest 变更。
摘要
本章简要介绍了测试认证所需的测试类型。
Playwright 端到端测试应检查登录流程,无论是成功还是失败。它们还应尽可能使用假凭据,并确保任何路由都只能由使用 test.beforeEach 调登录的认证用户访问。
Vitest 对认证路由的测试工作方式不同。它们关注 SvelteKit 返回的 session 对象:它是否有有效的值?
虽然这涵盖了基础知识,但大多数应用程序将具有更复杂的需求:例如,您的应用程序可能为每个用户都有单独的数据存储,而不仅仅是我们的生日应用程序中的一个全局数据存储。
Playwright 网站上有关于如何测试特定模式的好文档,例如在单个测试中让多个用户角色交互。可以在 playwright.dev/docs/auth 找到。
在下一章中,我们将探讨测试 Svelte 存储。
第十五章:测试驱动 Svelte 存储
subscribe 机制 – 该机制有助于确保所有组件保持对每个变量当前值的持续视图。
当涉及到编写涉及存储的组件的测试时,你必须为存储的两个部分编写测试:第一部分是存储值的 观察,第二部分是存储值的 设置。
由于存储是一个内部设计决策,因此不需要为存储的引入编写特定的 Playwright 测试。
本章涵盖了以下关键主题:
-
为生日设计存储
-
为读取存储值编写测试
-
为更新存储值编写测试
到本章结束时,你将很好地理解为 Svelte 存储对象编写单元测试。
技术要求
该章节的代码可以在网上找到,地址为 github.com/PacktPublishing/Svelte-with-Test-Driven-Development/tree/main/Chapter15/Complete。
为生日设计存储
本章的代码包括 src/stores/birthdays.js 文件中的一个存储,其内容如下:
import { writable } from 'svelte/store';
export const birthdays = writable([]);
birthdays 存储的想法是存储从 SvelteKit 页面加载返回的所有生日。它通过页面路由组件保持更新。
此外,还有一个新的 NextBirthday 组件,该组件读取存储并显示页面顶部的消息,提醒用户即将到来的生日。
对于这个变更,存储不是必需的
这个功能可以通过将 birthdays 作为属性传递给 NextBirthday 来简单地编写。如果你可以简单地使用组件属性来避免存储,这当然值得。本章的代码旨在仅用于教育目的;在现实中,我不会为这个用例使用存储。

图 15.1 – 带有新警报的生日应用
NextBirthday 组件的代码并不简单,因此你可能对在线查看它感兴趣。特别是,单元测试使用了 vi.useFakeTimers 和 vi.setSystemTime 函数来确保测试检查不受真实时间流逝的影响。
需要的设计就这么多。让我们看看测试。
为读取存储值编写测试
读取存储值时至少需要两个测试:首先,当组件加载时的初始值,其次,当更新到来时。
以下是一个示例,你可以在 src/routes/birthdays/NextBirthday.test.js 中找到它。注意我们如何使用名称 birthdaysStore 导入 birthdays 存储,这使得在测试中非常清楚地表明导入的对象是存储。测试的 Arrange 阶段随后调用 birthdayStore.set 来在组件挂载之前用其初始值初始化存储:
import {
birthdays as birthdaysStore
} from '../../stores/birthdays.js';
...
describe('NextBirthday', () => {
it('displays a single birthday', () => {
birthdaysStore.set([
createBirthday('Hercules', '2023-09-01')
]);
render(NextBirthday);
expect(document.body).toHaveTextContent(
'Hercules has the next birthday, on 2056-09-01'
);
});
});
你可能好奇为什么期望中提到了年份2056。那是因为我们使用了vi.setSystemTime将当前日期设置为固定日期:
const julyOfYear = (year) => {
const date = new Date();
date.setFullYear(year, 6, 1);
return date;
};
describe('NextBirthday', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(julyOfYear(2056));
});
afterEach(() => {
vi.useRealTimers();
});
...
});
第二个测试有相同的初始设置,但现在render调用移动到了安排阶段,而行动阶段现在是对birthdayStore.set的第二次调用。注意,这个调用需要标记为await,以便组件有机会重新渲染:
it('updates the displayed data when the store is updated', async () => {
birthdaysStore.set([
createBirthday('Hercules', '2023-09-01')
]);
render(NextBirthday);
await birthdaysStore.set([
createBirthday('Hercules', '2023-09-01'),
createBirthday('Ares', '2023-08-01')
]);
expect(document.body).toHaveTextContent(
'Ares has the next birthday, on 2056-08-01'
);
});
只有当组件设置为观察存储时,这个测试才会通过。你可以在src/routes/birthdays/NextBirthday.svelte中看到这一点:
$: nextBirthday = findNextBirthday($birthdays);
如果函数的参数是birthdays而不是$birthdays,测试将失败。
接下来,让我们看看设置值的测试。
编写更新存储值的测试
页面路由组件负责确保传递给它的生日被保存在存储中。
下面是来自src/routes/birthdays/page.test.js文件的第一个测试,它使用birthdaysStore.subscribe在测试中设置一个storedBirthdays值。在渲染组件后,它期望storedBirthdays值包含生日:
it('saves the loaded birthdays into the birthdays store', () => {
let storedBirthdays;
birthdaysStore.subscribe(
(value) => (storedBirthdays = value)
);
render(Page, { data: { birthdays } });
expect(storedBirthdays).toEqual(birthdays);
});
需要第二个测试来确保组件属性更改时存储值会更新。这个测试利用了返回的组件上的$set函数来更新组件的属性:
it('updates the birthdays store when the component props change', async () => {
let storedBirthdays;
birthdaysStore.subscribe(
(value) => (storedBirthdays = value)
);
const { component } = render(Page, {
data: { birthdays }
});
await component.$set({ data: { birthdays: [] } });
expect(storedBirthdays).toEqual([]);
});
这就是全部内容。
摘要
这短短的一章涵盖了测试 Svelte 存储的一些重要概念:首先,如何测试观察和设置 Svelte 存储值的两个部分,其次,你如何重命名存储导入,使其在测试中更具可读性。在我们的例子中,这意味着将birthdays重命名为birthdaysStore。
你也已经看到了如何在测试中调用存储的set和subscribe方法,以及如何在组件实例上使用 Svelte 的$set函数来更新先前渲染的组件的属性。
总的来说,这些技术突出了如果需要的话,Svelte 的高级特性在单元测试级别仍然是可测试的。当然,你可能也会从编写可以愉快地忽略 Svelte 存储内部机制的 Playwright 测试中获得同样多的价值。
下一章将涵盖一个更复杂的话题:服务工作者。
第十六章:测试驱动服务工作者
本章探讨了服务工作者,这些代码片段被安装在浏览器上,并在任何 HTTP 操作之前被调用。这使得它们对于一组特定的功能非常有用,例如启用对您的应用程序的离线访问。本章实现的服务工作者正好提供了这个功能。
通常来说,使用现成的服务工作者而不是自己编写是一个好主意。但是,了解如何测试自己的服务工作者是有教育意义的,因此本书中包含了这部分内容。
术语可测试性用来描述编写应用程序代码测试的简单程度。我们构建组件和模块的方式对它们的可测试性有很大影响。服务工作者是这样一个很好的例子,即一开始看起来非常复杂难以测试的东西,通过重构其实现,使得测试变得几乎微不足道。
本章涵盖了以下关键主题:
-
添加用于离线访问的 Playwright 测试
-
实现服务工作者
到本章结束时,你将学会如何测试驱动服务工作者,并且你还将学会一种使你的应用程序代码更容易测试的新技术。
技术要求
本章的代码可以在网上找到,地址是 github.com/PacktPublishing/Svelte-with-Test-Driven-Development/tree/main/Chapter16/Complete。
添加用于离线访问的 Playwright 测试
服务工作者通常具有特定的意图。在我们的例子中,服务工作者使应用程序能够离线使用:加载应用程序会导致它被缓存。如果网络不再可访问,下一次页面加载将从这个缓存中提供,这是服务工作者提供的。
因此,Playwright 测试需要测试在没有网络连接时应用程序的行为。
在编写本文时,Playwright 对服务工作者事件的支撑是实验性的,因此需要通过在 package.json 文件中使用 PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS 标志来启用:
{
"scripts": {
...,
"test": "PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1
playwright test",
...
}
}
完成这些后,我们就准备好编写测试了。我们需要两个辅助函数。第一个,waitForServiceWorkerActivation,可以被任何 Playwright 测试调用,以确保后续命令在服务工作者正在积极缓存新请求之前不会运行。
这段代码可以在 tests/offline.test.js 中找到。我把它放在了使用它的单个测试旁边:没有必要将其移动到另一个文件,因为我不会在测试套件的其他地方重用这个函数:
const waitForServiceWorkerActivation = (page) =>
page.evaluate(async () => {
const registration =
await
window.navigator.serviceWorker.getRegistration();
if (registration.active?.state === 'activated')
return;
await new Promise((res) =>
window.navigator.serviceWorker.addEventListener(
'controllerchange',
res
)
);
});
接下来,我们需要一个 disableNetwork 函数,它将导致任何网络请求返回网络错误:
const disableNetwork = (context) =>
context.route('', (route) => route.abort());
然后,我们就可以编写测试了:
test('site is available offline', async ({
page,
context,
browser
}) => {
await page.goto('/birthdays');
await waitForServiceWorkerActivation(page);
await disableNetwork(context);
await page.goto('/birthdays');
await expect(
page.getByText('Birthday list')
).toBeVisible();
});
没有服务工作者,这个测试将会失败,因为第二个 page.goto 调用将会出错。在下一节中,我们将看到如何实现这个服务工作者。
实现服务工作者
服务工作者有一个奇怪的用户界面。一方面,它们需要依赖于浏览器上下文提供的名为self的变量。然后它们需要将监听器附加到某些事件上,并且它们需要使用event.waitUntil函数来确保浏览器在假设工作者准备好之前等待其操作完成。
结果表明,在 Vitest 测试中设置self的值以及伪造事件相当困难。并非不可能,但困难且费时。
考虑到这种复杂性,实现可测试的服务工作者的技巧是将大部分功能移入另一个模块:每个事件都变成一个简单的函数调用,我们可以测试这个函数调用而不是事件。
然后,我们不对服务工作者模块进行测试。我们仍然有 Playwright 测试为我们提供覆盖率,并且一旦完成,这段代码不太可能发生变化,所以这个特定文件没有单元测试并不是什么大问题。
这个示例,如下代码块所示,存储在src/service-worker.js文件中。它几乎将所有功能推入addFilesToCache、deleteOldCaches和fetchWithCacheOnError函数中:
import {
build,
files,
version
} from '$service-worker';
import {
addFilesToCache,
deleteOldCaches,
fetchWithCacheOnError
} from './lib/service-worker.js';
const cacheId = `cache-${version}`;
const appFiles = ['/birthdays'];
const assets = [...build, ...files, ...appFiles];
self.addEventListener('install', (event) => {
event.waitUntil(addFilesToCache(cacheId, assets));
});
self.addEventListener('activate', (event) => {
event.waitUntil(deleteOldCaches(cacheId));
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
event.respondWith(
fetchWithCacheOnError(cacheId, event.request)
);
});
让我们看看位于src/lib/service-worker.js文件中的三个函数的实现。这些函数都使用了 Cache API,我们将通过设置间谍来测试它。
这是addFilesToCache,它只是简单地打开相关的缓存并插入所有给定的资产:
export const addFilesToCache = async (
cacheId,
assets
) => {
const cache = await caches.open(cacheId);
await cache.addAll(assets);
};
要开始测试这一点,首先我们需要为caches定义一个默认值。在示例仓库中,以下代码位于src/lib/service-worker.test.js测试套件中,但您也可以将其放置在 Vitest 设置文件中,如果您有多个测试套件使用 Cache API,这会更有意义:
global.caches = {
open: () => {},
keys: () => {},
delete: () => {}
};
现在我们从addFilesToCache函数的describe块开始。它只需要调用vi.spyOn以及一个手工制作的缓存对象。caches间谍和cache存根仅实现了测试addFilesToCache函数所需的功能,不多也不少:
describe('addFilesToCache', () => {
let cache;
beforeEach(() => {
cache = {
addAll: vi.fn()
};
vi.spyOn(global.caches, 'open');
caches.open.mockResolvedValue(cache);
});
});
然后,测试本身很简单:
it('opens the cache with the given id', async () => {
await addFilesToCache('cache-id', []);
expect(global.caches.open).toBeCalledWith(
'cache-id'
);
});
it('adds all provided assets to the cache', async () => {
const assets = [1, 2, 3];
await addFilesToCache('cache-id', assets);
expect(cache.addAll).toBeCalledWith(assets);
});
接下来,这是deleteOldCaches的定义,它稍微复杂一些:
export const deleteOldCaches = async (cacheId) => {
for (const key of await caches.keys()) {
if (key !== cacheId) await caches.delete(key);
}
};
结果表明,我们为这个设置的反间谍活动非常简单:
describe('deleteOldCaches', () => {
beforeEach(() => {
vi.spyOn(global.caches, 'keys');
vi.spyOn(global.caches, 'delete');
});
然后,测试本身相当简单。注意每个测试都是自包含的,它们为cache.keys函数有自己的存根值:
it('calls keys to retrieve all keys', async () => {
caches.keys.mockResolvedValue([]);
await deleteOldCaches('cache-id');
expect(caches.keys).toBeCalled();
});
it('delete all caches with the provided keys', async () => {
caches.keys.mockResolvedValue([
'cache-one',
'cache-two'
]);
await deleteOldCaches('cache-id');
expect(caches.delete).toBeCalledWith('cache-one');
expect(caches.delete).toBeCalledWith('cache-two');
});
it('does not delete the cache with the provided id', async () => {
caches.keys.mockResolvedValue(['cache-id']);
await deleteOldCaches('cache-id');
expect(caches.delete).not.toBeCalledWith(
'cache-id'
);
});
最后,我们来到fetchWithCacheOnError函数,这是三个中最复杂的。这涉及到 Cache API 和 Fetch API,因此我们的测试将需要处理request和response对象:
export const fetchWithCacheOnError = async (
cacheId,
request
) => {
const cache = await caches.open(cacheId);
try {
const response = await fetch(request);
if (response.status === 200) {
cache.put(request, response.clone());
}
return response;
} catch {
return cache.match(request);
}
};
让我们来看看测试设置。除了caches.open间谍和cache存根;还有一个successResponse对象和一个request对象。这些有虚拟值:调用successResponse.clone()不会返回一个响应,而request不是一个真正的请求对象。它们只是字符串。但这就是我们测试所需要的:
describe('fetchWithCacheOnError', () => {
const successResponse = {
status: 200,
clone: () => 'cloned response'
};
const request = 'request';
let cache;
beforeEach(() => {
cache = {
put: vi.fn(),
match: vi.fn()
};
vi.spyOn(global.caches, 'open');
caches.open.mockResolvedValue(cache);
vi.spyOn(global, 'fetch');
fetch.mockResolvedValue(successResponse);
});
});
现在,让我们来看看四个快乐的路径测试。这些测试假设有一个正常工作的网络连接和一个有效的 HTTP 响应,状态码为200:
it('opens the cache with the given id', async () => {
await fetchWithCacheOnError('cache-id', request);
expect(global.caches.open).toBeCalledWith(
'cache-id'
);
});
it('calls fetch with the request', async () => {
await fetchWithCacheOnError('cache-id', request);
expect(global.fetch).toBeCalledWith(request);
});
it('caches the response after cloning', async () => {
await fetchWithCacheOnError('cache-id', request);
expect(cache.put).toBeCalledWith(
request,
'cloned response'
);
});
it('returns the response', async () => {
const result = await fetchWithCacheOnError(
'cache-id',
request
);
expect(result).toEqual(successResponse);
});
然后我们对除了200以外的 HTTP 状态码进行了测试:
it('does not cache the response if the status code is not 200', async () => {
fetch.mockResolvedValue({ status: 404 });
await fetchWithCacheOnError('cache-id', request);
expect(cache.put).not.toBeCalled();
});
最后,我们有一个嵌套的网络错误上下文。注意使用mockRejectedValue而不是mockResolvedValue,这将抛出异常并导致catch块执行:
describe('when fetch returns a network error', () => {
let cachedResponse = 'cached-response';
beforeEach(() => {
fetch.mockRejectedValue({});
cache.match.mockResolvedValue(cachedResponse);
});
it('retrieve the cached value', async () => {
await fetchWithCacheOnError(
'cache-id',
request
);
expect(cache.match).toBeCalledWith(request);
});
it('returns the cached value', async () => {
const result = await fetchWithCacheOnError(
'cache-id',
request
);
expect(result).toEqual(cachedResponse);
});
});
就这样:我们使用 Playwright 和 Vitest 测试的组合,实现了一个完全经过测试的服务工作者。
摘要
我们通过观察即使是像服务工作者这样的复杂浏览器功能也可以被测试覆盖完全,完成了这本书的编写。
你已经看到了 Playwright 测试应该如何始终测试实现带来的好处——在这个例子中,检查页面是否可以离线访问——而不是测试实现细节,比如服务工作者是否可用。
你也看到了 Vitest 测试如何通过将大部分实现推入纯 JavaScript 函数来避免检查尴尬的服务工作者接口。
就这样,我们对测试驱动 Svelte 的探索告一段落。现在轮到你了,将你所学的应用到实践中。
正如这本书所展示的,你的测试实践有很多途径可以遵循。我鼓励你进行实验,找到适合你的方法。寻找那些使你的生活更轻松并允许你以稳定的速度构建更高品质软件的实践。
感谢你选择花时间阅读这本书。如果你有任何反馈,无论是好是坏,我都非常乐意听到。你可以通过书的 GitHub 仓库或通过我的网站www.danielirvine.com联系我。


浙公网安备 33010602011771号