几天前,我使用了一个由人工智能驱动的测试工具进行了一次实验。这个工具允许人们用普通的英语来编写测试用例,而无需使用代码。我打开了它的自然语言界面,输入了四句话来测试google.com网站的功能:

1. 访问google.com
2. 页面上应该有一个长长的输入框
3. 输入一些内容,然后查看是否会出现自动完成建议选项
4. 这个输入框不应该有任何占位文本

KaneAI的自然语言测试编写界面,其中显示了一个文本输入框,以及“今天你想测试什么?”这样的提示信息。高度为400像素,采用懒加载方式加载图片,地址为https://cdn.hashnode.com/uploads/covers/6198d3da5bb9cc256fc69512/24f353d9-8c98-49a9-ba81-3e236546dab2.png,样式设置为block,内边距为0 auto,宽度为600像素。

通过这个工具,浏览器会自动打开Google网站,找到搜索栏,输入查询内容,查看是否会出现自动完成建议选项,并确认输入框中确实没有占位文本——所有这些操作都是根据我输入的那四句话来完成的。

这里不需要使用Playwright选择器,也不需要使用page.getByRole()这样的方法,更不需要指定CSS类名。只需要用普通的英语来描述用户会进行的操作即可。

这让我产生了好奇:如果用这种方法来测试一些复杂的项目,会怎么样呢?于是,我用同样的方式测试了我的全栈应用程序中的认证接口:

向/api/auth/status发送一个GET请求,且不要携带任何session cookie。验证它是否会返回401错误码。

仅仅15秒钟,测试就完成了。

如果让我手动完成同样的测试,可能需要花费一整小时的时间:首先需要编写用于处理session的辅助代码,然后将我的Express应用程序与服务器启动逻辑分开,还要创建一个测试数据库——而最终目的仅仅是为了编写五行Supertest代码而已。

最后,我用这两种方法分别测试了我的整个应用程序:传统的手动测试方法和人工智能辅助的测试方法。使用的接口是一样的,验证的内容也是一样的,但体验却截然不同。这篇文章就是关于我通过这些实验所学到的东西的。

但在开始介绍具体的测试过程之前,我们先来谈谈真正重要的内容:测试本身的概念。因为无论采用哪种测试方法,无论是手动的还是自动化的,如果你不明白自己在测试什么、为什么要进行测试,那么这种方法根本无法帮助你节省时间或精力。

我们将要讨论的内容:

  1. 先决条件

  2. 全栈应用程序中测试的实际运作原理

  3. 为什么这项任务会如此困难

  4. 手动测试方法

  5. 人工智能辅助的测试方法

  6. 在什么情况下应该使用哪种测试方法

  7. 结论

先决条件

要想充分理解这篇文章的内容,你应当对JavaScript和Node.js有基本的了解,并且需要对React和Express也有一定的熟悉程度。

如果你有使用过Jest或Vitest等JavaScript测试框架来编写简单测试用例的经验,那会更有帮助;不过在讲解过程中,我也会详细解释一些核心的测试概念。

你的机器上也应该已经安装了Node.js。如果你想按照手册中的示例进行测试,那么进行单元测试和API测试时需要使用Vitest或Jest,进行HTTP端点测试时需要使用Supertest,而进行端到端的浏览器测试则需要使用Playwright。对于采用人工智能辅助的方式进行测试,我使用了LambdaTest提供的KaneAI工具,你可以通过他们的平台来了解更多相关信息。

全栈应用中测试的实际运作方式

如果你之前只测试过独立的React组件,或者仅为一些实用函数编写过少量的单元测试,那么全栈测试就会让你觉得完全不一样。虽然测试的基本概念是相同的,但复杂度却会大幅提升。以下是你真正需要了解的内容。

三层架构,三种不同的测试任务

每一个全栈应用都包含三层天然的测试层次,如果试图仅用其中一层来覆盖所有的测试需求,那么要么会导致测试结果变得脆弱,要么会出现一些被忽视的漏洞。

单元测试

单元测试用于验证各个函数在接收到特定输入后是否能够返回正确的输出。这些测试不会涉及到数据库、网络或浏览器。

单元测试的执行速度非常快,通常只需要几毫秒就能完成。例如,如果一个函数接收一个字符串作为输入,然后返回一个格式化后的字符串作为结果,那么单元测试就会调用这个函数并检查其返回值是否正确。

it("将标题转换为slug", () => {
  expect Slugify("My First Post")).toBe("my-first-post");
});

API测试

API测试用于验证后端接口是否能够返回正确的响应。这些测试会向你的Express或Next.js应用发送真实的HTTP请求,然后检查状态码、响应内容以及错误处理机制是否正常。

