فهم اختبار اللقطات
اختبار اللقطات هو تقنية اختبار تلتقط مخرجات مكون أو دالة وتحفظها في ملف. في عمليات التشغيل اللاحقة للاختبار، يتم مقارنة المخرجات الحالية مع اللقطة المحفوظة. إذا اختلفت، يفشل الاختبار. يعتبر اختبار اللقطات مفيداً بشكل خاص لاختبار مكونات واجهة المستخدم واستجابات API وتحويلات البيانات حيث تريد اكتشاف التغييرات غير المتوقعة.
مفهوم أساسي: اختبار اللقطات لا يتحقق من أن المخرجات صحيحة، فقط أنها لم تتغير. إنها أداة اكتشاف انحدار، وليست مدقق صحة. يجب عليك مراجعة اللقطات يدوياً للتأكد من أنها تمثل المخرجات المرغوبة.
متى تستخدم اختبار اللقطات
اختبار اللقطات مثالي لسيناريوهات محددة:
حالات الاستخدام الجيدة:
- مكونات واجهة المستخدم: مكونات React/Vue بعلامات متسقة
- مخرجات CLI: تنسيق مخرجات أدوات سطر الأوامر
- استجابات API: استجابات JSON/XML المهيكلة
- ملفات التكوين: مخرجات التكوين المُولدة
- رسائل الأخطاء: سلاسل الأخطاء المُنسقة
- قوالب البريد الإلكتروني HTML: مخرجات عرض البريد الإلكتروني
حالات الاستخدام السيئة:
- البيانات الديناميكية: الطوابع الزمنية، المعرفات العشوائية، التواريخ
- منطق الأعمال: الحسابات والخوارزميات
- تفاعلات المستخدم: معالجات النقر، إرسالات النماذج
- استدعاءات API الخارجية: استجابات خدمة الطرف الثالث
- اختبارات الأداء: العمليات المعتمدة على التوقيت
خطأ شائع: استخدام اختبار اللقطات كبديل للتأكيدات المناسبة. تكمل اللقطات الاختبار التقليدي لكن لا يجب أن تحل محل التأكيدات الصريحة حول السلوك. اسأل دائماً: "هل أختبر أن شيئاً يعمل، أم فقط أنه لم يتغير؟"
اختبار لقطات Jest
لدى Jest دعم مدمج ممتاز لاختبار اللقطات. إليك كيفية استخدامه بفعالية:
اختبار لقطة أساسي
// components/UserCard.jsx
function UserCard({ name, email, role }) {
return (
<div className="user-card">
<h2>{name}</h2>
<p>{email}</p>
<span className={`role role-${role}`}>{role}</span>
</div>
);
}
// components/__tests__/UserCard.test.jsx
import { render } from '@testing-library/react';
import UserCard from '../UserCard';
test('renders user card correctly', () => {
const { container } = render(
<UserCard
name="John Doe"
email="john@example.com"
role="admin"
/>
);
expect(container).toMatchSnapshot();
});
عندما يعمل هذا الاختبار لأول مرة، ينشئ Jest ملف لقطة:
// components/__tests__/__snapshots__/UserCard.test.jsx.snap
exports[`renders user card correctly 1`] = `
<div>
<div
class="user-card"
>
<h2>
John Doe
</h2>
<p>
john@example.com
</p>
<span
class="role role-admin"
>
admin
</span>
</div>
</div>
`;
مطابقات الخصائص للبيانات الديناميكية
استخدم مطابقات الخصائص للتعامل مع القيم الديناميكية مثل التواريخ والمعرفات:
test('creates order with timestamp', () => {
const order = createOrder({
userId: 123,
items: [{ id: 1, quantity: 2 }]
});
expect(order).toMatchSnapshot({
id: expect.any(String), // تجاهل المعرف المحدد
createdAt: expect.any(Date), // تجاهل الطابع الزمني
userId: 123, // احتفظ بالقيم المعروفة
items: [
{
id: 1,
quantity: 2,
addedAt: expect.any(Date) // تجاهل البيانات الديناميكية المتداخلة
}
]
});
});
ستخزن اللقطة:
exports[`creates order with timestamp 1`] = `
Object {
"createdAt": Any<Date>,
"id": Any<String>,
"items": Array [
Object {
"addedAt": Any<Date>,
"id": 1,
"quantity": 2,
},
],
"userId": 123,
}
`;
اللقطات المضمنة
تخزن اللقطات المضمنة اللقطة مباشرة في ملف الاختبار بدلاً من ملف منفصل:
test('formats price correctly', () => {
const result = formatPrice(1234.56, 'USD');
expect(result).toMatchInlineSnapshot(`"$1,234.56"`);
});
test('generates error message', () => {
const error = new ValidationError('Invalid email', 'email');
expect(error.toJSON()).toMatchInlineSnapshot(`
Object {
"field": "email",
"message": "Invalid email",
"type": "validation_error",
}
`);
});
اللقطات المضمنة مقابل الخارجية:
- مضمنة: الأفضل للقطات الصغيرة (1-10 أسطر) التي تستفيد من القرب من الاختبار. سهلة المراجعة أثناء مراجعات الكود.
- خارجية: أفضل للقطات الكبيرة (مكونات واجهة المستخدم، استجابات API) التي قد تزدحم ملفات الاختبار. تحافظ على الاختبارات قابلة للقراءة.
فوائد اللقطات المضمنة
- اللقطات مرئية في مراجعات الكود جنباً إلى جنب مع الاختبارات
- لا حاجة للتبديل بين ملفات الاختبار واللقطة
- أسهل لفهم نية الاختبار
- أفضل للقطات الصغيرة والمركزة
تحديث اللقطات
عندما تتغير مخرجات المكون بشكل مشروع، تحتاج إلى تحديث اللقطات:
تحديث جميع اللقطات
# تحديث جميع اللقطات الفاشلة
npm test -- --updateSnapshot
# أو
npm test -- -u
# وضع المراقبة - اضغط 'u' لتحديث الكل
npm test -- --watch
تحديث لقطات محددة (الوضع التفاعلي)
# التشغيل في الوضع التفاعلي
npm test -- --watch
# عند فشل الاختبارات:
# اضغط 'i' للدخول إلى وضع التحديث التفاعلي
# راجع كل تغيير في اللقطة
# اضغط 'u' للتحديث، 's' للتخطي
تحديث ملف اختبار واحد
# تحديث اللقطات لملف محدد
npm test UserCard.test.jsx -- -u
# تحديث اختبار محدد
npm test -- -t "renders user card correctly" -u
انضباط تحديث اللقطة: لا تقم أبداً بتحديث اللقطات بشكل أعمى باستخدام
-u دون مراجعة التغييرات. دائماً:
- قم بتشغيل الاختبارات لرؤية ما تغير
- راجع الفرق في ملف اللقطة أو الطرفية
- تحقق من أن التغيير مقصود
- حدّث فقط إذا كانت المخرجات الجديدة صحيحة
- قم بإجراء تحديثات اللقطة مع الكود الذي تسبب فيها
معاملة اللقطات كموافقات "زر أخضر" يهزم غرضها.
أفضل ممارسات اختبار اللقطات
1. احتفظ باللقطات صغيرة ومركزة
// سيء: لقطة الصفحة بأكملها
test('dashboard page', () => {
const { container } = render(<DashboardPage />);
expect(container).toMatchSnapshot(); // واسع جداً!
});
// جيد: لقطة مكونات فردية
test('dashboard header', () => {
const { container } = render(<DashboardHeader user={mockUser} />);
expect(container).toMatchSnapshot();
});
test('dashboard stats widget', () => {
const { container } = render(<StatsWidget data={mockData} />);
expect(container).toMatchSnapshot();
});
2. التقط البيانات، وليس التنفيذ
// سيء: التقاط البنية الداخلية
test('user service', () => {
const service = new UserService();
expect(service).toMatchSnapshot(); // يكشف الداخليات
});
// جيد: لقطة المخرجات/السلوك
test('user service formats user data', () => {
const service = new UserService();
const result = service.formatUser(mockUser);
expect(result).toMatchSnapshot();
});
3. ادمج مع التأكيدات التقليدية
test('generates invoice PDF', () => {
const invoice = generateInvoice(order);
// تأكيدات صريحة للقيم الحرجة
expect(invoice.total).toBe(150.00);
expect(invoice.tax).toBe(15.00);
expect(invoice.items).toHaveLength(3);
// لقطة للبنية الإجمالية
expect(invoice).toMatchSnapshot({
id: expect.any(String),
date: expect.any(Date)
});
});
4. استخدم أسماء اختبار وصفية
// سيء: اسم عام
test('button', () => {
expect(<Button />).toMatchSnapshot();
});
// جيد: اسم وصفي
test('primary button with icon and disabled state', () => {
const { container } = render(
<Button variant="primary" icon="check" disabled />
);
expect(container).toMatchSnapshot();
});
اختبار اللقطات في سيناريوهات مختلفة
لقطات مكون React
import { render } from '@testing-library/react';
import { ProductCard } from '../ProductCard';
describe('ProductCard', () => {
const mockProduct = {
id: 1,
name: 'Laptop',
price: 999.99,
inStock: true
};
test('renders in stock product', () => {
const { container } = render(<ProductCard product={mockProduct} />);
expect(container.firstChild).toMatchSnapshot();
});
test('renders out of stock product', () => {
const { container } = render(
<ProductCard product={{ ...mockProduct, inStock: false }} />
);
expect(container.firstChild).toMatchSnapshot();
});
test('renders with discount', () => {
const { container } = render(
<ProductCard product={mockProduct} discount={20} />
);
expect(container.firstChild).toMatchSnapshot();
});
});
لقطات استجابة API
test('formats API error response', () => {
const error = new APIError('Not found', 404);
const response = error.toResponse();
expect(response).toMatchInlineSnapshot(`
Object {
"error": Object {
"code": 404,
"message": "Not found",
"type": "not_found",
},
"success": false,
}
`);
});
test('paginates user list', async () => {
const result = await userService.list({ page: 1, limit: 10 });
expect(result).toMatchSnapshot({
data: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
createdAt: expect.any(String)
})
]),
meta: {
currentPage: 1,
totalPages: expect.any(Number),
total: expect.any(Number)
}
});
});
لقطات مخرجات CLI
import { formatTable } from '../cli-formatter';
test('formats data table', () => {
const data = [
{ name: 'Alice', age: 30, role: 'Admin' },
{ name: 'Bob', age: 25, role: 'User' }
];
const output = formatTable(data);
expect(output).toMatchInlineSnapshot(`
"┌───────┬─────┬───────┐
│ Name │ Age │ Role │
├───────┼─────┼───────┤
│ Alice │ 30 │ Admin │
│ Bob │ 25 │ User │
└───────┴─────┴───────┘"
`);
});
اختبار اللقطات في PHP
بينما لا تحتوي PHP على اختبار لقطات مدمج مثل Jest، توفر مكتبات مثل spatie/phpunit-snapshot-assertions وظائف مماثلة:
التثبيت والإعداد
# تثبيت الحزمة
composer require --dev spatie/phpunit-snapshot-assertions
# استخدام في الاختبارات
use Spatie\Snapshots\MatchesSnapshots;
class OrderTest extends TestCase
{
use MatchesSnapshots;
public function test_formats_order_summary()
{
$order = Order::factory()->create();
$summary = $order->formatSummary();
$this->assertMatchesSnapshot($summary);
}
public function test_generates_invoice_html()
{
$invoice = Invoice::generate($this->order);
$this->assertMatchesHtmlSnapshot($invoice->toHtml());
}
public function test_exports_user_to_json()
{
$user = User::factory()->create();
$this->assertMatchesJsonSnapshot($user->toArray());
}
}
تحديث لقطات PHP
# تحديث جميع اللقطات
./vendor/bin/phpunit -d --update-snapshots
# تحديث اختبار محدد
./vendor/bin/phpunit --filter test_formats_order_summary -d --update-snapshots
متى لا تستخدم اختبار اللقطات
تجنب اللقطات لـ:
- البيانات المتغيرة: الطوابع الزمنية الحالية، القيم العشوائية، معرفات الجلسة
- الكائنات الكبيرة: مقالب قاعدة بيانات كاملة، HTML صفحة كاملة (هش جداً)
- اختبار السلوك: هل النقر على زر يستدعي الدالة الصحيحة؟
- الحسابات: يجب أن تستخدم العمليات الحسابية تأكيدات صريحة
- مكتبات الطرف الثالث: لا تلتقط مخرجات المكتبة التي لا تتحكم فيها
بدائل أفضل
// سيء: لقطة للحساب
test('calculates total', () => {
const result = calculateTotal([10, 20, 30]);
expect(result).toMatchInlineSnapshot(`60`);
});
// جيد: تأكيد صريح
test('calculates total', () => {
const result = calculateTotal([10, 20, 30]);
expect(result).toBe(60);
});
// سيء: لقطة تاريخ ديناميكي
test('creates timestamp', () => {
expect(getCurrentTimestamp()).toMatchSnapshot();
});
// جيد: اختبار خصائص التاريخ
test('creates timestamp', () => {
const timestamp = getCurrentTimestamp();
expect(timestamp).toBeInstanceOf(Date);
expect(timestamp.getTime()).toBeGreaterThan(Date.now() - 1000);
});
تمرين عملي
التمرين 1: اختبار لقطة لمكون إشعار
// components/Notification.jsx
function Notification({ type, message, dismissible, onDismiss }) {
return (
<div className={`notification notification-${type}`}>
<span className="notification-message">{message}</span>
{dismissible && (
<button onClick={onDismiss} className="notification-close">
×
</button>
)}
</div>
);
}
// TODO: اكتب اختبارات لقطة لـ:
// 1. إشعار نجاح (غير قابل للإغلاق)
// 2. إشعار خطأ (قابل للإغلاق)
// 3. إشعار تحذير (قابل للإغلاق)
// 4. إشعار معلومات (غير قابل للإغلاق)
التمرين 2: إنشاء اختبارات لقطة استجابة API
// services/api.js
class APIClient {
formatSuccessResponse(data, meta = {}) {
return {
success: true,
data,
meta: {
timestamp: new Date(),
...meta
}
};
}
formatErrorResponse(message, code, details = null) {
return {
success: false,
error: {
message,
code,
details,
timestamp: new Date()
}
};
}
}
// TODO: اكتب اختبارات لقطة مع مطابقات خصائص لـ:
// 1. استجابة نجاح مع بيانات المستخدم
// 2. استجابة نجاح مع meta الترقيم
// 3. استجابة خطأ (404)
// 4. استجابة خطأ مع تفاصيل التحقق
التمرين 3: اختبار مُنسق بيانات باللقطات
// formatters/report.js
function formatSalesReport(sales) {
const total = sales.reduce((sum, s) => sum + s.amount, 0);
const average = total / sales.length;
return `
تقرير المبيعات
============
إجمالي المبيعات: ${sales.length}
الإيرادات: $${total.toFixed(2)}
المتوسط: $${average.toFixed(2)}
المنتجات الأعلى:
${sales
.sort((a, b) => b.amount - a.amount)
.slice(0, 3)
.map((s, i) => `${i + 1}. ${s.product} - $${s.amount}`)
.join('\n')}
`.trim();
}
// TODO: إنشاء اختبار لقطة مع بيانات مبيعات وهمية
// التحقق من أن تنسيق التقرير متسق