اختبار وحدة الكود غير المتزامن - Perl

March 18, 2024

كيف يمكن لكتابة الكود غير المتزامن باستخدام Futures أن تساعد في تبسيط اختبار الوحدات

الكود الذي تم اختباره جيدًا دائمًا مفضل على شيء ليس لديه اختبارات، فكلما زادت الاختبارات أصبح بإمكاننا أن نكون أكثر ثقة بأن الكود يُنفذ ما نريده فعلاً. ضمن عالم الاختبارات، هناك نطاق من المجالات - يتعلق بمدى تغطية الكود في الاختبار الواحد. في الجانب الصغير، تُسمى الاختبارات الخاصة بقطع صغيرة (غالبًا دوال منفردة أو طريقة واحدة أو قليلة من طرق الفئة) "اختبارات الوحدات"؛ أما في الجانب الآخر، فإن نطاق "اختبارات التكامل" الأكبر يجمع أنظمة كاملة ويفحص الوظائف من البداية للنهاية.

هناك آراء كثيرة ومختلفة حول أفضل مزيج من الاختبارات كما هو الحال مع الأشخاص الذين يمكنك سؤالهم، لكن رأيي الشخصي هو أن غالبية الاختبارات يجب أن تكون اختبارات وحدات ذات نطاق ضيق، تغطي معًا أكبر قدر ممكن من قاعدة الكود. هذا يضمن أن كل جزء من النظام يحصل على اختبار وحدة خاص به، حيث يمكن بسهولة عزل الأخطاء لتخصيصها لذلك الجزء المفرد الذي يُختبر. عزل الخطأ بهذه الصورة قد يكون أصعب في اختبارات التكامل ذات النطاق الأكبر.

عزل الوحدات

كيفية إجراء اختبار الوحدة هذا يعتمد بالطبع على ما يقوم به الجزء الذي يجري اختباره فعليًا. بالنسبة للدوال المستقلة التي لا تعتمد على الحالة، مثل الحسابات المساعدة، فإن اختبارها يكون أمرًا بسيطًا بضبط مجموعة متنوعة من المدخلات والتأكد من أن الناتج الصحيح يظهر. لكن غالبًا، الأجزاء التي تحتاج اختبارها ليست سهلة العزل بهذه الطريقة، لأنها تعتمد بدورها على أجزاء أخرى من النظام.

معظم الأجزاء التي تحتاج إلى اختبار في أي قاعدة كود معقدة ستكون كودًا يقع في وسط نظام أكبر ويتفاعل مع أجزاء أخرى على الجانبين - يستقبل نوعًا من الطلب أو الزناد من الأعلى، ويتسبب في نشاط داخلي داخل طبقات أدنى من النظام. نطلق أحيانًا على هذه الأجزاء middleware - كل البرمجيات التي تقع في وسط النظام، بين الواجهة الخارجية التي تتفاعل مع العالم الخارجي، ووظائف القاعدة الداخلية الأعمق.

عند اختبار أجزاء من الكود مثل هذه التي تقع بين طبقتين أخريين في مكان آخر، يصبح من المهم أن نوفر كلا الجانبين من سكريبت اختبار الوحدة نفسه، بحيث يكون الكود المختبر معزولًا بشكل صحيح عن بقية النظام. عادةً في اختبار الوحدات نستخدم تقنيات متنوعة قد تُسمى mocking، لتوفير نسخ زائفة من تلك الأجزاء السفلى من النظام التي يتفاعل معها الكود المختبر عادةً. هذه الأجزاء المزيفة تعمل تحت تحكم سكريبت اختبار الوحدة، بحيث يمكنه توفير السلوك المطلوب لأداء الاختبار فعليًا.

موضوع المحاكاة للاختبار منزوعة التأثيرات واسع وعميق، ولكن اليوم سننظر في حالة محددة حيث يجعل استخدام Futures لتنفيذ السلوك غير المتزامن الاختبار أسهل من خلال المحاكاة.

المحاكاة

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

لنفترض المثال التالي، وهو مثال اصطناعي إلى حد ما؛ دالة تعيد سعر منتج معين بعملة معينة، باستخدام بعض مثيلات المساعد.

sub get_product_price {
my %args = @_;
my $product_code = $args{product_code};
my $want_currency = $args{currency};

my $catalog = $args{catalog} // ProductCatalog->new;
my $converter = $args{converter} // CurrencyConverter->new;

return $catalog->get_product(product_code => $product_code)
->then(sub {
my ($product) = @_;

if($product->price_currency eq $want_currency) {
return Future->done($product->price);
} else {
return $converter->convert(
amount => $product->price,
from => $product->price_currency,
to => $want_currency,
);
}
});
}

