البناء بثقة: دليل لاختبارات JUnit
نشرت: 2022-03-11مع تقدم التكنولوجيا والصناعة التي تنتقل من نموذج الشلال إلى Agile والآن إلى DevOps ، يتم نشر التغييرات والتحسينات في أحد التطبيقات للإنتاج بمجرد إجرائها. مع نشر الكود للإنتاج بهذه السرعة ، نحتاج إلى أن نكون واثقين من أن تغييراتنا تعمل ، وأيضًا أنها لا تعطل أي وظائف موجودة مسبقًا.
لبناء هذه الثقة ، يجب أن يكون لدينا إطار عمل لاختبار الانحدار التلقائي. لإجراء اختبار الانحدار ، هناك العديد من الاختبارات التي يجب إجراؤها من وجهة نظر مستوى واجهة برمجة التطبيقات ، ولكننا سنغطي هنا نوعين رئيسيين من الاختبارات:
- اختبار الوحدة ، حيث يغطي أي اختبار معين أصغر وحدة في البرنامج (وظيفة أو إجراء). قد يستغرق أو لا يستغرق بعض معلمات الإدخال وقد أو لا يُرجع بعض القيم.
- اختبار التكامل ، حيث يتم اختبار الوحدات الفردية معًا للتحقق مما إذا كانت جميع الوحدات تتفاعل مع بعضها البعض كما هو متوقع.
هناك العديد من الأطر المتاحة لكل لغة برمجة. سنركز على وحدة الكتابة واختبار التكامل لتطبيق ويب مكتوب في إطار Java Spring framework.
في معظم الأحيان ، نكتب طرقًا في الفصل ، وتتفاعل هذه بدورها مع طرق بعض الفئات الأخرى. في عالم اليوم - خاصة في تطبيقات المؤسسات - درجة تعقيد التطبيقات تجعل طريقة واحدة قد تستدعي أكثر من طريقة لفئات متعددة. لذلك عند كتابة اختبار الوحدة لمثل هذه الطريقة ، نحتاج إلى طريقة لإرجاع البيانات المزيفة من تلك المكالمات. هذا لأن القصد من اختبار الوحدة هذا هو اختبار طريقة واحدة فقط وليس جميع المكالمات التي تجريها هذه الطريقة المعينة.
دعنا ننتقل إلى اختبار وحدة Java في Spring باستخدام إطار عمل JUnit. سنبدأ بشيء ربما سمعت عنه: السخرية.
ما هو الاستهزاء ومتى يظهر في الصورة؟
لنفترض أن لديك فئة ، CalculateArea
، لها وظيفة calculateArea(Type type, Double... args)
يذهب الرمز إلى شيء من هذا القبيل في تطبيق عادي لا يستخدم حقن التبعية:
public class CalculateArea { SquareService squareService; RectangleService rectangleService; CircleService circleService; CalculateArea(SquareService squareService, RectangleService rectangeService, CircleService circleService) { this.squareService = squareService; this.rectangleService = rectangeService; this.circleService = circleService; } public Double calculateArea(Type type, Double... r ) { switch (type) { case RECTANGLE: if(r.length >=2) return rectangleService.area(r[0],r[1]); else throw new RuntimeException("Missing required params"); case SQUARE: if(r.length >=1) return squareService.area(r[0]); else throw new RuntimeException("Missing required param"); case CIRCLE: if(r.length >=1) return circleService.area(r[0]); else throw new RuntimeException("Missing required param"); default: throw new RuntimeException("Operation not supported"); } } }
public class SquareService { public Double area(double r) { return r * r; } }
public class RectangleService { public Double area(Double r, Double h) { return r * h; } }
public class CircleService { public Double area(Double r) { return Math.PI * r * r; } }
public enum Type { RECTANGLE,SQUARE,CIRCLE; }
الآن ، إذا أردنا اختبار الوحدة للوظيفة calculateArea()
من الفئة CalculateArea
، فيجب أن يكون دافعنا هو التحقق مما إذا كانت حالات switch
وشروط الاستثناء تعمل أم لا. يجب ألا نختبر ما إذا كانت خدمات الشكل تعيد القيم الصحيحة ، لأنه كما ذكرنا سابقًا ، فإن الدافع من اختبار الوحدة للوظيفة هو اختبار منطق الوظيفة ، وليس منطق الاستدعاءات التي تقوم بها الوظيفة.
لذلك سوف نسخر من القيم التي يتم إرجاعها بواسطة وظائف الخدمة الفردية (على سبيل المثال ، rectangleService.area()
ونختبر وظيفة الاستدعاء (على سبيل المثال CalculateArea.calculateArea()
) بناءً على تلك القيم التي تم الاستهزاء بها.
ستبدو حالة الاختبار البسيطة لخدمة المستطيل - اختبار calculateArea()
الذي يستدعي بالفعل rectangleService.area()
مع المعلمات الصحيحة - على النحو التالي:
import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; public class CalculateAreaTest { RectangleService rectangleService; SquareService squareService; CircleService circleService; CalculateArea calculateArea; @Before public void init() { rectangleService = Mockito.mock(RectangleService.class); squareService = Mockito.mock(SquareService.class); circleService = Mockito.mock(CircleService.class); calculateArea = new CalculateArea(squareService,rectangleService,circleService); } @Test public void calculateRectangleAreaTest() { Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }
هناك سطرين رئيسيين يجب ملاحظتهما هنا:
-
rectangleService = Mockito.mock(RectangleService.class);
- يؤدي هذا إلى إنشاء صورة وهمية ، وهي ليست كائنًا حقيقيًا ، بل كائنًا تم الاستهزاء به. -
Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d);
يشير هذا إلى أنه عند الاستهزاء ، يتم استدعاء طريقةarea
الكائنrectangleService
باستخدام المعلمات المحددة ، ثم يتم إرجاع20d
.
الآن ، ماذا يحدث عندما يكون الرمز أعلاه جزءًا من تطبيق Spring؟
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class CalculateArea { SquareService squareService; RectangleService rectangleService; CircleService circleService; public CalculateArea(@Autowired SquareService squareService, @Autowired RectangleService rectangeService, @Autowired CircleService circleService) { this.squareService = squareService; this.rectangleService = rectangeService; this.circleService = circleService; } public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }
لدينا هنا تعليقان توضيحيان لإطار Spring الأساسي لاكتشافهما في وقت تهيئة السياق:
-
@Component
: إنشاء وحدة برامج من النوعCalculateArea
-
@Autowired
: البحث عن BeansrectangleService
،squareService
، andcircleService
في الفولcalculatedArea
وبالمثل ، نقوم بإنشاء حبوب للفصول الأخرى أيضًا:
import org.springframework.stereotype.Service; @Service public class SquareService { public Double area(double r) { return r*r; } }
import org.springframework.stereotype.Service; @Service public class CircleService { public Double area(Double r) { return Math.PI * r * r; } }
import org.springframework.stereotype.Service; @Service public class RectangleService { public Double area(Double r, Double h) { return r*h; } }
الآن إذا أجرينا الاختبارات ، فإن النتائج هي نفسها. استخدمنا حقنة المُنشئ هنا ، ولحسن الحظ ، لا تغير حالة الاختبار الخاصة بنا.
ولكن هناك طريقة أخرى لحقن خدمات المربع والدائرة والمستطيل: الحقن الميداني. إذا استخدمنا ذلك ، فستحتاج حالة الاختبار الخاصة بنا إلى بعض التغييرات الطفيفة.
لن ندخل في مناقشة أي آلية الحقن أفضل ، لأن هذا ليس في نطاق المقال. لكن يمكننا أن نقول هذا: بغض النظر عن نوع الآلية التي تستخدمها لحقن الفاصوليا ، هناك دائمًا طريقة لكتابة اختبارات JUnit لها.
في حالة الحقن الميداني ، فإن الكود يذهب إلى شيء مثل هذا:
@Component public class CalculateArea { @Autowired SquareService squareService; @Autowired RectangleService rectangleService; @Autowired CircleService circleService; public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }
ملاحظة: نظرًا لأننا نستخدم الحقن الميداني ، فلا حاجة إلى مُنشئ ذي معلمات ، لذلك يتم إنشاء الكائن باستخدام العنصر الافتراضي ويتم تعيين القيم باستخدام آلية حقن الحقل.
يظل رمز فئات الخدمة كما هو مذكور أعلاه ، لكن رمز فئة الاختبار هو كما يلي:
public class CalculateAreaTest { @Mock RectangleService rectangleService; @Mock SquareService squareService; @Mock CircleService circleService; @InjectMocks CalculateArea calculateArea; @Before public void init() { MockitoAnnotations.initMocks(this); } @Test public void calculateRectangleAreaTest() { Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }
تسير بعض الأشياء بشكل مختلف هنا: ليس الأساسيات ، ولكن الطريقة التي نحققها بها.
أولاً ، الطريقة التي نسخر بها @Mock
: نستخدم التعليقات التوضيحيةMock جنبًا إلى جنب مع initMocks()
لإنشاء نماذج وهمية. ثانيًا ، نقوم بحقن mocks في الكائن الفعلي باستخدام @InjectMocks
جنبًا إلى جنب مع initMocks()
.
يتم ذلك لتقليل عدد سطور التعليمات البرمجية.
ما المقصود بالعدائين وما أنواع العدائين؟
في النموذج أعلاه ، العداء الأساسي الذي يتم استخدامه لإجراء جميع الاختبارات هو BlockJUnit4ClassRunner
الذي يكتشف جميع التعليقات التوضيحية ويقوم بإجراء جميع الاختبارات وفقًا لذلك.
إذا أردنا المزيد من الوظائف ، فقد نكتب عداءًا مخصصًا. على سبيل المثال ، في فئة الاختبار أعلاه ، إذا أردنا تخطي السطر MockitoAnnotations.initMocks(this);
ثم يمكننا استخدام عداء مختلف مبني على BlockJUnit4ClassRunner
، على سبيل المثال MockitoJUnitRunner
.
باستخدام MockitoJUnitRunner
، لا نحتاج حتى إلى تهيئة النماذج وحقنها. سيتم تنفيذ ذلك بواسطة MockitoJUnitRunner
نفسها بمجرد قراءة التعليقات التوضيحية.
(هناك أيضًا SpringJUnit4ClassRunner
، الذي يهيئ ApplicationContext
اللازم لاختبار تكامل Spring - تمامًا مثل إنشاء ApplicationContext
عند بدء تطبيق Spring. سنغطي هذا لاحقًا.)
السخرية الجزئية
عندما نريد كائنًا في فئة الاختبار أن يسخر من بعض الطرق (الأساليب) ، ولكن أيضًا نسمي بعض الأساليب الفعلية ، فإننا نحتاج إلى الاستهزاء الجزئي. يتم تحقيق ذلك عبر @Spy
في JUnit.
على عكس استخدام @Mock
، مع @Spy
، يتم إنشاء كائن حقيقي ، ولكن يمكن الاستهزاء بأساليب هذا الكائن أو يمكن استدعاؤها فعليًا - كل ما نحتاج إليه.

على سبيل المثال ، إذا كانت طريقة area
في الفئة RectangleService
تستدعي log()
ونريد بالفعل طباعة هذا السجل ، فحينئذٍ يتغير الرمز إلى شيء مثل التالي:
@Service public class RectangleService { public Double area(Double r, Double h) { log(); return r*h; } public void log() { System.out.println("skip this"); } }
إذا قمنا بتغيير التعليق التوضيحي @Mock
الخاص بـ rectangleService
إلى @Spy
، وقمنا أيضًا بإجراء بعض التغييرات في التعليمات البرمجية كما هو موضح أدناه ، فسنرى في الواقع طباعة السجلات ، لكن area()
سيتم الاستهزاء بها. أي أن الوظيفة الأصلية تعمل فقط لآثارها الجانبية ؛ يتم استبدال قيم الإرجاع بأخرى تم الاستهزاء بها.
@RunWith(MockitoJUnitRunner.class) public class CalculateAreaTest { @Spy RectangleService rectangleService; @Mock SquareService squareService; @Mock CircleService circleService; @InjectMocks CalculateArea calculateArea; @Test public void calculateRectangleAreaTest() { Mockito.doCallRealMethod().when(rectangleService).log(); Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }
كيف نقوم باختبار Controller
أو RequestHandler
؟
مما تعلمناه أعلاه ، سيكون رمز اختبار وحدة التحكم لمثالنا شيئًا مشابهًا لما يلي:
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; @Controller public class AreaController { @Autowired CalculateArea calculateArea; @RequestMapping(value = "api/area", method = RequestMethod.GET) @ResponseBody public ResponseEntity calculateArea( @RequestParam("type") String type, @RequestParam("param1") String param1, @RequestParam(value = "param2", required = false) String param2 ) { try { Double area = calculateArea.calculateArea( Type.valueOf(type), Double.parseDouble(param1), Double.parseDouble(param2) ); return new ResponseEntity(area, HttpStatus.OK); } catch (Exception e) { return new ResponseEntity(e.getCause(), HttpStatus.INTERNAL_SERVER_ERROR); } } }
import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @RunWith(MockitoJUnitRunner.class) public class AreaControllerTest { @Mock CalculateArea calculateArea; @InjectMocks AreaController areaController; @Test public void calculateAreaTest() { Mockito .when(calculateArea.calculateArea(Type.RECTANGLE,5.0d, 4.0d)) .thenReturn(20d); ResponseEntity responseEntity = areaController.calculateArea("RECTANGLE", "5", "4"); Assert.assertEquals(HttpStatus.OK,responseEntity.getStatusCode()); Assert.assertEquals(20d,responseEntity.getBody()); } }
بالنظر إلى رمز اختبار وحدة التحكم أعلاه ، فإنه يعمل بشكل جيد ، ولكن لديه مشكلة أساسية واحدة: إنه يختبر فقط استدعاء الطريقة ، وليس استدعاء API الفعلي. كل حالات الاختبار هذه حيث يجب اختبار معلمات API وحالة استدعاءات API لمدخلات مختلفة مفقودة.
هذا الرمز أفضل:
import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @RunWith(SpringJUnit4ClassRunner.class) public class AreaControllerTest { @Mock CalculateArea calculateArea; @InjectMocks AreaController areaController; MockMvc mockMvc; @Before public void init() { mockMvc = standaloneSetup(areaController).build(); } @Test public void calculateAreaTest() throws Exception { Mockito .when(calculateArea.calculateArea(Type.RECTANGLE,5.0d, 4.0d)) .thenReturn(20d); mockMvc.perform( MockMvcRequestBuilders.get("/api/area?type=RECTANGLE¶m1=5¶m2=4") ) .andExpect(status().isOk()) .andExpect(content().string("20.0")); } }
هنا يمكننا أن نرى كيف يتولى MockMvc
مهمة إجراء مكالمات API الفعلية. كما أن لديها بعض المطابقات الخاصة مثل status()
content()
مما يسهل التحقق من صحة المحتوى.
اختبار تكامل Java باستخدام JUnit و Mocks
الآن بعد أن عرفنا أن الوحدات الفردية من الكود تعمل ، فلنتأكد من أنها تتفاعل أيضًا مع بعضها البعض كما نتوقع.
أولاً ، نحتاج إلى إنشاء مثيل لجميع وحدات الفول ، وهي نفس الأشياء التي تحدث في وقت تهيئة سياق الربيع أثناء بدء تشغيل التطبيق.
لهذا ، نحدد جميع الفاصوليا في الفصل ، دعنا نقول TestConfig.java
:
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class TestConfig { @Bean public AreaController areaController() { return new AreaController(); } @Bean public CalculateArea calculateArea() { return new CalculateArea(); } @Bean public RectangleService rectangleService() { return new RectangleService(); } @Bean public SquareService squareService() { return new SquareService(); } @Bean public CircleService circleService() { return new CircleService(); } }
لنرى الآن كيف نستخدم هذا الفصل ونكتب اختبار تكامل:
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(classes = {TestConfig.class}) public class AreaControllerIntegrationTest { @Autowired AreaController areaController; MockMvc mockMvc; @Before public void init() { mockMvc = standaloneSetup(areaController).build(); } @Test public void calculateAreaTest() throws Exception { mockMvc.perform( MockMvcRequestBuilders.get("/api/area?type=RECTANGLE¶m1=5¶m2=4") ) .andExpect(status().isOk()) .andExpect(content().string("20.0")); } }
تتغير بعض الأشياء هنا:
-
@ContextConfiguration(classes = {TestConfig.class})
- هذا يخبر حالة الاختبار حيث توجد جميع تعريفات الفول. - الآن بدلاً من
@InjectMocks
، نستخدم:
@Autowired AreaController areaController;
كل شيء آخر يبقى كما هو. إذا قمنا بتصحيح الاختبار ، فسنرى أن الكود يعمل بالفعل حتى السطر الأخير من طريقة area()
في RectangleService
حيث يتم حساب return r*h
. بمعنى آخر ، يتم تشغيل منطق العمل الفعلي.
هذا لا يعني أنه لا يوجد استهزاء باستدعاءات الطريقة أو استدعاءات قاعدة البيانات المتاحة في اختبار التكامل. في المثال أعلاه ، لم تكن هناك خدمة أو قاعدة بيانات تابعة لجهة خارجية مستخدمة ، وبالتالي لم نكن بحاجة إلى استخدام mocks. في الحياة الواقعية ، مثل هذه التطبيقات نادرة ، وغالبًا ما نصل إلى قاعدة بيانات أو واجهة برمجة تطبيقات تابعة لجهة خارجية ، أو كليهما. في هذه الحالة ، عندما ننشئ الفول في فئة TestConfig
، فإننا لا ننشئ الكائن الفعلي ، بل كائنًا تم الاستهزاء به ، ونستخدمه حيثما دعت الحاجة.
المكافأة: كيفية إنشاء بيانات اختبار كائن كبير
غالبًا ما يوقف مطورو الواجهة الخلفية في اختبارات الوحدة أو التكامل هو بيانات الاختبار التي يتعين علينا إعدادها لكل اختبار.
عادةً إذا كانت البيانات صغيرة بما يكفي ، وتحتوي على متغير واحد أو متغيرين ، فمن السهل فقط إنشاء كائن من فئة بيانات الاختبار وتعيين بعض القيم.
على سبيل المثال ، إذا كنا نتوقع أن يعيد كائن تم الاستهزاء به كائنًا آخر ، فعندما يتم استدعاء دالة على الكائن الذي تم الاستهزاء به ، فسنقوم بشيء مثل هذا:
Class1 object = new Class1(); object.setVariable1(1); object.setVariable2(2);
ثم لاستخدام هذا الكائن ، سنفعل شيئًا كالتالي:
Mockito.when(service.method(arguments...)).thenReturn(object);
هذا جيد في أمثلة JUnit المذكورة أعلاه ، ولكن عندما تستمر متغيرات الأعضاء في فئة Class1
أعلاه في الزيادة ، فإن تعيين الحقول الفردية يصبح أمرًا صعبًا للغاية. في بعض الأحيان قد يحدث أن يكون للفصل عضو آخر غير بدائي محدد. بعد ذلك ، يؤدي إنشاء كائن من تلك الفئة وتعيين الحقول المطلوبة الفردية إلى زيادة جهود التطوير فقط لإنجاز بعض النماذج المعيارية.
الحل هو إنشاء مخطط JSON للفئة أعلاه وإضافة البيانات المقابلة في ملف JSON مرة واحدة. الآن في فئة الاختبار حيث أنشأنا كائن Class1
، لا نحتاج إلى إنشاء الكائن يدويًا. بدلاً من ذلك ، نقرأ ملف JSON ، وباستخدام ObjectMapper
، نضعه في فئة Class1
المطلوبة:
ObjectMapper objectMapper = new ObjectMapper(); Class1 object = objectMapper.readValue( new String(Files.readAllBytes( Paths.get("src/test/resources/"+fileName)) ), Class1.class );
يعد هذا جهدًا لمرة واحدة لإنشاء ملف JSON وإضافة قيم إليه. يمكن لأي اختبارات جديدة بعد ذلك استخدام نسخة من ملف JSON مع تغيير الحقول وفقًا لاحتياجات الاختبار الجديد.
أساسيات الوحدة: مناهج متعددة ومهارات قابلة للتحويل
من الواضح أن هناك العديد من الطرق لكتابة اختبارات وحدة Java اعتمادًا على الطريقة التي نختار بها حقن الفاصوليا. لسوء الحظ ، تميل معظم المقالات حول هذا الموضوع إلى افتراض وجود طريقة واحدة فقط ، لذلك من السهل الخلط ، خاصة عند العمل باستخدام رمز تمت كتابته وفقًا لافتراض مختلف. نأمل أن يوفر نهجنا هنا الوقت للمطورين في اكتشاف الطريقة الصحيحة للسخرية وأي عداء اختبار يجب استخدامه.
بغض النظر عن اللغة أو إطار العمل الذي نستخدمه - ربما حتى أي إصدار جديد من Spring أو JUnit - تظل القاعدة المفاهيمية كما هي موضحة في البرنامج التعليمي أعلاه JUnit. نتمنى لك اختبارًا سعيدًا!