اختبار الوحدة في Flutter: من أساسيات سير العمل إلى السيناريوهات المعقدة
نشرت: 2022-09-21بلغ الاهتمام بـ Flutter أعلى مستوياته على الإطلاق - وقد طال انتظاره. تتوافق SDK مفتوحة المصدر من Google مع أنظمة التشغيل Android و iOS و macOS والويب و Windows و Linux. تدعمهم جميعًا قاعدة بيانات Flutter. ويُعد اختبار الوحدة أمرًا أساسيًا في تقديم تطبيق Flutter متسق وموثوق به ، مما يضمن عدم حدوث الأخطاء والعيوب والعيوب من خلال التحسين الاستباقي لجودة الكود قبل تجميعه.
في هذا البرنامج التعليمي ، نشارك تحسينات سير العمل لاختبار وحدة Flutter ، ونعرض اختبار وحدة Flutter أساسيًا ، ثم ننتقل إلى مكتبات وحالات اختبار Flutter الأكثر تعقيدًا.
اختبار تدفق الوحدة في الرفرفة
نقوم بتنفيذ اختبار الوحدة في Flutter بنفس الطريقة التي نقوم بها في مجموعات التكنولوجيا الأخرى:
- قم بتقييم الكود.
- إعداد الاستهزاء بالبيانات.
- حدد مجموعة (مجموعات) الاختبار.
- تحديد توقيع (توقيعات) وظيفة الاختبار لكل مجموعة اختبار.
- اكتب الاختبارات.
لإثبات اختبار الوحدة ، أعددت نموذجًا لمشروع Flutter وأشجعك على استخدام واختبار الكود في وقت فراغك. يستخدم المشروع واجهة برمجة تطبيقات خارجية لجلب وعرض قائمة بالجامعات التي يمكننا تصفيتها حسب الدولة.
بعض الملاحظات حول كيفية عمل Flutter: يسهل إطار العمل الاختبار عن طريق التحميل التلقائي لمكتبة flutter_test
عند إنشاء مشروع. تمكن المكتبة Flutter من قراءة اختبارات الوحدة وتشغيلها وتحليلها. يقوم Flutter أيضًا بإنشاء مجلد test
تلقائيًا لتخزين الاختبارات فيه. من الأهمية بمكان تجنب إعادة تسمية و / أو نقل مجلد test
، لأن هذا يكسر وظائفه ، وبالتالي ، قدرتنا على إجراء الاختبارات. من الضروري أيضًا تضمين _test.dart
في أسماء ملفات الاختبار الخاصة بنا ، لأن هذه اللاحقة هي الطريقة التي يتعرف بها Flutter على ملفات الاختبار.
اختبار هيكل الدليل
لتعزيز اختبار الوحدة في مشروعنا ، قمنا بتنفيذ MVVM بهندسة نظيفة وحقن التبعية (DI) ، كما يتضح من الأسماء المختارة للمجلدات الفرعية لشفرة المصدر. يضمن الجمع بين مبادئ MVVM و DI فصل الاهتمامات:
- تدعم كل فئة مشروع هدفًا واحدًا.
- كل وظيفة داخل الفصل تفي فقط بنطاقها الخاص.
سننشئ مساحة تخزين منظمة لملفات الاختبار التي سنكتبها ، وهو نظام يكون فيه لمجموعات الاختبارات "منازل" يمكن التعرف عليها بسهولة. في ضوء متطلبات Flutter لتحديد موقع الاختبارات داخل مجلد test
، دعنا نعكس بنية مجلد الكود المصدري قيد test
. بعد ذلك ، عندما نكتب اختبارًا ، سنقوم بتخزينه في المجلد الفرعي المناسب: تمامًا كما يتم وضع الجوارب النظيفة في درج الجورب في خزانة الملابس الخاصة بك والقمصان المطوية في درج القميص ، يتم إجراء اختبارات الوحدة لفئات Model
في مجلد باسم model
، فمثلا.
يؤدي اعتماد نظام الملفات هذا إلى بناء الشفافية في المشروع ويتيح للفريق طريقة سهلة لعرض أجزاء التعليمات البرمجية الخاصة بنا التي لها اختبارات مرتبطة.
نحن الآن جاهزون لوضع اختبار الوحدة موضع التنفيذ.
اختبار وحدة الرفرفة البسيط
سنبدأ بفئات model
(في طبقة data
في الكود المصدري) وسنقتصر على مثالنا ليشمل فئة model
واحدة فقط ، ApiUniversityModel
. تتميز هذه الفئة بوظيفتين:
- ابدأ نموذجنا بالسخرية من كائن JSON
Map
. - بناء نموذج بيانات
University
.
لاختبار كل وظيفة من وظائف النموذج ، سنخصص الخطوات العامة الموضحة سابقًا:
- قم بتقييم الكود.
- إعداد محاكاة البيانات: سنحدد استجابة الخادم لاستدعاء API الخاص بنا.
- حدد مجموعات الاختبار: سيكون لدينا مجموعتان للاختبار ، واحدة لكل وظيفة.
- تحديد تواقيع وظيفة الاختبار لكل مجموعة اختبار.
- اكتب الاختبارات.
بعد تقييم الكود الخاص بنا ، نحن على استعداد لتحقيق هدفنا الثاني: إعداد بيانات محاكاة ساخرة خاصة بوظيفتين ضمن فئة 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 أو من سطر الأوامر.
في المحطة الطرفية ، يمكننا تشغيل جميع الاختبارات الموجودة في مجلد 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
) ، لذلك سننشئ مجموعة اختبار واحدة فقط. سنختبر استجابة وظيفتنا لثلاث مواقف:
- هل تم استدعاء وظيفة فئة
Dio
بالفعل بواسطةgetUniversitiesByCountry
؟ - إذا أرجع طلب واجهة برمجة التطبيقات (API) خطأً ، فماذا يحدث؟
- إذا قام طلب 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 عن امتنانه لماتيجا بيسيرفيتش وبول هوسكينز لمراجعة نماذج الكود والمحتويات التقنية الأخرى المعروضة في هذه المقالة.