Skip to content

Last updated:

Unit Testing

Unit tests target individual functions, composables, and validation rules in isolation. In CPR, most business logic lives in composables and utility modules, making them the primary targets for unit testing.

Testing Validation Rules

The useValidationRules module contains pure functions that are ideal for unit testing. Each rule returns a validator function that takes a value and returns either an error string or null.

ts
// app/composables/useValidationRules.spec.ts
import { describe, it, expect } from 'vitest';
import {
  required,
  numeric,
  greaterThanZero,
  minLength,
  maxLength,
  email,
  minValue,
  maxValue,
  requiredIf,
  oneOf,
} from '~/composables/useValidationRules';

describe('required', () => {
  const validate = required('Patient Name');

  it('returns error for empty string', () => {
    expect(validate('')).toBe('Patient Name is required');
  });

  it('returns error for null', () => {
    expect(validate(null)).toBe('Patient Name is required');
  });

  it('returns error for undefined', () => {
    expect(validate(undefined)).toBe('Patient Name is required');
  });

  it('returns null for valid value', () => {
    expect(validate('Juan Dela Cruz')).toBeNull();
  });
});

describe('numeric', () => {
  const validate = numeric('Amount');

  it('returns error for non-numeric string', () => {
    expect(validate('abc')).toBe('Amount must be a number');
  });

  it('returns null for numeric string', () => {
    expect(validate('123.45')).toBeNull();
  });

  it('returns null for empty value (not required)', () => {
    expect(validate('')).toBeNull();
  });
});

describe('greaterThanZero', () => {
  const validate = greaterThanZero('Quantity');

  it('returns error for zero', () => {
    expect(validate(0)).toBe('Quantity must be greater than 0');
  });

  it('returns error for negative', () => {
    expect(validate(-5)).toBe('Quantity must be greater than 0');
  });

  it('returns null for positive number', () => {
    expect(validate(10)).toBeNull();
  });
});

describe('email', () => {
  const validate = email();

  it('returns error for invalid email', () => {
    expect(validate('not-an-email')).toBe(
      'Email must be a valid email address'
    );
  });

  it('returns null for valid email', () => {
    expect(validate('doctor@clinic.com')).toBeNull();
  });
});

describe('requiredIf', () => {
  it('returns error when condition is true and value is empty', () => {
    const validate = requiredIf('Insurance ID', true);
    expect(validate('')).toBe('Insurance ID is required');
  });

  it('returns null when condition is false', () => {
    const validate = requiredIf('Insurance ID', false);
    expect(validate('')).toBeNull();
  });
});

describe('oneOf', () => {
  const validate = oneOf('Status', ['ACTIVE', 'PENDING', 'DISCHARGED']);

  it('returns error for value not in list', () => {
    expect(validate('INVALID')).toBe('Status is invalid');
  });

  it('returns null for allowed value', () => {
    expect(validate('ACTIVE')).toBeNull();
  });
});

Testing useFormValidation

The useFormValidation composable combines validation rules and provides reactive error tracking.

ts
// app/composables/useFormValidation.spec.ts
import { describe, it, expect } from 'vitest';
import { useFormValidation } from '~/composables/useFormValidation';
import { required, email } from '~/composables/useValidationRules';

describe('useFormValidation', () => {
  it('validates form and populates errors', () => {
    const { errors, validate } = useFormValidation({
      name: [required('Name')],
      email: [required('Email'), email()],
    });

    const isValid = validate({ name: '', email: 'bad' });

    expect(isValid).toBe(false);
    expect(errors.name).toBe('Name is required');
    expect(errors.email).toBe('Email must be a valid email address');
  });

  it('returns true when all fields are valid', () => {
    const { validate } = useFormValidation({
      name: [required('Name')],
    });

    const isValid = validate({ name: 'Dr. Santos' });
    expect(isValid).toBe(true);
  });

  it('clears errors on re-validation', () => {
    const { errors, validate } = useFormValidation({
      name: [required('Name')],
    });

    validate({ name: '' });
    expect(errors.name).toBe('Name is required');

    validate({ name: 'Dr. Santos' });
    expect(errors.name).toBeFalsy();
  });

  it('supports manual error setting', () => {
    const { errors, setError, clearError } = useFormValidation({});

    setError('username', 'Username already taken');
    expect(errors.username).toBe('Username already taken');

    clearError('username');
    expect(errors.username).toBe('');
  });
});

Testing Composables with API Calls

Most CPR composables follow the loading/error/success pattern. To test them, mock the service layer.

ts
// app/composables/insurance/useCreateInsurance.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useCreateInsurance } from '~/composables/insurance/useCreateInsurance';

// Mock the service module
vi.mock('~/sevices/insurance.service', () => ({
  insuranceService: {
    createInsurance: vi.fn(),
  },
}));

import { insuranceService } from '~/sevices/insurance.service';

