اختبار الوحدة أصبح جزءًا قياسيًا من التطوير. يمكن استخدام العديد من الأدوات لذلك بطرق مختلفة. توضح هذه المقالة مجموعة من النصائح أو، دعونا نقول، أفضل الممارسات التي عملت بشكل جيد بالنسبة لي.
في هذه المقالة، ستتعلم
- كيفية كتابة اختبارات وحدة نظيفة وقابلة للقراءة باستخدام JUnit وإطارات الإعتراض
- كيفية التغلب على الاختبارات غير الإيجابية الخاطئة في بعض الحالات
- ما الذي يجب تجنبه عند كتابة اختبارات الوحدة
لا تستخدم فحص NPE بشكل مفرط
نحن نميل جميعًا لتجنب NullPointerException
بقدر الإمكان في الكود الرئيسي لأنه يمكن أن يؤدي إلى عواقب قبيحة. أعتقد أن محاولتنا الرئيسية ليست تجنب NPE في الاختبارات. هدفنا هو التحقق من سلوك المكون المختبر بطريقة نظيفة وقابلة للقراءة وموثوق بها.
ممارسة سيئة
في كثير من الأحيان في الماضي، استخدمت isNotNull
الإعتراض حتى عندما لم يكن مطلوبًا، كما في المثال أدناه:
@Test
public void getMessage() {
assertThat(service).isNotNull();
assertThat(service.getMessage()).isEqualTo("Hello world!");
}
ينتج هذا الاختبار أخطاء مثل هذه:
java.lang.AssertionError:
Expecting actual not to be null
at com.github.aha.poc.junit.spring.StandardSpringTest.test(StandardSpringTest.java:19)
ممارسة جيدة
على الرغم من أن الإعتراض الإضافي isNotNull
ليس ضارًا حقًا، يجب تجنبه للأسباب التالية:
- إنه لا يضيف أي قيمة إضافية. إنه مجرد المزيد من الكود للقراءة والصيانة.
- يفشل الاختبار على أي حال عندما يكون
service
null
ونرى السبب الجذري الحقيقي للفشل. لا يزال الاختبار يخدم هدفه. - حتى أن رسالة الخطأ الناتجة أفضل مع ادعاء AssertJ.
انظر إلى ادعاء الاختبار المعدل أدناه.
@Test
public void getMessage() {
assertThat(service.getMessage()).isEqualTo("Hello world!");
}
ينتج الاختبار المعدل خطأ كهذا:
java.lang.NullPointerException: Cannot invoke "com.github.aha.poc.junit.spring.HelloService.getMessage()" because "this.service" is null
at com.github.aha.poc.junit.spring.StandardSpringTest.test(StandardSpringTest.java:19)
ملاحظة: يمكن العثور على المثال في SimpleSpringTest.
ادعم القيم وليس النتيجة
من وقت لآخر، نكتب اختبارًا صحيحًا، لكن بطريقة “سيئة”. هذا يعني أن الاختبار يعمل تمامًا كما هو مقصود ويتحقق من مكوناتنا، لكن الفشل لا يوفر معلومات كافية. لذا، هدفنا هو ادعاء القيمة وليس نتيجة المقارنة.
ممارسة سيئة
دعونا نرى اثنين من هذه الاختبارات السيئة:
// #1
assertThat(argument.contains("o")).isTrue();
// #2
var result = "Welcome to JDK 10";
assertThat(result instanceof String).isTrue();
// #3
assertThat("".isBlank()).isTrue();
// #4
Optional<Method> testMethod = testInfo.getTestMethod();
assertThat(testMethod.isPresent()).isTrue();
يظهر بعض الأخطاء من الاختبارات أعلاه أدناه.
#1
Expecting value to be true but was false
at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502)
at com.github.aha.poc.junit5.params.SimpleParamTests.stringTest(SimpleParamTests.java:23)
#3
Expecting value to be true but was false
at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502)
at com.github.aha.poc.junit5.ConditionalTests.checkJdk11Feature(ConditionalTests.java:50)
ممارسة جيدة
الحل بسيط جدًا مع AssertJ و API السلس الخاصة به. يمكن إعادة كتابة كل الحالات المذكورة أعلاه بسهولة:
// #1
assertThat(argument).contains("o");
// #2
assertThat(result).isInstanceOf(String.class);
// #3
assertThat("").isBlank();
// #4
assertThat(testMethod).isPresent();
نفس الأخطاء المذكورة من قبل توفر الآن قيمة اكثر.
#1
Expecting actual:
"Hello"
to contain:
"f"
at com.github.aha.poc.junit5.params.SimpleParamTests.stringTest(SimpleParamTests.java:23)
#3
Expecting blank but was: "a"
at com.github.aha.poc.junit5.ConditionalTests.checkJdk11Feature(ConditionalTests.java:50)
Note: The example can be found in SimpleParamTests.
جمع التوقيعات المتعلقة ببعضها البعض
تساعد سلسلة التوقيع ومسافة المسطرة المتعلقة بالكود كثيرًا في زيادة وضوح وقابلية قراءة الاختبار.
ممارسة سيئة
عندما نكتب اختبارًا ، قد ننتهي بالنتيجة الصحيحة ولكن أقل قابلية للقراءة. دعونا نتخيل اختبارًا نريد فيه العثور على دول وإجراء هذه الشيكات:
- عد الدول الموجودة.
- احتساب القيمة الأولى مع عدة قيم.
يمكن أن تبدو مثل هذا المثال:
@Test
void listCountries() {
List<Country> result = ...;
assertThat(result).hasSize(5);
var country = result.get(0);
assertThat(country.getName()).isEqualTo("Spain");
assertThat(country.getCities().stream().map(City::getName)).contains("Barcelona");
}
ممارسة جيدة
على الرغم من أن الاختبار السابق صحيح ، يجب أن نحسن قابلية القراءة كثيرًا من خلال تجميع التوقيعات المتعلقة ببعضها البعض معًا (الأسطر من 9 إلى 11). الهدف هنا هو التوقيع على result
مرة واحدة فقط وكتابة العديد من التوقيعات المتصلة المطلوبة. انظر إلى الإصدار المعدل أدناه.
@Test
void listCountries() {
List<Country> result = ...;
assertThat(result)
.hasSize(5)
.singleElement()
.satisfies(c -> {
assertThat(c.getName()).isEqualTo("Spain");
assertThat(c.getCities().stream().map(City::getName)).contains("Barcelona");
});
}
Note: The example can be found in CountryRepositoryOtherTests.
منع الاختبارات الناجحة بشكل زائف
عند استخدام أي طريقة ادعاء تحتوي على وسيط ThrowingConsumer
، يجب أن يحتوي الوسيط على assertThat
داخل المستهدف أيضًا. وإلا ستجتاز الاختبار دائمًا حتى عندما يفشل المقارنة، مما يعني أن الاختبار غير صحيح. يفشل الاختبار فقط عندما يلقي ادعاء RuntimeException
أو AssertionError
استثنائي. أعتقد أن الأمر واضح، لكن من السهل نسيانه وكتابة الاختبار الخاطئ. يحدث لي هذا من وقت لآخر.
ممارسة سيئة
لنتخيل أن لدينا عدد من رموز الدول ونرغب في التحقق من أن كل رمز يفي بشرط معين. في حالتنا الوهمية، نريد التأكد من أن كل رمز دولة يحتوي على حرف “a”. كما ترون، هذا سخيف: لدينا رموز بالأحرف الكبيرة، لكننا لم نطبق الحساسية للحالة في الادعاء.
@Test
void assertValues() throws Exception {
var countryCodes = List.of("CZ", "AT", "CA");
assertThat( countryCodes )
.hasSize(3)
.allSatisfy(countryCode -> countryCode.contains("a"));
}
ومن المستغرب أن تلك الاختبارات اجتازت بنجاح.
ممارسة جيدة
كما ذكرنا في بداية هذا القسم، يمكن تصحيح الاختبار بسهولة بإضافة assertThat
داخل المستهدف (السطر 7). يجب أن يكون الاختبار الصحيح هو:
@Test
void assertValues() throws Exception {
var countryCodes = List.of("CZ", "AT", "CA");
assertThat( countryCodes )
.hasSize(3)
.allSatisfy(countryCode -> assertThat( countryCode ).containsIgnoringCase("a"));
}
الآن يفشل الاختبار كما هو متوقع مع رسالة الخطأ الصحيحة.
java.lang.AssertionError:
Expecting all elements of:
["CZ", "AT", "CA"]
to satisfy given requirements, but these elements did not:
"CZ"
error:
Expecting actual:
"CZ"
to contain:
"a"
(ignoring case)
at com.github.aha.sat.core.clr.AppleTest.assertValues(AppleTest.java:45)
سلاسل الادعاء
النصيحة الأخيرة ليست بالضرورة ممارسة، ولكنها توصية. يجب استخدام واجهة برمجة التطبيقات السلسة في AssertJ من أجل إنشاء اختبارات أكثر قابلية للقراءة.
إدعاءات غير متشابكة
لنأخذ listLogs
اختباراً، والذي يهدف لاختبار تسجيل مكون. الهدف هنا هو التحقق:
- عدد السجلات المجمعة
- اثبات وجود
DEBUG
وINFO
للرسائل السجل
@Test
void listLogs() throws Exception {
ListAppender<ILoggingEvent> logAppender = ...;
assertThat( logAppender.list ).hasSize(2);
assertThat( logAppender.list ).anySatisfy(logEntry -> {
assertThat( logEntry.getLevel() ).isEqualTo(DEBUG);
assertThat( logEntry.getFormattedMessage() ).startsWith("Initializing Apple");
});
assertThat( logAppender.list ).anySatisfy(logEntry -> {
assertThat( logEntry.getLevel() ).isEqualTo(INFO);
assertThat( logEntry.getFormattedMessage() ).isEqualTo("Here's Apple runner" );
});
}
تسلسل الادعاءات
باستخدام API السلس المذكور والتسلسل، يمكن تغيير الاختبار بهذه الطريقة:
@Test
void listLogs() throws Exception {
ListAppender<ILoggingEvent> logAppender = ...;
assertThat( logAppender.list )
.hasSize(2)
.anySatisfy(logEntry -> {
assertThat( logEntry.getLevel() ).isEqualTo(DEBUG);
assertThat( logEntry.getFormattedMessage() ).startsWith("Initializing Apple");
})
.anySatisfy(logEntry -> {
assertThat( logEntry.getLevel() ).isEqualTo(INFO);
assertThat( logEntry.getFormattedMessage() ).isEqualTo("Here's Apple runner" );
});
}
ملاحظة: يمكن العثور على المثال في AppleTest.
ملخص وكود المصدر
وحدة AssertJ توفر الكثير من المساعدة مع API السلس الخاصة بهم. في هذا المقال، تم تقديم عدة نصائح وتلميحات من أجل إنتاج اختبارات أوضح وأكثر موثوقية. يرجى أخذ بعين الاعتبار أن معظم هذه التوصيات هي شخصية. يعتمد ذلك على التفضيلات الشخصية وأسلوب التعليمات البرمجية.
يمكن العثور على الكود المستخدم في مستودعاتي:
Source:
https://dzone.com/articles/hints-for-unit-testing-with-assertj