Test Vue
This guide outlines best practices for writing unit tests for Vue components in your project.
Setup
Test Environment
We use the following tools for testing:
- Vitest as the test runner
- Vue Test Utils for component testing
- JSDOM for browser environment simulation
- Vuetify for UI components
Test File Location
Test files should be placed adjacent to the component files they test. For example:
src/
components/
MyComponent.vue
MyComponent.test.ts
This makes it easy to find tests and keeps them close to the code they're testing.
Basic Test Structure
import { describe, it, expect, vi } from "vitest";
import { stubGlobals, mountWithPlugins } from "@saflib/vue-spa/testing";
import { type VueWrapper } from "@vue/test-utils";
import { http, HttpResponse } from "msw";
import { setupMockServer } from "@saflib/vue-spa/testing";
import YourComponent from "./YourComponent.vue";
import { router } from "../router"; // Import your app's router
// Set up MSW server if component makes network requests
const handlers = [
http.post("http://api.localhost:3000/endpoint", async () => {
return HttpResponse.json({
success: true,
data: {
/* response data */
},
});
}),
];
describe("YourComponent", () => {
stubGlobals();
const server = setupMockServer(handlers);
// Helper functions for element selection
const getEmailInput = (wrapper: VueWrapper) => {
const inputs = wrapper.findAllComponents({ name: "v-text-field" });
const emailInput = inputs.find(
(input) => input.props("placeholder") === "Email address",
);
expect(emailInput?.exists()).toBe(true);
return emailInput!;
};
const getSubmitButton = (wrapper: VueWrapper) => {
const buttons = wrapper.findAllComponents({ name: "v-btn" });
const submitButton = buttons.find((button) => button.text() === "Submit");
expect(submitButton?.exists()).toBe(true);
return submitButton!;
};
const mountComponent = () => {
return mountWithPlugins(YourComponent, {}, { router });
};
// Tests go here
it("should render the form", () => {
const wrapper = mountComponent();
expect(getEmailInput(wrapper).exists()).toBe(true);
expect(getSubmitButton(wrapper).exists()).toBe(true);
});
});
Using Test Utilities
We provide several test utilities to simplify component testing, especially with Vuetify components:
Global Mocks
Always use stubGlobals()
at the start of your test suite to set up necessary global mocks:
import { stubGlobals } from "@saflib/vue-spa/testing";
describe("YourComponent", () => {
stubGlobals();
// Your tests here
});
This helper:
- Sets up a mock ResizeObserver implementation
- Mocks the global
location
object - Cleans up all global mocks after tests complete
Network Mocking with MSW
We use MSW (Mock Service Worker) to mock network requests at the HTTP level. This approach is more reliable than mocking query hooks or API clients directly.
- Set up the mock server in your test file:
import { http, HttpResponse } from "msw";
import { setupMockServer } from "@saflib/vue-spa/testing";
const handlers = [
http.post("http://api.localhost:3000/endpoint", async () => {
return HttpResponse.json({
success: true,
data: {
/* response data */
},
});
}),
];
const server = setupMockServer(handlers);
- Override handlers for specific test cases:
it("should handle error response", async () => {
server.use(
http.post("http://api.localhost:3000/endpoint", () => {
return new HttpResponse(JSON.stringify({ error: "API Error" }), {
status: 500,
});
}),
);
// Test error handling...
});
- Use
vi.waitFor
orvi.waitUntil
for async operations:
it("should show success message after API call", async () => {
const wrapper = mountComponent();
await submitButton.trigger("click");
// Wait for the success alert to appear
const successAlert = await vi.waitUntil(() => getSuccessAlert(wrapper));
expect(successAlert?.text()).toContain("Success message");
});
Mounting Components with Plugins
The mountWithPlugins
function simplifies mounting components that use Vuetify and other plugins like Vue Router:
import { mountWithPlugins } from "@saflib/vue-spa/testing";
import { router } from "../router";
const mountComponent = () => {
return mountWithPlugins(YourComponent, {}, { router });
};
This function:
- Creates a Vuetify instance with all components and directives
- Sets up the router you provide (or creates a default one if none is provided)
- Mounts the component with the Vuetify plugin and router
- Merges any additional options you provide
Always provide your app's router when testing components that use routing:
// Good - provide the actual router
return mountWithPlugins(YourComponent, {}, { router });
// Avoid - using router stubs
return mountWithPlugins(YourComponent, {
global: {
stubs: ["router-link"],
},
});
Best Practices
1. Start with a Render Test
Always begin with a render test that validates your element selection helpers work:
it("should render the form", () => {
const wrapper = mountComponent();
expect(getEmailInput(wrapper).exists()).toBe(true);
expect(getPasswordInput(wrapper).exists()).toBe(true);
expect(getSubmitButton(wrapper).exists()).toBe(true);
});
This test serves two purposes:
- Verifies the component renders correctly
- Tests your element selection helpers early
2. Element Selection Strategy
Prefer
findComponent
andfindAllComponents
overfind
:typescript// Good - using findComponent for single components const getSubmitButton = (wrapper: VueWrapper) => { const buttons = wrapper.findAllComponents({ name: "v-btn" }); const submitButton = buttons.find((button) => button.text() === "Submit"); expect(submitButton?.exists()).toBe(true); return submitButton!; }; // Good - using findAllComponents for multiple components const getErrorAlert = (wrapper: VueWrapper) => { const alerts = wrapper.findAllComponents({ name: "v-alert" }); const errorAlert = alerts.find((alert) => alert.props("type") === "error"); expect(errorAlert?.exists()).toBe(true); return errorAlert; }; // Avoid - using find with selectors, especially internal Vuetify classes wrapper.find(".v-alert--error");
Selection priority (in order of preference):
- Component name + props (e.g.,
findComponent({ name: "v-btn", props: { color: "error" } })
) - Component name + text content
- Component name + context
- Placeholder text for inputs
- Button/element text content
- Component-specific classes
- Custom data attributes (last resort)
- Component name + props (e.g.,
Make selection helpers robust:
typescriptconst getInput = (wrapper: VueWrapper, placeholder: string) => { const inputs = wrapper.findAllComponents({ name: "v-text-field" }); const input = inputs.find( (input) => input.props("placeholder") === placeholder, ); expect(input?.exists()).toBe(true); return input!; };
3. Async Testing
Always use
async/await
when:- Setting input values
- Triggering events
- Checking validation messages
- After any state changes
Use
wrapper.vm.$nextTick()
after state changes:typescriptawait input.setValue("value"); await wrapper.vm.$nextTick(); expect(wrapper.text()).toContain("Validation message");
Use
vi.waitFor
orvi.waitUntil
for async operations:typescript// For checking conditions await vi.waitFor(() => expect(location.href).toBe("/app/")); // For finding elements const successAlert = await vi.waitUntil(() => getSuccessAlert(wrapper)); expect(successAlert?.text()).toContain("Success message");
Common misconceptions:
- Multiple
$nextTick
calls are not needed - a single call is sufficient - Adding more
$nextTick
calls won't fix timing issues - If a test is flaky, look for other causes like missing awaits on events or setValue calls
- Multiple
4. Form Interaction Helpers
Create reusable helpers for common form interactions:
const fillForm = async (
wrapper: VueWrapper,
{ email, password }: { email: string; password: string },
) => {
await getEmailInput(wrapper).setValue(email);
await getPasswordInput(wrapper).setValue(password);
await wrapper.vm.$nextTick();
// Wait for validation to complete
await new Promise((resolve) => setTimeout(resolve, 0));
};
5. Validation Testing
Test validation messages using text content:
it("should validate email format", async () => {
const wrapper = mountComponent();
const emailInput = getEmailInput(wrapper);
// Test invalid email
await emailInput.setValue("invalid-email");
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("Email must be valid");
// Test valid email
await emailInput.setValue("valid@email.com");
await wrapper.vm.$nextTick();
expect(wrapper.text()).not.toContain("Email must be valid");
});
6. Button State Testing
Test button states using attributes:
it("should disable submit button when form is invalid", async () => {
const wrapper = mountComponent();
const submitButton = getSubmitButton(wrapper);
// Initially disabled
expect(submitButton.attributes("disabled")).toBe("");
// After valid input
await fillForm(wrapper, {
email: "valid@email.com",
password: "validpassword123",
});
expect(submitButton.attributes("disabled")).toBeUndefined();
});
7. Network Request Testing
Set up default handlers for successful responses using types from your OpenAPI spec:
Important: When using API clients created via
createSafClient
(like those in@saflib/auth-vue
or@saflib/app-vue
), MSW handlers must use the full URL, including the host and the API prefix (e.g.,http://api.localhost:3000/auth/login
). Relative paths will not work because the client uses the full base URL.typescriptimport type { LoginRequest, LoginResponse } from "@saflib/identity-spec"; export const handlers = [ // Use the full URL matching the client's request http.post("http://api.localhost:3000/auth/login", async ({ request }) => { const body = (await request.json()) as LoginRequest; return HttpResponse.json({ success: true, token: "mock-token", user: { id: 1, email: body.email, // "as LoginRequest" ensures "email" is present // Add other required user fields }, } satisfies LoginResponse); // Ensures response structure is correct }), ];
Override handlers for specific test cases using
server.use()
:typescriptimport type { LoginResponse } from "@saflib/identity-spec"; // Import response type it("should handle error response", async () => { // Use server.use() to add or override handlers for this test server.use( // Ensure the URL matches the actual request URL http.post("http://api.localhost:3000/auth/login", () => { // Return an error response (doesn't need 'satisfies' unless it has a specific error structure) return new HttpResponse(JSON.stringify({ error: "API Error" }), { status: 500, }); }), ); // Test error handling... }); it("should handle specific success response", async () => { // Use server.use() to override the default success handler server.use( http.post("http://api.localhost:3000/auth/login", async () => { // You can also use the response type this way instead of "satisfies" const specificResponseData: LoginResponse = { token: "specific-test-token", user: { id: 2, email: "specific@example.com" }, }; // Use 'satisfies' here as well for the specific override return HttpResponse.json(specificResponseData); }), ); // Test specific success scenario... });
Important notes about handler overrides:
- Use
server.use()
to add or override handlers for specific tests. - New handlers added via
server.use()
are prepended to the existing list, meaning they take precedence if they match the same request. - You don't need to recreate the entire
handlers
array. - The original handlers defined outside the test remain available for other tests or if no override matches.
- Handlers are reset automatically between tests by
setupMockServer
.
- Use
Use
vi.waitFor
orvi.waitUntil
to wait for async operations:typescriptawait submitButton.trigger("click"); // Wait for an element affected by the API response const successAlert = await vi.waitUntil(() => getSuccessAlert(wrapper)); expect(successAlert?.text()).toContain("Login successful");
Type Safety:
- Always import the relevant response type (e.g.,
LoginResponse
) from your OpenAPI specification types. - Use the
satisfies
keyword on the object returned byHttpResponse.json
in your success handlers. This ensures TypeScript checks that your mock response data structure perfectly matches the expected API response type. - Alternatively type your response object directly (
const response: ResponseType = { ... }
) - Benefit: This prevents runtime errors caused by mismatches between your mock data shape and what the component expects based on the API contract, as demonstrated in the refactoring example.
- Avoid creating local interfaces for API types; always use the generated types.
- Always import the relevant response type (e.g.,
8. Testing Vuetify Components
Finding Vuetify Components:
typescript// Use findComponent with the name option const dialog = wrapper.findComponent({ name: "v-dialog" }); expect(dialog.exists()).toBe(true); // For buttons with icons, find by icon class const deleteButton = wrapper .findAll(".v-btn") .find((btn) => btn.find(".mdi-delete").exists());
Testing Dialogs:
It's unclear how to do this. It may also not be that valuable. Perhaps it would be better to separate out dialog components and then test the component that opens a modal with a mock modal. If testing modal interactions can't be done well, then just skip the tests.
9. Black-Box Testing Approach
Prefer black-box testing over accessing component internals:
typescript// Avoid - accessing component's internal state // @ts-expect-error - Accessing component's internal state expect(wrapper.vm.showDeleteDialog).toBe(true); // Better - verify the dialog is visible in the DOM const dialog = wrapper.findComponent({ name: "v-dialog" }); expect(dialog.exists()).toBe(true);
Avoid calling component methods directly:
typescript// Avoid - calling component's internal method // @ts-expect-error - Accessing component's internal method await wrapper.vm.deleteCallSeries(); // Better - simulate user interaction await deleteButton.trigger("click"); await wrapper.vm.$nextTick(); const confirmButton = wrapper.findAllComponents({ name: "v-btn" }).at(-1); await confirmButton.trigger("click");
Focus on testing behavior, not implementation:
- Test what the user sees and can interact with
- Verify that the correct events are emitted
- Check that the right API calls are made
- Ensure error messages are displayed correctly
10. Common Gotchas
Vuetify Validation:
- May need multiple
$nextTick
calls - Use
wrapper.text()
to check validation messages - Button states may depend on form validation
- May need multiple
Component Mounting:
- Always use
mountWithPlugins
for Vuetify components - Provide your app's router when testing components that use routing
- Consider global plugins and providers
- Always use
Async Operations:
- Always use
async/await
- Wait for component updates with
$nextTick
- Test both success and error states
- Always use
Reactive Properties in Mocks:
- Reactive properties from composables should be objects with a
value
property - Example:
isPending: { value: false }
instead ofisPending: false
- Reactive properties from composables should be objects with a
Finding Elements in Dialogs:
- Dialogs may be rendered in portals outside the component's DOM tree
- Use
wrapper.findAll()
instead ofdialog.findAll()
- Identify elements by text content rather than by class names
ResizeObserver Issues:
- Always wrap Vuetify component tests with
withResizeObserverMock
- Place the wrapper at the top level of your test file
- This ensures ResizeObserver is properly mocked for all tests
- Always wrap Vuetify component tests with
11. Testing Route-Dependent Components
When testing components that depend on specific route configurations (URLs or query parameters), set the route before mounting the component:
describe("ResetPasswordPage", () => {
it("should show success message after password reset", async () => {
// Set up the route with required query parameters
await router.push("/reset-password?token=valid-token");
const wrapper = mountComponent();
const resetButton = getResetButton(wrapper);
// Test the component...
});
});
Key points:
- Use
router.push()
before mounting the component - Include any required query parameters in the URL
- Wait for the route change to complete with
await
- Mount the component after the route is set
This ensures the component receives the correct route configuration during testing.
Example Test
import { describe, it, expect, vi } from "vitest";
import { stubGlobals, mountWithPlugins } from "@saflib/vue-spa/testing";
import { type VueWrapper } from "@vue/test-utils";
import { http, HttpResponse } from "msw";
import { setupMockServer } from "@saflib/vue-spa/testing";
import LoginForm from "../LoginForm.vue";
import { router } from "../router";
import type { LoginRequest, UserResponse } from "../requests/types";
// Set up MSW server
const handlers = [
http.post("http://api.localhost:3000/auth/login", async ({ request }) => {
const body = (await request.json()) as LoginRequest;
return HttpResponse.json({
success: true,
data: {
token: "mock-token",
user: {
id: 1,
email: body.email,
} satisfies UserResponse,
},
});
}),
];
describe("LoginForm", () => {
stubGlobals();
const server = setupMockServer(handlers);
// Helper functions for element selection
const getEmailInput = (wrapper: VueWrapper) => {
const inputs = wrapper.findAllComponents({ name: "v-text-field" });
const emailInput = inputs.find(
(input) => input.props("placeholder") === "Email address",
);
expect(emailInput?.exists()).toBe(true);
return emailInput!;
};
const getPasswordInput = (wrapper: VueWrapper) => {
const inputs = wrapper.findAllComponents({ name: "v-text-field" });
const passwordInput = inputs.find(
(input) => input.props("placeholder") === "Enter your password",
);
expect(passwordInput?.exists()).toBe(true);
return passwordInput!;
};
const getLoginButton = (wrapper: VueWrapper) => {
const buttons = wrapper.findAllComponents({ name: "v-btn" });
const loginButton = buttons.find((button) => button.text() === "Log In");
expect(loginButton?.exists()).toBe(true);
return loginButton!;
};
const mountComponent = () => {
return mountWithPlugins(LoginForm, {}, { router });
};
const fillLoginForm = async (
wrapper: VueWrapper,
{ email, password }: { email: string; password: string },
) => {
await getEmailInput(wrapper).setValue(email);
await getPasswordInput(wrapper).setValue(password);
await vi.waitFor(() =>
expect(wrapper.text()).not.toContain("Email must be valid"),
);
};
it("should render the login form", () => {
const wrapper = mountComponent();
expect(getEmailInput(wrapper).exists()).toBe(true);
expect(getPasswordInput(wrapper).exists()).toBe(true);
expect(getLoginButton(wrapper).exists()).toBe(true);
});
it("should disable login button when form is invalid", async () => {
const wrapper = mountComponent();
const loginButton = getLoginButton(wrapper);
// Initially disabled
expect(loginButton.attributes("disabled")).toBe("");
// Invalid email
await fillLoginForm(wrapper, {
email: "invalid-email",
password: "password123",
});
expect(loginButton.attributes("disabled")).toBe("");
// Valid email but short password
await fillLoginForm(wrapper, {
email: "test@example.com",
password: "short",
});
expect(loginButton.attributes("disabled")).toBe("");
// Valid form
await fillLoginForm(wrapper, {
email: "test@example.com",
password: "validpassword123",
});
expect(loginButton.attributes("disabled")).toBeUndefined();
});
it("should call login API with correct credentials", async () => {
const wrapper = mountComponent();
const loginButton = getLoginButton(wrapper);
const testEmail = "test@example.com";
const testPassword = "validpassword123";
await fillLoginForm(wrapper, {
email: testEmail,
password: testPassword,
});
await wrapper.vm.$nextTick();
// Create a spy for the API request
let requestBody: LoginRequest | null = null;
server.use(
http.post("http://api.localhost:3000/auth/login", async ({ request }) => {
requestBody = (await request.json()) as LoginRequest;
return HttpResponse.json({
success: true,
data: {
token: "mock-token",
user: {
id: 1,
email: requestBody.email,
},
} satisfies UserResponse,
});
}),
);
await loginButton.trigger("click");
// Wait for the API request to complete
await vi.waitFor(() => expect(location.href).toBe("/app/"));
});
});