Skip to main content

Testing Strategy

Panduan lengkap untuk testing di MStore Dashboard, termasuk unit tests, component tests, dan E2E tests.

Testing Stack

TypeToolPurpose
Unit TestsVitestTest functions, composables, stores
Component TestsVitest + Vue Test UtilsTest Vue components
E2E TestsPlaywright / CypressTest full user flows

Test Directory Structure

tests/
├── unit/
│   ├── features/
│   │   ├── inventory/
│   │   │   ├── store.test.ts
│   │   │   └── composables.test.ts
│   │   └── auth/
│   │       └── store.test.ts
│   └── utils/
│       └── format.test.ts
├── components/
│   └── features/
│       └── inventory/
│           └── InventoryTable.test.ts
└── e2e/
    ├── auth.spec.ts
    └── inventory.spec.ts

Setup

Vitest Configuration

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'happy-dom',
    globals: true,
    setupFiles: ['./tests/setup.ts'],
  },
  resolve: {
    alias: {
      '~': resolve(__dirname, './'),
      '#imports': resolve(__dirname, './.nuxt/imports.d.ts'),
    },
  },
})

Test Setup File

// tests/setup.ts
import { config } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'

// Global mocks
vi.mock('#imports', () => ({
  useNuxtApp: () => ({
    $api: vi.fn(),
  }),
  useRuntimeConfig: () => ({
    public: {
      apiBase: 'http://localhost:3002',
    },
  }),
  navigateTo: vi.fn(),
  useRoute: () => ({
    path: '/',
    query: {},
  }),
  useRouter: () => ({
    push: vi.fn(),
  }),
}))

// Default Vue Test Utils config
config.global.plugins = [createTestingPinia()]

Unit Tests

Testing Utility Functions

// tests/unit/utils/format.test.ts
import { describe, it, expect } from 'vitest'
import { formatCurrency, formatDate } from '~/utils/format'

describe('formatCurrency', () => {
  it('should format number to IDR currency', () => {
    expect(formatCurrency(1500000)).toBe('Rp 1.500.000')
  })

  it('should handle zero', () => {
    expect(formatCurrency(0)).toBe('Rp 0')
  })

  it('should handle decimals', () => {
    expect(formatCurrency(1500000.5)).toBe('Rp 1.500.001')
  })
})

describe('formatDate', () => {
  it('should format date to Indonesian format', () => {
    const date = new Date('2024-01-15')
    expect(formatDate(date)).toBe('15 Januari 2024')
  })
})

Testing Pinia Stores

// tests/unit/features/inventory/store.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useInventoryStore } from '~/features/03_inventory'

// Mock API
vi.mock('~/features/03_inventory/api', () => ({
  getInventoryList: vi.fn(),
  createInventory: vi.fn(),
  deleteInventory: vi.fn(),
}))

import { getInventoryList, createInventory } from '~/features/03_inventory/api'

describe('Inventory Store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
    vi.clearAllMocks()
  })

  describe('fetchItems', () => {
    it('should fetch and store inventory items', async () => {
      const mockItems = [
        { id: '1', name: 'Product A', price: 100000 },
        { id: '2', name: 'Product B', price: 200000 },
      ]

      vi.mocked(getInventoryList).mockResolvedValue({
        data: mockItems,
        meta: { total: 2, page: 1, limit: 20 },
      })

      const store = useInventoryStore()
      await store.fetchItems()

      expect(store.items).toHaveLength(2)
      expect(store.items[0].name).toBe('Product A')
      expect(store.loading).toBe(false)
    })

    it('should set error on fetch failure', async () => {
      vi.mocked(getInventoryList).mockRejectedValue(new Error('Network error'))

      const store = useInventoryStore()
      await expect(store.fetchItems()).rejects.toThrow()

      expect(store.error).toBe('Network error')
      expect(store.loading).toBe(false)
    })

    it('should set loading state during fetch', async () => {
      vi.mocked(getInventoryList).mockImplementation(
        () => new Promise(resolve => setTimeout(resolve, 100))
      )

      const store = useInventoryStore()
      const promise = store.fetchItems()

      expect(store.loading).toBe(true)
      await promise
      expect(store.loading).toBe(false)
    })
  })

  describe('addItem', () => {
    it('should add new item to store', async () => {
      const newItem = { id: '3', name: 'Product C', price: 300000 }
      vi.mocked(createInventory).mockResolvedValue(newItem)

      const store = useInventoryStore()
      await store.addItem({ name: 'Product C', price: 300000 })

      expect(store.items).toContainEqual(newItem)
    })
  })

  describe('getters', () => {
    it('should compute hasItems correctly', () => {
      const store = useInventoryStore()

      expect(store.hasItems).toBe(false)

      store.items = [{ id: '1', name: 'Test', price: 100 }]
      expect(store.hasItems).toBe(true)
    })

    it('should compute lowStockItems correctly', () => {
      const store = useInventoryStore()
      store.items = [
        { id: '1', name: 'A', stock: 5, minStock: 10 },
        { id: '2', name: 'B', stock: 20, minStock: 10 },
        { id: '3', name: 'C', stock: 3, minStock: 5 },
      ]

      expect(store.lowStockItems).toHaveLength(2)
    })
  })
})

