From fb5d9d0eea4318009ba8f5a8dfb25a7007a30caa Mon Sep 17 00:00:00 2001 From: Bujidao <3317431882@qq.com> Date: Fri, 12 Jun 2026 17:54:09 +0800 Subject: [PATCH] feat(rules): add Vue testing rules Add rules/vue/testing.md: - Vitest + Vue Test Utils + @pinia/testing stack - Component mounting (mount vs shallowMount), stubs and mocks - Composable testing with effectScope and mountComposable helper - Pinia store testing pattern (setActivePinia + ) - Vue Router testing with createMemoryHistory - Async assertion pitfalls (flushPromises/nextTick) - Testing implementation details vs rendered output - Coverage thresholds: 80%+ for composables/stores, smoke tests for components - Vitest configuration with jsdom environment and v8 coverage --- rules/vue/testing.md | 311 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 rules/vue/testing.md diff --git a/rules/vue/testing.md b/rules/vue/testing.md new file mode 100644 index 00000000..21375442 --- /dev/null +++ b/rules/vue/testing.md @@ -0,0 +1,311 @@ +--- +paths: + - "**/*.vue" + - "**/*.test.ts" + - "**/*.spec.ts" + - "**/composables/**/*.ts" + - "**/stores/**/*.ts" +--- +# Vue Testing + +> This file extends [typescript/testing.md](../typescript/testing.md), [common/testing.md](../common/testing.md), and [react/testing.md](../react/testing.md) with Vue-specific testing guidance. + +## Testing Stack + +- **Vitest** as the test runner (fast, Vite-native, Jest-compatible API). +- **Vue Test Utils** (`@vue/test-utils`) for component mounting and interaction. +- **@pinia/testing** for mocking Pinia stores in tests. +- **@testing-library/vue** when preferring user-centric queries over component internals. +- **Playwright / Cypress** for end-to-end tests. + +```bash +npm install -D vitest @vue/test-utils jsdom +``` + +## Test File Location + +``` +src/components/UserCard/ + UserCard.vue + UserCard.test.ts # co-located + index.ts +``` + +Or: + +``` +src/components/UserCard.vue +src/components/__tests__/UserCard.test.ts +``` + +Follow the project's existing convention consistently. + +## Component Testing + +### Mounting + +```ts +import { mount, shallowMount } from "@vue/test-utils"; +import UserCard from "./UserCard.vue"; +import { createPinia, setActivePinia } from "pinia"; + +describe("UserCard", () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it("renders user name", () => { + const wrapper = mount(UserCard, { + props: { + user: { id: "1", name: "Alice" }, + }, + }); + expect(wrapper.text()).toContain("Alice"); + }); + + it("emits select event on click", async () => { + const wrapper = mount(UserCard, { + props: { + user: { id: "1", name: "Alice" }, + }, + }); + await wrapper.find("button").trigger("click"); + expect(wrapper.emitted("select")).toBeTruthy(); + expect(wrapper.emitted("select")![0]).toEqual(["1"]); + }); +}); +``` + +### shallowMount vs mount + +- `shallowMount`: Stubs all child components — faster, tests the component in isolation. Useful for unit tests. +- `mount`: Renders the full tree — tests integration. Use for critical user-facing component tests. + +```ts +// Unit test — use shallowMount +const wrapper = shallowMount(UserCard, { props: { user } }); +// Only UserCard renders; child components are stubs + +// Integration test — use mount +const wrapper = mount(Form, { + global: { + plugins: [createPinia()], + }, +}); +// Full rendering tree +``` + +### Stubs and Mocks + +```ts +// Stub a child component +const wrapper = mount(Parent, { + global: { + stubs: { + HeavyChart: true, // renders as empty placeholder + UserAvatar: { + template: '
', + }, + }, + }, +}); + +// Mock a composable +vi.mock("@/composables/useUser", () => ({ + useUser: () => ({ + user: ref({ id: "1", name: "Test" }), + isLoading: ref(false), + }), +})); + +// Mock a Pinia store +const store = useUserStore(); +store.$patch({ currentUser: { id: "1", name: "Test" } }); +``` + +## Composable Testing + +```ts +import { useCounter } from "./useCounter"; +import { createApp, ref } from "vue"; + +// With effectScope for cleanup +import { effectScope } from "vue"; + +describe("useCounter", () => { + it("increments count", () => { + const scope = effectScope(); + const { count, increment } = scope.run(() => useCounter())!; + + expect(count.value).toBe(0); + increment(); + expect(count.value).toBe(1); + + scope.stop(); // cleanup watchers + }); +}); +``` + +For composables with lifecycle hooks, mount inside a component: + +```ts +import { mount } from "@vue/test-utils"; +import { defineComponent, h } from "vue"; + +function mountComposable(composable: () => T) { + let result: T; + const TestComponent = defineComponent({ + setup() { result = composable(); }, + render: () => h("div"), + }); + mount(TestComponent); + return result!; +} + +it("useEventListener attaches listener", () => { + const handler = vi.fn(); + mountComposable(() => useEventListener("click", handler)); + window.dispatchEvent(new Event("click")); + expect(handler).toHaveBeenCalled(); +}); +``` + +## Pinia Store Testing + +```ts +import { setActivePinia, createPinia } from "pinia"; +import { useUserStore } from "./useUserStore"; + +describe("useUserStore", () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it("fetches user and updates state", async () => { + vi.mocked(getUser).mockResolvedValue({ id: "1", name: "Alice" }); + + const store = useUserStore(); + await store.fetchUser("1"); + + expect(store.currentUser).toEqual({ id: "1", name: "Alice" }); + expect(store.isLoading).toBe(false); + expect(store.error).toBeNull(); + }); + + it("handles fetch error", async () => { + vi.mocked(getUser).mockRejectedValue(new Error("Not found")); + + const store = useUserStore(); + await store.fetchUser("999"); + + expect(store.currentUser).toBeNull(); + expect(store.isLoading).toBe(false); + expect(store.error).toBeInstanceOf(Error); + }); +}); +``` + +## Vue Router Testing + +```ts +import { createRouter, createWebHistory } from "vue-router"; +import { mount } from "@vue/test-utils"; +import UserPage from "./UserPage.vue"; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: "/users/:id", component: UserPage, props: true }, + ], +}); + +// Navigate before mounting +await router.push("/users/42"); +await router.isReady(); + +const wrapper = mount(UserPage, { + global: { plugins: [router] }, +}); +``` + +- Use `createMemoryHistory()` for tests to avoid URL state leaks. +- Always `await router.isReady()` before asserting. + +## What to Test + +| Level | What | Example | +|-------|------|---------| +| Unit | Composables, utility functions, store actions | `useCounter().increment()` works | +| Component | Rendering, props, events, slots | `UserCard` renders name, emits on click | +| Integration | Multiple components, router, store together | Form submits, store updates, route navigates | +| E2E | Critical user flows | Login → dashboard → create item | + +## Coverage Thresholds + +- Unit tests: 80%+ for composables and store logic. +- Component tests: every component with logic beyond pure presentation should have at least a smoke test. +- E2E: at minimum, login, signup, and the primary user journey. + +## Common Pitfalls + +### Async Assertions + +```ts +// WRONG: assertion not awaited +it("loads user", () => { + const wrapper = mount(UserCard, { props: { userId: "1" } }); + expect(wrapper.text()).toContain("Alice"); // fetch hasn't resolved yet +}); + +// CORRECT: wait for DOM updates +it("loads user", async () => { + const wrapper = mount(UserCard, { props: { userId: "1" } }); + await flushPromises(); // or nextTick / waitFor + expect(wrapper.text()).toContain("Alice"); +}); +``` + +### Not Cleaning Up + +Vitest auto-cleans with `vi.restoreAllMocks()`, but manual timers and subscriptions need explicit cleanup: + +```ts +beforeEach(() => { + vi.useFakeTimers(); +}); +afterEach(() => { + vi.useRealTimers(); +}); +``` + +### Testing Implementation Details + +```ts +// WRONG: testing internal ref name +expect(wrapper.vm._internalCounter).toBe(5); + +// CORRECT: testing rendered output or public API +expect(wrapper.find(".count-display").text()).toBe("5"); +``` + +## Vitest Configuration + +```ts +// vitest.config.ts +import { defineConfig } from "vitest/config"; +import vue from "@vitejs/plugin-vue"; + +export default defineConfig({ + plugins: [vue()], + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/test/setup.ts"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + include: ["src/**/*.{vue,ts}"], + }, + }, +}); +```