اذهب إلى المحتوى

كتابة الاختبارات في لغة رست Rust


Naser Dakhel

الاختبارات Tests هي مجموعة من دوال رست تتأكد من أن الشيفرة البرمجية الأساسية تعمل كما هو مطلوب منها، ويؤدي متن دوال الاختبار عادةً هذه العمليات الثلاث:

  1. تجهيز أي بيانات أو حالة ضرورية.
  2. تنفيذ الشيفرة البرمجية التي تريد اختبارها.
  3. التأكد من أن النتائج وفق المتوقع.

لننظر إلى المزايا التي توفرها رست لكتابة الاختبارات التي تؤدي العمليات الثلاث السابقة، ويتضمن ذلك السمة test وبعض الماكرو والسمة should_panic.

بنية دالة الاختبار

‎ تُوصَّف دالة الاختبار في رست باستخدام السمة test؛ والسمات attributes هي بيانات وصفية metadata تصف أجزاءً من شيفرة رست البرمجية، ومثال على هذه السمات هي السمة derive التي استخدمناها مع الهياكل سابقًا. لتغيير دالة عادية إلى دالة اختبار نُضيف ‎#[test]‎ قبل السطر الذي نكتب فيه fn، إذ تبني رست ملف تنفيذ اختبار ثنائي عند تنفيذ الاختبارات باستخدام الأمر cargo test، وتختبر الدوال المُشار إليها بأنها دوال اختبار وتعرض لك تقريرًا يوضح أي الدوال التي فشلت وأيها التي نجحت.

تُولَّد وحدة اختبار test module مع دالة اختبار تلقائيًا عندما نُنشئ مشروع مكتبة جديدة باستخدام كارجو Cargo، وتمنحنا هذه الوحدة قالبًا لكتابة الاختبارات التي نريد، بحيث لا يتوجب عليك النظر إلى هيكل الاختبار وطريقة كتابته كل مرة تُنشئ فيها مشروعًا جديدًا، ويمكنك إضافة دوال اختبار ووحدات اختبار إضافية قدر ما تشاء.

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

ننشئ مشروع مكتبة جديدة نسميه adder يضيف رقمين إلى بعضهما:

$ cargo new adder --lib
     Created library `adder` project
$ cd adder

يجب أن تبدو محتويات الملف src/lib.rs في مكتبة adder كما هو موضح في الشيفرة 1.

اسم الملف: src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let result = 2 + 2;
        assert_eq!(result, 4);
    }
}

الشيفرة 1: وحدة الاختبار والدالة المولّدة تلقائيًا باستخدام cargo new

لنتجاهل أول سطرين ونركّز على الدالةحاليًا. لاحظ التوصيف ‎#[test]‎: تُشير هذه السمة إلى أن هذه دالة اختبار، ما يعني أن منفّذ الاختبار سيعامل هذه الدالة على أنها اختبار، وقد تحتوي شيفرتنا البرمجية أيضًا على دوال ليست بدوال اختبار في وحدة tests وذلك بهدف مساعدتنا لضبط حالات معينة، أو إجراء عمليات شائعة، لذا نحدّد دومًا فيما إذا كانت الدالة دالة اختبار.

يُستخدم متن الدالة في المثال الماكرو assert_eq!‎ للتأكد من أن result تحتوي على القيمة 4 (وهي تحتوي على نتيجة جمع الرقم 2 مع 2). تمثّل عملية التأكد هذه عملية اختبارًا تقليديًا، دعنا ننفذ الاختبار لنرى إذا ما كان سينجح أم لا.

ينفِّذ الأمر cargo test جميع الاختبارات الموجودة في المشروع كما هو موضح في الشيفرة 2.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.57s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

الشيفرة 2: الخرج الناتج عن عملية تنفيذ الاختبارات المولّدة تلقائيًا