Testing Composables

// tests/unit/features/inventory/composables.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useInventoryList } from '~/features/03_inventory'

describe('useInventoryList', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('should provide reactive items from store', () => {
    const { items, loading } = useInventoryList()

    expect(items.value).toEqual([])
    expect(loading.value).toBe(false)
  })

  it('should filter items by search query', () => {
    const { items, searchQuery, filteredItems } = useInventoryList()

    // Setup mock data
    items.value = [
      { id: '1', name: 'Laptop', sku: 'ELEC-001' },
      { id: '2', name: 'Keyboard', sku: 'ELEC-002' },
      { id: '3', name: 'T-Shirt', sku: 'CLTH-001' },
    ]

    searchQuery.value = 'lap'
    expect(filteredItems.value).toHaveLength(1)
    expect(filteredItems.value[0].name).toBe('Laptop')
  })
})

Component Tests

Testing Vue Components

// tests/components/features/inventory/InventoryTable.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import InventoryTable from '~/features/03_inventory/components/InventoryTable.vue'

describe('InventoryTable', () => {
  const mockItems = [
    { id: '1', name: 'Product A', sku: 'SKU-001', price: 100000, stock: 10 },
    { id: '2', name: 'Product B', sku: 'SKU-002', price: 200000, stock: 5 },
  ]

  const createWrapper = (props = {}) => {
    return mount(InventoryTable, {
      props: {
        items: mockItems,
        loading: false,
        ...props,
      },
      global: {
        plugins: [createTestingPinia()],
        stubs: {
          UTable: true,
          UBadge: true,
          UButton: true,
        },
      },
    })
  }

  it('should render table with items', () => {
    const wrapper = createWrapper()
    expect(wrapper.html()).toContain('Product A')
    expect(wrapper.html()).toContain('Product B')
  })

  it('should show loading state', () => {
    const wrapper = createWrapper({ loading: true })
    expect(wrapper.find('[data-testid="loading"]').exists()).toBe(true)
  })

  it('should emit delete event when delete button clicked', async () => {
    const wrapper = createWrapper()
    await wrapper.find('[data-testid="delete-btn-1"]').trigger('click')

    expect(wrapper.emitted('delete')).toBeTruthy()
    expect(wrapper.emitted('delete')[0]).toEqual(['1'])
  })

  it('should emit edit event when edit button clicked', async () => {
    const wrapper = createWrapper()
    await wrapper.find('[data-testid="edit-btn-1"]').trigger('click')

    expect(wrapper.emitted('edit')).toBeTruthy()
    expect(wrapper.emitted('edit')[0][0]).toEqual(mockItems[0])
  })
})

Testing Forms

// tests/components/features/inventory/InventoryForm.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import InventoryForm from '~/features/03_inventory/components/InventoryForm.vue'

describe('InventoryForm', () => {
  const createWrapper = () => {
    return mount(InventoryForm, {
      global: {
        stubs: {
          UFormGroup: true,
          UInput: true,
          USelect: true,
          UButton: true,
        },
      },
    })
  }

  it('should validate required fields', async () => {
    const wrapper = createWrapper()

    await wrapper.find('form').trigger('submit')

    expect(wrapper.text()).toContain('Name is required')
    expect(wrapper.text()).toContain('SKU is required')
  })

  it('should emit submit event with form data', async () => {
    const wrapper = createWrapper()

    await wrapper.find('[data-testid="name-input"]').setValue('Test Product')
    await wrapper.find('[data-testid="sku-input"]').setValue('TEST-001')
    await wrapper.find('[data-testid="price-input"]').setValue('100000')

    await wrapper.find('form').trigger('submit')

    expect(wrapper.emitted('submit')).toBeTruthy()
    expect(wrapper.emitted('submit')[0][0]).toMatchObject({
      name: 'Test Product',
      sku: 'TEST-001',
      price: 100000,
    })
  })
})