بشكل افتراضي، ستستخدم هذه الدالة مساعدين من أجزاء الكود الحقيقية ProductCatalog و CurrencyConverter، ولكن عندما نريد اختبارها في سكريبت اختبار الوحدة، يمكننا تمرير بعض المثيلات مخصصًا. إذا وضعنا كود هذه داخل سكريبت الاختبار نفسه فيمكننا توفير أي سلوك مطلوب للاختبار.

use Test::More;

package t::TestCatalog {
...
}

is(
get_product_price(
product_code => "ABC123",
currency => "USD",
catalog => t::TestCatalog->new,
)->get,
10.00,
'get_product_price for ABC123 in USD'
);

إذا كانت دالة بسيطة يجب أن تعيد نفس القيمة دائمًا، فيمكننا ببساطة كتابتها داخل تعريف الفئة:

package t::TestCatalog {
sub get_product {
return Future->done(Product->new(
price => 10,
price_currency => "USD",
));
}
}

هذا تعريف قصير مفيد وهو ربما كافٍ لاختبارات الوحدة القصيرة، لكنه ليس مرنًا جدًا لأنه من الصعب تغييره وتخصيصه لكل اختبار داخل الملف. إذا أردنا تجربة سلوكيات مختلفة كثيرة من الجزء تحت الاختبار لقيم مختلفة معادة من وظيفة المحاكاة هذه، فسنحتاج إلى طريقة لجعلها تعيد قيم مختلفة لاختبارات مختلفة.

إحدى الطرق التي يمكننا ترتيبها لهذه الغاية هي كتابة تنفيذ أكبر للدالة، ربما يتم التحكم فيه بواسطة بعض المتغيرات العامة. هذا يمكن أن يعمل جيدًا، لكنه يعني أن نصف السلوك يقع في تعريف هذه النسخة المشتركة للمحاكاة، والنصف الآخر يقع مع الاختبارات الفردية نفسها. هذا قد يؤدي إلى أن تكون منطقية الاختبار العامة صعبة القراءة، حيث يجب التبديل باستمرار بين جزأي تعريفه في مكانين مختلفين.

نظرًا لأن الواجهة حول الدالة التي نختبرها تعتمد بالكامل على futures، يمكننا استخدام ترتيب أفضل يستفيد من دلالات القيمة المؤجلة لـ future للسماح بسلوك أكثر مرونة مع إبقاء كل الكود مضمنًا في مكان واحد في الاختبارات الفردية.

المحاكاة باستخدام Futures

بما أن الهدف الكامل من المستقبل (future) هو تمثيل عملية متبقية وربما لم تنتهِ بعد، يمكننا استخدام هذا في دوال المحاكاة المحيطة بالقطعة التي تُختبر. بدلاً من الحاجة إلى تنفيذ السلوك الكامل للمحاكاة داخل تعريف الدالة، علينا فقط أن نبني ونُرجع future جديد لم يكتمل بعد. في وقت لاحق، ستملأ منطق الاختبار هذا المستقبل.

my $GET_PRODUCT_F;

package t::TestCatalog {
sub get_product {
return $GET_PRODUCT_F = Future->new;
}
}

هذه المحاكاة الوحيدة ستكون كافية الآن لأي عدد من الاختبارات المختلفة التي نريد تطبيقها.

لكن الآن كيف نستخدم هذا التنفيذ؟ القراء المألوفون باستخدام المحاكاة لاختبار الوحدات قد يتساءلون متى يبدأ السلوك داخل هذه الدالة بالتطبيق لإنتاج النتيجة المطلوبة للاختبار. المفتاح لهذا هو ملاحظة أن القطعة التي يتم اختبارها تعيد أيضًا future، مما يعني أنه لا يجب أن تكتمل بعد ولا تقدم إجابة نهائية قبل أن يستعيد اختبار الوحدة السيطرة. في الواقع، فقط بتخزين المستقبل المعاد من القطعة التي تُختبر في متغير، يصبح اختبار الوحدة حرًا في أداء بعض الإجراءات الأخرى أولاً. بحلول هذه النقطة، ستكون الدالة التي تُختبر قد استدعت طريقة get_product، لذا يمكننا توفير نتيجة إتمام لـ $GET_PRODUCT_F المستقبل الذي أنشأته.

