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}"],
+ },
+ },
+});
+```