Testing Strategy
Panduan lengkap untuk testing di MStore Dashboard, termasuk unit tests, component tests, dan E2E tests.Testing Stack
| Type | Tool | Purpose |
|---|---|---|
| Unit Tests | Vitest | Test functions, composables, stores |
| Component Tests | Vitest + Vue Test Utils | Test Vue components |
| E2E Tests | Playwright / Cypress | Test full user flows |
Test Directory Structure
Copy
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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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')
})
})
Copy
// 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
Copy
# 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
Test Structure
Test Structure
Follow AAA pattern:
Copy
it('should do something', () => {
// Arrange
const store = useInventoryStore()
// Act
store.addItem(newItem)
// Assert
expect(store.items).toContain(newItem)
})
Mocking
Mocking
- Mock API calls, not stores
- Use
vi.mock()for module mocks - Use
createTestingPinia()for store testing - Clear mocks between tests
Test Isolation
Test Isolation
- Each test should be independent
- Use
beforeEachfor setup - Clean up after tests
- Don’t rely on test order
Meaningful Assertions
Meaningful Assertions
Copy
// Good - specific assertion
expect(store.items).toHaveLength(2)
expect(store.items[0].name).toBe('Product A')
// Avoid - vague assertion
expect(store.items).toBeTruthy()