اختبار Vitest + Playwright
أصبح MegaShop متاحاً، لكن كل إصدار كان به أخطاء—أخطاء في حساب سلة التسوق، أعطال عرضية في تسجيل الدخول، وأخطاء 404 على صفحات المنتجات. قرر Charlie إنشاء نظام اختبار آلي، باستخدام Vitest لاختبارات الوحدة وPlaywright لاختبارات النهاية إلى النهاية (E2E)، لضمان جودة موثوقة مع كل إصدار.
1. ما ستتعلمه
- @nuxt/test-utils: سلسلة أدوات اختبار Nuxt 3 الرسمية
- اختبارات وحدة Vitest: اختبار المكوّنات وComposables وStores
- اختبارات E2E عبر Playwright: تفاعل الصفحات + التحقق من SSR
- اختبار API: $fetch يختبر API الخادم
- سير عمل سلة تسوق MegaShop E2E + اختبار وحدة usePriceFormat
2. قصة حقيقية لمهندس معماري
(1) نقطة الألم: الاختبار اليدوي لا يغطي جميع السيناريوهات
قبل كل إصدار، يختبر Bob يدوياً MegaShop—الصفحة الرئيسية، صفحات المنتجات، سلة التسوق، الدفع، ولوحة الإدارة. 5 متصفحات x 3 لغات x 20 صفحة = 300 اختبار يدوي. مع فجوة تغطية اختبار 30٪، يتم اكتشاف خطأين على الأقل في الإنتاج مع كل إصدار.
(2) حلول الاختبار الآلي
Vitest + Playwright: أتمتة تشغيل الاختبارات:
TYPESCRIPT
// Vitest: اختبار وحدة لـ usePriceFormat
test('formats USD price correctly', () => {
const price = ref(2999.99)
const { formatted } = usePriceFormat(price)
expect(formatted.value).toBe('$2,999.99')
})
(3) النتائج: 0 اختبار يدوي + 0 خطأ في الإنتاج
الاختبار الآلي يغطي العمليات الأساسية؛ CI يعمل تلقائياً مع كل التزام، مما يقلل معدل إغفال الاختبار إلى 2٪ ويصل بعدد أخطاء الإنتاج إلى 0.
3. هرم الاختبار
(1) التسلسل الهرمي للاختبار
graph TB
A[اختبارات E2E - Playwright<br/>مسارات المستخدم الحرجة] --> B[اختبارات التكامل<br/>تفاعلات API + المكوّنات]
B --> C[اختبارات الوحدة - Vitest<br/>Composables / Stores / الأدوات]
style A fill:#f96,stroke:#333
style B fill:#ff9,stroke:#333
style C fill:#9f9,stroke:#333
(2) مقارنة أنواع الاختبار
| البُعد | اختبار الوحدة | اختبار التكامل | اختبار E2E |
|---|---|---|---|
| الأدوات | Vitest | Vitest | Playwright |
| النطاق | دالة/مكوّن واحد | تفاعل بين وحدات متعددة | سير مستخدم كامل |
| السرعة | ⚡⚡⚡ سريع | ⚡⚡ متوسط | 🐢 بطيء |
| الكمية | عالية | متوسطة | منخفضة |
| القيمة | صحة المنطق | اتساق الواجهة | ضمان سير المستخدم |
- مثال MegaShop | usePriceFormat | مسارات API | سلة التسوق → الدفع
4. تكوين Vitest واختبارات الوحدة
(1) ▶ مثال: التثبيت والتكوين
BASH
npm install -D vitest @nuxt/test-utils @vue/test-utils happy-dom
الناتج:
TEXT
# تم تنفيذ الأمر بنجاح
TYPESCRIPT
// vitest.config.ts
import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
environment: 'nuxt',
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['node_modules', '.nuxt']
}
}
})
(2) ▶ مثال: اختبارات وحدة Composable
TYPESCRIPT
// tests/composables/usePriceFormat.test.ts
import { ref } from 'vue'
describe('usePriceFormat', () => {
test('formats USD price with comma separator', () => {
const price = ref(2999.99)
const { formatted } = usePriceFormat(price)
expect(formatted.value).toBe('$2,999.99')
})
test('formats JPY price with no decimals', () => {
const price = ref(29999)
const { formatted } = usePriceFormat(price, { currency: 'JPY', locale: 'ja-JP' })
expect(formatted.value).toBe('¥29,999')
})
test('reactively updates when price changes', () => {
const price = ref(100)
const { formatted } = usePriceFormat(price)
expect(formatted.value).toBe('$100.00')
price.value = 200
expect(formatted.value).toBe('$200.00')
})
test('handles zero price', () => {
const price = ref(0)
const { formatted } = usePriceFormat(price)
expect(formatted.value).toBe('$0.00')
})
test('handles raw number input (not ref)', () => {
const { formatted } = usePriceFormat(50.5)
expect(formatted.value).toBe('$50.50')
})
})
الناتج:
TEXT
// التنفيذ ناجح
(3) ▶ مثال: اختبار Pinia Store
TYPESCRIPT
// tests/stores/cart.test.ts
import { setActivePinia, createPinia } from 'pinia'
describe('useCartStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
test('adds item to cart', () => {
const store = useCartStore()
store.addItem({ id: 1, name: 'Headphones', price: 299.99, image: '/img.jpg' })
expect(store.items.length).toBe(1)
expect(store.totalItems).toBe(1)
})
test('increments quantity for existing item', () => {
const store = useCartStore()
const product = { id: 1, name: 'Headphones', price: 299.99, image: '/img.jpg' }
store.addItem(product)
store.addItem(product)
expect(store.items.length).toBe(1)
expect(store.items[0].quantity).toBe(2)
})
test('removes item from cart', () => {
const store = useCartStore()
store.addItem({ id: 1, name: 'Headphones', price: 299.99, image: '/img.jpg' })
store.removeItem(1)
expect(store.items.length).toBe(0)
})
test('calculates total price correctly', () => {
const store = useCartStore()
store.addItem({ id: 1, name: 'A', price: 100, image: '/a.jpg' })
store.addItem({ id: 2, name: 'B', price: 200, image: '/b.jpg' })
store.addItem({ id: 1, name: 'A', price: 100, image: '/a.jpg' }) // الكمية = 2
expect(store.totalPrice).toBe(400) // 100*2 + 200
})
test('clears cart', () => {
const store = useCartStore()
store.addItem({ id: 1, name: 'A', price: 100, image: '/a.jpg' })
store.clearCart()
expect(store.items.length).toBe(0)
expect(store.totalPrice).toBe(0)
})
})
الناتج:
TEXT
// التنفيذ ناجح
5. اختبار المكوّنات
(1) ▶ مثال: اختبار مكوّن mountSuspended
TYPESCRIPT
// tests/components/ProductCard.test.ts
import { mountSuspended } from '@nuxt/test-utils/runtime'
describe('ProductCard', () => {
const mockProduct = {
id: 1, name: 'Premium Headphones', price: 299.99,
image: '/headphones.jpg', inStock: true
}
test('renders product name and price', async () => {
const wrapper = await mountSuspended(ProductCard, {
props: { product: mockProduct }
})
expect(wrapper.text()).toContain('Premium Headphones')
expect(wrapper.text()).toContain('299.99')
})
test('emits add-to-cart event on button click', async () => {
const wrapper = await mountSuspended(ProductCard, {
props: { product: mockProduct }
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('add-to-cart')).toBeTruthy()
expect(wrapper.emitted('add-to-cart')[0][0]).toEqual(mockProduct)
})
test('shows out of stock when not available', async () => {
const wrapper = await mountSuspended(ProductCard, {
props: { product: { ...mockProduct, inStock: false } }
})
expect(wrapper.text()).toContain('Out of Stock')
})
})
الناتج:
TEXT
// التنفيذ ناجح
6. اختبار E2E عبر Playwright
(1) ▶ مثال: تكوين Playwright
TYPESCRIPT
// playwright.config.ts
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
baseURL: 'http://localhost:3000',
webServer: {
command: 'npm run dev',
port: 3000,
reuseExistingServer: !process.env.CI
},
use: {
headless: true,
screenshot: 'only-on-failure'
}
})
الناتج:
TEXT
// التنفيذ ناجح
(2) ▶ مثال: سير عمل سلة التسوق من النهاية إلى النهاية
TYPESCRIPT
// e2e/cart-flow.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Shopping Cart Flow', () => {
test('add product to cart and verify', async ({ page }) => {
// الانتقال إلى قائمة المنتجات
await page.goto('/products')
// انتظار تحميل المنتجات
await page.waitForSelector('.product-card')
// النقر على زر "أضف إلى السلة" للمنتج الأول
await page.locator('.product-card:first-child button').click()
// التحقق من تحديث عدد السلة
const cartCount = page.locator('.cart-count')
await expect(cartCount).toHaveText('1')
// الانتقال إلى صفحة السلة
await page.goto('/cart')
// التحقق من وجود عنصر في السلة
await expect(page.locator('.cart-item')).toHaveCount(1)
})
test('complete checkout flow', async ({ page }) => {
// تسجيل الدخول أولاً
await page.goto('/login')
await page.fill('input[type="email"]', 'alice@example.com')
await page.fill('input[type="password"]', 'password123')
await page.click('button[type="submit"]')
// إضافة عنصر والانتقال إلى الدفع
await page.goto('/products/1')
await page.click('button:has-text("Add to Cart")')
await page.click('a:has-text("Cart")')
await page.click('a:has-text("Proceed to Checkout")')
// التحقق من صفحة الدفع
await expect(page.locator('h1')).toHaveText(/checkout/i)
})
})
الناتج:
TEXT
// التنفيذ ناجح
7. اختبار API
(1) ▶ مثال: اختبار API الخادم
TYPESCRIPT
// tests/api/products.test.ts
import { setupTest } from '@nuxt/test-utils'
describe('Products API', () => {
setupTest()
test('GET /api/products returns product list', async () => {
const response = await $fetch('/api/products')
expect(response.items).toBeDefined()
expect(response.total).toBeGreaterThan(0)
expect(response.page).toBe(1)
})
test('GET /api/products/:id returns product detail', async () => {
const product = await $fetch('/api/products/1')
expect(product.id).toBe(1)
expect(product.name).toBeDefined()
expect(product.price).toBeDefined()
})
test('GET /api/products/:id returns 404 for invalid id', async () => {
await expect($fetch('/api/products/99999')).rejects.toThrow('404')
})
test('GET /api/products supports pagination', async () => {
const page1 = await $fetch('/api/products?page=1&limit=5')
const page2 = await $fetch('/api/products?page=2&limit=5')
expect(page1.items.length).toBeLessThanOrEqual(5)
expect(page2.items[0].id).not.toBe(page1.items[0].id)
})
test('GET /api/products supports category filter', async () => {
const result = await $fetch('/api/products?category=electronics')
result.items.forEach((item: any) => {
expect(item.category).toBe('electronics')
})
})
})
الناتج:
TEXT
// التنفيذ ناجح
8. مثال شامل: إطار اختبار MegaShop
JSON
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:all": "npm run test && npm run test:e2e"
}
}
| نوع الاختبار | الأمر | التغطية | متى يُشغّل |
|---|---|---|---|
| اختبارات الوحدة | vitest | Composable/Store/أدوات | كل التزام |
| اختبار المكوّنات | vitest | مكوّنات Vue | كل التزام |
| اختبار API | vitest | API الخادم | كل إرسال |
| اختبار E2E | Playwright | سير المستخدم | قبل دمج PR |
| التغطية | vitest --coverage | الكل | خط CI |
❓ أسئلة شائعة
س ما الفرق بين Vitest وJest؟
ج Vitest مبني على Vite ويشارك تكوينات البناء مع Nuxt 3، مما ينتج أوقات بدء أسرع. Jest يتطلب تكوين Babel/webpack إضافياً. Nuxt 3 يوصي باستخدام Vitest.
س ما الفرق بين
mountSuspended وmount؟ج
mount هي دالة التركيب الأساسية التي توفرها Vue Test Utils، بينما mountSuspended توفرها @nuxt/test-utils وتدعم السياق الكامل، بما في ذلك إضافات Nuxt والاستيرادات التلقائية وComposables.س ماذا أفعل إذا كان اختبار E2E بطيئاً جداً؟
ج اختبر فقط مسارات المستخدم الرئيسية (سلة التسوق/الدفع/تسجيل الدخول)، وليس كل صفحة. اختبار المكوّنات يغطي منطق واجهة المستخدم، بينما اختبار E2E يغطي التدفقات من النهاية إلى النهاية.
س كيف أختبر سلوك SSR؟
ج استخدم طريقة
$fetch من @nuxt/test-utils لطلب HTML المعروض من جانب الخادم مباشرة وتحقق من أن HTML يحتوي على البيانات. يمكنك أيضاً استخدام Playwright لتعطيل JavaScript وفحص الصفحة.س هل يتطلب اختبار Pinia Store وهم API؟
ج إجراء
$fetch المستدعى داخل Store يحتاج إلى وهم. استخدم vitest.mock لاعتراض $fetch، أو استخرج استدعاء API إلى Composable لتسهيل الوهم.س كيف يعمل Vitest وPlaywright معاً في CI؟
ج Vitest يعمل بعد الفحص (سريع، 1-2 دقيقة)، وPlaywright يعمل بعد البناء (بطيء، 5-10 دقائق). كلاهما يعمل عند إنشاء طلب سحب؛ فقط Vitest يعمل عند الدفع إلى الفرع الرئيسي.
📖 ملخص
- هرم الاختبار: اختبارات وحدة واسعة + اختبارات تكامل معتدلة + اختبارات E2E قليلة
- Vitest + @nuxt/test-utils لاختبار Composables وStores والمكوّنات؛ يدعم سياق Nuxt
- mountSuspended يركّب المكوّنات في بيئة Nuxt؛ الاستيراد التلقائي والإضافات متاحة تلقائياً
- اختبارات E2E عبر Playwright لسير عمل المستخدم الرئيسي؛ التحقق من SSR عبر تعطيل JavaScript
- استخدم
$fetchللتحقق المباشر من استجابة API الخادم أثناء اختبار API
📝 تمارين
- تمرين أساسي (الصعوبة ⭐): ثبّت Vitest و@nuxt/test-utils، واكتب 5 اختبارات وحدة لـ
usePriceFormat. - تمرين متقدم (الصعوبة: ⭐⭐): اكتب اختبار CRUD كامل لـ
cartStoreواختبار مكوّن لـProductCard. - تحدٍ (الصعوبة: ⭐⭐⭐): اكتب اختبار E2E كامل لسلة تسوق باستخدام Playwright: تصفح المنتجات → إضافة إلى السلة → عرض السلة → الدفع
---|