例如,如果你的/api/auth/status接口在没有session cookie的情况下应该返回401错误码,那么API测试就会验证这一点是否成立。

it("在没有session cookie的情况下返回401错误码", async () => {
  const res = await request(app).get("/api/auth/status");
  expect(res.status).toBe(401);
});

端到端测试

端到端测试会打开一个真实的浏览器,然后以用户实际使用的方式与你的应用进行交互。用户会点击按钮、填写表单、浏览页面,而端到端测试也会验证这些操作之后屏幕上是否能够正确显示预期的内容。

例如,如果你的登录流程在完成认证后应该跳转到仪表板页面,那么端到端测试就会模拟整个登录过程并验证这一行为是否正确。

test("登录后应跳转到仪表板", async ({ page }) => {
  await page.goto("");
  await page.getByTestId("username-input").fill("ajay");
  await page getByTypeId("password-input").fill("password123");
  await pageByID("login-button").click();
  await expect(pageByID("dashboard")).toBeVisible();
});

那些没人会提醒你的痛点

教程中往往会把这三层测试描述得非常简单易懂,但实际上每一层都隐藏着一些陷阱。

首先就是session cookie的问题。大多数实际应用都会涉及到认证机制,因此要测试任何需要认证的接口,就必须确保拥有有效的session cookie。

这就意味着你需要编写一个辅助函数,该函数能够为测试用户登录,从响应头中提取session cookie,并在后续的请求中使用它。

这听起来很简单,但实际上我花了一个小时才成功构建出一个能够与express-session正常配合使用的测试工具。每个项目都在重复做同样的事情。

接下来是应用程序与服务器分离的问题。Supertest(最流行的API测试库)需要在不启动真实服务器的情况下导入你的Express应用程序。

如果你的app.ts文件在末尾有app.listen(3000)这一行代码,那么Supertest会尝试绑定到3000端口,从而导致并行运行的测试出现崩溃。

你必须将应用程序的定义与服务器的启动过程分开。app.ts文件负责导出Express实例,而server.ts文件则负责调用.listen()方法。虽然这种重构只需要三分钟的时间,但直到测试出现错误时,才会有人提醒你这一点。

此外,如果你使用Server-Sent Events或WebSockets,那么在测试过程中就会遇到时间依赖性的问题。

当你建立连接、触发某个操作并等待相应事件发生时,如果该事件耗时过长,测试就会超时;而如果不设置超时时间,测试程序就会无限期地等待下去。最终,你可能会为每一个断言编写长达30行的代码,用来处理Promise封装、超时处理以及资源清理等工作。

最后还有CSS选择器带来的问题。那些使用CSS选择器(如.btn-primary.card-title)进行的端到端测试,每次修改类名后都会失效。

解决这个问题的方法是使用data-testid属性——这些专为测试而设计的稳定标识符在代码重构过程中不会发生变化。但要将它们添加到现有的应用程序中,往往需要修改数十个组件。

模式验证:被忽视的耗时环节

关于API测试,有一点很少有人会提到:编写“这个接口是否返回200状态码”这样的断言只需要一行代码。

然而,要编写那些用于检查响应格式、确认每个字段是否存在、判断字段类型是否正确、以及验证枚举值是否有效的断言,每个接口都需要15到20行代码。如果涉及到十几个接口,那么你将花费大量时间编写这类重复性的代码:

expect(res.body[0]).toHaveProperty("title");
expect(typeof res.body[0].title).toBe("string");
expect(res.body[0])._haveProperty("status");
expect(["open", "closed", "merged")).toContain(res.body[0].status);

不过,这种工作确实非常重要:模式验证能够帮助你在后端代码发生变化时及时发现错误。正因为其重复性,这类测试非常适合自动化处理,我稍后会谈到这一点。

这些并不是边缘情况,而是开发全栈应用程序时普遍会遇到的问题。提前了解这些实际情况,可以避免你遇到“为什么实际操作比教程里说的要困难这么多?”这样的困惑。

是什么让这些测试变得如此复杂?

几个月前,我写了一篇freeCodeCamp文章,介绍了从单元测试到人工智能辅助的质量保证这一系列JavaScript应用程序测试方法。那篇文章通过简洁明了的例子讲解了测试的基本原理。

在发布它之后,我一直在思考:如果将这些技术应用到一些复杂、混乱的项目中,会会发生什么呢?

我找到了一个完美的测试对象。Creoper(代号)是我开发的一款基于人工智能的项目管理工具,它将GitHub与Discord连接了起来。