{
my $f = get_product_price(
product_code => "ABC123",
currency => "USD",
catalog => t::TestCatalog->new,
);

$GET_PRODUCT_F->done(Product->new(
price => 10,
price_currency => "USD",
));

is($f->get, 10.00,
'get_product_price for ABC123 in USD');
}

بكتابة الكود بهذه الصورة، أعدنا الطبيعة التقريرية للشفرة المصدرية (من الأعلى إلى الأسفل) لتتوافق مع تدفق السلوك عبر الزمن. يمكننا قراءة مراحل السلوك المختلفة خلال كتلة الاختبار، بالترتيب الصحيح.

يمكننا الذهاب أبعد من ذلك. باحتجاز الحجج الواردة، وتنفيذ كلتا طريقتي المحاكاة، يمكننا بناء هيكل اختبار أكثر اكتمالًا للقطعة التي تُختبر، قادر على تطبيق كافة أنواع اختبارات السلوك عليها.

my %GET_PRODUCT_ARGS;
my $GET_PRODUCT_F;
package t::TestCatalog {
sub get_product {
%GET_PRODUCT_ARGS = @_;
return $GET_PRODUCT_F = Future->new;
}
}

my %CONVERT_ARGS;
my $CONVERT_F;
package t::CurrencyConverter {
sub convert {
my %CONVERT_ARGS = @_;
return $CONVERT_F = Future->new;
}
}

يمكننا الآن المضي قدمًا لكتابة اختبار أكثر اكتمالًا يتحقق من الطرق التي تستدعى بها هذه الدوال من قبل الجزء المختبر، وأنها تستخدم القيم التي تعيدها بشكل صحيح.

{
my $f = get_product_price(
product_code => "DEF456",
currency => "EUR",
catalog => t::TestCatalog->new,
converter => t::CurrencyConverter->new,
);

is_deeply(\%GET_PRODUCT_ARGS,
{
product_code => "DEF456",
... # يتم وضع المعاملات الأخرى هنا
},
"ProductCatalog invoked with correct arguments"
);
$GET_PRODUCT_F->done(Product->new(
price => 15,
price_currency => "USD",
));

ok(!$f->is_done, "النتيجة غير متاحة بعد قبل تحويل العملة");

is_deeply(\%CONVERT_ARGS,
{
amount => 15,
... # يتم وضع المعاملات الأخرى هنا
},
"CurrencyConverter invoked with correct arguments"
);
$CONVERT_F->done(20);

is($f->get, 20.00,
'get_product_price for DEF456 in USD');
}

بعد أن صممنا API الدالة الأصلية لإرجاع نتيجتها باستخدام مستقبل (future) بالإضافة إلى استخدام futures داخل تنفيذها، تمكنا من كتابة اختبار وحدة بطريقة موجزة لكنها قوية.

من خلال السماح لاختبار الوحدة بأداء إجراءات كل من المستدعي للمختبر، والمرسَل إليهم من الكود تحت الاختبار في وقت واحد، نكون مسيطرين تمامًا على منطق الاختبار. يمكننا تنفيذ الخطوات المطلوبة على أي جانب من جوانب الكود بشكل منطقي وأنيق، مما يسمح للكود الخاص بالاختبار بأن يُقرأ بطريقة منظمة من الأعلى إلى الأسفل. تتبع الشفرة المصدرية السلوك أثناء التنفيذ عن طريق "الترجيع المتبادل" بين خطوات المستدعي الخارجي الذي ينادي الدالة المختبرة، وخطوات تنفيذ الدوال المساعدة الداخلية التي استدعاها ذلك الكود. هذا يجعل منطق اختبار الوحدة أسهل في القراءة والفهم.

الكود المختبر جيدًا هو أمر جيد يجب أن نطمح إليه؛ واختبارات الوحدة المكتوبة جيدًا والقابلة للقراءة لذلك الكود تساعدنا بالتأكيد في تحقيق ذلك. الطبيعة المؤجلة لـ futures تتيح لنا أن نتداخل في الخطوات المطلوبة لاختبار وحدة middleware بشكل أنيق وقابل للقراءة، مما يساعد على ضمان أن يكون كودنا مختبرًا بشكل جيد وموثوق.

ملاحظة: تم نقل هذه التدوينة من https://tech.binary.com/ (مدونتنا التقنية القديمة). مؤلف هذه المقالة هو https://metacpan.org/author/PEVANS

المحتويات