الاختبارات و TDD

بدائل الاختبار: المحاكيات والبدائل والجواسيس

22 دقيقة الدرس 7 من 35

فهم بدائل الاختبار

بدائل الاختبار (Test Doubles) هي كائنات تحل محل التبعيات الحقيقية في الاختبارات، مما يسمح لك بعزل الكود قيد الاختبار والتحكم في العوامل الخارجية. تماماً كما يحل بدلاء المشاهد الخطرة محل الممثلين في المشاهد الخطرة، تحل بدائل الاختبار محل الكائنات الحقيقية في الاختبارات. إنها ضرورية لكتابة اختبارات وحدة سريعة وموثوقة ومركزة على اختبار مكون واحد في كل مرة دون الاعتماد على الأنظمة الخارجية مثل قواعد البيانات أو واجهات برمجة التطبيقات أو أنظمة الملفات.

مصطلح "بديل الاختبار" هو مصطلح شامل صاغه Gerard Meszaros ويشمل عدة أنواع: المحاكيات (mocks) والبدائل (stubs) والجواسيس (spies) والمزيفات (fakes) والدمى (dummies). كل نوع يخدم غرضاً مختلفاً ويستخدم في سيناريوهات مختلفة. فهم متى وكيفية استخدام كل نوع أمر بالغ الأهمية لكتابة اختبارات فعالة.

لماذا نستخدم بدائل الاختبار؟
  • السرعة: الاختبارات تعمل بشكل أسرع بدون استدعاءات حقيقية لقواعد البيانات أو واجهات برمجة التطبيقات
  • العزل: اختبر فقط الكود الذي تهتم به، وليس التبعيات
  • الموثوقية: الاختبارات لا تفشل بسبب مشاكل الخدمات الخارجية
  • التحكم: محاكاة الحالات الحدية وظروف الخطأ بسهولة

أنواع بدائل الاختبار

1. الدمى (Dummies)

الدمى هي أبسط بدائل الاختبار. يتم تمريرها ولكن لا تستخدم فعلياً أبداً، عادةً ما تملأ قوائم المعاملات فقط:

// مثال JavaScript class Logger { log(message) { // التنفيذ الحقيقي سيكتب إلى ملف } } class DummyLogger { log(message) { // لا يفعل شيئاً - يرضي الواجهة فقط } } // الاستخدام const processor = new DataProcessor(new DummyLogger());

2. البدائل (Stubs)

توفر البدائل إجابات محددة مسبقاً للاستدعاءات. لا تستجيب لأي شيء لم تُبرمج للاستجابة له:

// مثال بديل PHPUnit public function test_calculates_price_with_discount() { // إنشاء بديل لخدمة الخصم $discountService = $this->createStub(DiscountService::class); // تكوين البديل لإرجاع خصم 10% $discountService->method('getDiscount') ->willReturn(0.10); $calculator = new PriceCalculator($discountService); $price = $calculator->calculatePrice(100); $this->assertEquals(90, $price); }
// مثال بديل Jest test('يحسب السعر مع الخصم', () => { // إنشاء كائن بديل const discountService = { getDiscount: () => 0.10 }; const calculator = new PriceCalculator(discountService); const price = calculator.calculatePrice(100); expect(price).toBe(90); });

3. المحاكيات (Mocks)

المحاكيات مبرمجة مسبقاً مع توقعات حول الاستدعاءات التي يجب أن تتلقاها. تتحقق من السلوك بدلاً من الحالة:

// مثال محاكاة PHPUnit public function test_sends_welcome_email_on_registration() { // إنشاء محاكاة تتوقع استدعاء sendEmail $emailService = $this->createMock(EmailService::class); $emailService->expects($this->once()) ->method('sendEmail') ->with( $this->equalTo('welcome@example.com'), $this->stringContains('Welcome') ); $userService = new UserService($emailService); $userService->register('john@example.com'); }
// مثال محاكاة Jest مع jest.fn() test('يرسل بريد ترحيب عند التسجيل', () => { const emailService = { sendEmail: jest.fn() }; const userService = new UserService(emailService); userService.register('john@example.com'); expect(emailService.sendEmail).toHaveBeenCalledTimes(1); expect(emailService.sendEmail).toHaveBeenCalledWith( 'welcome@example.com', expect.stringContaining('Welcome') ); });