يُصرّف كارجو الاختبار ويشغّله؛ إذ نجد في أحد السطور running 1 test، ثم سطر يليه يوضح اسم دالة الاختبار المولّدة التي تدعى it_works وأن نتيجة ذلك الاختبار هي ok، وتعني النتيجة النهائية test result: ok.‎ أن جميع الاختبارات نجحت، بينما يشير الجزء 1 passed; 0 failed إلى عدد الاختبارات الناجحة وعدد الاختبارات الفاشلة.

من الممكن تجاهل الاختبار بحيث لا يُنفّذ في حالات معينة وسنتكلم عن هذا الأمر لاحقًا، إلا أن ملخص نتيجة الاختبارات يوضح ‎0 ignored لأننا لم نفعل ذلك هنا، كما يمكننا تمرير وسيط إلى الأمر cargo test بحيث ينفذ الاختبارات التي يوافق اسمها السلسلة النصية ويدعى هذا بالترشيح filtering وسنتكلم عن هذا الموضوع لاحقًا. تظهر نهاية الملخص ‎0 filtered out لأننا لم نستخدم الترشيح على الاختبارات التي ستُنفّذ.

يشير ‎0 measured إلى الاختبارات المعيارية التي تقيس الأداء، إذ أن الاختبارات المعيارية benchmark tests متاحةٌ فقط في رست الليلية nightly Rust -في وقت كتابة هذه الكلمات- ويمكنك النظر إلى التوثيق المتعلق بالاختبارات المعيارية لتعلُّم المزيد.

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

لنبدأ بتخصيص الاختبار ليوافق حاجتنا؛ إذ سنغيّر أولًا اسم الدالة it_works إلى اسم مختلف مثل exploration كما يلي:

اسم الملف: src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
        assert_eq!(2 + 2, 4);
    }
}

نشغِّل cargo test مجددًا. يعرض لنا الخرج الآن exploration بدلًا من it_works:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.59s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::exploration ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

نضيف الآن اختبارًا جديدًا، إلا أننا سنجعل هذا الاختبار يفشل عمدًا، إذ تفشل الاختبارات عندما يهلع panic شيءٌ ما داخل دالة الاختبار. يُجرى كل اختبار ضمن خيط thread جديد وعندما يرى الخيط الرئيس أن خيط الاختبار قد "انتهى" يُعَلَّم الاختبار بأنه فشل.

تكلمنا سابقًا عن طرق أبسط لهلع الشيفرة البرمجية باستخدام الماكرو panic!. الآن، نُدخل الاختبار الجديد مثل دالة تسمى another إلى الملف src/lib.rs كما هو موضح في الشيفرة 3.

اسم الملف: src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
        assert_eq!(2 + 2, 4);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}

الشيفرة 3: إضافة اختبار جديد يفشل لأننا نستدعي الماكرو panic!‎

شغِّل الاختبارات مجددًا باستخدام cargo test، يجب أن يكون الخرج مشابهًا لما هو موجود في الشيفرة 4، وهو يوضّح أن اختبار exploration نجح بينما فشل another.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.72s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok

failures:

---- tests::another stdout ----
thread 'main' panicked at 'Make this test fail', src/lib.rs:10:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::another

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

الشيفرة 4: نتائج الاختبارات، إذ نجح اختبار وفشل آخر

يعرض السطر test tests::another النتيجة FAILED بدلًا من ok، ويظهر لنا قسمين جديدين بين النتائج الفردية والملخص: إذ يعرض الأول السبب لفشل كل من الاختبارات بالتفصيل، وفي هذه الحالة نحصل على التفاصيل الخاصة بفشل another وهي أن panicked at 'Make this test fail'‎ في السطر 10 ضمن الملف src/lib.rs؛ بينما يعرض القسم التالي أسماء جميع الاختبارات التي فشلت، وهي معلومة مفيدة في حال وجد لدينا العديد من الاختبارات مع العديد من التفاصيل لكل اختبار فشل. يمكن استخدام اسم الاختبار الذي فشل لتشغيل الاختبار وحده والحصول على معلومات أدق لتنقيح الأخطاء، وسنتكلم عن طرق تشغيل الاختبارات لاحقًا.