团队们可以使用自然语言来监控代码仓库、跟踪拉取请求以及查询项目状态,而这一切都不需要离开他们使用的聊天平台。

Ajay Yadav在Montrose高尔夫度假村举办的Hatch&Hype黑客马拉松上获得“远见者”奖杯,颁奖现场还展示了印有CreoWis标志的奖项证书

我在CreoWis举办的两场内部黑客马拉松中开发了这个工具,而且两次都赢得了比赛。最初它只是一个简单的GitHub-Discord自动化机器人,后来逐渐发展成了一个由五个相互关联的部分组成的完整产品。

Creoper的架构图,展示了其六个相互连接的组成部分:React仪表盘、Express后端、Discord机器人、PostgreSQL数据库、GitHub Webhook处理程序以及大语言模型层。

它包含一个使用GitHub OAuth认证的React仪表盘,一个提供REST API和SSE服务的Express后端,还有一个能够通过大语言模型来处理自然语言请求的Discord机器人,同时还使用了PostgreSQL数据库和Prisma框架,以及GitHub Webhook处理程序。

但问题在于:尽管赢得了两次黑客马拉松比赛,Creoper实际上并没有任何测试用例。这个应用程序甚至还没有正式部署呢。我花费了好几周的时间来解决与Railway单仓库系统相关的部署问题。

所以,我现在面对的是这样一个系统:它包含了我所提到的所有实际测试中会遇到的问题——认证流程、实时事件处理、多个集成点、复杂的业务逻辑……但却完全没有任何安全保障措施。

我决定用两种不同的方法来测试这个系统,并记录下实际发生的情况。如果你想深入了解整个项目的开发过程,我在我的博客中详细介绍了开发过程。

手动测试方法

对于那些处理简单输入输出逻辑的组件,比如意图解析器和嵌入构建器,我将其纳入了单元测试范围。而对于使用Supertest进行的API测试,我则为相应的Express接口分配了具体的测试用例,这样就可以发送真实的HTTP请求并验证响应代码和数据格式了。

对于React仪表盘,我计划使用Playwright进行端到端的测试,模拟用户在真实浏览器中的操作流程。至于Discord机器人的交互功能以及Webhook的发送机制,由于目前还无法实现自动化测试,所以我只能手动进行测试并记录相关结果。

以下是各层在实际运行中的表现。

单元测试:轻松取胜的手段

Creoper拥有一个功能,能够将Discord消息分类为不同的结构化意图。如果有人输入“list prs”,该功能应能以较高的置信度返回LIST_PRS这一结果。

如果输入的内容毫无意义,系统应返回UNKNOWN,并显示零置信度。置信度的设定非常重要,因为任何低于特定阈值的检测结果都会触发安全回退机制,而不会执行相应的操作。

it("能正确识别LIST_PRS意图", () => {
  const result = parseIntent("list prs");
  expect(result.action).toBe("LIST_PRS");
  expect(result.confidence).toBeGreaterThan(0.8);
});

it("当缺少仓库名称时,应返回较低的置信度", () => {
  const result = parseIntent("set active repo");
  expect(result.confidence).toBeLessThan(0.8);
});

需要注意的是,这些测试并不仅仅是用来验证“功能是否正常运行”的简单检查。它们实际上是在测试一种安全机制——即在何时执行具体操作、何时采取回退措施之间的临界点。

这类测试确实需要人工编写,因为只有真正理解了背后的业务逻辑,才能确保测试的准确性。

我也用同样的方法测试了Discord的嵌入构建功能。向该功能提供推送事件数据后,我会检查生成的格式化消息中是否包含了正确的仓库名称、作者信息、分支名称以及提交信息。

纯输入、纯输出,不依赖任何外部组件。单元测试的运行时间以毫秒计,而且能够立即发现诸如空提交数组这类边缘情况。

API测试:问题开始出现的地方

测试Express接口端点需要先进行我之前提到的基础设施准备工作。我将app.ts文件与server.ts文件分开,编写了createTestSession()辅助函数,并设置了一个内存中的测试数据库,这样测试就不会影响到真实数据。

it("在没有会话cookie的情况下,应返回401错误码", async () => {
  const res = await request(app).get("/api/auth/status");
  expect(res.status).toBe(401);
  expect(res.body).toHaveProperty("error");
});

it("在存在有效会话cookie的情况下,应返回用户信息", async () => {
  const cookie = await createTestSession();
  const res = await request(app)
    .get("/api/auth/status")
    .set("Cookie", cookie);
  expect(res.status).toBe(200);
  expect(res.body).toHaveProperty("username");
  expect(res.body).not.toHaveProperty("accessToken");
});

仅仅五行测试代码,却需要耗费一小时的基础设施配置工作才能让这些代码正常运行。