4. الجواسيس (Spies)

الجواسيس تسجل معلومات حول كيفية استدعائها، ولكن على عكس المحاكيات، لا تفشل الاختبار بنفسها:

// مثال جاسوس Jest test('يتتبع استدعاءات الطرق', () => { const calculator = { add: (a, b) => a + b }; // جاسوس على الطريقة الموجودة const spy = jest.spyOn(calculator, 'add'); const result = calculator.add(2, 3); expect(result).toBe(5); expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalledWith(2, 3); // استعادة التنفيذ الأصلي spy.mockRestore(); });

5. المزيفات (Fakes)

المزيفات لها تنفيذات عاملة، لكنها تأخذ اختصارات تجعلها غير مناسبة للإنتاج (مثل قاعدة بيانات في الذاكرة):

// المستودع الحقيقي يستخدم قاعدة البيانات class UserRepository { save(user) { // استعلام SQL INSERT return this.db.insert('users', user); } findById(id) { // استعلام SQL SELECT return this.db.query('SELECT * FROM users WHERE id = ?', [id]); } } // المستودع المزيف يستخدم مصفوفة في الذاكرة class FakeUserRepository { constructor() { this.users = []; } save(user) { this.users.push(user); return user; } findById(id) { return this.users.find(u => u.id === id); } }
متى تستخدم كل نوع:
  • دمية: عندما تحتاج لملء معامل لكن لن تستخدمه
  • بديل: عندما تحتاج إدخالاً متحكماً به من التبعيات
  • محاكاة: عندما تحتاج للتحقق من التفاعل مع التبعيات
  • جاسوس: عندما تريد تتبع الاستدعاءات إلى كائن حقيقي
  • مزيف: عندما تحتاج تنفيذاً عاملاً ولكن مبسطاً

المحاكاة في PHPUnit

إنشاء المحاكيات

// إنشاء محاكاة أساسية $mock = $this->createMock(EmailService::class); // محاكاة مع طرق متعددة $mock = $this->createMock(UserRepository::class); $mock->method('findById') ->willReturn(new User('john@example.com')); $mock->method('save') ->willReturn(true);

تكوين القيم المرجعة

// قيمة إرجاع بسيطة $mock->method('getUser') ->willReturn($user); // قيم إرجاع مختلفة للاستدعاءات المتتالية $mock->method('getNext') ->willReturn(1, 2, 3); // قيمة إرجاع بناءً على المدخل $mock->method('calculate') ->willReturnCallback(function($x) { return $x * 2; }); // إرجاع المعامل $mock->method('echo') ->willReturnArgument(0); // إرجاع self للواجهة السلسة $mock->method('where') ->willReturnSelf();

تعيين التوقعات

// توقع استدعاء الطريقة مرة واحدة $mock->expects($this->once()) ->method('sendEmail'); // توقع استدعاء الطريقة عدة مرات $mock->expects($this->exactly(3)) ->method('log'); // توقع عدم استدعاء الطريقة أبداً $mock->expects($this->never()) ->method('delete'); // توقع الطريقة مع معاملات محددة $mock->expects($this->once()) ->method('sendEmail') ->with( $this->equalTo('john@example.com'), $this->stringContains('Welcome') ); // توقع الطريقة عند استدعاء محدد $mock->expects($this->at(0)) ->method('log') ->with('Starting process'); $mock->expects($this->at(1)) ->method('log') ->with('Finished process');

رمي الاستثناءات

$mock->method('connect') ->willThrowException(new ConnectionException('Connection failed')); // اختبار معالجة الاستثناء public function test_handles_connection_failure() { $db = $this->createMock(Database::class); $db->method('connect') ->willThrowException(new ConnectionException()); $service = new DataService($db); $this->expectException(ConnectionException::class); $service->fetchData(); }

المحاكاة في Jest

jest.fn() - دوال المحاكاة

// إنشاء دالة محاكاة const mockFn = jest.fn(); // دالة محاكاة مع قيمة إرجاع const mockFn = jest.fn(() => 42); // دالة محاكاة مع تنفيذ const mockAdd = jest.fn((a, b) => a + b); // الاستخدام const result = mockAdd(2, 3); expect(result).toBe(5); expect(mockAdd).toHaveBeenCalledWith(2, 3);

