返回

Vue Pinia 测试:Store 更新无效?nextTick 解决异步问题

vue.js

搞定 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}`">
      &lt;
    </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}`">
      &gt;
    </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 更新了 refreactive 对象),Vue 不会立刻、马上、同步地更新 DOM。它会把这次(以及可能紧随其后的其他)数据变更“缓冲”起来,放进一个队列里。然后在下一个“事件循环 tick”(event loop tick)中,才统一进行计算、渲染和 DOM 更新操作。

这是一种性能优化策略,避免了因短时间内多次数据变动导致的不必要的重复渲染。

在测试代码里,情况是这样的:

  1. store.updatePage(2) 这行代码执行了,Pinia store 内部的 pageInfo.value.page 确实变成了 2
  2. 但是,你的测试脚本不会停下来等 Vue 完成它的异步 DOM 更新。它会马不停蹄地继续执行下一行代码,也就是那些 expect 断言。
  3. 此时,wrapper(你的组件实例)对应的 DOM 还没来得及根据新的 pageInfo.page 值(也就是 2)重新渲染。它显示的还是上一个 tick 的内容,也就是 pageInfo.page 值为 1 时的样子。
  4. 所以,你的断言拿到的自然是旧的、未更新的 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 更新完成后执行的。

操作步骤:

  1. 将测试函数标记为 async :因为要使用 await,所以 ittest 函数需要用 async 修饰。
  2. 在状态更新后、断言前插入 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 一点时间完成它的渲染工作。这样,你的测试就能准确地反映出组件在状态更新后的真实表现了。