يعرض سطر الملخص في النهاية نتيجة الاختبارات كاملةً، إذ أن نتيجة الاختبار هي FAILED ووجِد لدينا اختبارٌ نجح وآخر فشل.

الآن بعد أن تعرفنا إلى كيفية عرض نتائج الاختبار في حالات مختلفة، ننظر إلى ماكرو مختلفة عن panic!‎ مفيدة في الاختبارات.

التحقق من النتائج باستخدام الماكرو assert!‎

تُعد الماكرو assert!‎ الموجودة في المكتبة القياسية مفيدةً عندما تريد التأكد من أن شرطًا ما ضمن الاختبار يُقيَّم إلى true، ونمرّر للماكرو assert!‎ وسيطًا يمكن تقييمه لقيمة بوليانية boolean؛ فإذا كانت القيمة true لا يحدث شيء عندها ونجتاز الاختبار بنجاح؛ وإذا حصلنا على القيمة false فهذا يعني فشل الاختبار، ويستدعي الماكرو assert!‎ عندها الماكرو panic!‎. يساعدنا استخدام الماكرو assert!‎ في التحقق من شيفرتنا البرمجية بالطريقة التي نريدها.

استخدمنا في الأمثلة البرمجية سابقًا هيكلًا يدعى Rectangle وتابع can_hold، وتجد المثال مكررًا هنا في الشيفرة 5. دعنا نضع هذه الشيفرة البرمجية في الملف src/lib.rs ومن ثم نكتب بعض الاختبارات باستخدام الماكرو assert!‎.

اسم الملف: src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

الشيفرة 5: استخدام الهيكل Rectangle وتابعه can_hold من مثال سابق

يمكن أن يُعيد التابع can_hold قيمةً بوليانية، وهذا يعني أننا نستطيع استخدامه مع الماكرو assert!‎. سنكتب اختبارًا لنتدرّب على التابع can_hold بإنشاء نسخة Rectangle في الشيفرة 6، وتحمل النسخة عرضًا بمقدار 8 وطولًا بمقدار 7، ومن ثم نتأكد من أنها تستطيع حمل hold نسخة Rectangle أخرى بعرض 5 وطول 1.

اسم الملف: src/lib.rs

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }
}

الشيفرة 6: اختبار للتابع can_hold يتحقق فيما إذا كان المستطيل الكبير يتسع مستطيلًا أصغر

لاحظ أننا أضفنا سطرًا جديدًا داخل وحدة tests ألا وهو use super::*;‎، إذ تمثّل وحدة test وحدةً اعتيادية تتبع قواعد الظهورالاعتيادية visibility rules التي ناقشناها سابقًا في مقال المسارات paths وشجرة الوحدة module tree في رست Rust، ولأن وحدة tests هي وحدة داخلية inner module، فنحن بحاجة لإضافة الشيفرة البرمجية التي نريد إجراء الاختبار عليها في الوحدة الخارجية outer module إلى نطاق scope الوحدة الداخلية، ونستخدم هنا glob بحيث يكون كل شيء نعرفه في الوحدة الخارجية متاحًا للوحدة tests.

سمّينا الاختبار بالاسم larger_can_hold_smaller وأنشأنا نسختين من الهيكل Rectangle ومن ثم استدعينا الماكرو assert!‎ ومرّرنا النتيجة باستدعاء larger.can_hold(&smaller)‎. يجب أن يُعيد هذا التعبير القيمة true إذا اجتاز الاختبار بنجاح، دعنا نرى بأنفسنا.

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 1 test
test tests::larger_can_hold_smaller ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

اجتاز الاختبار فعلًا. دعنا نضيف اختبارًا آخر بالتأكد من أن المستطيل الصغير لا يتسع داخل المستطيل الكبير:

اسم الملف: src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        // --snip--
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

