اختبار الوحدة في Flutter: من أساسيات سير العمل إلى السيناريوهات المعقدة

نشرت: 2022-09-21

بلغ الاهتمام بـ Flutter أعلى مستوياته على الإطلاق - وقد طال انتظاره. تتوافق SDK مفتوحة المصدر من Google مع أنظمة التشغيل Android و iOS و macOS والويب و Windows و Linux. تدعمهم جميعًا قاعدة بيانات Flutter. ويُعد اختبار الوحدة أمرًا أساسيًا في تقديم تطبيق Flutter متسق وموثوق به ، مما يضمن عدم حدوث الأخطاء والعيوب والعيوب من خلال التحسين الاستباقي لجودة الكود قبل تجميعه.

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

اختبار تدفق الوحدة في الرفرفة

نقوم بتنفيذ اختبار الوحدة في Flutter بنفس الطريقة التي نقوم بها في مجموعات التكنولوجيا الأخرى:

  1. قم بتقييم الكود.
  2. إعداد الاستهزاء بالبيانات.
  3. حدد مجموعة (مجموعات) الاختبار.
  4. تحديد توقيع (توقيعات) وظيفة الاختبار لكل مجموعة اختبار.
  5. اكتب الاختبارات.

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

بعض الملاحظات حول كيفية عمل Flutter: يسهل إطار العمل الاختبار عن طريق التحميل التلقائي لمكتبة flutter_test عند إنشاء مشروع. تمكن المكتبة Flutter من قراءة اختبارات الوحدة وتشغيلها وتحليلها. يقوم Flutter أيضًا بإنشاء مجلد test تلقائيًا لتخزين الاختبارات فيه. من الأهمية بمكان تجنب إعادة تسمية و / أو نقل مجلد test ، لأن هذا يكسر وظائفه ، وبالتالي ، قدرتنا على إجراء الاختبارات. من الضروري أيضًا تضمين _test.dart في أسماء ملفات الاختبار الخاصة بنا ، لأن هذه اللاحقة هي الطريقة التي يتعرف بها Flutter على ملفات الاختبار.

اختبار هيكل الدليل

لتعزيز اختبار الوحدة في مشروعنا ، قمنا بتنفيذ MVVM بهندسة نظيفة وحقن التبعية (DI) ، كما يتضح من الأسماء المختارة للمجلدات الفرعية لشفرة المصدر. يضمن الجمع بين مبادئ MVVM و DI فصل الاهتمامات:

  1. تدعم كل فئة مشروع هدفًا واحدًا.
  2. كل وظيفة داخل الفصل تفي فقط بنطاقها الخاص.

سننشئ مساحة تخزين منظمة لملفات الاختبار التي سنكتبها ، وهو نظام يكون فيه لمجموعات الاختبارات "منازل" يمكن التعرف عليها بسهولة. في ضوء متطلبات Flutter لتحديد موقع الاختبارات داخل مجلد test ، دعنا نعكس بنية مجلد الكود المصدري قيد test . بعد ذلك ، عندما نكتب اختبارًا ، سنقوم بتخزينه في المجلد الفرعي المناسب: تمامًا كما يتم وضع الجوارب النظيفة في درج الجورب في خزانة الملابس الخاصة بك والقمصان المطوية في درج القميص ، يتم إجراء اختبارات الوحدة لفئات Model في مجلد باسم model ، فمثلا.

بنية مجلد الملفات مع مجلدين من المستوى الأول: lib و test. متداخلاً أسفل lib لدينا مجلد الميزات ، والمزيد من التداخل هو university_feed ، وكذلك البيانات المتداخلة. يحتوي مجلد البيانات على المستودع ومجلدات المصدر. يوجد مجلد الشبكة المتداخل أسفل المجلد المصدر. تتداخل أسفل الشبكة مجلدات نقطة النهاية والنموذج ، بالإضافة إلى ملف university_remote_data_source.dart. في مجلد النموذج يوجد ملف api_university_model.dart. في نفس المستوى مثل مجلد university_feed المذكور سابقًا ، يوجد مجلدات المجال والعرض التقديمي. يوجد مجلد حالة الاستخدام المتداخل أسفل المجال. تتداخل أسفل العرض التقديمي النماذج ومجلدات الشاشة. يحاكي هيكل مجلد الاختبار المذكور سابقًا بنية lib. يوجد أسفل مجلد الاختبار مجلد unit_test الذي يحتوي على مجلد university_feed. هيكل المجلد الخاص به هو نفسه مجلد university_feed أعلاه ، حيث يتم إلحاق "_test" بملفاته بأسمائهم.
يعكس هيكل مجلد الاختبار الخاص بالمشروع بنية كود المصدر

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