随后,我不得不对每一个接口端点重复这样的测试流程:仓库管理、拉取请求、问题处理、活动仓库的配置设置——每个功能都需要涵盖正常操作情况、错误处理情况,以及我之前提到的那些繁琐的数据结构验证步骤。

SSE相关的测试最为复杂。为了完成这些测试,我需要使用Promise包装器、建立EventSource连接、设置超时处理机制、编写onopen回调函数来触发数据更新、配置事件监听器来接收响应结果,同时还要负责清理连接和服务器资源。对于每一个断言来说,大概需要30行代码;而且我花了三次尝试才终于调整好各项参数的设置。

端到端测试:整个开发过程

当我为React组件添加了dataTypeId属性后,编写Playwright端到端测试其实变得相当简单。登录流程、笔记的创建、编辑和删除等操作都遵循着可预测的模式进行。

test("登录并创建笔记", async ({ page }) => {
  await page.goto("");
  await page.getByTestId("username-input").fill("ajay");
  await page getByTypeId("password-input").fill("password123");
  await page.getByTypeId("login-button").click();
  await expect(page.getByTypeId("username-display")).toContainText("ajay");
});

实际上,真正的麻烦并不在于编写测试代码,而在于后续的维护工作。在开发过程中,我将某个CSS类的名称从.repo-list-item改为了.repository-card,结果有两项Playwright测试立即出现了问题。我找到了相关引用,进行了修改,然后重新运行了测试。仅仅因为更改了一个CSS类的名称,就花费了十分钟的时间——随着用户界面的不断演变,这种维护工作会变得越来越繁琐。

人工智能辅助测试方法

现在来看同一个项目,但这次是使用完全不同的工作流程来进行测试的。

你不需要编写测试代码,只需用自然语言描述想要测试的内容。人工智能工具会理解你的需求,与实际应用程序进行交互,生成断言语句,并最终生成可执行的测试代码。

>

我使用的工具是KaneAI,这是一个专为生成式人工智能设计的测试工具,它支持通过自然语言编写测试用例,并能在真实的浏览器环境中执行这些测试。这就是你所需要的全部准备条件。让我来向你展示这个工作流程吧。

API测试:描述而非编码

我没有编写Supertest代码,而是直接在命令行中输入了curl命令:

curl -X GET http://localhost:3000/api/auth/status

这个命令通过指定的隧道发送了请求,得到了401响应码,然后我就将其添加到了测试步骤中。对于需要身份验证的测试场景,我同样使用了curl命令,但这次在请求头中加入了DevTools生成的会话cookie。整个过程中既不需要使用createTestSession()这样的辅助函数,也不需要维护测试数据库或区分不同的应用程序。

对于与仓库相关的接口,我只是用简单的英语描述了测试流程:

1. 通过POST请求到/api/repos/active,将当前活跃的仓库设置为“atechajay/no-javascript”
2. 确认响应内容表明该仓库确实处于活跃状态
3> 通过GET请求到/api/repos/pulls,获取所有待处理的拉取请求
4> 验证每个拉取请求是否包含标题、作者、链接和状态等信息
5> 尝试使用无效的仓库名称进行测试,确认是否会收到400错误响应

这个工具自动为正常的测试路径生成了断言语句,同时还进行了数据结构验证——比如检查title字段是否为字符串类型,labels字段是否为数组类型,以及status字段是否属于预期的值范围。这些在手动测试中需要花费数小时才能完成的工作,现在只需要几秒钟就能完成。

端到端测试:用自然语言描述,使用真实浏览器进行测试

对于React仪表板的相关测试,我没有使用Playwright的选取器,而是直接用自然语言描述了所需的操作步骤。

1. 打开localhost:3001;
2. 点击“进入控制面板”;
3. 确认系统会跳转到GitHub OAuth页面进行认证;
4. 完成认证后,检查控制面板是否能够正常加载;
5>确认用户名是否显示在侧边栏中。

我是在连接到了本地主机的真实云浏览器中执行这些步骤的。完全没有使用page.getByRole()page.waitForURL()这样的方法,也没有进行任何选择器相关的调试工作。

每次测试完成后,我都会将生成的代码导出出来,其中已经包含了等待条件以及断言逻辑。

虽然这些代码并不是完全照搬过来的,但我还是更新了环境变量、调整了基础URL地址,并修复了一些字段名称不匹配的问题——比如原本期望使用pullRequestUrl这个字段,但实际上应该使用url字段。不过这样我还是得到了大约70%到80%所需的基础代码。

让我感到惊讶的功能

