Testing Vue components in the browser

Julia Evans

在浏览器中测试 Vue 组件

你好!我在这个博客上的一个长期项目是探索如何在不使用 Node 或任何其他服务端 JS 运行时的情况下编写前端 Javascript

我在前端 JS 项目中经常遇到的一个问题是不知道该如何为它们编写测试。过去我尝试过使用 Playwright,但每次都要启动新的浏览器进程,感觉又慢又笨重,而且还需要用一些 Node 代码来编排测试。

结果就是我干脆不给前端代码写测试,这感觉并不好。平时我也不怎么频繁地更新这些项目,所以这个问题倒也不常出现,但如果能更有底气地做改动就好了!所以,长期以来,找到一种我喜欢的前端测试方式一直都在我的心愿清单上。

想法:直接在浏览器标签页里运行测试

之前,Alex Chan(亚历克斯·陈)写过一篇很棒的文章叫 Testing JavaScript without a (third-party) framework(《不使用第三方框架测试 JavaScript》),回应了我这个系列中的上一篇帖子,文章里介绍了如何编写一个在浏览器页面中运行的微型单元测试框架。

当时我非常喜欢这篇文章,但它只讲了单元测试,而我想为我的 Vue 组件编写 end-to-end integration tests(端到端集成测试),却不知道该怎么做。

所以前几天和 Marco(马可)聊天时,他说了句类似“你知道吗,你完全可以直接在浏览器里为 Vue 组件跑测试”,我想“嘿,我应该再试一次!!!”

这些事都是我昨天刚做完的,所以肯定还有很多可以改进的地方,但在忘记之前,我想先把过程中注意到的几点记录下来。

这件事对我来说有点棘手,因为 Vue 官网通常默认你在构建过程中会以某种方式使用 Node(到处都是“第一步:npm install 某个东西”),而我不想用 Node/Deno 等等。但最后发现其实也没那么复杂。

我要讲的测试项目是这个我在 2023 年写的 zine 反馈网站

测试框架:QUnit

我用了 QUnit。它用起来很好,但我没什么特别值得说的,就不多讲了。我觉得亚历克斯·陈那种“自己写个测试框架”的方法应该也行得通。我是按照这些说明来做的。

我很喜欢 QUnit 有一个“重新运行测试”按钮,可以只重跑单个测试。因为我的测试里有很多网络请求,能只跑一个测试会让调试清晰得多。

第 1 步:为测试准备组件

我首先需要做的是在测试环境中把 Vue 组件准备好。

我把主应用改成了把所有组件放到 window._components 里,大概像这样:

const components = {
  'Feedback': FeedbackComponent,
  ...
}
window._components = components;

然后我就能写一个 mountComponent 函数,它做的事情和我正常主应用做的几乎一模一样(用我想用的组件渲染一个小模板)。唯一的区别是:

  1. 我可以选择性地传入一些额外数据作为它的 props。
  2. 它会把组件挂载到一个临时的不可见 div 上,测试结束后这个 div 会从 DOM 中移除。这个 div 被定位到页面之外(position: absolute; top: -10000, ...),所以你看不见它。

使用 mountComponent 函数的效果是这样的:

const {div} = mountComponent(
  '<Page :feedbacks="feedbacks" id=2 />',
  {feedbacks: [testFeedback]},
);

它的代码如下:

function mountComponent(template, data) {
  const app = Vue.createApp({
    template: template,
    data: () => data,
  })
  for (const [c, v] of Object.entries(window._components)) {
    app.component(c, v);
  }
  const div = document.getElementById('qunit-fixture')
             .appendChild(document.createElement('div'));
  return div;
}

结果就是得到一个 div,我可以在上面以编程方式点击、填写表单数据、检查是否出现了正确的内容等等。

第 2 步:添加一些夹具数据

因为我要编写 end-to-end integration tests 来确保客户端 JS 能和服务端正常协作,所以需要在数据库里准备一些测试数据。于是我写了大约 25 行 SQL 来在数据库中设置测试数据,并在开发服务器上加了一个端点来运行这些 SQL,把测试数据重置到已知状态。

async function reset() {
    return fetch('/api/reset_test_data', {method: "POST"})
}

然后在任何需要测试数据的测试开头,我只要运行 await reset() 就行。

其实我的 reset() 函数并没有完全重置所有内容,这有点不好,但作为起步已经够用了,以后总可以再改进。

第 3 步:一个基础测试

基础测试长这样!基本上就是渲染这个 div,并确保它包含了大致正确的数据。

