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
This commit is contained in:
Bujidao 2026-06-12 17:54:09 +08:00
parent ff8b1c4b37
commit fb5d9d0eea

311
rules/vue/testing.md Normal file
View File

@ -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: '<div class="mock-avatar" />',
},
},
},
});
// 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<T>(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}"],
},
},
});
```