طرق دوال المحاكاة

const mockFn = jest.fn(); // تكوين قيمة الإرجاع mockFn.mockReturnValue(42); // سلسلة قيم الإرجاع mockFn .mockReturnValueOnce(1) .mockReturnValueOnce(2) .mockReturnValue(3); console.log(mockFn(), mockFn(), mockFn()); // 1, 2, 3 // تنفيذ المحاكاة mockFn.mockImplementation((a, b) => a + b); // تنفيذ المحاكاة مرة واحدة mockFn.mockImplementationOnce((a, b) => a * b); // Promises محلولة/مرفوضة mockFn.mockResolvedValue({ id: 1, name: 'John' }); mockFn.mockRejectedValue(new Error('Failed'));

فحص استدعاءات المحاكاة

const mockFn = jest.fn(); mockFn('arg1', 'arg2'); mockFn('arg3'); // التحقق من الاستدعاء expect(mockFn).toHaveBeenCalled(); // التحقق من عدد الاستدعاءات expect(mockFn).toHaveBeenCalledTimes(2); // التحقق من معاملات استدعاء محددة expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2'); expect(mockFn).toHaveBeenLastCalledWith('arg3'); // التحقق من الاستدعاء رقم n expect(mockFn).toHaveBeenNthCalledWith(1, 'arg1', 'arg2'); expect(mockFn).toHaveBeenNthCalledWith(2, 'arg3'); // الوصول إلى سجل الاستدعاءات expect(mockFn.mock.calls).toEqual([ ['arg1', 'arg2'], ['arg3'] ]); // الوصول إلى النتائج expect(mockFn.mock.results[0].value).toBe(expectedValue);

jest.spyOn() - التجسس على الطرق

const video = { play() { return true; } }; test('يشغل الفيديو', () => { const spy = jest.spyOn(video, 'play'); const result = video.play(); expect(spy).toHaveBeenCalled(); expect(result).toBe(true); spy.mockRestore(); }); // جاسوس مع تنفيذ مخصص test('يحاكي تشغيل الفيديو', () => { const spy = jest.spyOn(video, 'play') .mockImplementation(() => false); expect(video.play()).toBe(false); expect(spy).toHaveBeenCalled(); spy.mockRestore(); });

محاكاة الوحدات

// محاكاة وحدة كاملة jest.mock('./userService'); // مع التنفيذ jest.mock('./userService', () => ({ getUser: jest.fn(() => ({ id: 1, name: 'John' })), saveUser: jest.fn() })); // محاكاة جزئية jest.mock('./userService', () => { const originalModule = jest.requireActual('./userService'); return { __esModule: true, ...originalModule, getUser: jest.fn(() => ({ id: 1, name: 'John' })) }; }); // الاستخدام في الاختبار import { getUser, saveUser } from './userService'; test('يجلب المستخدم', () => { const user = getUser(1); expect(user).toEqual({ id: 1, name: 'John' }); expect(getUser).toHaveBeenCalledWith(1); });

أفضل الممارسات

1. الحاكاة في المستوى الصحيح

// سيء - محاكاة الكثير test('خدمة المستخدم', () => { const db = mock(Database); const cache = mock(Cache); const logger = mock(Logger); const emailer = mock(Emailer); const notifier = mock(Notifier); // الكثير من المحاكيات - لا تختبر شيئاً! }); // جيد - حاكي التبعيات الخارجية فقط test('خدمة المستخدم', () => { const db = mock(Database); const userService = new UserService(db); // اختبر سلوك UserService });

2. لا تحاكي ما لا تملكه

// سيء - محاكاة مكتبة طرف ثالث مباشرة const axios = jest.mock('axios'); // جيد - أنشئ محول وحاكي المحول الخاص بك class HttpClient { async get(url) { return axios.get(url); } } const httpClient = jest.mock('./HttpClient');

3. فضل البدائل على المحاكيات عندما يكون ذلك ممكناً

