mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-16 08:26:52 +08:00
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:
parent
ff8b1c4b37
commit
fb5d9d0eea
311
rules/vue/testing.md
Normal file
311
rules/vue/testing.md
Normal 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}"],
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
Loading…
x
Reference in New Issue
Block a user