describe('useCreateInsurance', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('sets loading during request', async () => {
    vi.mocked(insuranceService.createInsurance).mockResolvedValue(undefined);

    const { loading, createInsurance } = useCreateInsurance();

    expect(loading.value).toBe(false);

    const promise = createInsurance({ name: 'PhilHealth', plan_type: 'HMO' });
    expect(loading.value).toBe(true);

    await promise;
    expect(loading.value).toBe(false);
  });

  it('sets success on successful creation', async () => {
    vi.mocked(insuranceService.createInsurance).mockResolvedValue(undefined);

    const { success, createInsurance } = useCreateInsurance();

    await createInsurance({ name: 'PhilHealth', plan_type: 'HMO' });

    expect(success.value).toBe(true);
  });

  it('sets error message on failure', async () => {
    vi.mocked(insuranceService.createInsurance).mockRejectedValue(
      new Error('Duplicate entry')
    );

    const { error, success, createInsurance } = useCreateInsurance();

    await createInsurance({ name: 'PhilHealth', plan_type: 'HMO' });

    expect(error.value).toBe('Duplicate entry');
    expect(success.value).toBe(false);
  });
});

Testing useApiFetch

Since useApiFetch depends on runtime config and the auth store, mock both:

ts
// app/composables/useApiFetch.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock dependencies
vi.mock('#imports', () => ({
  useRuntimeConfig: () => ({
    public: { API_URL: 'http://localhost:8000/api/v1' },
  }),
}));

vi.mock('~/stores/auth.store', () => ({
  useAuthStore: () => ({
    state: { token: 'test-token-123' },
    hydrate: vi.fn(),
  }),
}));

vi.mock('ofetch', () => ({
  $fetch: vi.fn(),
}));

import { useApiFetch } from '~/composables/useApiFetch';
import { $fetch } from 'ofetch';

describe('useApiFetch', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('prepends base URL to endpoint', async () => {
    vi.mocked($fetch).mockResolvedValue({ data: [] });

    await useApiFetch('/patients');

    expect($fetch).toHaveBeenCalledWith(
      'http://localhost:8000/api/v1/patients',
      expect.any(Object)
    );
  });

  it('includes Authorization header with bearer token', async () => {
    vi.mocked($fetch).mockResolvedValue({ data: [] });

    await useApiFetch('/patients');

    expect($fetch).toHaveBeenCalledWith(
      expect.any(String),
      expect.objectContaining({
        headers: expect.objectContaining({
          Authorization: 'Bearer test-token-123',
        }),
      })
    );
  });
});

Testing useStatusColor

Pure mapping functions are straightforward to test:

ts
// app/composables/useStatusColor.spec.ts
import { describe, it, expect } from 'vitest';
import { useStatus } from '~/composables/useStatusColor';

describe('useStatus', () => {
  const { getStatusColor } = useStatus();

  it('returns green for ACTIVE', () => {
    expect(getStatusColor('ACTIVE')).toBe('green');
  });

  it('returns yellow for PENDING', () => {
    expect(getStatusColor('PENDING')).toBe('yellow');
  });

  it('returns gray for DISCHARGED', () => {
    expect(getStatusColor('DISCHARGED')).toBe('gray');
  });

  it('returns gray for unknown status', () => {
    expect(getStatusColor('UNKNOWN')).toBe('gray');
  });

  it('returns gray when status is undefined', () => {
    expect(getStatusColor(undefined)).toBe('gray');
  });
});

Testing Pinia Stores

Use createPinia and setActivePinia to test stores in isolation:

ts
// app/stores/auth.store.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useAuthStore } from '~/stores/auth.store';

// Mock sessionStorage
const mockSessionStorage = {
  getItem: vi.fn(),
  setItem: vi.fn(),
  removeItem: vi.fn(),
};
Object.defineProperty(globalThis, 'sessionStorage', {
  value: mockSessionStorage,
});

describe('useAuthStore', () => {
  beforeEach(() => {
    setActivePinia(createPinia());
    vi.clearAllMocks();
  });

  it('hydrates state from sessionStorage', () => {
    const stored = {
      user: {
        id: 1,
        name: 'Dr. Santos',
        username: 'dsantos',
        email: 'dr@clinic.com',
      },
      token: 'jwt-token-here',
    };
    mockSessionStorage.getItem.mockReturnValue(JSON.stringify(stored));

    const store = useAuthStore();
    store.hydrate();

    expect(store.state.user?.name).toBe('Dr. Santos');
    expect(store.state.token).toBe('jwt-token-here');
    expect(store.isAuthenticated).toBe(true);
  });

  it('resets state correctly', () => {
    const store = useAuthStore();
    store.state.token = 'some-token';
    store.state.user = {
      id: 1,
      name: 'Test',
      username: 'test',
      email: 'test@test.com',
    };

    store.reset();

    expect(store.state.token).toBeNull();
    expect(store.state.user).toBeNull();
    expect(store.isAuthenticated).toBe(false);
  });

  it('handles corrupted sessionStorage gracefully', () => {
    mockSessionStorage.getItem.mockReturnValue('invalid-json');

    const store = useAuthStore();
    store.hydrate();

    expect(store.state.user).toBeNull();
    expect(store.state.token).toBeNull();
  });
});

Best Practices

  1. Mock at the service boundary -- Mock insuranceService, authService, etc. rather than useApiFetch. This keeps tests focused on composable logic.
  2. Test the loading/error/success lifecycle -- Every async composable in CPR follows this pattern. Always test all three states.
  3. Use vi.clearAllMocks() in beforeEach -- Prevents state leaks between tests.
  4. Test edge cases for medical data -- Patient records, medication quantities, and billing amounts need validation for null, undefined, 0, negative numbers, and boundary values.
  5. Keep tests fast -- Unit tests should not hit the network. Mock everything external.

CPR - Clinical Patient Records