E2E Tests

Playwright Setup

// playwright.config.ts
import { defineConfig } from '@playwright/test'

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:4001',
    trace: 'on-first-retry',
  },
  webServer: {
    command: 'pnpm dev',
    url: 'http://localhost:4001',
    reuseExistingServer: !process.env.CI,
  },
})

E2E Test Examples

// tests/e2e/auth.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Authentication', () => {
  test('should login with valid credentials', async ({ page }) => {
    await page.goto('/login')

    await page.fill('[data-testid="email-input"]', '[email protected]')
    await page.fill('[data-testid="password-input"]', 'password123')
    await page.click('[data-testid="login-button"]')

    // Wait for redirect
    await expect(page).toHaveURL('/')
    await expect(page.locator('[data-testid="user-menu"]')).toBeVisible()
  })

  test('should show error with invalid credentials', async ({ page }) => {
    await page.goto('/login')

    await page.fill('[data-testid="email-input"]', '[email protected]')
    await page.fill('[data-testid="password-input"]', 'wrongpassword')
    await page.click('[data-testid="login-button"]')

    await expect(page.locator('[data-testid="error-message"]')).toBeVisible()
    await expect(page).toHaveURL('/login')
  })

  test('should logout successfully', async ({ page }) => {
    // Login first
    await page.goto('/login')
    await page.fill('[data-testid="email-input"]', '[email protected]')
    await page.fill('[data-testid="password-input"]', 'password123')
    await page.click('[data-testid="login-button"]')
    await expect(page).toHaveURL('/')

    // Logout
    await page.click('[data-testid="user-menu"]')
    await page.click('[data-testid="logout-button"]')

    await expect(page).toHaveURL('/login')
  })
})
// tests/e2e/inventory.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Inventory Management', () => {
  test.beforeEach(async ({ page }) => {
    // Login before each test
    await page.goto('/login')
    await page.fill('[data-testid="email-input"]', '[email protected]')
    await page.fill('[data-testid="password-input"]', 'password123')
    await page.click('[data-testid="login-button"]')
    await expect(page).toHaveURL('/')
  })

  test('should display inventory list', async ({ page }) => {
    await page.goto('/inventory')

    await expect(page.locator('[data-testid="inventory-table"]')).toBeVisible()
    await expect(page.locator('tr')).toHaveCount.above(0)
  })

  test('should create new product', async ({ page }) => {
    await page.goto('/inventory')
    await page.click('[data-testid="add-product-button"]')

    await page.fill('[data-testid="name-input"]', 'E2E Test Product')
    await page.fill('[data-testid="sku-input"]', 'E2E-001')
    await page.fill('[data-testid="price-input"]', '150000')
    await page.click('[data-testid="save-button"]')

    await expect(page.locator('text=E2E Test Product')).toBeVisible()
  })

  test('should search products', async ({ page }) => {
    await page.goto('/inventory')

    await page.fill('[data-testid="search-input"]', 'laptop')

    await expect(page.locator('[data-testid="inventory-table"]')).toContainText('Laptop')
  })
})

Running Tests

# Run all unit tests
pnpm test

# Run tests in watch mode
pnpm test:watch

# Run tests with coverage
pnpm test:coverage

# Run E2E tests
pnpm test:e2e

# Run E2E tests in UI mode
pnpm test:e2e:ui

Best Practices

Follow AAA pattern:
it('should do something', () => {
  // Arrange
  const store = useInventoryStore()

  // Act
  store.addItem(newItem)

  // Assert
  expect(store.items).toContain(newItem)
})
  • Mock API calls, not stores
  • Use vi.mock() for module mocks
  • Use createTestingPinia() for store testing
  • Clear mocks between tests
  • Each test should be independent
  • Use beforeEach for setup
  • Clean up after tests
  • Don’t rely on test order
// Good - specific assertion
expect(store.items).toHaveLength(2)
expect(store.items[0].name).toBe('Product A')

// Avoid - vague assertion
expect(store.items).toBeTruthy()

Next Steps