// فضل هذا (بديل - يختبر الحالة) test('يحسب الخصم', () => { const discountService = { getDiscount: () => 0.10 }; const result = calculatePrice(100, discountService); expect(result).toBe(90); }); // على هذا (محاكاة - يختبر التفاعل) test('يحسب الخصم', () => { const discountService = { getDiscount: jest.fn(() => 0.10) }; calculatePrice(100, discountService); expect(discountService.getDiscount).toHaveBeenCalled(); });
تجنب الإفراط في المحاكاة:

الإفراط في المحاكاة يؤدي إلى اختبارات هشة تنكسر عندما تتغير تفاصيل التنفيذ. حاكي فقط ما هو ضروري - التبعيات الخارجية والمتعاونين المعقدين. لا تحاكي النظام قيد الاختبار نفسه!

4. امسح وأعد تعيين المحاكيات بين الاختبارات

// Jest - مسح سجل المحاكاة بين الاختبارات beforeEach(() => { jest.clearAllMocks(); // يمسح سجل الاستدعاءات }); afterEach(() => { jest.restoreAllMocks(); // يستعيد التنفيذات الأصلية }); // مسح محاكاة محددة mockFn.mockClear(); // إعادة تعيين المحاكاة (مسح + إزالة التنفيذ) mockFn.mockReset(); // استعادة الجاسوس spy.mockRestore();

5. استخدم أسماء وصفية

// سيء - غير واضح const mock1 = jest.fn(); const mock2 = jest.fn(); // جيد - وصفي const mockEmailSender = jest.fn(); const mockUserRepository = { findById: jest.fn(), save: jest.fn() };
تمرين تطبيقي:
  1. أنشئ OrderService يعتمد على PaymentGateway وInventoryService وEmailService
  2. اكتب اختبارات باستخدام بدائل لـ InventoryService للتحكم في توفر المنتج
  3. اكتب اختبارات باستخدام محاكيات لـ PaymentGateway للتحقق من استدعاء معالجة الدفع بشكل صحيح
  4. اكتب اختبارات باستخدام جواسيس على EmailService لتتبع إرسال بريد التأكيد
  5. اختبر سيناريوهات الخطأ بجعل المحاكيات ترمي استثناءات
  6. تأكد من مسح المحاكيات بشكل صحيح بين الاختبارات

المخاطر الشائعة

المخطر 1: محاكاة تفاصيل التنفيذ

// سيء - اختبار التنفيذ test('خدمة المستخدم', () => { const repo = mock(UserRepository); repo.findById.mockReturnValue(user); service.getUser(1); expect(repo.findById).toHaveBeenCalled(); // اختبار كيف وليس ماذا }); // جيد - اختبار السلوك test('خدمة المستخدم تُرجع المستخدم', () => { const repo = { findById: () => user }; const result = service.getUser(1); expect(result).toEqual(user); // اختبار الناتج });

المخطر 2: نسيان التأكيد على استدعاءات المحاكاة

// سيء - محاكاة منشأة لكن لم يتم التحقق منها أبداً test('يرسل بريد', () => { const emailService = { send: jest.fn() }; userService.register(user); // تأكيد مفقود! }); // جيد - تحقق من استدعاء المحاكاة test('يرسل بريد', () => { const emailService = { send: jest.fn() }; userService.register(user); expect(emailService.send).toHaveBeenCalled(); });

الخلاصة

بدائل الاختبار هي أدوات قوية تمكن من إجراء اختبارات وحدة سريعة وموثوقة ومعزولة. فهم الفروق بين الدمى والبدائل والمحاكيات والجواسيس والمزيفات - ومعرفة متى تستخدم كل منها - أمر ضروري للاختبار الفعال. استخدم البدائل عندما تحتاج إلى مدخلات متحكم بها، والمحاكيات عندما تحتاج للتحقق من التفاعلات، والجواسيس عندما تريد مراقبة سلوك الكائن الحقيقي.

تذكر: بدائل الاختبار هي وسيلة لتحقيق غاية، وليست الغاية نفسها. استخدمها لتحسين اختباراتك، لكن لا تدعها تجعل اختباراتك هشة. حاكي التبعيات الخارجية فقط، وتجنب اختبار تفاصيل التنفيذ، وتأكد دائماً من أن اختباراتك تتحقق من سلوك ذي مغزى.