العمل على الواجهة يبدأ غالبًا قبل أن يجهز الـ backend. الـ API ما يزال قيد التصميم، أو موجود لكنك في القطار، أو عليك عرض التطبيق ولا تستطيع الاعتماد على خادم. الجواب المعتاد بضع قوائم مكتوبة مباشرة في الكود هنا وهناك، ثم يجب العثور عليها وحذفها لاحقًا.
الـ backend الوهمي جواب أنظف: التطبيق يواصل إرسال طلبات HTTP حقيقية عبر الـ client الحقيقي، وشيء ما يجيب عنها من ملفات JSON محلية. لكن الوهمي الجيد يجب أن يفعل أكثر من إرجاع ملف. الـ APIs الحقيقية بطيئة، وتفشل، وتتذكر ما أرسلته لها. وإن لم يفعل الوهمي شيئًا من ذلك، فحالات التحميل وشاشات الخطأ ومسارات «أنشئ ثم اعرض القائمة» لا تُجرَّب أبدًا.
mock_api_assets هي backend وهمي مصنوع من ملفات JSON جاهزة (fixtures). فيها مطابقة للمسارات (routes)، و fixtures بقوالب، وتأخير محاكى (latency)، وحقن أخطاء، ومخزن CRUD يحتفظ بالحالة، وسيناريوهات قابلة للتبديل، ومحوّلات (adapters) لـ Dio و package:http.
ماذا تقدّم
Router. MockRouter يطابق على الـ method ونمط المسار. :id يلتقط مقطعًا واحدًا، و* يطابق مقطعًا واحدًا، و** (في المقطع الأخير فقط) يطابق بقية المسار. قيود الـ query string اختيارية. أول تطابق يفوز، وpriority: يقدّم route على routes مسجّلة قبله. الشرطة المائلة في النهاية تُتجاهل، ويمكن نزع basePath مثل /api/v1 قبل المطابقة.
Handlers. MockHandler.fixture(path) يقدّم ملف fixture. MockHandler.json(value) يقدّم قيمة JSON مباشرة. MockHandler.callback(...) يستقبل معاملات المسار والـ query والـ headers والـ body بعد تحليله، ويُرجع ردًّا. وMockHandler.paginated(...) وMockHandler.paginatedList(...) يقطّعان قائمة حسب page وlimit.
القوالب. العناصر النائبة في الـ fixtures وفي JSON المباشر تُملأ من الطلب: {{params.id}} و{{query.q}} و{{headers.x-token}} و{{body.items.0.sku}} و{{method}} و{{path}} و{{now}}. النص الذي هو عنصر نائب واحد بالضبط يحتفظ بنوع القيمة، والفلاتر | int و| num و| bool تحوّله.
محمِّلات fixtures قابلة للاستبدال. MapFixtureLoader يحتفظ بالـ fixtures في الذاكرة. CallbackFixtureLoader ينادي دالة تعطيها له، مثل rootBundle.loadString في Flutter. وFileFixtureLoader يقرأ من القرص وموجود في package:mock_api_assets/io.dart.
التأخير. MockLatency.fixed أو .uniform أو .normal. القيم العشوائية تأتي من Random قابل للحقن، ودالة التأخير قابلة للحقن أيضًا، فتستطيع الاختبارات العمل على ساعة وهمية.
حقن الأخطاء. نسبة فشل عامة، وأخطاء لـ routes محددة مع عدد مرات اختياري، و«أفشِل الطلبات الـ N القادمة». الأخطاء إما حالات HTTP أو أخطاء شبكة: timeout، connection refused، connection reset و host lookup.
CRUD بحالة. router.resource('/todos', seed: [...]) يسجّل routes للقائمة والإنشاء والقراءة والاستبدال والتحديث والحذف فوق مجموعة في الذاكرة. POST ثم GET يُرجع ما أُرسل. وrouter.store.reset() يستعيد البيانات الأولية.
السيناريوهات. مجموعات مسمّاة من التجاوزات تبدّلها وقت التشغيل: تجاوز routes، تأخير، خطأ لكل طلب، أو نسبة فشل. مثل "empty-state" و"server-down" و"slow-3g". ويمكن تفعيل عدة سيناريوهات معًا.
سجل الطلبات. تحقق عبر router.calls وrouter.callsTo('POST', '/todos') وrouter.wasCalled(...).
ردود 404 مفيدة. الطلب الذي لا يطابق شيئًا يحصل على 404 بجسم JSON يسرد أقرب الـ routes المسجّلة.
المحوّلات. MockDioAdapter من package:mock_api_assets/dio.dart وMockHttpClient من package:mock_api_assets/http.dart. والمكتبة الأساسية لا تستورد أيًّا منهما.
طريقة الاستعمال
أضِفها من GitHub:
dependencies: mock_api_assets: git: https://github.com/AbdeldjalilChougui/mock_api_assets مع Dio:
import 'package:dio/dio.dart'; import 'package:mock_api_assets/dio.dart'; import 'package:mock_api_assets/mock_api_assets.dart'; Future<void> main() async { final router = MockRouter( loader: MapFixtureLoader({ 'users/detail.json': {'id': '{{params.id | int}}', 'name': 'User'}, }), latency: const MockLatency.uniform( Duration(milliseconds: 200), Duration(milliseconds: 800), ), ) ..get('/users/:id', MockHandler.fixture('users/detail.json')) ..get('/orders/queue', MockHandler.json([]), priority: 10) ..get('/orders/:id', MockHandler.callback((req) { return MockResponse.json({'id': req.pathParams['id'], 'q': req.query}); })) ..resource('/todos', seed: [ {'id': 1, 'title': 'Buy milk'}, ]); final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com')) ..httpClientAdapter = MockDioAdapter(router); await dio.post<Object?>('/todos', data: {'title': 'Walk dog'}); final todo = await dio.get<Object?>('/todos/2'); // {id: 2, title: Walk dog} print(todo.data); } بقية التطبيق لا تتغير: ما يزال ينادي dio.get وdio.post. ومع package:http تستبدل الـ client:
final router = MockRouter()..get('/ping', MockHandler.json({'pong': true})); final http.Client client = MockHttpClient(router); final res = await client.get(Uri.parse('https://api.example.com/ping')); print(res.body); // {"pong":true} Fixtures من assets في Flutter
الحزمة لا تعتمد على Flutter، لكن CallbackFixtureLoader يأخذ أي دالة String -> Future<String>:
final router = MockRouter( loader: CallbackFixtureLoader(rootBundle.loadString, prefix: 'assets/mocks/'), )..get('/users/me', MockHandler.fixture('auth/me.json')); كل مجلد assets يجب أن يُذكر تحت flutter: assets: في pubspec.yaml. وإلا يُبلَّغ عن الـ fixture كمفقود. ولأدوات سطر الأوامر والاختبارات استعمل FileFixtureLoader('test/fixtures').
اجعله يفشل عن قصد
router.faults.failNext(2, MockFault.status(503)); // next two calls router.faults.failNext(1, MockFault.timeout()); // then a timeout router.faults.failRoute('POST', '/orders/:id/confirm', MockFault.status(409, body: {'detail': 'Already confirmed'}), times: 1); router.faults.failureRate = 0.1; // 10% of calls get a 500 router.faults.clear(); أخطاء الحالة تعود كردود عادية. وأخطاء الشبكة تجعل router.handle يرمي MockNetworkException. محوّل Dio يحوّله إلى DioException (connectionTimeout عند الـ timeout، وconnectionError في غيره)، و client الـ http يحوّله إلى http.ClientException. فكود معالجة الأخطاء عندك يستقبل أنواع الاستثناءات التي يرميها هذان الـ clients عادة.
السيناريوهات
router ..defineScenario(MockScenario('empty-state', routes: [ MockRoute('GET', '/todos', MockHandler.json(<Object?>[])), ])) ..defineScenario(MockScenario('server-down', fault: MockFault.status(503))) ..defineScenario(const MockScenario('slow-3g', latency: MockLatency.normal( Duration(milliseconds: 1500), Duration(milliseconds: 400)))); router.activateScenario('empty-state'); // only this one router.activateScenarios(['slow-3g', 'empty-state']); // stacked, later wins router.clearScenarios(); routes السيناريو تُجرَّب قبل routes الـ router نفسه. وخطأ السيناريو يُطبَّق على كل طلب، حتى الطلبات التي لا تطابق أي route.
في الاختبارات
expect(router.callsTo('POST', '/todos'), hasLength(1)); expect(router.calls.where((c) => c.response?.statusCode == 404), isEmpty); router.reset(); // log, faults, scenarios and store لاختبار التأخير بلا انتظار حقيقي، احقن random: وشغّل الكود داخل fakeAsync من package:fake_async. ويمكنك أيضًا تمرير delay: (d) async {} لتجاوز الانتظار كليًا.
أشياء يجب معرفتها
-
dioوhttpكلاهما اعتمادية عادية. المحوّلات في مكتبات منفصلة، والمكتبة الأساسية لا تستورد أيًّا من الحزمتين. لكن pub يجب أن يحلّ الاثنتين حتى تُترجم المحوّلات، و Dart ليس فيه اعتماديات اختيارية. ومترجمات Dart تُسقط المكتبات التي لا تستوردها، فالتطبيق الذي يستعمل محوّلًا واحدًا لا يحمل الآخر. - أجسام نصية فقط. الأجسام تُعامل كنص UTF-8. أجسام الطلبات من نوع multipart والثنائية تصل إلى الـ handlers كنص خام ولا تُحلَّل، والردود JSON أو نص فقط.
- لا streaming ولا server-sent events ولا WebSockets ولا cookies ولا redirects.
- القوالب تملأ القيم ولا تبني البنية. تستبدل العناصر النائبة داخل القيم النصية في JSON. للحلقات والشروط استعمل callback handler.
- مخزن CRUD في الذاكرة. إعادة تشغيل التطبيق تستعيد البيانات الأولية.
- التأخير يأتي قبل الأخطاء. خطأ الـ timeout يفشل بعد التأخير المحاكى، لا بعد timeout مضبوط في الـ client.
- إلغاء طلب Dio أثناء تأخير محاكى غير مختبر. يتولاه Dio نفسه، والمحوّل ليس فيه منطق إلغاء خاص به.
الحزمة لا تحمل أي بيانات. الـ fixtures تأتي منك.
لماذا بنيتها
تقديم ملف JSON مكان الرد هو العُشر السهل من backend وهمي. الحالات الصعبة البناء والسهلة النسيان هي البقية: مؤشر التحميل على اتصال بطيء، إعادة المحاولة بعد 503، القائمة الفارغة، الـ timeout، والعنصر الذي يجب أن يظهر في القائمة مباشرة بعد إنشائه.
أردت أن تكون كل واحدة منها سطرًا واحدًا، وقابلة للتبديل والتطبيق يعمل، فتُفحص الشاشة في "empty-state" أو "slow-3g" بلا تعديل الـ fixtures. وأردت أن يخدم نفس الـ router مهمتين: تشغيل التطبيق بلا شبكة، والاختبار، مع سجل طلبات للتحقق منه وساعة تتحكم فيها الاختبارات. والربط على مستوى HTTP client، بمحوّلات Dio و package:http، يعني أن الكود المُختبَر هو الكود الحقيقي، لا repository وهمي.
احصل على الكود
المصدر، والـ README مع كل الخيارات، والمثال والاختبارات، كلها على GitHub: https://github.com/AbdeldjalilChougui/mock_api_assets. الرخصة MIT. ومرحبًا بالـ issues والـ pull requests.
نُشر في الأصل على abdeldjalilchougui.biz.