من أجل الحصول على تغطية كافية للكود ، ولإنشاء وظائف جديدة وإعادة بناء الوظائف القديمة دون الخوف من كسر شيء ما ، يجب أن تكون الاختبارات قابلة للصيانة وسهلة القراءة. في هذا المقال سوف أتحدث عن العديد من التقنيات الخاصة بكتابة اختبارات الوحدة والتكامل في Java ، والتي جمعتها على مر السنين. سأعتمد على التقنيات الحديثة: JUnit5 و AssertJ و Testcontainers وأيضًا لن أتجاهل Kotlin. ستبدو بعض النصائح واضحة لك ، بينما قد يتعارض البعض الآخر مع ما قرأته في الكتب حول تطوير البرامج واختبارها.
شيء صغير
- اكتب الاختبارات بإيجاز وعلى وجه التحديد ، باستخدام وظائف المساعد ، والمعلمات ، والأساسيات المختلفة لمكتبة AssertJ ، ولا تسيء استخدام المتغيرات ، وتحقق فقط مما يتعلق بالوظيفة المختبرة ولا تلصق جميع الحالات غير القياسية في اختبار واحد
- , ,
- , -,
- KISS DRY
- , , , in-memory-
- JUnit5 AssertJ —
- : , , Clock - .
Given, When, Then (, , )
يجب أن يحتوي الاختبار على ثلاث كتل ، مفصولة بأسطر فارغة. يجب أن تكون كل كتلة قصيرة قدر الإمكان. استخدم الأساليب المحلية لإبقاء الأشياء مضغوطة.
معطى / معطى (إدخال): إعداد الاختبار ، على سبيل المثال ، إنشاء البيانات وتكوين الاستهزاء.
عندما / متى (الإجراء): استدعاء الطريقة المختبرة
ثم / ثم (الإخراج): تحقق من صحة القيمة المستلمة
//
@Test
public void findProduct() {
insertIntoDatabase(new Product(100, "Smartphone"));
Product product = dao.findProduct(100);
assertThat(product.getName()).isEqualTo("Smartphone");
}
استخدم البادئات "الفعلي *" و "المتوقع *"
//
ProductDTO product1 = requestProduct(1);
ProductDTO product2 = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(product1).isEqualTo(product2);
إذا كنت ستستخدم المتغيرات في اختبار المطابقة ، أضف البادئات "الفعلية" و "المتوقعة" إلى هذه المتغيرات. سيؤدي ذلك إلى تحسين قابلية قراءة التعليمات البرمجية الخاصة بك وتوضيح الغرض من المتغيرات. كما أنه يزيد من صعوبة الخلط بينها عند المقارنة.
//
ProductDTO actualProduct = requestProduct(1);
ProductDTO expectedProduct = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(actualProduct).isEqualTo(expectedProduct); //
استخدم القيم المحددة مسبقًا بدلاً من القيم العشوائية
تجنب تغذية القيم العشوائية لاختبار المدخلات. يمكن أن يؤدي هذا إلى اختبارات الوميض ، والتي يصعب تصحيحها. بالإضافة إلى ذلك ، إذا رأيت قيمة عشوائية في رسالة خطأ ، فلا يمكنك تتبعها مرة أخرى إلى مكان حدوث الخطأ.
//
Instant ts1 = Instant.now(); // 1557582788
Instant ts2 = ts1.plusSeconds(1); // 1557582789
int randomAmount = new Random().nextInt(500); // 232
UUID uuid = UUID.randomUUID(); // d5d1f61b-0a8b-42be-b05a-bd458bb563ad
استخدم قيمًا مختلفة محددة مسبقًا لكل شيء. بهذه الطريقة ستحصل على نتائج اختبار قابلة للتكرار تمامًا ، بالإضافة إلى العثور بسرعة على المكان المناسب في الرمز من خلال رسالة الخطأ.
//
Instant ts1 = Instant.ofEpochSecond(1550000001);
Instant ts2 = Instant.ofEpochSecond(1550000002);
int amount = 50;
UUID uuid = UUID.fromString("00000000-000-0000-0000-000000000001");
يمكنك كتابة هذا بشكل أقصر باستخدام وظائف المساعد (انظر أدناه).
اكتب اختبارات موجزة ومحددة
استخدم الوظائف المساعدة حيثما أمكن ذلك
افصل التعليمات البرمجية المتكررة إلى وظائف محلية وأعطها أسماء ذات معنى. سيؤدي ذلك إلى الحفاظ على اختباراتك مضغوطة وسهلة القراءة في لمح البصر.
//
@Test
public void categoryQueryParameter() throws Exception {
List<ProductEntity> products = List.of(
new ProductEntity().setId("1").setName("Envelope").setCategory("Office").setDescription("An Envelope").setStockAmount(1),
new ProductEntity().setId("2").setName("Pen").setCategory("Office").setDescription("A Pen").setStockAmount(1),
new ProductEntity().setId("3").setName("Notebook").setCategory("Hardware").setDescription("A Notebook").setStockAmount(2)
);
for (ProductEntity product : products) {
template.execute(createSqlInsertStatement(product));
}
String responseJson = client.perform(get("/products?category=Office"))
.andExpect(status().is(200))
.andReturn().getResponse().getContentAsString();
assertThat(toDTOs(responseJson))
.extracting(ProductDTO::getId)
.containsOnly("1", "2");
}
//
@Test
public void categoryQueryParameter2() throws Exception {
insertIntoDatabase(
createProductWithCategory("1", "Office"),
createProductWithCategory("2", "Office"),
createProductWithCategory("3", "Hardware")
);
String responseJson = requestProductsByCategory("Office");
assertThat(toDTOs(responseJson))
.extracting(ProductDTO::getId)
.containsOnly("1", "2");
}
- استخدام الدوال المساعدة لإنشاء البيانات (الكائنات) (
createProductWithCategory()) والتحقق المعقدة. قم بتمرير تلك المعلمات فقط إلى وظائف المساعد ذات الصلة بهذا الاختبار ؛ بالنسبة للباقي ، استخدم الإعدادات الافتراضية المناسبة. في Kotlin ، توجد قيم معلمات افتراضية لهذا ، وفي Java ، يمكنك استخدام سلاسل استدعاء الأسلوب والتحميل الزائد لمحاكاة المعلمات الافتراضية. - قائمة معلمات الطول المتغير ستجعل الكود الخاص بك أكثر أناقة (
ìnsertIntoDatabase()) - يمكن أيضًا استخدام الدوال المساعدة لإنشاء قيم بسيطة. تقوم Kotlin بعملها بشكل أفضل من خلال وظائف الامتداد.
// (Java)
Instant ts = toInstant(1); // Instant.ofEpochSecond(1550000001)
UUID id = toUUID(1); // UUID.fromString("00000000-0000-0000-a000-000000000001")
// (Kotlin)
val ts = 1.toInstant()
val id = 1.toUUID()
يمكن تنفيذ وظائف المساعد في Kotlin على النحو التالي:
fun Int.toInstant(): Instant = Instant.ofEpochSecond(this.toLong())
fun Int.toUUID(): UUID = UUID.fromString("00000000-0000-0000-a000-${this.toString().padStart(11, '0')}")
لا تفرط في استخدام المتغيرات
المنعكس الشرطي للمبرمج هو نقل القيم المستخدمة بشكل متكرر إلى المتغيرات.
//
@Test
public void variables() throws Exception {
String relevantCategory = "Office";
String id1 = "4243";
String id2 = "1123";
String id3 = "9213";
String irrelevantCategory = "Hardware";
insertIntoDatabase(
createProductWithCategory(id1, relevantCategory),
createProductWithCategory(id2, relevantCategory),
createProductWithCategory(id3, irrelevantCategory)
);
String responseJson = requestProductsByCategory(relevantCategory);
assertThat(toDTOs(responseJson))
.extracting(ProductDTO::getId)
.containsOnly(id1, id2);
}
للأسف ، هذا هو زيادة في التعليمات البرمجية للغاية. والأسوأ من ذلك ، أن رؤية القيمة في رسالة الخطأ سيكون من المستحيل تتبعها إلى مكان حدوث الخطأ.
"KISS أهم من DRY"
//
@Test
public void variables() throws Exception {
insertIntoDatabase(
createProductWithCategory("4243", "Office"),
createProductWithCategory("1123", "Office"),
createProductWithCategory("9213", "Hardware")
);
String responseJson = requestProductsByCategory("Office");
assertThat(toDTOs(responseJson))
.extracting(ProductDTO::getId)
.containsOnly("4243", "1123");
}
إذا كنت تحاول كتابة اختبارات مضغوطة قدر الإمكان (وهو ما أوصي به بشدة على أي حال) ، فإن القيم المعاد استخدامها مرئية بوضوح. يصبح الرمز نفسه أكثر إحكاما وأكثر قابلية للقراءة. أخيرًا ، ستقودك رسالة الخطأ إلى السطر المحدد الذي حدث فيه الخطأ.
لا تمدد الاختبارات الحالية إلى "إضافة شيء بسيط آخر"
//
public class ProductControllerTest {
@Test
public void happyPath() {
// ...
}
}
من المغري دائمًا إضافة حالة خاصة إلى اختبار حالي يتحقق من صحة الوظائف الأساسية. ولكن نتيجة لذلك ، تصبح الاختبارات أكبر وأصعب في الفهم. من السهل تفويت الحالات الخاصة المنتشرة على ورقة كبيرة من التعليمات البرمجية. إذا فشل الاختبار ، فقد لا تفهم على الفور سبب ذلك بالضبط.
//
public class ProductControllerTest {
@Test
public void multipleProductsAreReturned() {}
@Test
public void allProductValuesAreReturned() {}
@Test
public void filterByCategory() {}
@Test
public void filterByDateCreated() {}
}
بدلاً من ذلك ، اكتب اختبارًا جديدًا باسم وصفي يوضح على الفور السلوك الذي يتوقعه من الكود قيد الاختبار. نعم ، سيتعين عليك كتابة المزيد من الأحرف على لوحة المفاتيح (في مقابل ذلك ، دعني أذكرك ، تساعد الوظائف المساعدة جيدًا) ، لكنك ستحصل على اختبار بسيط ومفهوم بنتيجة يمكن التنبؤ بها. هذه طريقة رائعة لتوثيق الوظائف الجديدة بالمناسبة.
تحقق فقط مما تريد اختباره
فكر في الوظيفة التي تختبرها. تجنب إجراء الفحوصات غير الضرورية لمجرد أنك تستطيع ذلك. علاوة على ذلك ، كن على دراية بما تم اختباره بالفعل في الاختبارات المكتوبة مسبقًا ولا تعيد اختباره. يجب أن تكون الاختبارات مضغوطة وأن يكون سلوكها المتوقع واضحًا وخاليًا من التفاصيل غير الضرورية.
لنفترض أننا نريد اختبار مقبض HTTP الذي يعرض قائمة بالمنتجات. يجب أن تحتوي مجموعة الاختبار الخاصة بنا على الاختبارات التالية:
1. اختبار رسم خرائط كبير يتحقق من إرجاع جميع القيم من قاعدة البيانات بشكل صحيح في استجابة JSON ويتم تعيينها بشكل صحيح بالتنسيق الصحيح. يمكننا كتابة هذا بسهولة باستخدام الوظائف
isEqualTo()(لعنصر واحد) أو containsOnly()(لعناصر متعددة) من حزمة AssertJ ، إذا قمت بتطبيق الطريقة بشكل صحيحequals()...
String responseJson = requestProducts();
ProductDTO expectedDTO1 = new ProductDTO("1", "envelope", new Category("office"), List.of(States.ACTIVE, States.REJECTED));
ProductDTO expectedDTO2 = new ProductDTO("2", "envelope", new Category("smartphone"), List.of(States.ACTIVE));
assertThat(toDTOs(responseJson))
.containsOnly(expectedDTO1, expectedDTO2);
2. العديد من الاختبارات التي تتحقق من السلوك الصحيح لمعلمة الفئة. هنا نريد فقط التحقق مما إذا كانت المرشحات تعمل بشكل صحيح ، وليس قيم الخاصية ، لأننا فعلنا ذلك من قبل. لذلك ، يكفي أن نتحقق من مطابقات معرّفات المنتجات المستلمة:
String responseJson = requestProductsByCategory("Office");
assertThat(toDTOs(responseJson))
.extracting(ProductDTO::getId)
.containsOnly("1", "2");
3. اختباران إضافيان يتحققان من حالات خاصة أو منطق عمل خاص ، على سبيل المثال ، يتم حساب قيم معينة في الاستجابة بشكل صحيح. في هذه الحالة ، نحن مهتمون فقط ببضعة حقول من استجابة JSON بالكامل. وهكذا ، فإننا نوثق هذا المنطق الخاص باختبارنا. من الواضح أننا لا نحتاج إلى أي شيء آخر غير هذه المجالات هنا.
assertThat(actualProduct.getPrice()).isEqualTo(100);
الاختبارات الذاتية
لا تخفي المعلمات ذات الصلة (في الوظائف المساعدة)
//
insertIntoDatabase(createProduct());
List<ProductDTO> actualProducts = requestProductsByCategory();
assertThat(actualProducts).containsOnly(new ProductDTO("1", "Office"));
من الملائم استخدام الوظائف المساعدة لتوليد البيانات والتحقق من الشروط ، ولكن يجب استدعائها بالمعلمات. اقبل المعلمات لكل ما هو مفيد في الاختبار ويحتاج إلى التحكم فيه من رمز الاختبار. لا تجبر القارئ على القفز إلى وظيفة المساعد لفهم معنى الاختبار. قاعدة بسيطة: يجب أن يكون معنى الاختبار واضحًا عند النظر إلى الاختبار نفسه.
//
insertIntoDatabase(createProduct("1", "Office"));
List<ProductDTO> actualProducts = requestProductsByCategory("Office");
assertThat(actualProducts).containsOnly(new ProductDTO("1", "Office"));
احتفظ ببيانات الاختبار داخل الاختبارات نفسها
يجب أن يكون كل شيء بالداخل. من المغري نقل بعض البيانات إلى طريقة
@Beforeوإعادة استخدامها من هناك. لكن هذا سيجبر القارئ على القفز ذهابًا وإيابًا خلال الملف لفهم ما يجري هنا بالضبط. مرة أخرى ، ستساعدك الوظائف المساعدة على تجنب التكرار وتسهيل فهم اختباراتك.
استخدم التكوين بدلاً من الميراث
لا تقم ببناء تسلسلات هرمية معقدة لفئات الاختبار.
//
class SimpleBaseTest {}
class AdvancedBaseTest extends SimpleBaseTest {}
class AllInklusiveBaseTest extends AdvancedBaseTest {}
class MyTest extends AllInklusiveBaseTest {}
تؤدي هذه التسلسلات الهرمية إلى تعقيد الفهم ، وعلى الأرجح ستجد نفسك سريعًا تكتب الوريث التالي للاختبار الأساسي ، حيث يتم حياكة الكثير من القمامة التي لا يحتاجها الاختبار الحالي على الإطلاق. هذا يصرف القارئ ويؤدي إلى أخطاء دقيقة. الميراث ليس مرنًا: هل تعتقد أنه يمكنك استخدام جميع أساليب الفصل
AllInclusiveBaseTest، لكن لا أحد من أبويه ؟ AdvancedBaseTest?علاوة على ذلك ، سيتعين على القارئ التنقل باستمرار بين الفئات الأساسية المختلفة لفهم الصورة الكبيرة.
"من الأفضل نسخ الرمز بدلاً من اختيار التجريد الخاطئ" (ساندي ميتز)
أوصي باستخدام التركيب بدلاً من ذلك. اكتب مقتطفات وفئات صغيرة لكل مهمة متعلقة بالتركيبات (ابدأ قاعدة بيانات اختبارية ، أنشئ مخططًا ، أدخل البيانات ، ابدأ خادمًا وهميًا). أعد استخدام هذه الأجزاء بطريقة
@BeforeAllأو عن طريق تعيين الكائنات التي تم إنشاؤها في حقول فئة الاختبار. بهذه الطريقة ، ستتمكن من بناء كل فئة اختبار جديدة من هذه الفراغات ، بدءًا من أجزاء Lego. نتيجة لذلك ، سيكون لكل اختبار مجموعته المفهومة من التركيبات والتأكد من عدم حدوث أي شيء خارجها. يصبح الاختبار مكتفيًا ذاتيًا ، لأنه يحتوي على كل ما تحتاجه.
//
public class MyTest {
//
private JdbcTemplate template;
private MockWebServer taxService;
@BeforeAll
public void setupDatabaseSchemaAndMockWebServer() throws IOException {
this.template = new DatabaseFixture().startDatabaseAndCreateSchema();
this.taxService = new MockWebServer();
taxService.start();
}
}
//
public class DatabaseFixture {
public JdbcTemplate startDatabaseAndCreateSchema() throws IOException {
PostgreSQLContainer db = new PostgreSQLContainer("postgres:11.2-alpine");
db.start();
DataSource dataSource = DataSourceBuilder.create()
.driverClassName("org.postgresql.Driver")
.username(db.getUsername())
.password(db.getPassword())
.url(db.getJdbcUrl())
.build();
JdbcTemplate template = new JdbcTemplate(dataSource);
SchemaCreator.createSchema(template);
return template;
}
}
مرة اخرى:
"KISS أهم من DRY"
الاختبارات المباشرة جيدة. قارن النتيجة بالثوابت
لا تعيد استخدام كود الإنتاج
يجب أن تتحقق الاختبارات من صحة كود الإنتاج ، وليس إعادة استخدامها. إذا أعدت استخدام رمز القتال في اختبار ، فقد يفوتك خطأ في هذا الرمز لأنك لم تعد تختبره.
//
boolean isActive = true;
boolean isRejected = true;
insertIntoDatabase(new Product(1, isActive, isRejected));
ProductDTO actualDTO = requestProduct(1);
//
List<State> expectedStates = ProductionCode.mapBooleansToEnumList(isActive, isRejected);
assertThat(actualDTO.states).isEqualTo(expectedStates);
بدلاً من ذلك ، فكر في المدخلات والمخرجات عند كتابة الاختبارات. يقوم الاختبار بتغذية البيانات إلى المدخلات ويقارن الإخراج بالثوابت المحددة مسبقًا. في معظم الأحيان ، لا يلزم إعادة استخدام الكود.
// Do
assertThat(actualDTO.states).isEqualTo(List.of(States.ACTIVE, States.REJECTED));
لا تنسخ منطق الأعمال في الاختبارات
يعد تعيين الكائن مثالًا رئيسيًا على حالة تسحب فيها الاختبارات المنطق من رمز القتال إلى نفسها. لنفترض أن اختبارنا يحتوي على طريقة
mapEntityToDto()، يتم استخدام نتيجتها للتحقق من احتواء DTO الناتج على نفس القيم مثل العناصر التي تمت إضافتها إلى القاعدة في بداية الاختبار. في هذه الحالة ، ستقوم على الأرجح بنسخ كود القتال في الاختبار ، والذي قد يحتوي على أخطاء.
//
ProductEntity inputEntity = new ProductEntity(1, "envelope", "office", false, true, 200, 10.0);
insertIntoDatabase(input);
ProductDTO actualDTO = requestProduct(1);
// mapEntityToDto() , -
ProductDTO expectedDTO = mapEntityToDto(inputEntity);
assertThat(actualDTO).isEqualTo(expectedDTO);
الحل الصحيح هو
actualDTOمقارنته بكائن مرجعي تم إنشاؤه يدويًا بالقيم المحددة. إنه بسيط للغاية ومباشر ويحمي من الأخطاء المحتملة.
//
ProductDTO expectedDTO = new ProductDTO("1", "envelope", new Category("office"), List.of(States.ACTIVE, States.REJECTED))
assertThat(actualDTO).isEqualTo(expectedDTO);
إذا كنت لا ترغب في الإنشاء والتحقق من تطابق الكائن المرجعي بالكامل ، فيمكنك التحقق من الكائن الفرعي أو عمومًا فقط خصائص الكائن ذات الصلة بالاختبار.
لا تكتب الكثير من المنطق
دعني أذكرك أن الاختبار يتعلق بشكل أساسي بالمدخلات والمخرجات. إرسال البيانات والتحقق مما يتم إرجاعه إليك. ليست هناك حاجة لكتابة منطق معقد داخل الاختبارات. إذا أدخلت الحلقات والشروط في الاختبار ، فإنك تجعله أقل قابلية للفهم وأكثر عرضة للخطأ. إذا كان منطق التحقق الخاص بك معقدًا ، فاستخدم العديد من وظائف AssertJ للقيام بالمهمة نيابة عنك.
قم بإجراء الاختبارات في بيئة تشبه القتال
اختبر أكبر حزمة ممكنة من المكونات
يوصى عمومًا باختبار كل فئة على حدة باستخدام السحابات. ومع ذلك ، فإن هذا النهج له عيوب: بهذه الطريقة ، لا يتم اختبار تفاعل الفئات مع بعضها البعض ، وأي إعادة هيكلة للكيانات العامة ستؤدي إلى كسر جميع الاختبارات دفعة واحدة ، لأن كل فئة داخلية لها اختباراتها الخاصة. بالإضافة إلى ذلك ، إذا كتبت اختبارات لكل فصل ، فسيكون هناك عدد كبير جدًا منها.
اختبار الوحدة المعزولة لكل فئة
بدلاً من ذلك ، أوصي بالتركيز على اختبار التكامل. أعني بـ "اختبار التكامل" تجميع كل الفئات معًا (كما هو الحال في الإنتاج) واختبار الحزمة بأكملها ، بما في ذلك مكونات البنية التحتية (خادم HTTP ، قاعدة البيانات ، منطق الأعمال). في هذه الحالة ، تقوم باختبار السلوك بدلاً من التنفيذ. هذه الاختبارات أكثر دقة وأقرب إلى العالم الحقيقي ومقاومة لإعادة هيكلة المكونات الداخلية. من الناحية المثالية ، تكفي فئة واحدة من الاختبارات.
اختبار التكامل (= ضع كل الفئات معًا واختبر الحزمة)
لا تستخدم قواعد البيانات في الذاكرة للاختبارات
باستخدام قاعدة في الذاكرة ، يمكنك الاختبار في بيئة مختلفة حيث سيعمل الرمز الخاص بك.
باستخدام قاعدة في الذاكرة ( H2 ، HSQLDB ، Fongo ) للاختبارات ، فإنك تضحي بصلاحيتها ونطاقها. غالبًا ما تتصرف قواعد البيانات هذه بشكل مختلف وتنتج نتائج مختلفة. قد يجتاز هذا الاختبار بنجاح ، لكنه لا يضمن التشغيل الصحيح للتطبيق في الإنتاج. علاوة على ذلك ، يمكنك أن تجد نفسك بسهولة في موقف لا يمكنك فيه استخدام أو اختبار بعض السلوك أو السمات المميزة لقاعدتك ، لأنها لم يتم تنفيذها في قاعدة البيانات في الذاكرة أو تتصرف بشكل مختلف.
الحل: استخدم نفس قاعدة البيانات المستخدمة في العملية الحقيقية. مكتبة رائعة من حاويات الاختبار يوفر واجهة برمجة تطبيقات غنية لتطبيقات Java تتيح لك إدارة الحاويات مباشرة من كود الاختبار الخاص بك.
جافا / JVM
استعمال -noverify -XX:TieredStopAtLevel=1
أضف دائمًا خيارات
JVM -noverify -XX:TieredStopAtLevel=1إلى التكوين الخاص بك لتشغيل الاختبارات. سيوفر لك ذلك 1-2 ثانية من بدء تشغيل الجهاز الظاهري قبل بدء الاختبارات. هذا مفيد بشكل خاص في الأيام الأولى لاختباراتك ، عندما تقوم بتشغيلها غالبًا من IDE.
يرجى ملاحظة أنه نظرًا لإيقاف تشغيل Java 13
-noverify.
نصيحة: أضف هذه الوسيطات إلى قالب تكوين "JUnit" في IntelliJ IDEA لتجنب الاضطرار إلى القيام بذلك في كل مرة تنشئ فيها مشروعًا جديدًا.
استخدم AssertJ
AssertJ هي مكتبة قوية للغاية وناضجة مع واجهة برمجة تطبيقات غنية وآمنة ، بالإضافة إلى مجموعة غنية من وظائف التحقق من صحة القيمة ورسائل خطأ اختبار مفيدة. العديد من وظائف التحقق من الصحة المريحة تعفي المبرمج من الحاجة إلى وصف المنطق المعقد في مجموعة الاختبارات ، مما يسمح بإبقاء الاختبارات موجزة. على سبيل المثال:
assertThat(actualProduct)
.isEqualToIgnoringGivenFields(expectedProduct, "id");
assertThat(actualProductList).containsExactly(
createProductDTO("1", "Smartphone", 250.00),
createProductDTO("1", "Smartphone", 250.00)
);
assertThat(actualProductList)
.usingElementComparatorIgnoringFields("id")
.containsExactly(expectedProduct1, expectedProduct2);
assertThat(actualProductList)
.extracting(Product::getId)
.containsExactly("1", "2");
assertThat(actualProductList)
.anySatisfy(product -> assertThat(product.getDateCreated()).isBetween(instant1, instant2));
assertThat(actualProductList)
.filteredOn(product -> product.getCategory().equals("Smartphone"))
.allSatisfy(product -> assertThat(product.isLiked()).isTrue());
تجنب استخدام assertTrue()وassertFalse()
استخدام الرسائل البسيطة
assertTrue()أو التي assertFalse()تؤدي إلى رسائل خطأ اختبار مشفرة:
//
assertTrue(actualProductList.contains(expectedProduct));
assertTrue(actualProductList.size() == 5);
assertTrue(actualProduct instanceof Product);
expected: <true> but was: <false>
استخدم مكالمات AssertJ بدلاً من ذلك ، والتي تعيد رسائل واضحة وغنية بالمعلومات خارج الصندوق.
//
assertThat(actualProductList).contains(expectedProduct);
assertThat(actualProductList).hasSize(5);
assertThat(actualProduct).isInstanceOf(Product.class);
Expecting:
<[Product[id=1, name='Samsung Galaxy']]>
to contain:
<[Product[id=2, name='iPhone']]>
but could not find:
<[Product[id=2, name='iPhone']]>
إذا كنت بحاجة إلى التحقق من القيمة المنطقية ، فاجعل الرسالة أكثر
as()وصفية باستخدام طريقة AssertJ.
استخدم JUnit5
JUnit5 هي مكتبة ممتازة لاختبار (الوحدة). إنه قيد التطوير المستمر ويوفر للمبرمج العديد من الميزات المفيدة ، مثل الاختبارات ذات المعلمات والتجمعات والاختبارات الشرطية والتحكم في دورة الحياة.
استخدم الاختبارات ذات المعلمات
تسمح لك الاختبارات ذات المعلمات بإجراء نفس الاختبار بمجموعة من قيم الإدخال المختلفة. يتيح لك هذا التحقق من حالات متعددة دون كتابة رمز إضافي. في JUnit5 لهذا هي أدوات ممتازة
@ValueSource، @EnumSource، @CsvSourceو @MethodSource.
//
@ParameterizedTest
@ValueSource(strings = ["§ed2d", "sdf_", "123123", "§_sdf__dfww!"])
public void rejectedInvalidTokens(String invalidToken) {
client.perform(get("/products").param("token", invalidToken))
.andExpect(status().is(400))
}
@ParameterizedTest
@EnumSource(WorkflowState::class, mode = EnumSource.Mode.INCLUDE, names = ["FAILED", "SUCCEEDED"])
public void dontProcessWorkflowInCaseOfAFinalState(WorkflowState itemsInitialState) {
// ...
}
أوصي بشدة بالاستفادة القصوى من هذه الخدعة ، لأنها تتيح لك اختبار المزيد من الحالات بأقل جهد.
أخيرًا ، أود أن ألفت انتباهك إلى
@CsvSourceو @MethodSource، والتي يمكن استخدامها لمزيد من المعاملات المعقدة ، حيث تحتاج أيضًا إلى التحكم في النتيجة: يمكنك تمريرها في أحد المعلمات.
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"5, 3, 8",
"10, -20, -10"
})
public void add(int summand1, int summand2, int expectedSum) {
assertThat(calculator.add(summand1, summand2)).isEqualTo(expectedSum);
}
@MethodSourceفعالة بشكل خاص مع كائن اختبار منفصل يحتوي على جميع المعلمات المطلوبة والنتائج المتوقعة. لسوء الحظ ، في Java ، وصف هياكل البيانات هذه (ما يسمى POJOs) مرهق للغاية. لذلك ، سأقدم مثالاً باستخدام فئات بيانات Kotlin.
data class TestData(
val input: String?,
val expected: Token?
)
@ParameterizedTest
@MethodSource("validTokenProvider")
fun `parse valid tokens`(data: TestData) {
assertThat(parse(data.input)).isEqualTo(data.expected)
}
private fun validTokenProvider() = Stream.of(
TestData(input = "1511443755_2", expected = Token(1511443755, "2")),
TestData(input = "151175_13521", expected = Token(151175, "13521")),
TestData(input = "151144375_id", expected = Token(151144375, "id")),
TestData(input = "15114437599_1", expected = Token(15114437599, "1")),
TestData(input = null, expected = null)
)
اختبارات المجموعة
التعليقات التوضيحية
@Nestedمن JUnit5 سهلة الاستخدام لتجميع طرق الاختبار. منطقيا ، من المنطقي تجميع أنواع معينة من الاختبارات (مثل InputIsXY، ErrorCases) أو جمع كل طرق اختبار ( GetDesignو UpdateDesign) في مجموعتك .
public class DesignControllerTest {
@Nested
class GetDesigns {
@Test
void allFieldsAreIncluded() {}
@Test
void limitParameter() {}
@Test
void filterParameter() {}
}
@Nested
class DeleteDesign {
@Test
void designIsRemovedFromDb() {}
@Test
void return404OnInvalidIdParameter() {}
@Test
void return401IfNotAuthorized() {}
}
}
أسماء الاختبارات المقروءة مع @DisplayNameأو الاقتباس الخلفي في Kotlin
في Java ، يمكنك استخدام التعليقات التوضيحية
@DisplayNameلمنح اختباراتك أسماء أكثر قابلية للقراءة.
public class DisplayNameTest {
@Test
@DisplayName("Design is removed from database")
void designIsRemoved() {}
@Test
@DisplayName("Return 404 in case of an invalid parameter")
void return404() {}
@Test
@DisplayName("Return 401 if the request is not authorized")
void return401() {}
}
في Kotlin ، يمكنك استخدام أسماء الوظائف مع وجود مسافات بداخلها عن طريق تضمينها في علامات اقتباس مفردة backtick. بهذه الطريقة تحصل على إمكانية قراءة النتائج بدون تكرار الكود.
@Test
fun `design is removed from db`() {}
محاكاة الخدمات الخارجية
لاختبار عملاء HTTP ، نحتاج إلى محاكاة الخدمات التي يصلون إليها. غالبًا ما أستخدم MockWebServer من OkHttp لهذا الغرض . البدائل هي WireMock أو Mockserver من Testcontainers .
MockWebServer serviceMock = new MockWebServer();
serviceMock.start();
HttpUrl baseUrl = serviceMock.url("/v1/");
ProductClient client = new ProductClient(baseUrl.host(), baseUrl.port());
serviceMock.enqueue(new MockResponse()
.addHeader("Content-Type", "application/json")
.setBody("{\"name\": \"Smartphone\"}"));
ProductDTO productDTO = client.retrieveProduct("1");
assertThat(productDTO.getName()).isEqualTo("Smartphone");
استخدم ميزة الانتظار لاختبار التعليمات البرمجية غير المتزامنة
Awaitility هي مكتبة لاختبار التعليمات البرمجية غير المتزامنة. يمكنك تحديد عدد مرات إعادة المحاولة للتحقق من النتيجة قبل إعلان أن الاختبار غير ناجح.
private static final ConditionFactory WAIT = await()
.atMost(Duration.ofSeconds(6))
.pollInterval(Duration.ofSeconds(1))
.pollDelay(Duration.ofSeconds(1));
@Test
public void waitAndPoll(){
triggerAsyncEvent();
WAIT.untilAsserted(() -> {
assertThat(findInDatabase(1).getState()).isEqualTo(State.SUCCESS);
});
}
لا حاجة لحل تبعيات DI (الربيع)
يستغرق إطار عمل DI بضع ثوانٍ للتهيئة قبل أن تبدأ الاختبارات. يؤدي هذا إلى إبطاء حلقة التغذية الراجعة ، خاصة في المراحل الأولى من التطوير.
لذلك ، أحاول عدم استخدام DI في اختبارات التكامل ، ولكن أنشئ الكائنات الضرورية يدويًا و "اربطها" معًا. إذا كنت تستخدم حقن المُنشئ ، فهذا هو الأسهل. عادةً ، في اختباراتك ، تقوم بالتحقق من صحة منطق الأعمال ، ولا تحتاج إلى DI لذلك.
علاوة على ذلك ، منذ الإصدار 2.2 ، يدعم Spring Boot التهيئة البطيئة للفاصوليا ، مما يسرع بشكل كبير الاختبارات باستخدام DI.
يجب أن يكون الرمز الخاص بك قابل للاختبار
لا تستخدم الوصول الثابت. أبدا
الوصول الثابت هو نمط مضاد. أولاً ، إنه يخفي التبعيات والآثار الجانبية ، مما يجعل من الصعب قراءة الكود بأكمله وعرضة لأخطاء طفيفة. ثانيًا ، الوصول الثابت يقف في طريق الاختبار. لم يعد بإمكانك استبدال الكائنات ، ولكن في الاختبارات تحتاج إلى استخدام نماذج أو كائنات حقيقية بتكوين مختلف (على سبيل المثال ، كائن DAO يشير إلى قاعدة بيانات الاختبار).
بدلاً من الوصول إلى الشفرة بشكل ثابت ، ضعها في طريقة غير ثابتة ، وقم بإنشاء مثيل للفئة ، ومرر الكائن الناتج إلى المنشئ.
//
public class ProductController {
public List<ProductDTO> getProducts() {
List<ProductEntity> products = ProductDAO.getProducts();
return mapToDTOs(products);
}
}
//
public class ProductController {
private ProductDAO dao;
public ProductController(ProductDAO dao) {
this.dao = dao;
}
public List<ProductDTO> getProducts() {
List<ProductEntity> products = dao.getProducts();
return mapToDTOs(products);
}
}
لحسن الحظ ، توفر أطر عمل DI مثل Spring أدوات تجعل الوصول الثابت غير ضروري عن طريق إنشاء الكائنات وربطها تلقائيًا دون مشاركتنا.
معلمة
يجب أن تكون جميع الأجزاء ذات الصلة بالفصل قابلة للتكوين من جانب الاختبار. يمكن تمرير هذه الإعدادات إلى مُنشئ الفئة.
تخيل ، على سبيل المثال ، أن DAO الخاص بك لديه حد ثابت يبلغ 1000 عنصر لكل طلب. للتحقق من هذا الحد ، ستحتاج إلى إضافة 1001 عنصر إلى قاعدة بيانات الاختبار قبل الاختبار. باستخدام وسيطة المُنشئ ، يمكنك جعل هذه القيمة قابلة للتخصيص: في الإنتاج ، اترك 1000 ، في الاختبار ، قلل إلى 2. وبالتالي ، للتحقق من عمل الحد ، ستحتاج فقط إلى إضافة 3 سجلات فقط إلى قاعدة بيانات الاختبار.
استخدم حقن المنشئ
إن الحقن الميداني شرير ويؤدي إلى ضعف قابلية اختبار الكود. تحتاج إلى تهيئة DI قبل الاختبارات أو القيام ببعض السحر الانعكاسي الغريب. لذلك ، من الأفضل استخدام حقن المُنشئ للتحكم بسهولة في الكائنات التابعة أثناء الاختبار.
في Java ، عليك كتابة رمز إضافي بسيط:
//
public class ProductController {
private ProductDAO dao;
private TaxClient client;
public ProductController(ProductDAO dao, TaxClient client) {
this.dao = dao;
this.client = client;
}
}
في Kotlin ، يتم كتابة نفس الشيء بشكل أكثر إيجازًا:
//
class ProductController(
private val dao: ProductDAO,
private val client: TaxClient
){
}
لا تستخدم Instant.now() أوnew Date()
لا تحتاج إلى الحصول على الوقت الحالي عن طريق المكالمات
Instant.now()أو new Date()في كود الإنتاج إذا كنت ترغب في اختبار هذا السلوك.
//
public class ProductDAO {
public void updateDateModified(String productId) {
Instant now = Instant.now(); // !
Update update = Update()
.set("dateModified", now);
Query query = Query()
.addCriteria(where("_id").eq(productId));
return mongoTemplate.updateOne(query, update, ProductEntity.class);
}
}
المشكلة هي أن الوقت الذي يستغرقه الاختبار لا يمكن التحكم فيه. لن تتمكن من مقارنة النتيجة التي تم الحصول عليها بقيمة محددة ، لأنها مختلفة طوال الوقت. استخدم فئة
Clockمن Java بدلاً من ذلك .
//
public class ProductDAO {
private Clock clock;
public ProductDAO(Clock clock) {
this.clock = clock;
}
public void updateProductState(String productId, State state) {
Instant now = clock.instant();
// ...
}
}
في هذا الاختبار ، يمكنك إنشاء كائن وهمي لـ
Clock، وتمريره إليه ، ProductDAOوتكوين الكائن الوهمي للعودة في نفس الوقت. بعد المكالمات ، updateProductState()سنتمكن من التحقق من أن القيمة التي حددناها قد وصلت إلى قاعدة البيانات.
افصل التنفيذ غير المتزامن عن المنطق الفعلي
يعد اختبار الشفرة غير المتزامنة أمرًا صعبًا. المكتبات مثل Awaitility مفيدة للغاية ، لكن العملية لا تزال معقدة وقد ينتهي بنا المطاف باختبار وميض. من المنطقي فصل منطق الأعمال (عادةً ما يكون متزامنًا) ورمز البنية التحتية غير المتزامن ، إن أمكن.
على سبيل المثال ، من خلال إدخال منطق الأعمال في ProductController ، يمكننا بسهولة اختباره بشكل متزامن. ستبقى جميع المنطق غير المتزامن والمتوازي في ProductScheduler ، والتي يمكن اختبارها بمعزل عن غيرها.
//
public class ProductScheduler {
private ProductController controller;
@Scheduled
public void start() {
CompletableFuture<String> usFuture = CompletableFuture.supplyAsync(() -> controller.doBusinessLogic(Locale.US));
CompletableFuture<String> germanyFuture = CompletableFuture.supplyAsync(() -> controller.doBusinessLogic(Locale.GERMANY));
String usResult = usFuture.get();
String germanyResult = germanyFuture.get();
}
}
كوتلن
تحتوي مقالتي " أفضل الممارسات لاختبار الوحدة" في Kotlin على العديد من تقنيات اختبار الوحدات الخاصة بـ Kotlin. (ملاحظة الترجمة: اكتب التعليقات إذا كنت مهتمًا بالترجمة الروسية لهذه المقالة).