Skip to content

Last updated:

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:

ts
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.

ts
// 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.

ts
// 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.

ts
// 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.

ts
// 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:

ts
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:

ts
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.

ts
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

  1. Use data-testid attributes for selecting elements in tests. CSS classes may change with design updates.
  2. Prefer shallowMount when you only need to test the component's own logic, not child components.
  3. Use flushPromises after any async operation to let Vue update the DOM.
  4. Stub Teleport when testing modals, dialogs, and snackbars to avoid DOM mounting issues.
  5. Test accessibility -- verify that labels are associated with inputs (for/id pairing), buttons have text content, and ARIA attributes are present.
  6. Avoid testing implementation details -- test what the user sees (text, visibility, interactions), not internal state.

CPR - Clinical Patient Records