Vue Pinia 测试:Store 更新无效?nextTick 解决异步问题
2025-04-24 03:17:01
搞定 Vue + Pinia 组件测试:Store 状态更新不起作用?看这里!
写 Vue 组件测试,特别是用了 Pinia 做状态管理的时候,有时会碰到一个有点绕的问题:在测试里明明更新了 Pinia store 的状态,但组件似乎没反应,断言(assertion)跑下来还是旧的状态,导致测试失败。就像下面这位朋友遇到的情况:测试一个依赖 Pinia store 的分页导航组件 (BookNav
),第一个测试(检查初始状态)跑得好好的,但第二个测试尝试调用 store 的 action (store.updatePage(2)
) 来改变当前页码后,组件里的页码、链接状态并没有按预期更新。
原始组件 (BookNav.vue
) 大概长这样:
<script setup lang="ts">
import { ComputedRef } from 'vue';
import { storeToRefs } from 'pinia';
import { RouterLink } from 'vue-router';
import { useBooksStore } from '@/stores/books';
interface BookStore {
pageInfo: { page: number; totalPages: number }; // 简化类型以便理解
nextPage: ComputedRef<number>;
previousPage: ComputedRef<number>;
}
const bookStore = useBooksStore();
// 使用 storeToRefs 保持响应性
const { pageInfo, nextPage, previousPage }: BookStore = storeToRefs(bookStore);
</script>
<template>
<nav class="book-nav">
<RouterLink
class="button"
data-test-id="previous-page"
:class="{ 'button--disabled': previousPage < 1 }"
:to="`/books/${previousPage}`">
<
</RouterLink>
<span>
Page
<span data-test-id="page-no">{{ pageInfo.page }}</span>
of
<span data-test-id="total-pages">{{ pageInfo.totalPages }}</span>
</span>
<RouterLink
class="button"
data-test-id="next-page"
:class="{ 'button--disabled': nextPage > pageInfo.totalPages }"
:to="`/books/${nextPage}`">
>
</RouterLink>
</nav>
</template>
<style lang="scss" scoped>
.book-nav {
// ...样式省略...
width: 100%;
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
}
.button--disabled {
// 假设的禁用样式
opacity: 0.5;
pointer-events: none;
}
</style>
对应的 Pinia Store (stores/books.ts
):
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';
interface PageInfo {
page: number;
totalPages: number;
}
export const useBooksStore = defineStore('books', () => {
const pageInfo = ref<PageInfo>({
page: 1,
totalPages: 5,
});
const nextPage = computed<number>(() => pageInfo.value.page + 1);
const previousPage = computed<number>(() => pageInfo.value.page - 1);
const updatePage = (page: number) => {
// 保证页码在 1 到 totalPages 之间
const clampedPage = Math.min(Math.max(page, 1), pageInfo.value.totalPages);
pageInfo.value.page = clampedPage;
};
return { pageInfo, nextPage, previousPage, updatePage };
});
还有那个出问题的测试 (BookNav.spec.ts
):
import { describe, beforeEach, afterEach, it, expect, vi } from 'vitest';
import { shallowMount, RouterLinkStub, VueWrapper } from '@vue/test-utils';
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
import { setActivePinia, createPinia } from 'pinia'; // setActivePinia 和 createPinia 可能不需要,因为用了 createTestingPinia
import { useBooksStore } from '@/stores/books';
import BookNav from './BookNav.vue'; // 调整路径假设
const PREV_PAGE = '[data-test-id=previous-page]';
const NEXT_PAGE = '[data-test-id=next-page]';
const PAGE_NO = '[data-test-id=page-no]';
const TOTAL_PAGES = '[data-test-id=total-pages]';
let wrapper: VueWrapper;
let pinia: TestingPinia;
let store: ReturnType<typeof useBooksStore>; // 更精确的类型
describe('BookNav 组件测试', () => {
beforeEach(() => {
// 创建一个测试用的 Pinia 实例
pinia = createTestingPinia({
createSpy: vi.fn, // 使用 vi.fn 来创建 spy
stubActions: false, // 很重要:我们需要真实调用 action 来改变 state
initialState: { // 可以设置初始状态,虽然这里 store 有默认值
books: {
pageInfo: { page: 1, totalPages: 5 }
}
}
});
// 挂载组件,并注入测试 Pinia 实例
wrapper = shallowMount(BookNav, {
global: {
plugins: [pinia], // 把测试 Pinia 实例作为插件传入
stubs: { // Stub 掉 RouterLink,避免路由相关警告或错误
RouterLink: RouterLinkStub,
},
},
});
// 获取 store 实例,以便在测试中调用 action 或访问 state
store = useBooksStore(pinia); // 把 pinia 实例传给 useStore
});
afterEach(() => {
// 清理工作
wrapper.unmount();
// 重置 Pinia 状态(createTestingPinia 每次 beforeEach 都会创建新的,通常不用手动重置)
});
it('初始状态下,显示正确的页码和链接状态', () => {
// 检查上一页链接
expect(wrapper.find(PREV_PAGE).findComponent(RouterLinkStub).props('to')).toBe('/books/0');
expect(wrapper.find(PREV_PAGE).classes()).toContain('button--disabled'); // 第一页,上一页禁用
// 检查当前页码和总页数
expect(wrapper.find(PAGE_NO).text()).toBe('1');
expect(wrapper.find(TOTAL_PAGES).text()).toBe('5');
// 检查下一页链接
expect(wrapper.find(NEXT_PAGE).findComponent(RouterLinkStub).props('to')).toBe('/books/2');
expect(wrapper.find(NEXT_PAGE).classes()).not.toContain('button--disabled'); // 有下一页,不禁用
});
// 这个测试失败了!
it('调用 updatePage(2) 后,更新链接和页码显示', async () => { // 注意:这里加了 async
// 尝试更新 store 状态
store.updatePage(2);
// 断言失败的地方:AssertionError: expected '/books/0' to deeply equal '/books/1'
// 组件似乎没有响应 store 的更新
// 我们期望的结果:
// expect(wrapper.find(PREV_PAGE).findComponent(RouterLinkStub).props('to')).toBe('/books/1');
// expect(wrapper.find(PREV_PAGE).classes()).not.toContain('button--disabled');
// expect(wrapper.find(PAGE_NO).text()).toBe('2');
// expect(wrapper.find(NEXT_PAGE).findComponent(RouterLinkStub).props('to')).toBe('/books/3');
// 我们实际得到的是更新前的状态
expect(wrapper.find(PREV_PAGE).findComponent(RouterLinkStub).props('to')).toEqual('/books/0'); // 仍然是 0
// ... 其他断言也会失败 ...
});
});
问题出在哪?为啥 Store 更新了组件没反应?
核心原因在于 Vue 的异步更新机制 。
当你修改 Vue 的响应式数据时(比如通过 Pinia action 更新了 ref
或 reactive
对象),Vue 不会立刻、马上、同步地更新 DOM。它会把这次(以及可能紧随其后的其他)数据变更“缓冲”起来,放进一个队列里。然后在下一个“事件循环 tick”(event loop tick)中,才统一进行计算、渲染和 DOM 更新操作。
这是一种性能优化策略,避免了因短时间内多次数据变动导致的不必要的重复渲染。
在测试代码里,情况是这样的:
store.updatePage(2)
这行代码执行了,Pinia store 内部的pageInfo.value.page
确实变成了2
。- 但是,你的测试脚本不会停下来等 Vue 完成它的异步 DOM 更新。它会马不停蹄地继续执行下一行代码,也就是那些
expect
断言。 - 此时,
wrapper
(你的组件实例)对应的 DOM 还没来得及根据新的pageInfo.page
值(也就是 2)重新渲染。它显示的还是上一个 tick 的内容,也就是pageInfo.page
值为1
时的样子。 - 所以,你的断言拿到的自然是旧的、未更新的 DOM 状态,比如上一页链接还是
/books/0
,当前页码还是1
,导致测试失败。
就好比你跟朋友说:“帮我把电视换到体育频道!” 然后你眼睛都不眨一下,立刻问他:“现在是体育频道吗?” 他可能刚拿起遥控器,还没按下去呢。你需要给他一点时间操作,然后再确认结果。
怎么解决?让测试等等 Vue 更新
知道了原因,解决办法就呼之欲出了:在调用了会触发组件更新的操作(比如 store.updatePage(2)
)之后,但在执行断言之前,需要明确地告诉测试框架:“嘿,等一下,等 Vue 把该更新的都更新完了再说。”
这里就轮到 @vue/test-utils
提供的好帮手登场了。
方案:使用 await wrapper.vm.$nextTick()
或 await nextTick()
这是最常用也最符合 Vue 理念的方式。$nextTick
是 Vue 实例上的一个方法(或者可以直接从 vue
导入 nextTick
函数),它返回一个 Promise。这个 Promise 会在 Vue 完成当前这一轮的 DOM 更新之后才 resolve。
因此,只需要在修改状态的代码后面,加上 await wrapper.vm.$nextTick()
,就能确保后续的断言是在 DOM 更新完成后执行的。
操作步骤:
- 将测试函数标记为
async
:因为要使用await
,所以it
或test
函数需要用async
修饰。 - 在状态更新后、断言前插入
await wrapper.vm.$nextTick()
:
修改后的测试代码 (BookNav.spec.ts
的第二个 it
块):
// ... (beforeEach, afterEach, 第一个 it 保持不变) ...
it('调用 updatePage(2) 后,更新链接和页码显示', async () => { // 1. 标记为 async
// 尝试更新 store 状态
store.updatePage(2);
// 2. 等待 Vue 完成 DOM 更新
await wrapper.vm.$nextTick(); // <--- 关键改动!
// 现在,可以安全地进行断言了,因为 DOM 应该已经更新
expect(wrapper.find(PREV_PAGE).findComponent(RouterLinkStub).props('to')).toBe('/books/1');
expect(wrapper.find(PREV_PAGE).classes()).not.toContain('button--disabled'); // 第二页,上一页不禁用
expect(wrapper.find(PAGE_NO).text()).toBe('2'); // 页码应为 2
expect(wrapper.find(TOTAL_PAGES).text()).toBe('5'); // 总页数不变
expect(wrapper.find(NEXT_PAGE).findComponent(RouterLinkStub).props('to')).toBe('/books/3');
expect(wrapper.find(NEXT_PAGE).classes()).not.toContain('button--disabled'); // 还有下一页
});
// 可以再加一个测试,比如测试边界情况
it('调用 updatePage 到最后一页时,下一页按钮禁用', async () => {
store.updatePage(5); // 跳到最后一页
await wrapper.vm.$nextTick();
expect(wrapper.find(PAGE_NO).text()).toBe('5');
expect(wrapper.find(NEXT_PAGE).findComponent(RouterLinkStub).props('to')).toBe('/books/6'); // 理论上的下一页是 6
expect(wrapper.find(NEXT_PAGE).classes()).toContain('button--disabled'); // 但下一页按钮应该禁用
expect(wrapper.find(PREV_PAGE).findComponent(RouterLinkStub).props('to')).toBe('/books/4');
expect(wrapper.find(PREV_PAGE).classes()).not.toContain('button--disabled');
});
it('尝试跳到超出范围的页码,会被限制在边界', async () => {
store.updatePage(10); // 尝试跳到第 10 页 (超出范围)
await wrapper.vm.$nextTick();
// store 里的 updatePage action 会把页码限制在 totalPages (5)
expect(wrapper.find(PAGE_NO).text()).toBe('5');
expect(wrapper.find(NEXT_PAGE).classes()).toContain('button--disabled');
store.updatePage(0); // 尝试跳到第 0 页 (超出范围)
await wrapper.vm.$nextTick();
// store 里的 updatePage action 会把页码限制在 1
expect(wrapper.find(PAGE_NO).text()).toBe('1');
expect(wrapper.find(PREV_PAGE).classes()).toContain('button--disabled');
});
});
原理和作用解释:
wrapper.vm
指向的是被挂载的 Vue 组件实例。$nextTick()
是这个实例上的方法,它注册一个回调函数,这个函数会在 DOM 更新循环结束之后执行。当不带参数调用nextTick()
或在其返回的 Promise 上使用await
时,它会暂停测试代码的执行,直到 Vue 完成了因为store.updatePage(2)
而触发的所有状态计算和 DOM 渲染。- 等到
await wrapper.vm.$nextTick()
执行完毕,wrapper
所代表的组件及其内部的 DOM 结构就已经反映了最新的pageInfo.page
值(即 2)。此时再用wrapper.find()
和.text()
、.classes()
、.props()
等方法去检查,就能获取到正确的结果了。
可以直接导入 nextTick
吗?
可以。你也可以从 vue
导入 nextTick
函数:
import { nextTick } from 'vue';
// ... in test
it('...', async () => {
store.updatePage(2);
await nextTick(); // 直接使用导入的 nextTick
// assertions...
});
wrapper.vm.$nextTick()
和直接导入的 nextTick()
在测试环境下的行为基本一致。使用哪个看个人或团队偏好。wrapper.vm.$nextTick()
更明确地表示是等待这个特定组件实例相关的更新,但实际上 Vue 的更新队列是全局的。
安全建议:
$nextTick
是处理 Vue 异步更新的标准方式,用它是安全的。- 什么时候用? 只要你执行了一个操作(用户交互模拟、直接调用方法、更新 props、更新 store 状态等)预期会引起被测组件及其子组件的 DOM 变化,并且你紧接着就要断言这些变化时,通常都需要
await nextTick()
。 - 要 await 几次? 大多数情况下,一次状态更新后,只需要
await nextTick()
一次就够了。Vue 会合并同一 tick 内的所有更新。除非你的操作会触发一系列复杂的、跨 tick 的异步流程(比如包含 Promise、setTimeout 等),但对于简单的 Pinia state 更新触发的响应式重渲染,一次通常就够了。
进阶技巧 - 配合 @pinia/testing
的其他特性:
store.$patch()
:如果想在测试中直接修改 store 的多个状态,可以用store.$patch({ pageInfo: { page: 3, totalPages: 10 } })
,同样记得修改后要await nextTick()
。pinia.setState()
:createTestingPinia
返回的pinia
对象有个setState
方法,可以完全重置某个 store 的状态,比如pinia.setState({ books: { pageInfo: { page: 4, totalPages: 8 } } })
。这对于在测试之间或测试中设置特定的初始状态非常方便。修改后同样需要await nextTick()
来等待组件响应。- Spying on Actions/Getters (
createSpy: vi.fn
) : 你设置了createSpy: vi.fn
,这意味着你可以检查 action 是否被调用,以及以什么参数被调用。例如,如果你想测试点击按钮是否会触发updatePage
:
(注意:原组件是通过it('点击下一页按钮调用 updatePage', async () => { await wrapper.find(NEXT_PAGE).trigger('click'); // 模拟点击,注意 RouterLinkStub 可能需要特殊处理点击或测试点击目标 // 注意:直接点击 RouterLinkStub 可能不会触发 action,取决于你的组件逻辑。 // 如果是按钮触发 store action,模拟按钮点击。 // 假设有个 <button @click="store.updatePage(nextPage)">Next</button> // await wrapper.find('button.next').trigger('click'); // 假设 store.updatePage(2) 应该被调用 // expect(store.updatePage).toHaveBeenCalledTimes(1); // expect(store.updatePage).toHaveBeenCalledWith(2); // 检查参数 // 即使 spy 了 action,如果 action 修改了 state,仍需要 nextTick 检查 DOM 更新 // await nextTick(); // expect(wrapper.find(PAGE_NO).text()).toBe('2'); });
:to
属性导航,不是直接@click
调用updatePage
,所以这个 spy action 的例子在这里不太适用,仅作@pinia/testing
功能演示。)
总结一下,在 Vue 组件测试中遇到因状态(无论是组件内部 data/ref
还是 Pinia store)更新而导致的断言失败,首要考虑的就是 Vue 的异步 DOM 更新机制。解决方案通常就是在状态变更代码之后、执行断言之前,加上 await wrapper.vm.$nextTick()
或 await nextTick()
,给 Vue 一点时间完成它的渲染工作。这样,你的测试就能准确地反映出组件在状态更新后的真实表现了。