نحن الآن جاهزون لوضع اختبار الوحدة موضع التنفيذ.

اختبار وحدة الرفرفة البسيط

سنبدأ بفئات model (في طبقة data في الكود المصدري) وسنقتصر على مثالنا ليشمل فئة model واحدة فقط ، ApiUniversityModel . تتميز هذه الفئة بوظيفتين:

  • ابدأ نموذجنا بالسخرية من كائن JSON Map .
  • بناء نموذج بيانات University .

لاختبار كل وظيفة من وظائف النموذج ، سنخصص الخطوات العامة الموضحة سابقًا:

  1. قم بتقييم الكود.
  2. إعداد محاكاة البيانات: سنحدد استجابة الخادم لاستدعاء API الخاص بنا.
  3. حدد مجموعات الاختبار: سيكون لدينا مجموعتان للاختبار ، واحدة لكل وظيفة.
  4. تحديد تواقيع وظيفة الاختبار لكل مجموعة اختبار.
  5. اكتب الاختبارات.

بعد تقييم الكود الخاص بنا ، نحن على استعداد لتحقيق هدفنا الثاني: إعداد بيانات محاكاة ساخرة خاصة بوظيفتين ضمن فئة ApiUniversityModel .

للسخرية من الوظيفة الأولى (تهيئة نموذجنا عن طريق الاستهزاء بـ JSON Map ) ، fromJson ، سننشئ كائنين من Map لمحاكاة بيانات الإدخال للوظيفة. سنقوم أيضًا بإنشاء كائنين مكافئين من ApiUniversityModel لتمثيل النتيجة المتوقعة للدالة مع المدخلات المقدمة.