لأن النتيجة الصحيحة من الدالة can_hold هي false في هذه الحالة، فنحن بحاجة لنفي النتيجة قبل أن نمررها إلى الماكرو assert!‎ وبالتالي سيجتاز الاختبار إذا أعادت can_hold القيمة false:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

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

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width < other.width && self.height > other.height
    }
}

نحصل على التالي عند تنفيذ الاختبارات:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok

failures:

---- tests::larger_can_hold_smaller stdout ----
thread 'main' panicked at 'assertion failed: larger.can_hold(&smaller)', src/lib.rs:28:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::larger_can_hold_smaller

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

تنبّهت اختباراتنا للخطأ، لأن larger.width هو 8 و smaller.width هو 5، والمقارنة بين العرضَين في can_hold تُعيد النتيجة false إذ أن 8 ليست أقل من 5.

اختبار المساواة باستخدام الماكرو assert_eq!‎ و assert_ne!‎

نستخدم المساواة كثيرًا لاختبار البرنامج وذلك بين القيمة التي تعيدها الشيفرة البرمجية عند التنفيذ والقيمة التي تتوقع من الشيفرة البرمجية أن تعيدها، ويمكننا تحقيق ذلك باستخدام الماكرو assert!‎ وتمرير تعبير باستخدام العامل ==، إلا أن هناك طريقة أخرى أكثر شيوعًا موجودة في المكتبة القياسية ألا وهي الماكرو assert_eq!‎ و assert_ne!‎؛ إذ يقارن كلًا من الماكرو المذكورَين سابقًا القيمة للتحقق من المساواة أو عدم المساواة، كما أننا سنحصل على طباعة للقيمتين إذا فشل الاختبار، بينما يدلنا الماكرو assert!‎ فقط على حصوله على القيمة false من التعبير == دون طباعة القيم التي أدت لحصولنا للقيمة false في المقام الأول.

نكتب في الشيفرة 11 دالة تدعى add_two تُضيف القيمة "2" إلى معاملها، ثم نفحص هذه الدالة باستخدام الماكرو assert_eq!‎.

اسم الملف: src/lib.rs

pub fn add_two(a: i32) -> i32 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        assert_eq!(4, add_two(2));
    }
}

الشيفرة 7: اختبار الدالة add_two باستخدام الماكرو assert_eq!‎

لنتحقّق من عمل الدالة:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

نمرّر القيمة "4" على أنها وسيط إلى الماكرو assert_eq!‎ وهي قيمة تساوي قيمة استدعاء add_two(2)‎. السطر الخاص بالاختبار هو كالآتي:

test tests::it_adds_two ... ok 

وتُشير ok إلى أن نجاح الاختبار.

دعنا نضيف خطأً برمجيًا متعمّدًا على شيفرتنا البرمجية لنرى كيف ستكون رسالة فشل الاختبار باستخدام الماكرو assert_eq!‎. لنغيّر من تطبيق add_two لنضيف قيمة "3" بدلًا من "2":

pub fn add_two(a: i32) -> i32 {
    a + 3
}

نشغِّل الاختبارات مجددًا:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... FAILED

failures:

---- tests::it_adds_two stdout ----
thread 'main' panicked at 'assertion failed: `(left == right)`
  left: `4`,
 right: `5`', src/lib.rs:11:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_adds_two

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

نجح الاختبار بالتعرف على الخطأ، إذ أن الاختبار it_adds_two فشل وتخبرنا رسالة الخطأ أن سبب الفشل هو:

assertion failed: (left == right)

بالإضافة لتحديد قيمة كل من left و right، وتساعدنا رسالة الخطأ هذه بالبدء بعملية تنقيح الأخطاء، إذ أن قيمة left هي "4" إلا أن right -القيمة التي حصلنا عليها من add_two(2)‎- هي "5". يصبح هذا الأمر مفيدًا جدًا عندما يكون لدينا الكثير من الاختبارات.

