Component Testing
Component tests verify that Vue components render correctly, respond to user interaction, and emit the right events. CPR uses @vue/test-utils 2.x with happy-dom to mount components in a simulated DOM.
Mounting Components
Use mount for full rendering (including child components) or shallowMount to stub child components:
import { mount, shallowMount } from '@vue/test-utils';
import UiButton from '~/components/ui/UiButton.vue';
// Full mount -- renders child components
const wrapper = mount(UiButton, {
props: { variant: 'primary' },
slots: { default: 'Save Patient' },
});
// Shallow mount -- stubs child components
const shallow = shallowMount(UiButton, {
props: { variant: 'primary' },
slots: { default: 'Save Patient' },
});Testing UiButton
The UiButton component supports variants (primary, secondary, outline, ghost, danger), sizes, loading state, disabled state, and icon props.
// app/components/ui/UiButton.spec.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import UiButton from '~/components/ui/UiButton.vue';
describe('UiButton', () => {
it('renders slot content', () => {
const wrapper = mount(UiButton, {
slots: { default: 'Add Patient' },
});
expect(wrapper.text()).toContain('Add Patient');
});
it('applies primary variant classes by default', () => {
const wrapper = mount(UiButton);
expect(wrapper.classes()).toContain('bg-[#117ea7]');
});
it('applies danger variant classes', () => {
const wrapper = mount(UiButton, {
props: { variant: 'danger' },
});
expect(wrapper.classes()).toContain('bg-red-600');
});
it('disables button when disabled prop is true', () => {
const wrapper = mount(UiButton, {
props: { disabled: true },
});
expect(wrapper.attributes('disabled')).toBeDefined();
});
it('disables button when loading is true', () => {
const wrapper = mount(UiButton, {
props: { loading: true },
});
expect(wrapper.attributes('disabled')).toBeDefined();
});
it('shows spinner SVG when loading', () => {
const wrapper = mount(UiButton, {
props: { loading: true },
});
expect(wrapper.find('svg.animate-spin').exists()).toBe(true);
});
it('does not show spinner when not loading', () => {
const wrapper = mount(UiButton, {
props: { loading: false },
});
expect(wrapper.find('svg.animate-spin').exists()).toBe(false);
});
it('defaults to type="button"', () => {
const wrapper = mount(UiButton);
expect(wrapper.attributes('type')).toBe('button');
});
it('can be set to type="submit"', () => {
const wrapper = mount(UiButton, {
props: { type: 'submit' },
});
expect(wrapper.attributes('type')).toBe('submit');
});
});Testing UiInput
The UiInput component handles labels, error display, v-model updates, and different input types.
// app/components/ui/UiInput.spec.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import UiInput from '~/components/ui/UiInput.vue';
describe('UiInput', () => {
it('renders label text', () => {
const wrapper = mount(UiInput, {
props: { label: 'Patient Name', modelValue: '' },
});
expect(wrapper.find('label').text()).toContain('Patient Name');
});
it('shows required asterisk when required', () => {
const wrapper = mount(UiInput, {
props: { label: 'Email', required: true, modelValue: '' },
});
expect(wrapper.find('.text-red-500').text()).toBe('*');
});
it('emits update:modelValue on input', async () => {
const wrapper = mount(UiInput, {
props: { modelValue: '' },
});
await wrapper.find('input').setValue('Juan Dela Cruz');
expect(wrapper.emitted('update:modelValue')).toBeTruthy();
expect(wrapper.emitted('update:modelValue')![0]).toEqual([
'Juan Dela Cruz',
]);
});
it('emits number for type="number"', async () => {
const wrapper = mount(UiInput, {
props: { modelValue: null, type: 'number' },
});
await wrapper.find('input').setValue('42');
expect(wrapper.emitted('update:modelValue')![0]).toEqual([42]);
});
it('displays error message', () => {
const wrapper = mount(UiInput, {
props: {
modelValue: '',
error: 'Patient Name is required',
},
});
expect(wrapper.find('.text-red-500').text()).toContain(
'Patient Name is required'
);
});
it('applies error border classes when error is present', () => {
const wrapper = mount(UiInput, {
props: { modelValue: '', error: 'Required' },
});
expect(wrapper.find('input').classes()).toContain('border-red-500');
});
it('disables input when disabled prop is true', () => {
const wrapper = mount(UiInput, {
props: { modelValue: '', disabled: true },
});
expect(wrapper.find('input').attributes('disabled')).toBeDefined();
});
});Testing UiModal
The UiModal component uses Teleport, Transition, and keyboard events. Stub Teleport to test its rendering behavior.
// app/components/ui/UiModal.spec.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import UiModal from '~/components/ui/UiModal.vue';
describe('UiModal', () => {
const mountModal = (props = {}) =>
mount(UiModal, {
props: { modelValue: true, title: 'Add Patient', ...props },
global: {
stubs: { Teleport: true },
},
});
it('renders title when modelValue is true', () => {
const wrapper = mountModal();
expect(wrapper.text()).toContain('Add Patient');
});
it('does not render when modelValue is false', () => {
const wrapper = mountModal({ modelValue: false });
expect(wrapper.text()).not.toContain('Add Patient');
});
it('emits update:modelValue false on close button click', async () => {
const wrapper = mountModal();
await wrapper.find('button').trigger('click');
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false]);
});
it('renders subtitle when provided', () => {
const wrapper = mountModal({ subtitle: 'Fill in patient details' });
expect(wrapper.text()).toContain('Fill in patient details');
});
it('emits close on backdrop click when closeOnBackdrop is true', async () => {
const wrapper = mountModal({ closeOnBackdrop: true });
await wrapper.find('.bg-black\\/50').trigger('click');
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false]);
});
});Testing UiTable
The UiTable component accepts columns and data, and supports clickable rows.
// app/components/ui/UiTable.spec.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import UiTable from '~/components/ui/UiTable.vue';
describe('UiTable', () => {
const columns = [
{ key: 'name', label: 'Patient Name' },
{ key: 'status', label: 'Status' },
];
const data = [
{ id: 1, name: 'Juan Dela Cruz', status: 'ACTIVE' },
{ id: 2, name: 'Maria Santos', status: 'PENDING' },
];
it('renders column headers', () => {
const wrapper = mount(UiTable, { props: { columns, data } });
expect(wrapper.text()).toContain('Patient Name');
expect(wrapper.text()).toContain('Status');
});
it('renders data rows', () => {
const wrapper = mount(UiTable, { props: { columns, data } });
expect(wrapper.text()).toContain('Juan Dela Cruz');
expect(wrapper.text()).toContain('Maria Santos');
});
it('emits row-click when clickable and row is clicked', async () => {
const wrapper = mount(UiTable, {
props: { columns, data, clickable: true },
});
const rows = wrapper.findAll('tbody tr');
await rows[0].trigger('click');
expect(wrapper.emitted('row-click')?.[0]).toEqual([data[0]]);
});
it('does not add cursor-pointer class when not clickable', () => {
const wrapper = mount(UiTable, {
props: { columns, data, clickable: false },
});
const row = wrapper.find('tbody tr');
expect(row.classes()).not.toContain('cursor-pointer');
});
});Testing Components with Pinia Stores
When a component depends on a Pinia store, provide a test Pinia instance:
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
const wrapper = mount(MyComponent, {
global: {
plugins: [
createTestingPinia({
initialState: {
auth: {
state: {
user: {
id: 1,
name: 'Dr. Santos',
permissions: ['view_patients'],
},
token: 'test-token',
},
},
},
}),
],
},
});Mocking Composables
Mock composables at the module level before importing the component under test:
import { vi } from 'vitest';
import { ref } from 'vue';
vi.mock('~/composables/useSnackBar', () => ({
useSnackBar: () => ({
show: vi.fn(),
close: vi.fn(),
message: ref(''),
type: ref('success'),
visible: ref(false),
}),
}));
vi.mock('~/composables/useConfirmDialog', () => ({
useConfirmDialog: () => ({
open: vi.fn().mockResolvedValue(true),
confirm: vi.fn(),
cancel: vi.fn(),
visible: ref(false),
title: ref(''),
message: ref(''),
loading: ref(false),
}),
}));Testing Form Submission
Many CPR forms follow a pattern: validate, call composable, show snackbar.
import { describe, it, expect, vi } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import { ref } from 'vue';
const mockCreate = vi.fn();
vi.mock('~/composables/insurance/useCreateInsurance', () => ({
useCreateInsurance: () => ({
loading: ref(false),
error: ref(''),
success: ref(false),
createInsurance: mockCreate,
}),
}));
describe('InsuranceForm', () => {
it('calls createInsurance on valid form submission', async () => {
mockCreate.mockResolvedValue(undefined);
const wrapper = mount(InsuranceCreatePage, {
global: {
plugins: [createTestingPinia()],
},
});
await wrapper.find('[data-testid="name-input"]').setValue('PhilHealth');
await wrapper.find('form').trigger('submit');
await flushPromises();
expect(mockCreate).toHaveBeenCalled();
});
});Best Practices
- Use
data-testidattributes for selecting elements in tests. CSS classes may change with design updates. - Prefer
shallowMountwhen you only need to test the component's own logic, not child components. - Use
flushPromisesafter any async operation to let Vue update the DOM. - Stub
Teleportwhen testing modals, dialogs, and snackbars to avoid DOM mounting issues. - Test accessibility -- verify that labels are associated with inputs (
for/idpairing), buttons have text content, and ARIA attributes are present. - Avoid testing implementation details -- test what the user sees (text, visibility, interactions), not internal state.