للسخرية من الوظيفة الثانية (بناء نموذج بيانات University ) ، toDomain ، University كائنين جامعيين ، وهما النتيجة المتوقعة بعد تشغيل هذه الوظيفة في كائنات ApiUniversityModel التي تم إنشاؤها مسبقًا:

 void main() { Map<String, dynamic> apiUniversityOneAsJson = { "alpha_two_code": "US", "domains": ["marywood.edu"], "country": "United States", "state-province": null, "web_pages": ["http://www.marywood.edu"], "name": "Marywood University" }; ApiUniversityModel expectedApiUniversityOne = ApiUniversityModel( alphaCode: "US", country: "United States", state: null, name: "Marywood University", websites: ["http://www.marywood.edu"], domains: ["marywood.edu"], ); University expectedUniversityOne = University( alphaCode: "US", country: "United States", state: "", name: "Marywood University", websites: ["http://www.marywood.edu"], domains: ["marywood.edu"], ); Map<String, dynamic> apiUniversityTwoAsJson = { "alpha_two_code": "US", "domains": ["lindenwood.edu"], "country": "United States", "state-province":"MJ", "web_pages": null, "name": "Lindenwood University" }; ApiUniversityModel expectedApiUniversityTwo = ApiUniversityModel( alphaCode: "US", country: "United States", state:"MJ", name: "Lindenwood University", websites: null, domains: ["lindenwood.edu"], ); University expectedUniversityTwo = University( alphaCode: "US", country: "United States", state: "MJ", name: "Lindenwood University", websites: [], domains: ["lindenwood.edu"], ); }

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

 void main() { // Previous declarations group("Test ApiUniversityModel initialization from JSON", () { test('Test using json one', () {}); test('Test using json two', () {}); }); group("Test ApiUniversityModel toDomain", () { test('Test toDomain using json one', () {}); test('Test toDomain using json two', () {}); }); }

لقد حددنا تواقيع اختبارين للتحقق من وظيفة fromJson ، واثنان للتحقق من وظيفة toDomain .

لتحقيق هدفنا الخامس وكتابة الاختبارات ، دعنا نستخدم طريقة expect مكتبة flutter_test لمقارنة نتائج الوظائف مقابل توقعاتنا:

 void main() { // Previous declarations group("Test ApiUniversityModel initialization from json", () { test('Test using json one', () { expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson), expectedApiUniversityOne); }); test('Test using json two', () { expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson), expectedApiUniversityTwo); }); }); group("Test ApiUniversityModel toDomain", () { test('Test toDomain using json one', () { expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson).toDomain(), expectedUniversityOne); }); test('Test toDomain using json two', () { expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson).toDomain(), expectedUniversityTwo); }); }); }

بعد أن أنجزنا أهدافنا الخمسة ، يمكننا الآن إجراء الاختبارات ، إما من IDE أو من سطر الأوامر.

لقطة شاشة تشير إلى اجتياز خمسة اختبارات من أصل خمسة. يقرأ الرأس: تشغيل: الاختبارات في api_university_model_test.dart. تقرأ اللوحة اليسرى من الشاشة: نتائج الاختبار - تحميل api_university_model_test.dart --- api_university_model_test.dart --- اختبار تهيئة ApiUniversityModel من json --- اختبار باستخدام json one - الاختبار باستخدام json two --- الاختبارات ApiUniversityModel toDomain - اختبر toDomain باستخدام json one - اختبر toDomain باستخدام json two. تقرأ اللوحة اليمنى للشاشة: اجتازت الاختبارات: خمسة من خمسة اختبارات - اختبار رفرفة / اختبار وحدة / جامعات_ تغذية / بيانات / مصدر / شبكة / نموذج / api_university_model_test.dart

في المحطة الطرفية ، يمكننا تشغيل جميع الاختبارات الموجودة في مجلد test عن طريق إدخال أمر flutter test ، ومعرفة أن اختباراتنا نجحت.

بدلاً من ذلك ، يمكننا إجراء اختبار أو مجموعة اختبار واحدة عن طريق إدخال أمر flutter test --plain-name "ReplaceWithName" ، واستبدال اسم مجموعة الاختبار أو الاختبار الخاصة بنا لـ ReplaceWithName .

وحدة اختبار نقطة نهاية في رفرفة

بعد الانتهاء من اختبار بسيط بدون تبعيات ، دعنا نستكشف مثالًا أكثر إثارة للاهتمام: سنختبر فئة endpoint ، التي يشمل نطاقها:

  • تنفيذ استدعاء API للخادم.
  • تحويل استجابة API JSON إلى تنسيق مختلف.

بعد تقييم الكود الخاص بنا ، سنستخدم طريقة setUp الخاصة بمكتبة setUp لتهيئة الفئات داخل مجموعة الاختبار الخاصة بنا:

 group("Test University Endpoint API calls", () { setUp(() { baseUrl = "https://test.url"; dioClient = Dio(BaseOptions()); endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl); }); }

لتقديم طلبات الشبكة إلى واجهات برمجة التطبيقات ، أفضل استخدام مكتبة التعديل التحديثي ، التي تولد معظم الكود الضروري. لاختبار صنف UniversityEndpoint بشكل صحيح ، سنجبر مكتبة dio - التي يستخدمها Retrofit لتنفيذ استدعاءات API - لإرجاع النتيجة المرجوة عن طريق الاستهزاء بسلوك فئة Dio من خلال محول استجابة مخصص.

مخصص شبكة اعتراض وهمية

السخرية ممكنة بسبب قيامنا ببناء فصل UniversityEndpoint من خلال DI. (إذا كان على فصل UniversityEndpoint تهيئة فئة Dio بنفسه ، فلن تكون هناك طريقة للسخرية من سلوك الفصل.)

من أجل السخرية من سلوك فئة Dio ، نحتاج إلى معرفة طرق Dio المستخدمة داخل مكتبة Retrofit - لكن ليس لدينا وصول مباشر إلى Dio . لذلك ، سوف نسخر من Dio باستخدام معترض استجابة شبكة مخصص:

 class DioMockResponsesAdapter extends HttpClientAdapter { final MockAdapterInterceptor interceptor; DioMockResponsesAdapter(this.interceptor); @override void close({bool force = false}) {} @override Future<ResponseBody> fetch(RequestOptions options, Stream<Uint8List>? requestStream, Future? cancelFuture) { if (options.method == interceptor.type.name.toUpperCase() && options.baseUrl == interceptor.uri && options.queryParameters.hasSameElementsAs(interceptor.query) && options.path == interceptor.path) { return Future.value(ResponseBody.fromString( jsonEncode(interceptor.serializableResponse), interceptor.responseCode, headers: { "content-type": ["application/json"] }, )); } return Future.value(ResponseBody.fromString( jsonEncode( {"error": "Request doesn't match the mock interceptor details!"}), -1, statusMessage: "Request doesn't match the mock interceptor details!")); } } enum RequestType { GET, POST, PUT, PATCH, DELETE } class MockAdapterInterceptor { final RequestType type; final String uri; final String path; final Map<String, dynamic> query; final Object serializableResponse; final int responseCode; MockAdapterInterceptor(this.type, this.uri, this.path, this.query, this.serializableResponse, this.responseCode); }

الآن بعد أن أنشأنا المعترض للاستهزاء بردود شبكتنا ، يمكننا تحديد مجموعات الاختبار واختبار التوقيعات الوظيفية.

في حالتنا ، لدينا وظيفة واحدة فقط لاختبارها ( getUniversitiesByCountry ) ، لذلك سننشئ مجموعة اختبار واحدة فقط. سنختبر استجابة وظيفتنا لثلاث مواقف:

  1. هل تم استدعاء وظيفة فئة Dio بالفعل بواسطة getUniversitiesByCountry ؟
  2. إذا أرجع طلب واجهة برمجة التطبيقات (API) خطأً ، فماذا يحدث؟
  3. إذا قام طلب API الخاص بنا بإرجاع النتيجة المتوقعة ، فماذا يحدث؟

إليك مجموعة الاختبار وتوقيعات وظيفة الاختبار:

 group("Test University Endpoint API calls", () { test('Test endpoint calls dio', () async {}); test('Test endpoint returns error', () async {}); test('Test endpoint calls and returns 2 valid universities', () async {}); });

نحن على استعداد لكتابة اختباراتنا. لكل حالة اختبار ، سننشئ مثيلًا لـ DioMockResponsesAdapter المقابل:

 group("Test University Endpoint API calls", () { setUp(() { baseUrl = "https://test.url"; dioClient = Dio(BaseOptions()); endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl); }); test('Test endpoint calls dio', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 200, [], ); var result = await endpoint.getUniversitiesByCountry("us"); expect(result, <ApiUniversityModel>[]); }); test('Test endpoint returns error', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 404, {"error": "Not found!"}, ); List<ApiUniversityModel>? response; DioError? error; try { response = await endpoint.getUniversitiesByCountry("us"); } on DioError catch (dioError, _) { error = dioError; } expect(response, null); expect(error?.error, "Http status error [404]"); }); test('Test endpoint calls and returns 2 valid universities', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 200, generateTwoValidUniversities(), ); var result = await endpoint.getUniversitiesByCountry("us"); expect(result, expectedTwoValidUniversities()); }); });