يُطلق على معامل التأكد من المساواة للتوابع في بعض لغات البرمجة وأطر العمل اسم expected و actual ويكون ترتيب تحديد المعاملات مهمًّا، إلا أنهما يحملان اسم left و right في رست ولا يهمّ ترتيبهما في عند تمريرهما للماكرو، إذ يمكننا كتابة التأكيد assertion في الاختبار بالشكل:

assert_eq!(add_two(2), 4)‎

الذي سينتج رسالة الخطأ ذاتها التي حصلنا عليها سابقًا، وهي:

assertion failed: (left == right)‎

ينجح الاختبار باستخدام الماكرو assert_ne!‎ إذا كانت القيمتين المُرّرتين له غير متساويتين، ويفشل في حال المساواة. نستفيد من هذا الماكرو عندما لا نعلم ماذا ستكون القيمة الناتجة تحديدًا إلا أننا نعلم أنها لا يجب أن تكون مساويةً لقيمة معينة؛ على سبيل المثال إذا كنا نختبر دالة يتغير دخلها بحسب يوم الأسبوع الذي ننفّذ فيه الاختبار، سيكون الأفضل في هذه الحالة هو التأكد من أن القيمة التي حصلنا عليها من خرج الدالة لا تساوي قيمة الدخل.

يُستخدم كل من الماكرو assert_eq!‎ و assert_ne!‎ كلًا من العامل == و =! على الترتيب، وعندما تفشل عملية التأكيد، يطبع الماكرو معاملاته باستخدام تنسيق تنقيح الأخطاء، ما يعني أن القيم التي ستُقارن فيما بينها يجب أن تُطبّق السمتين ‏PartialEq و Debug، وتطبق جميع الأنواع البدائية ومعظم أنواع المكتبة القياسية هاتين السمتين.

ستحتاج لتطبيق PartialEq للهياكل والمعددات enums التي تعرّفها بنفسك للتأكد من مساواة النوعين، كما ستحتاج أيضًا لتطبيق Debug لطباعة القيم عندما يفشل التأكيد. كلا من السمتين المذكورتين هي سمات قابلة للاشتقاق derivable كما ذكرنا سابقًا في الشيفرة 12 من فصل استخدام الهياكل structs لتنظيم البيانات في لغة رست Rust، وهذا يعني أننا نستطيع إضافة الشيفرة التالية مباشرةً في تعريف الهيكل أو المعدّد:

#[derive(PartialEq, Debug)]

إضافة رسائل فشل مخصصة

يمكنك إضافة رسائل مخصّصة لطباعتها مع رسالة الخطأ مثل معامل اختياري للماكرو assert!‎ و assert_eq!‎ و assert_ne!‎. تمرّر أي معاملات تُحدد بعد المعاملات المطلوبة للماكرو بدورها إلى الماكرو format!‎ (الذي ناقشناه في مقال تخزين لائحة من القيم باستخدام الأشعة Vectors)، بحيث يمكنك تمرير سلسلة نصية منسّقة تحتوي على {} بمثابة موضع مؤقت والقيم بداخلها. الرسائل المخصصة مفيدة لتوثيق معنى التأكيد؛ إذ سيكون لديك فهم واضح عن المشكلة في الشيفرة البرمجية عندما يفشل الاختبار.

على سبيل المثال، دعنا نفترض وجود دالة تحيّي الناس بالاسم ونريد أن نختبر إذا كان الاسم الذي نمرّره إلى الدالة يظهر في الخرج:

اسم الملف: src/lib.rs

