Vue 3 测试
随着我们转向使用 Vue 3,确保测试在 Vue 3 模式下通过变得非常重要。 我们正在逐步加强对流水线的检查,以确保正确进行 Vue 3 测试。
目前,在以下情况下我们会让流水线失败:
- 新增的测试文件在 Vue 3 模式下失败。
- 现有测试文件在 Vue 3 模式下失败,而之前是通过的。
- 隔离列表 中的某个已知失败项现在通过了,但尚未从隔离列表中移除。
使用 Vue 3 运行单元测试
要使用 Vue 3 运行单元测试,在执行 jest 时将 VUE_VERSION 环境变量设置为 3。
VUE_VERSION=3 yarn jest #[file-path]测试注意事项
模拟 composables 时的引用管理
测试 Vue 3 composables 时的一个常见模式是模拟这些文件返回的 ref 或 computed 值。
考虑以下示例 composable:
export const useCounter = () => {
const counter = ref(1)
const increase = () => { counter.value += 1 }
return { counter, increase }
}如果我们有一个组件正在使用这个 composable 并暴露计数器,我们将需要编写测试来覆盖其功能。在像这个简单示例的某些情况下,我们可以完全不模拟 composable,但对于更复杂的功能,如 Tanstack Query 包装器或 Apollo 包装器,使用 jest.mock 可能是必要的。
在这种情况下,测试文件将需要模拟 composable:
<script setup>
const { counter, increase } = useCounter()
</script>
<template>
<p>超级有用的计数器: {{ counter }}</p>
<button @click="increase">+</button>
</template>import { ref } from 'vue'
import { useCounter } from '~/composables/useCounter'
jest.mock('~/composables/useCounter')
describe('MyComponent', () => {
const increaseMock = jest.fn()
const counter = ref(1)
beforeEach(() => {
useCounter.mockReturnValue({
increase: increaseMock,
counter
})
})
describe('当计数器为 2 时', () => {
beforeEach(() => {
counter.value = 2
createComponent()
})
it('...', () => {})
})
it('应该默认为 1', () => {
createComponent()
expect(findSuperUsefulCounter().text()).toBe(1)
// 失败
})
})注意在上面的示例中,我们既模拟了 composable 返回的函数,也模拟了 counter ref - 但是示例中缺少了一个非常重要的步骤。
counter 常量是一个 ref,这意味着在每次测试中修改它时,我们分配给它的值都会被保留。在示例中,第二个 it 块会失败,因为 counter 会保留在我们之前某些测试中分配的值。
解决方案和最佳实践是在最顶层的 beforeEach 块中始终重置你的 refs。
import { ref } from 'vue'
import { useCounter } from '~/composables/useCounter'
jest.mock('~/composables/useCounter')
describe('MyComponent', () => {
const increaseMock = jest.fn()
// 我们可以初始化为 `undefined` 以更加谨慎
const counter = ref(undefined)
beforeEach(() => {
counter.value = 1
useCounter.mockReturnValue({
increase: increaseMock,
counter
})
})
describe('当计数器为 2 时', () => {
beforeEach(() => {
counter.value = 2
createComponent()
})
it('...', () => {})
})
it('应该默认为 1', () => {
createComponent()
expect(findSuperUsefulCounter().text()).toBe(1)
// 通过
})
})Vue Router
如果你正在使用真实的(非模拟的)VueRouter 对象测试 Vue Router 配置,请阅读以下
指南。一个
失败的原因是 Vue Router 4 异步处理路由,因此我们应该
await 路由操作完成。你可以使用 waitForPromises 工具来
等待所有 promise 被刷新。
在下面的示例中,一个测试断言在点击按钮后 VueRouter 导航到了一个页面。如果
在点击按钮后没有调用 waitForPromises,断言将失败,因为路由器的
状态尚未转换到目标页面。
it('点击新建工作区按钮时导航到 /create', async () => {
expect(findWorkspacesListPage().exists()).toBe(true);
await findNewWorkspaceButton().trigger('click');
await waitForPromises();
expect(findCreateWorkspacePage().exists()).toBe(true);
});Vue Apollo 故障排除
你可能会遇到一些执行 Apollo 突变并更新内存查询缓存的组件的单元测试失败,例如:
ApolloError: 'get' on proxy: property '[property]' is a read-only and non-configurable data property on the proxy target but the proxy did not return its actual value (expected '#<Object>' but got '#<Object>')这个错误发生是因为当我们调用 writeQuery 或 updateQuery 方法时,Apollo 试图修改一个 Vue 响应式对象。
避免在更新 Apollo 缓存的操作中使用通过组件属性传递的对象。你应该始终依赖构造新对象或
已经存在于 Apollo 缓存中的数据。作为最后的手段,使用 cloneDeep 工具从目标对象中移除
Vue 的响应式代理。
在下面的示例中,组件在突变成功后通过交换两个数组中的 agent 对象来更新 Apollo 的内存缓存。
agent 对象在 agent 属性中也可用,但它是一个响应式对象。错误的方法引用了传递给
组件的 agent 对象作为属性,这会导致代理错误。正确的方法是找到已经存储在
Apollo 缓存中的 agent 对象。
<script>
import { toRaw } from 'vue';
export default {
props: {
namespace: {
type: String,
required: true,
},
agent: {
type: Object,
required: true,
},
},
methods: {
async execute() {
try {
await this.$apollo.mutate({
mutation: createClusterAgentMappingMutation,
update(store) {
store.updateQuery(
{
query: getAgentsWithAuthorizationStatusQuery,
variables: { namespace },
},
(sourceData) =>
produce(sourceData, (draftData) => {
const { mappedAgents, unmappedAgents } = draftData.namespace;
/*
* 错误:本节描述的错误是由于向 nodes 数组添加 Vue 响应式
* 对象造成的。`this.agent` 是组件属性,因此它被包装
* 在响应式代理中。
*/
mappedAgents.nodes.push(this.agent);
unmappedAgents.nodes = removeFrom.nodes.filter((node) => node.id !== agent.id);
/*
* 推荐的修复:只使用已经存在于内存缓存中的数据。
*/
const targetAgentIndex = removeFrom.nodes.findIndex((node) => node.id === agent.id);
mappedAgents.nodes.push(removeFrom.nodes[targetAgentIndex]);
unmappedAgents.nodes.splice(targetAgentIndex, 1);
/*
* 替代(最后手段)修复:使用 lodash `cloneDeep` 创建一个克隆
* 没有Vue响应式的对象:
*/
mappedAgents.nodes.push(cloneDeep(this.agent));
unmappedAgents.nodes = removeFrom.nodes.filter((node) => node.id !== agent.id);
}),
);
},
});
} catch (e) {
Sentry.captureException(e);
this.$emit('error', e);
}
},
},
};
</script>测试 Vue Router
当测试完整的非模拟 vue-router@4 时,为了与 Vue 2 兼容,需要注意几个事项。
窗口位置
vue-router@4 不会检测窗口位置的变化,因此使用 setWindowLocation 等辅助函数设置当前 URL 将不会生效。
相反,应该手动设置初始路由或导航到另一个路由。
初始路由
为测试设置初始路由时,vue-router@4 将默认使用 / 路由。如果路由配置没有为 / 路径定义路由,测试默认会出错。在这种情况下,在创建组件之前导航到已定义的路由之一很重要。
router = createRouter();
await router.push({ name: 'tab', params: { tabId }})注意 await 是必要的,因为所有导航始终是异步的。
导航到另一个路由
要在已挂载的组件上导航到另一个路由,必须 await 对路由器上 push 或 replace 的调用。
createComponent()
await router.push('/different-route')当无法访问 push 方法时,例如当我们通过事件在组件代码内部触发 push 时,await waitForPromises 将足够。
考虑以下组件:
<script>
export default {
methods: {
nextPage() {
this.$router.push({
path: 'some path'
})
}
}
}
</script>
<template>
<gl-keyset-pagination @push="nextPage" />
</template>如果我们想要能够测试 $router.push 调用被触发,我们必须通过 gl-keyset-pagination 组件上的 next 事件来触发导航。
wrapper.findComponent(GlKeysetNavigation).vm.$emit('push');
// $router.push 在组件中被触发
await waitForPromises()导航守卫
导航守卫必须在任何给定的导航守卫遍历中恰好调用其第三个参数 next 一次。这在 vue-router@3 和 vue-router@4 中都是必要的,但在 vue-router@4 中更重要,因为所有导航都是异步的并且必须被等待。
不调用 next 可能会产生难以调试的错误。例如:
Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout调试
你经常会发现自己遇到像下面这样神秘的错误。
Unexpected calls to console (1) with:
[1] warn: [Vue Router warn]: uncaught error during route navigation:
23 | .join('\n');
24 |
> 25 | throw new Error(
| ^
26 | `Unexpected calls to console (${consoleCalls.length}) with:\n${consoleCallsList}\n`,
27 | );
28 | };为了更好地理解 Vue router 的需求,使用 jest.fn() 覆盖 console.warn 以便你可以看到错误的输出。
console.warn = jest.fn()
afterEach(() => {
console.log(console.warn.mock.calls)
})这会将上述内容转化为可理解的错误。在提交你的 MR 之前不要忘记删除这段代码。
'[Vue Router warn]: Record with path "/" is either missing a "component(s)" or "children" property.'组件和 Children 属性
与 Vue router 3(Vue 2)不同,Vue router 4 需要定义 component 或 children 属性(及其相应的 component)。在某些场景中,我们历史上使用 Vue router 来管理路由查询变量而没有 router-view,例如在 app/assets/javascripts/projects/your_work/components/app.vue 中。
这是一种反模式,因为 Vue router 过于复杂,更好的方法是使用原生 JS 和 URL searchParams 来管理查询路由。
当无法重写组件时,传递应用程序正在渲染的 App 组件而不使用 router-view 将使测试通过,但是,如果向组件添加 <router-view />,这可能会在未来引入不想要的行为,应该谨慎使用。
隔离列表
scripts/frontend/quarantined_vue3_specs.txt 文件包含所有已知的 Vue 3 失败测试文件。
为了不让失败的流水线让我们应接不暇,这些文件在 Vue 3 测试作业中被跳过。
如果你正在阅读这个,很可能是失败的隔离作业把你带到了这里。 这个作业很令人困惑,因为它在测试通过时失败,而在所有测试都失败时通过。 原因是因为所有新通过的测试都应该从隔离列表中移除。 恭喜你修复了一个之前失败的测试,并将其从隔离列表中移除,让这个流水线再次通过。
从隔离列表中移除
如果你的流水线因为 vue3 check quarantined 作业而失败,好消息!
你修复了一个之前失败的测试!
你现在需要做的是将新通过的测试从隔离列表中移除。
这确保测试将继续通过,并防止任何进一步的回归。
添加到隔离列表
不要这样做。 这个列表应该只会变小,而不是变大。 如果你的 MR 引入了新的测试文件或破坏了当前通过的测试,那么你应该修复它。
如果你正在将测试文件从一个位置移动到另一个位置,那么修改隔离列表中的位置是可以的。 但是,在这样做之前,考虑先修复测试。