الآن وقد اكتمل اختبار نقطة النهاية ، فلنختبر فئة مصدر البيانات ، UniversityRemoteDataSource . في وقت سابق ، لاحظنا أن فئة UniversityEndpoint هي جزء من المُنشئ UniversityRemoteDataSource({UniversityEndpoint? universityEndpoint}) ، مما يشير إلى أن UniversityRemoteDataSource تستخدم فئة UniversityEndpoint لتحقيق نطاقها ، لذلك هذا هو الفصل الذي سنحكم عليه.

السخرية مع موكيتو

في مثالنا السابق ، سخرنا يدويًا من محول طلب عميل Dio الخاص بنا باستخدام NetworkInterceptor مخصص. نحن هنا نسخر من فصل كامل. القيام بذلك يدويًا - السخرية من فصل دراسي ووظائفه - سيكون مضيعة للوقت. لحسن الحظ ، تم تصميم المكتبات الوهمية للتعامل مع مثل هذه المواقف ويمكنها إنشاء فئات وهمية بأقل جهد. دعنا نستخدم مكتبة mockito ، مكتبة الصناعة القياسية للسخرية في Flutter.

للسخرية من Mockito ، نضيف أولاً التعليق التوضيحي " @GenerateMocks([class_1,class_2,…]) قبل رمز الاختبار — أعلى وظيفة void main() {} مباشرةً. في التعليق التوضيحي ، سنقوم بتضمين قائمة بأسماء الفئات كمعامل (بدلاً من class_1,class_2… ).

بعد ذلك ، نقوم بتشغيل أمر flutter pub run build_runner build الذي يولد الكود الخاص بفئاتنا الوهمية في نفس الدليل مثل الاختبار. سيكون اسم الملف الوهمي الناتج مزيجًا من اسم ملف الاختبار بالإضافة إلى .mocks.dart ، لتحل محل لاحقة .dart الخاصة بالاختبار. سيتضمن محتوى الملف فئات وهمية تبدأ أسماؤها بالبادئة Mock . على سبيل المثال ، تصبح UniversityEndpoint MockUniversityEndpoint .