pub fn greeting(name: &str) -> String {
    format!("Hello {}!", name)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

لم يُتّفق على متطلبات هذا البرنامج بعد، ونحن متأكدون أن النص "Hello" في بداية نص التحية سيتغير لاحقًا، لذا قررنا أننا لا نريد أن نعدل النص عند تغيير المتطلبات، وتحققنا بدلًا من ذلك من المساواة بين القيمة المُعادة من الدالة greeting للتأكد من أن الخرج يحتوي على النص الموجود في معامل الدخل.

لنضيف خطأ برمجي متعمد على الشيفرة البرمجية بتغيير greeting بحيث لا تحتوي على الاسم لنرى ما ستكون نتيجة فشل الاختبار الافتراضي:

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

تكون نتيجة تشغيل الاختبار على النحو التالي:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished test [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----
thread 'main' panicked at 'assertion failed: result.contains(\"Carol\")', src/lib.rs:12:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

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

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(
            result.contains("Carol"),
            "Greeting did not contain name, value was `{}`",
            result
        );
    }

نحصل عندما نشغّل الاختبار الآن على رسالة خطأ أكثر وضوحًا:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished test [unoptimized + debuginfo] target(s) in 0.93s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----
thread 'main' panicked at 'Greeting did not contain name, value was `Hello!`', src/lib.rs:12:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

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

التحقق من حالات الهلع باستخدام should_panic

من المهم بالإضافة للتحقق من القيم المعادة التحقق من أن شيفرتنا البرمجية تتعامل مع حالات الخطأ كما نتوقع، على سبيل المثال تذكر النوع Guess الذي أنشأناه في أمثلة سابقة في مقال الأخطاء والتعامل معها، إذ استخدمت بعض الشيفرات البرمجية Guess واعتمدت على أن نسخ Guess ستحتوي على قيمة تترواح بين 1 و100. يمكننا كتابة اختبار يتسبب بحالة هلع إذا حاولنا إنشاء نسخة من Guess خارج المجال، وذلك عن طريق إضافة سمة should_panic إلى دالة الاختبار، وينجح الاختبار إذا كانت الشيفرة البرمجية داخل الدالة تهلع، وإلا يفشل الاختبار في حالة عدم هلع الدالة.

توضح الشيفرة 8 اختبارًا يتأكد أن حالات الخطأ المتعلقة بالدالة Guess::new تحصل عندما نتوقع حصولها.

اسم الملف: src/lib.rs

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

الشيفرة 8: اختبار أن حالة ما ستتسبب بالهلع panic!‎

نضع السمة ‎#[should_panic]‎ بعد السمة ‎#[test]‎ وقبل دالة الاختبار التي نريد تطبيق السمة عليها.دعنا نلقي نظرةًعلى النتيجة بعد نجاح الاختبار:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests guessing_game

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

يبدو أن الأمر على ما يرام. لنضيف خطأ برمجي عن عمد إلى شيفرتنا البرمجية بإزالة الشرط الذي يتسبب بهلع الدالة new إذا كانت القيمة أكبر من 100:

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess { value }
    }
}

يفشل الاختبار في الشيفرة 8 عندما نشغّله:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----
note: test did not panic as expected

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

لا نحصل على رسالة مفيدة جدًافي هذه الحالة، لكن إذا نظرنا إلى دالة الاختبار نرى توصيفها بالتالي: ‎#[should_panic]‎. حالة الفشل التي حصلنا عليها تعني أن الشيفرة البرمجية في دالة الاختبار لم تتسبب بحالة هلع.

يمكن أن تصبح الاختبارات التي تستخدم should_panic غير دقيقة، إذ ينجح اختبار should_pass في حال هلع الاختبار لسبب مختلف عمّا كنا متوقعين، ولجعل اختبارات should_panic أكثر دقة، يمكننا إضافة معامل اختياري يدعى expected للسمة should_panic،وسيتأكد عندها الاختبار من أن رسالة الخطأ تحتوي على النص الذي زوّدناه به. على سبيل المثال، لنفترض أن الشيفرة البرمجية الخاصة بالدالة Guess في الشيفرة 9 احتوت على هلع الدالة new برسائل مختلفة بحسب إذا ما كانت القيمة صغيرة أو كبيرة.

