Testing Vue components in the browser

Julia Evans

在浏览器中测试 Vue 组件

原文由 Julia Evans 发布,订阅该博客

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

我在前端 JS 项目中经常遇到的一个问题,就是不知道该怎么写测试。之前我试过 Playwright,但感觉它又慢又笨重,老是要启动新的浏览器进程,还得写一些 Node 代码来调度测试。

结果就是,我干脆不给前端代码写测试,这感觉很不好。好在我平时也不怎么更新这些项目,所以这个问题倒不常出现,但如果能更放心地做改动就好了!所以,拥有一套我喜欢的前端测试方案,一直在我的心愿单上。

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

Alex Chan 前不久写了一篇很棒的文章,叫 Testing JavaScript without a (third-party) framework,算是对我这个系列中某篇文章的回应,里面介绍了如何写一个在浏览器页面中运行的极简单元测试框架。

我当时非常喜欢那篇文章,不过它只讲了单元测试,而我想为自己的 Vue 组件写端到端的集成测试,却不知道该怎么做。

所以前几天和 Marco 聊天时,他随口说了句“你知道吗,其实你可以直接在浏览器里给 Vue 组件跑测试”,我就想“嘿,我得再试试看!!!”

这些都是我昨天刚做完的,肯定还有很多可以改进的地方,不过我想趁还没忘记,先把过程中的一些体会记录下来。

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

接下来要聊的测试对象,是我 2023 年写的这个小册子反馈网站

测试框架:QUnit

我用的是 QUnit。它用起来很顺手,没什么特别值得展开的,就不多说了。我觉得 Alex 那种“自己写个测试框架”的思路应该也行得通。我是按照这里的说明来配置的。

我很喜欢 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 步:添加固件数据

因为我要写端到端的集成测试,来确保客户端 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 里跑这些测试?

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

评论