QUnit.test('renders feedback content', async function (assert) {
  const {div} = mountComponent(
    '<Page :feedbacks="feedbacks" id=2 image=2 page_hash=2 />',
    {feedbacks: [testFeedback]},
  );
  assert.ok(div.textContent.includes('loved this section'));
})

这些就是全部的基础部分!接下来是过程中遇到的几个问题

等待页面部分内容渲染完成

我的测试里有很多网络请求,需要一些时间才能完成,并让 Vue 代码处理完结果、更新 DOM。

我想我们早就知道,在测试里随便加 sleep() 然后祈祷时间刚好是又慢又不稳定、让人极其抓狂的做法,所以我需要另一种办法。

据我所知,通常的做法是通过 DOM 来判断是否可以继续。比如“如果这个按钮可见了,就可以……”

所以我写了一个小的 waitFor() 函数,每 20 毫秒轮询一次,看看条件是否已经满足。它会在 2 秒后超时。

用法是这样的:

QUnit.test("click item", async function (assert) {
  const {div} = mountComponent(
    '<Feedback zine_id="test123" image_width="800px" />',
    {});
  const item = await waitFor(() => div.querySelector('.feedback-item'));
  item.click();
  // rest of test goes here... 
})

看起来这类概念的实现有很多,而且都比我的考虑得更周全。(随手一搜就有:qunit-wait-forplaywright expect.poll

弄清楚该等待什么并不简单

有些情况下,我以为自己已经在 DOM 中找到了正确的等待目标(“只要等这个 textarea 出现就行!”),但由于程序内部的一些细节,实际上我需要等待的是更晚出现的、很难准确定位的其他东西。

最后我改了一个组件,让它在完成某个重要操作时往 DOM 里添加一个随机值(比如 data-this-thing-is-ready=true),这感觉不太好。

我猜,解决这类测试问题的正确方式其实也是一次能让应用对用户更可靠的重构:如果 DOM 里有一个元素其实还没准备好让用户交互,也许我本来就不该先把它显示出来!

添加一些 CSS 类来定位元素(但这样对吗?)

我最后给一些需要在测试中查找的 HTML 元素加了几个类,要么是因为需要点击它们,要么是因为需要等待它们出现在 DOM 中。

以后我可能会想换一种做法——前端测试框架似乎建议避免使用 CSS 类,而是使用像 getByRole 这样的定位方式,或者在万不得已时使用类似 data-testid 的方式。感觉应该有办法在让应用更易于访问的同时,也让它更易于测试。

填写表单很棘手

要填写表单,光设置 value 是不够的,我还需要派发一个事件来告诉 Vue 元素已经改变。比如,checkboxtextarea 需要不同类型的事件。

textarea.value = 'banana banana banana';
textarea.dispatchEvent(new Event('input'));
checkbox.checked = true;
checkbox.dispatchEvent(new Event('change'));

这有点烦人,也让我意识到为什么我可能会想用某种 UI 测试库,例如:

测试覆盖率

我想了解自己的测试覆盖率如何,结果发现 Chrome 其实内置了针对 JS 和 CSS 的代码覆盖率功能!

我的 JS 是用 esbuild 打包成一个叫 bundle.js 的文件的,所以我只要查看 bundle.js,就能看到哪些行没有被覆盖到。

过程有点挑剔:我必须在 Chrome 开发者工具中关闭 sourcemap 才能让它生效,而且要看到覆盖率数据,需要执行一系列不太直观的操作。

这太有趣了!

和往常一样,我其实从未真正做过前端或后端开发(除了为自己做项目!),总感觉自己在不断学习如何完成一些超级基础的任务。

这次我真的玩得很开心。我的前端项目总是因为没有测试而感觉很脆弱,也许有一天我会拥有一套让我有信心的测试套件!

还有一些我在思考的问题:

  • 在写这篇文章时,我发现了一个叫 Testing Library 的前端测试库,它有很多编写测试的指导原则,和我最初的想法非常不同。我尝试用 Testing Library 重写了所有内容,感觉还不错,所以看看后续会怎样吧。它们分发了一个无需 Node 就能使用的 .umd.js 文件。
  • 我还不确定自己对完全无法在命令行中运行这些测试这件事怎么看。也许有一种简单的方法,可以主要在浏览器中工作,但如果需要的话也能在 CI 中运行?

原文由 Julia Evans 发布

本文章由 muse-spark-1.2-contributor 进行翻译