اسم الملف: src/lib.rs

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be greater than or equal to 1, got {}.",
                value
            );
        } else if value > 100 {
            panic!(
                "Guess value must be less than or equal to 100, got {}.",
                value
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

الشيفرة 9: اختبار panic!‎ برسالة هلع تحتوي على سلسلة نصية جزئية محددة

سينجح الاختبار لأن القيمة التي نضعها في معامل expected الخاص بالسمة should_panic هي سلسلة نصية جزئية للرسالة التي تعرضها الدالة Guess::new عند الهلع. يمكننا تحديد كامل رسالة الهلع التي نتوقعها، وستكون في هذه الحالة:

Guess value must be less than or equal to 100, got 200.‎

يعتمد ما تختار أن تحدده على رسالة الهلع إذا ما كانت مميزة أو ديناميكية ودقة الاختبار التي تريدها، وتُعد سلسلةً نصيةً جزئيةً لرسالة الهلع كافية في هذه الحالة للتأكد من أن الشيفرة البرمجية في دالة الاختبار تنفّذ حالة else if value > 100.

دعنا نضيف خطأ برمجي جديد مجددًا لرؤية ما الذي سيحصل للاختبار should_panic باستخدام رسالة expected وذلك بتبديل محتوى الكتلة if value < 1 مع else if value > 100:

        if value < 1 {
            panic!(
                "Guess value must be less than or equal to 100, got {}.",
                value
            );
        } else if value > 100 {
            panic!(
                "Guess value must be greater than or equal to 1, got {}.",
                value
            );
        }

يفشل الاختبار should_panic هذه المرة عند تشغيله:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----
thread 'main' panicked at 'Guess value must be greater than or equal to 1, got 200.', src/lib.rs:13:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
      panic message: `"Guess value must be greater than or equal to 1, got 200."`,
 expected substring: `"less than or equal to 100"`

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

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

'Guess value must be less than or equal to 100'

إذ حصلنا على رسالة الهلع التالية في هذه الحالة:

Guess value must be greater than or equal to 1, got 200.‎

لنبدأ الآن بالبحث عن الخطأ.

استخدام Result‎ في الاختبارات

تهلع جميع اختباراتنا لحد هذه اللحظة عند الفشل، إلا أنه يمكننا كتابة اختبارات تستخدم Result<T, E>‎. إليك اختبارًا من الشيفرة 1 إذ أعدنا كتابته باستخدام Result<T, E>‎ ليعيد Err بدلًا من الهلع:

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() -> Result<(), String> {
        if 2 + 2 == 4 {
            Ok(())
        } else {
            Err(String::from("two plus two does not equal four"))
        }
    }
}

للدالة it_works الآن النوع المُعاد Result<(), String>‎، ونُعيد Ok(())‎ عندما ينجح الاختبار و Err مع Sring بداخله عندما يفشل وذلك بدلًا من استدعاء الماكرو assert_eq!‎.

تمكّنك كتابة الاختبارات بحيث تعيد Result<T, E>‎ من استخدام عامل إشارة الاستفهام في محتوى الاختبار بحيث تكون وسيلةً ملائمةً لكتابة الاختبارات التي يجب أن تفشل إذا أعادت أي عملية داخلها المتغاير Err.

لا يمكنك استخدام التوصيف ‎#[should_panic]‎ على الاختبارات التي تستخدم Result<T, E>‎. لا تستخدم عامل إشارة الاستفهام على Result<T, E>‎ للتأكد من أن عملية ما تُعيد المتغاير Err بل استخدم assert!(value.is_err())‎ بدلًا من ذلك.

الآن وبعد أن تعلمنا الطرق المختلفة لكتابة الاختبارات، حان الوقت لننظر إلى ما يحدث عندما ننفذ الاختبارات ونكتشف الخيارات المختلفة التي يمكننا استخدامها مع cargo test في المقال التالي.

ترجمة -وبتصرف- لقسم من الفصل Writing Automated Tests من كتاب The Rust Programming Language.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...