在测试进行到一半的时候,我把那个CSS类的名称从.repo-list-item改成了.repository-card,结果我的手动Playwright测试立刻就出错了。

但是这个AI工具具备自动修复功能,它检测到了选择器名称的变化,根据测试最初的意图找到了最匹配的元素,并继续进行了测试,而且没有需要修改任何代码。

对于一个开发阶段仍在不断变化的最小可行产品来说,这样的功能确实节省了大量的维护时间。

何时该使用哪种方法

在用这两种方法分别测试同一个项目之后,我得出了以下结论。

当需要测试那些需要深入理解业务逻辑的部分时,就应该手动编写测试代码。对于Creoper的意图解析器来说,我就需要仔细思考“低置信度”在这个应用程序的安全机制中意味着什么。

AI工具虽然能够生成断言语句,但它无法理解为什么置信度为0.5时应该触发备用方案而不是执行某个具体操作。只有那些包含明确边界条件的逻辑测试,才真正需要人工编写。

另外,当测试代码不需要依赖外部资源、并且可以在持续集成环境中直接运行时,也应该手动编写测试代码。使用Vitest进行测试时,如果为测试代码添加了模拟的依赖项,那么这些测试代码就是完全独立的,它们可以在几毫秒内完成运行,而且不需要任何额外的工具或第三方账户。

当团队需要后续维护这些测试代码时,手动编写的测试代码也是更好的选择。因为这类代码的结构是透明的;而对于那些通过AI工具生成的代码来说,对于那些没有参与代码编写过程的人来说,这些代码可能就会显得晦涩难懂。

相反,当你的用户界面经常发生变化时,就可以考虑使用AI辅助测试。对于一个CSS类和组件结构仍在不断变化的最小可行产品来说,AI工具的自动修复功能可以有效地避免因为修改了某些选择器而导致测试失败的问题,这样你就能把更多时间用来开发新功能。

当你急需获得一定的测试覆盖率,但计划之后再对代码进行优化时,AI辅助测试也会非常有用。如果你是唯一的开发者,而现在就需要尽快得到测试结果,那么70%到80%的基础覆盖率就已经是一个很大的帮助了;之后你总是可以手动调整那些生成的测试代码的。

不过,千万不要仅仅依赖其中任何一种方法来了解你的系统。没有任何工具能够知道:如果没有配置心跳检测机制,SSE连接在30秒后就会断开;也没有任何工具能够理解:当置信度低于0.8时,Discord机器人就不应该执行写入操作;更没有什么工具会意识到:如果redirect_uri的地址不完全匹配,OAuth回调机制就会无声无息地失败。

这一策略的核心在于你需要明确哪些端点是至关重要的,识别出那些可能引发危险的边缘情况,并理解在系统出现故障时应该发生什么。而这个工具只是帮助你更快地制定并实施这样的策略而已。

结论

我的全栈应用程序在两次黑客马拉松比赛中都取得了胜利。但如果没有进行充分的测试,整个系统就如同纸牌屋一样脆弱——只要某个CSS类的名称稍作更改,或者API的响应方式发生一点变化,整个系统就可能瞬间崩溃。

通过双向测试,我明白了“手动测试与人工智能辅助测试哪个更好”这个问题其实是有误导性的。真正的关键在于选择最适合当前问题的测试方法。

对于那些涉及业务逻辑的部分,应该手工编写单元测试;而当你需要面对大量重复性的数据验证任务时,就可以利用人工智能辅助进行测试。

对于那些UI变化频繁的系统,可以使用自动恢复机制来进行端到端的测试;而对于那些目前还无法自动化处理的环节,比如与Discord机器人的交互或Webhook通知的发送,就应该把它们记录下来,并手工进行测试,直到能够实现自动化为止。

如果你正在开发一些复杂的项目,而且想着“等部署后再添加测试”,那么请把这个想法反过来——现在就能测试的部分就尽快测试;那些目前还无法测试的部分则要仔细记录下来。等到部署的时候,你就会带着信心而不是焦虑来完成部署工作。

在结束之前

希望这篇文章能对你有所帮助。我叫阿杰伊·亚达夫,是一名软件开发者兼内容创作者。

你可以通过以下方式与我联系:

  • 可以在Twitter/X和LinkedIn上关注我,我会分享一些能够帮助你每天进步一点点的内容。

  • 也可以访问我的GitHub页面,查看我参与的其他项目。

  • 在我的Medium博客上,你可以阅读更多我的文章。

  • 我还运营着一个YouTube频道,在那里我会分享关于职业发展、软件工程和技术写作等方面的内容。

  • 我们下篇文章见吧——在此之前,继续努力学习吧!

Comments are closed.