الآن ، نقوم باستيراد university_remote_data_source_test.dart.mocks.dart (ملفنا النموذجي) إلى university_remote_data_source_test.dart (ملف الاختبار).

بعد ذلك ، في دالة setUp ، سنقوم بسخرية UniversityEndpoint باستخدام MockUniversityEndpoint وتهيئة فئة UniversityRemoteDataSource :

 import 'university_remote_data_source_test.mocks.dart'; @GenerateMocks([UniversityEndpoint]) void main() { late UniversityEndpoint endpoint; late UniversityRemoteDataSource dataSource; group("Test function calls", () { setUp(() { endpoint = MockUniversityEndpoint(); dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint); }); }

لقد سخرنا بنجاح من UniversityEndpoint ثم قمنا بتهيئة فصل UniversityRemoteDataSource الخاص بنا. نحن الآن جاهزون لتحديد مجموعات الاختبار واختبار التواقيع الوظيفية:

 group("Test function calls", () { test('Test dataSource calls getUniversitiesByCountry from endpoint', () {}); test('Test dataSource maps getUniversitiesByCountry response to Stream', () {}); test('Test dataSource maps getUniversitiesByCountry response to Stream with error', () {}); });

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

يتحقق أول اختبار لنا مما إذا كانت وظيفة UniversityEndpoint يتم استدعاؤها عندما يبدأ مصدر البيانات في جلب معلومات الدولة. نبدأ بتحديد كيفية تفاعل كل فئة عند استدعاء وظائفها. نظرًا لأننا سخرنا من فئة UniversityEndpoint ، فهذا هو الفصل الذي سنعمل معه ، باستخدام بنية الكود when( function_that_will_be_called ).then( what_will_be_returned ) .

الدوال التي نختبرها غير متزامنة (دوال تعيد كائنًا Future ) ، لذلك سنستخدم when(function name).thenanswer( (_) {modified function result} ) لتعديل نتائجنا.

للتحقق مما إذا كانت دالة getUniversitiesByCountry تستدعي دالة getUniversitiesByCountry داخل فئة UniversityEndpoint ، سنستخدم when(...).thenAnswer( (_) {...} ) للاستهزاء بوظيفة getUniversitiesByCountry داخل فئة UniversityEndpoint :

 when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[]));

الآن بعد أن سخرنا من ردنا ، نسمي وظيفة مصدر البيانات ونتحقق - باستخدام وظيفة verify - ما إذا كانت وظيفة UniversityEndpoint تسمى:

 test('Test dataSource calls getUniversitiesByCountry from endpoint', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); dataSource.getUniversitiesByCountry("test"); verify(endpoint.getUniversitiesByCountry("test")); });

يمكننا استخدام نفس المبادئ لكتابة اختبارات إضافية تتحقق مما إذا كانت وظيفتنا تحول نتائج نقطة النهاية لدينا بشكل صحيح إلى تدفقات البيانات ذات الصلة:

 import 'university_remote_data_source_test.mocks.dart'; @GenerateMocks([UniversityEndpoint]) void main() { late UniversityEndpoint endpoint; late UniversityRemoteDataSource dataSource; group("Test function calls", () { setUp(() { endpoint = MockUniversityEndpoint(); dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint); }); test('Test dataSource calls getUniversitiesByCountry from endpoint', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); dataSource.getUniversitiesByCountry("test"); verify(endpoint.getUniversitiesByCountry("test")); }); test('Test dataSource maps getUniversitiesByCountry response to Stream', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); expect( dataSource.getUniversitiesByCountry("test"), emitsInOrder([ const AppResult<List<University>>.loading(), const AppResult<List<University>>.data([]) ]), ); }); test( 'Test dataSource maps getUniversitiesByCountry response to Stream with error', () { ApiError mockApiError = ApiError( statusCode: 400, message: "error", errors: null, ); when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.error(mockApiError)); expect( dataSource.getUniversitiesByCountry("test"), emitsInOrder([ const AppResult<List<University>>.loading(), AppResult<List<University>>.apiError(mockApiError) ]), ); }); }); }

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

اختبارات وحدة الرفرفة: مفتاحك لتجربة المستخدم الفائقة

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

يعرب فريق التحرير في مدونة Toptal Engineering عن امتنانه لماتيجا بيسيرفيتش وبول هوسكينز لمراجعة نماذج الكود والمحتويات التقنية الأخرى المعروضة في هذه المقالة.