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

بالترتيب:
- يحتوي الجدول (رقم 1) على بيانات يمكن أن تكون مختلفة جدًا (نص عادي ، ارتباطات ، صور ، إلخ).
- تقوم لوحة الفرز (رقم 2) بتعيين إعدادات فرز البيانات في الجدول حسب الأعمدة.
- تقوم لوحة التصفية (رقم 3) بتعيين مرشحات مختلفة وفقًا لأعمدة الجدول.
- تتيح لك لوحة الأعمدة (رقم 4) ضبط عرض أعمدة الجدول (إظهار / إخفاء).
- تسمح لك لوحة القوالب (رقم 5) بتحديد قوالب الإعدادات التي تم إنشاؤها مسبقًا. تشتمل القوالب على بيانات من اللوحات رقم 2 ورقم 3 ورقم 4 ، بالإضافة إلى بعض البيانات الأخرى ، على سبيل المثال ، موضع الأعمدة وحجمها ، إلخ.
يتم فتح اللوحات عن طريق النقر على الأزرار المقابلة.
البيانات الموجودة على الأعمدة الموجودة في الجدول ، والبيانات التي يمكن أن تكون فيها ، وكيف ينبغي عرضها ، والقيم التي يمكن أن تحتوي عليها عوامل التصفية ، والمعلومات الأخرى الموجودة في البيانات الوصفية للجدول ، والتي تُطلب بشكل منفصل عن البيانات نفسها في بداية تحميل الصفحة.
اتضح أن الحالة الحالية للجدول والبيانات الموجودة فيه تعتمد على ثلاثة عوامل:
- البيانات من البيانات الوصفية للجدول.
- إعدادات القالب المحدد حاليًا.
- الإعدادات المخصصة (يتم حفظ أي تغييرات تتعلق بالقالب المحدد بنوع من "المسودة" ، والتي يمكن تحويلها إلى قالب جديد ، إما تحديث القالب الحالي بإعدادات جديدة ، أو حذفها وإعادة القالب إلى حالته الأصلية).
كما ذكر أعلاه ، مثل هذه الصفحة هي نموذجية. لكل صفحة من هذه الصفحات (أو بشكل أكثر دقة ، بالنسبة للجدول الموجود فيها) ، يتم إنشاء كيان منفصل في مستودع Redux لتوفير الراحة في التشغيل باستخدام بياناته ومعلماته.
من أجل أن تكون قادرًا على تعيين مجموعات متجانسة من منشئي thunk و action وتحديث البيانات على كيان محدد ، يتم استخدام الطريقة التالية (نوع من المصنع):
export const actionsCreator = (prefix, getCurrentStore, entityModel) => { function fetchTotalCounterStart() { return { type: `${prefix}FETCH_TOTAL_COUNTER_START` }; } function fetchTotalCounterSuccess(payload) { return { type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`, payload }; } function fetchTotalCounterError(error) { return { type: `${prefix}FETCH_TOTAL_COUNTER_ERROR`, error }; } function applyFilterSuccess(payload) { return { type: `${prefix}APPLY_FILTER_SUCCESS`, payload }; } function applyFilterError(error) { return { type: `${prefix}APPLY_FILTER_ERROR`, error }; } function fetchTotalCounter(filter) { return async dispatch => { dispatch(fetchTotalCounterStart()); try { const { data: { payload } } = await entityModel.fetchTotalCounter(filter); dispatch(fetchTotalCounterSuccess(payload)); } catch (error) { dispatch(fetchTotalCounterError(error)); } }; } function fetchData(filter, dispatch) { dispatch(fetchTotalCounter(filter)); return entityModel.fetchData(filter); } function applyFilter(newFilter) { return async (dispatch, getStore) => { try { const store = getStore(); const currentStore = getCurrentStore(store);
حيث:
prefix
- prefix
كيان في مستودع Redux. إنها سلسلة من النموذج "CATS_" ، "MICE_" ، إلخ.getCurrentStore
- محدد يقوم بإرجاع البيانات الحالية على الكيان من مستودع Redux.entityModel
- مثيل لفئة نموذج الكيان. من ناحية ، يتم الوصول إلى api من خلال النموذج لإنشاء طلب إلى الخادم ، من ناحية أخرى ، يتم وصف بعض منطق معالجة البيانات (أو غير ذلك) المعقدة.
وبالتالي ، يتيح لك هذا المصنع أن يصف بمرونة إدارة البيانات والمعلمات الخاصة بكيان معين في مستودع Redux وربطه بالجدول المقابل لهذا الكيان.
نظرًا لوجود الكثير من الفروق الدقيقة في إدارة هذا النظام ، فإن thunk يمكن أن يكون معقدًا وضخمًا ومربكًا ويحتوي على أجزاء مكررة. لتبسيطها ، وكذلك لإعادة استخدام الرمز ، يتم تقسيم thunks معقدة إلى أبسط وتجمع في تكوين. نتيجة لذلك ، قد fetchTotalCounter
الآن إلى أن thunk يستدعي الآخر ، والذي يمكنه بالفعل إرسال applyFilter
عادية (مثل applyFilter
- fetchTotalCounter
من المثال أعلاه). وعندما تم أخذ جميع النقاط الرئيسية في الاعتبار ، وتم توضيح جميع منشئي الإجراءات والإجراءات اللازمة ، كان الملف الذي يحتوي على دالة actionsCreator
يحتوي على actionsCreator
1200 سطر من التعليمات البرمجية وتم اختباره actionsCreator
كبير. يحتوي ملف الاختبار أيضًا على حوالي 1200 خط ، لكن التغطية كانت في أفضل الأحوال 40-50٪.
هنا ، بالطبع ، يتم تبسيط المثال إلى حد كبير ، سواء من حيث عدد thunk ومنطقها الداخلي ، ولكن هذا سيكون كافياً لإظهار المشكلة.
انتبه إلى نوعين من thunk في المثال أعلاه:
fetchTotalCounter
- إرسال fetchTotalCounter
فقط.applyFilter
- بالإضافة إلى إرسال applyFilterSuccess
( applyFilterSuccess
، applyFilterError
) ، فهو أيضًا thunk آخر ( fetchTotalCounter
).
سوف نعود إليهم بعد قليل.
تم اختبار كل هذا على النحو التالي (تم استخدام الإطار لاختبار Jest ):
import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { actionsCreator } from '../actions'; describe('actionsCreator', () => { const defaultState = {}; const middlewares = [thunk]; const mockStore = configureMockStore(middlewares); const prefix = 'TEST_'; const getCurrentStore = () => defaultState; const entityModel = { fetchTotalCounter: jest.fn(), fetchData: jest.fn(), }; let actions; beforeEach(() => { actions = actionsCreator(prefix, getCurrentStore, entityModel); }); describe('fetchTotalCounter', () => { it('should dispatch correct actions on success', () => { const filter = {}; const payload = 0; const store = mockStore(defaultState); entityModel.fetchTotalCounter.mockResolvedValueOnce({ data: { payload }, }); const expectedActions = [ { type: `${prefix}FETCH_TOTAL_COUNTER_START` }, { type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`, payload, } ]; return store.dispatch(actions.fetchTotalCounter(filter)).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); it('should dispatch correct actions on error', () => { const filter = {}; const error = {}; const store = mockStore(defaultState); entityModel.fetchTotalCounter.mockRejectedValueOnce(error); const expectedActions = [ { type: `${prefix}FETCH_TOTAL_COUNTER_START` }, { type: `${prefix}FETCH_TOTAL_COUNTER_ERROR`, error, } ]; return store.dispatch(actions.fetchTotalCounter(filter)).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); }); describe('applyFilter', () => { it('should dispatch correct actions on success', () => { const payload = {}; const counter = 0; const newFilter = {}; const store = mockStore(defaultState); entityModel.fetchData.mockResolvedValueOnce({ data: { payload } }); entityModel.fetchTotalCounter.mockResolvedValueOnce({ data: { payload: counter }, }); const expectedActions = [
كما ترون ، لا توجد مشاكل في اختبار النوع الأول من thunk - كل ما تحتاجه هو ربط entityModel
طراز entityModel ، ولكن النوع الثاني أكثر تعقيدًا - يجب عليك مسح البيانات الخاصة بسلسلة كاملة تسمى thunk وطرق النموذج المقابلة. بخلاف ذلك ، يقع الاختبار في تدمير البيانات ( {data: {payload}} ) ، ويمكن أن يحدث هذا بشكل صريح أو ضمني (لقد مر الاختبار بنجاح ، ولكن مع إجراء بحث دقيق ، لاحظنا أنه في الثانية / الثالثة رابط من هذه السلسلة كان هناك انخفاض في الاختبار بسبب عدم وجود بيانات مقفلة). من السيء أيضًا أن تتحول اختبارات الوحدات للوظائف الفردية إلى نوع من التكامل ، وتصبح مرتبطة بشكل وثيق.
السؤال الذي يطرح نفسه: لماذا في وظيفة applyFilter
تحقق من كيفية fetchTotalCounter
إذا كانت اختبارات مفصلة منفصلة قد كتبت بالفعل من أجلها؟ كيف يمكنني جعل اختبار النوع الثاني من thunk أكثر استقلالية؟ سيكون من الرائع الحصول على فرصة لاختبار أن thunk (في هذه الحالة fetchTotalCounter
) يسمى فقط بالمعلمات الصحيحة ، ولن تكون هناك حاجة لرعاية moks حتى تعمل بشكل صحيح.
ولكن كيف نفعل ذلك؟ يتبادر إلى الذهن القرار الواضح: ربط دالة fetchData ، والتي تسمى في applyFilter
، أو لقفل fetchTotalCounter
(حيث يتم استدعاء thunk آخر في كثير من الأحيان مباشرةً ، وليس من خلال بعض الوظائف الأخرى مثل fetchData
).
لنجربها. على سبيل المثال ، سوف نقوم بتغيير البرنامج النصي الناجح فقط.
describe('applyFilter', () => { it('should dispatch correct actions on success', () => { const payload = {}; - const counter = 0; const newFilter = {}; const store = mockStore(defaultState); - entityModel.fetchData.mockResolvedValueOnce({ data: { payload } }); - entityModel.fetchTotalCounter.mockResolvedValueOnce({ - data: { payload: counter }, - }); + const fetchData = jest.spyOn(actions, 'fetchData'); + // or fetchData.mockImplementationOnce(Promise.resolve({ data: { payload } })); + fetchData.mockResolvedValueOnce({ data: { payload } }); const expectedActions = [ - // Total counter actions - { type: `${prefix}FETCH_TOTAL_COUNTER_START` }, - { - type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`, - payload: counter, - }, - // apply filter actions { type: `${prefix}APPLY_FILTER_SUCCESS`, payload, } ]; return store.dispatch(actions.applyFilter(newFilter)).then(() => { expect(store.getActions()).toEqual(expectedActions); + expect(actions.fetchTotalCounter).toBeCalledWith(newFilter); }); }); });
هنا ، يحل أسلوب jest.spyOn
محل التطبيق التالي (وربما بالضبط):
actions.fetchData = jest.fn(actions.fetchData);
هذا يسمح لنا "بمراقبة" الوظيفة وفهم ما إذا كان تم استدعاؤها ومع ما المعلمات.
حصلنا على الخطأ التالي:
Difference: - Expected + Received Array [ Object { - "payload": Object {}, - "type": "TEST_APPLY_FILTER_SUCCESS", + "type": "TEST_FETCH_TOTAL_COUNTER_START", }, + Object { + "error": [TypeError: Cannot read property 'data' of undefined], + "type": "TEST_FETCH_TOTAL_COUNTER_ERROR", + }, + Object { + "error": [TypeError: Cannot read property 'data' of undefined], + "type": "TEST_APPLY_FILTER_ERROR", + }, ]
غريب ، لقد اختبأنا دالة fetchData ، مع fetchData
تنفيذنا
fetchData.mockResolvedValueOnce({ data: { payload } })
لكن الوظيفة تعمل بالضبط كما كان من قبل ، وهذا هو ، وهمية لم تنجح! لنجربها بطريقة مختلفة.
describe('applyFilter', () => { it('should dispatch correct actions on success', () => { const payload = {}; - const counter = 0; const newFilter = {}; const store = mockStore(defaultState); entityModel.fetchData.mockResolvedValueOnce({ data: { payload } }); - entityModel.fetchTotalCounter.mockResolvedValueOnce({ - data: { payload: counter }, - }); + const fetchTotalCounter = jest.spyOn(actions, 'fetchTotalCounter'; + fetchTotalCounter.mockImplementation(() => {}); const expectedActions = [ - // Total counter actions - { type: `${prefix}FETCH_TOTAL_COUNTER_START` }, - { - type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`, - payload: counter, - }, - // apply filter actions { type: `${prefix}APPLY_FILTER_SUCCESS`, payload, } ]; return store.dispatch(actions.applyFilter(newFilter)).then(() => { expect(store.getActions()).toEqual(expectedActions); + expect(actions.fetchTotalCounter).toBeCalledWith(newFilter); }); }); });
نحصل على نفس الخطأ بالضبط. لسبب ما ، موكا لدينا لا تحل محل التنفيذ الأصلي للوظائف.
بعد استكشاف هذه المشكلة بمفردي والعثور على بعض المعلومات على الإنترنت ، أدركت أن هذه المشكلة موجودة ليس فقط معي ، وتم حلها (برأيي) بشكل كبير. علاوة على ذلك ، تعد الأمثلة الموضحة في هذه المصادر جيدة إلى أن تصبح جزءًا من شيء يربطها بنظام واحد (في حالتنا ، هذا المصنع ذو معلمات).
في مشروعنا في خط أنابيب جينكينز ، هناك اختبار رمز من SonarQube ، والذي يتطلب تغطية الملفات المعدلة (التي هي في طلب دمج / سحب) > 60%
. نظرًا لأن تغطية هذا المصنع ، كما قيل سابقًا ، لم تكن مرضية ، والحاجة الماسة لتغطية مثل هذا الملف تسبب الاكتئاب فقط ، يجب القيام بشيء ما ، وإلا فقد يتباطأ تسليم الوظائف الجديدة مع مرور الوقت. تم حفظ تغطية اختبار الملفات الأخرى فقط (المكونات والوظائف) في نفس طلب الدمج / السحب ، من أجل الوصول إلى تغطية٪ للعلامة المرغوبة ، ولكن في الواقع ، كان حل بديل ، وليس حلاً للمشكلة. ولحظة واحدة جيدة ، بعد أن خصصت القليل من الوقت في سباق العدو ، بدأت أفكر في كيفية حل هذه المشكلة.
محاولة لحل المشكلة رقم 1. سمعت شيئًا عن Redux-Saga ...
... وأخبروني أن الاختبار يتم تبسيطه إلى حد كبير عند استخدام هذه الوسيطة.
في الواقع ، إذا نظرت إلى الوثائق ، فأنت مندهش من مدى بساطة اختبار الشفرة. يكمن العصير نفسه في حقيقة أنه مع هذا النهج لا توجد مشكلة على الإطلاق في حقيقة أن بعض الملحمة يمكنها استدعاء ملحمة أخرى - يمكننا أن نتبلل و "نصغي" إلى الوظائف التي توفرها الوسيطة ( put
، take
، إلخ) ، و تحقق من أنه تم استدعاؤها (وتم استدعاؤها بالمعلمات الصحيحة). هذا هو ، في هذه الحالة ، لا تصل الوظيفة إلى وظيفة أخرى مباشرة ، ولكنها تشير إلى وظيفة من المكتبة ، والتي تستدعي بعد ذلك الوظائف / المجموعات المهمة الأخرى.
"لماذا لا تجرب هذه الوسيطة؟" فكرت ، وحصلت على العمل. بدأ تاريخًا تقنيًا في Jira ، أنشأ العديد من المهام فيه (من البحث إلى التنفيذ ووصف بنية هذا النظام برمته) ، وتلقى "الضوء الأخضر" وبدأ في عمل نسخة بسيطة من النظام الحالي بنهج جديد.
في البداية ، سارت الأمور على ما يرام. بناءً على نصيحة أحد المطورين ، كان من الممكن إنشاء ملحمة عالمية لتحميل البيانات ومعالجة الأخطاء في طريقة جديدة. ومع ذلك ، في مرحلة ما كانت هناك مشاكل مع الاختبار (والتي ، بالمناسبة ، لم يتم حلها حتى الآن). اعتقدت أن هذا يمكن أن يدمر جميع الاختبارات المتاحة حاليًا وينتج عنه مجموعة من الأخطاء ، لذلك قررت تأجيل العمل في هذه المهمة إلى أن يكون هناك حل ما للمشكلة ، وتوصلت إلى مهام مثمرة.
لقد مر شهر أو شهرين ، ولم يتم العثور على حل ، وبعد ذلك ناقشنا مع هؤلاء الأشخاص. كقيادة تقدم (غائب) في هذه المهمة ، قرروا التخلي عن تطبيق Redux-Saga في المشروع ، لأنه بحلول ذلك الوقت أصبح مكلفًا للغاية من حيث تكاليف العمالة والعدد المحتمل من الأخطاء. لذلك استقرنا أخيرًا على استخدام Redux Thunk.
محاولة لحل المشكلة رقم 2. وحدات ثانك
يمكنك فرز كل thunk إلى ملفات مختلفة ، وفي تلك الملفات التي يستدعي فيها thunk الآخر (مستورد) ، يمكنك مسح هذا الاستيراد إما باستخدام طريقة jest.mock
أو باستخدام jest.spyOn
نفسه. وبالتالي ، سوف نحقق المهمة المذكورة أعلاه للتحقق من أن بعض thunk الخارجي كان يسمى مع المعلمات اللازمة ، دون الحاجة إلى القلق بشأن moks لذلك. بالإضافة إلى ذلك ، سيكون من الأفضل كسر كل thunk وفقًا لغرضها الوظيفي ، حتى لا يتم الاحتفاظ بها جميعًا في كومة واحدة. لذلك تميزت ثلاثة من هذه الأنواع:
- المتعلقة بالعمل مع القوالب -
templates
. - المتعلقة بالعمل مع عامل التصفية (الفرز ، عرض الأعمدة) -
filter
. - يتعلق بالعمل مع الجدول (تحميل بيانات جديدة عند التمرير ، نظرًا لأن الجدول به تمرير افتراضي ، وتحميل بيانات التعريف ، وتحميل البيانات عن طريق عداد السجلات في الجدول ، إلخ) -
table
.
تم اقتراح بنية المجلد والملف التالية:
src/ |-- store/ | |-- filter/ | | |-- actions/ | | | |-- thunks/ | | | | |-- __tests__/ | | | | | |-- applyFilter.test.js | | | | |-- applyFilter.js | | | |-- actionCreators.js | | | |-- index.js | |-- table/ | | |-- actions/ | | | |-- thunks/ | | | | |-- __tests__/ | | | | | |-- fetchData.test.js | | | | | |-- fetchTotalCounter.test.js | | | | |-- fetchData.js | | | | |-- fetchTotalCounter.js | | | |-- actionCreators.js | | | |-- index.js (main file with actionsCreator)
مثال على هذه العمارة هنا .
في ملف الاختبار الخاص بـ applicationFilter ، يمكنك أن ترى أننا وصلنا إلى الهدف الذي نسعى جاهدين من أجله - لا يمكنك كتابة mokas للحفاظ على التشغيل الصحيح لـ fetchData
/ fetchTotalCounter
. ولكن بأي ثمن ...
import { applyFilterSuccess, applyFilterError } from '../'; import { fetchData } from '../../../table/actions';
import * as filterActions from './filter/actions'; import * as tableActions from './table/actions'; export const actionsCreator = (prefix, getCurrentStore, entityModel) => { return { fetchTotalCounterStart: tableActions.fetchTotalCounterStart(prefix), fetchTotalCounterSuccess: tableActions.fetchTotalCounterSuccess(prefix), fetchTotalCounterError: tableActions.fetchTotalCounterError(prefix), applyFilterSuccess: filterActions.applyFilterSuccess(prefix), applyFilterError: filterActions.applyFilterError(prefix), fetchTotalCounter: tableActions.fetchTotalCounter(prefix, entityModel), fetchData: tableActions.fetchData(prefix, entityModel), applyFilter: filterActions.applyFilter(prefix, getCurrentStore, entityModel) }; };
من أجل نموذجية الاختبارات ، كان علي أن أدفع مع ازدواجية الكود واعتماد قوي للغاية من thunk على بعضها البعض. إن أدنى تغيير في سلسلة الاتصال سيؤدي إلى إعادة بيع مكثفة.
في المثال أعلاه ، تم عرض مثال table
filter
للحفاظ على تناسق الأمثلة المعطاة. في الواقع ، تم بدء إعادة إنشاء المباني باستخدام templates
(كما اتضح أنها أبسط) ، وهناك ، بالإضافة إلى إعادة المعالجة ، تم تغيير بسيط في مفهوم العمل مع القوالب. كافتراض ، تم قبول أنه لا يمكن أن يكون هناك سوى لوحة واحدة من القوالب على صفحة (مثل جدول). في ذلك الوقت كان الأمر كذلك ، وهذا إغفال لقد سمح لنا الافتراض بتبسيط الكود قليلاً عن طريق إزالة prefix
.
بعد صب التغييرات في فرع التطوير الرئيسي واختبارها ، ذهبت في إجازة بروح هادئة من أجل الاستمرار في نقل بقية الكود إلى نهج جديد بعد العودة.
بعد العودة من عطلة ، فوجئت عندما وجدت أن التغييرات التي قمت بها قد تم التراجع عنها. اتضح أن صفحة ظهرت عليها عدة جداول مستقلة ، أي الافتراض الذي تم في وقت سابق كسر كل شيء. لذلك كل العمل تم دون جدوى ...
حسنا ، تقريبا. في الواقع ، سيكون من الممكن إعادة تنفيذ جميع الإجراءات نفسها (لم تختف فائدة طلب الدمج / السحب ، لكنها بقيت في التاريخ) ، مع ترك النهج الخاص بهيكل القالب دون تغيير ، وتغيير النهج الخاص بتنظيم الملفات الصوتية فقط. ولكن هذا النهج لا يزال لا يوحي بالثقة بسبب تماسكه وتعقيده. لم تكن هناك رغبة في العودة إليها ، على الرغم من أن هذا حل المشكلة المشار إليها في الاختبار. كان من الضروري التوصل إلى شيء آخر ، أبسط وأكثر موثوقية.
محاولة لحل المشكلة رقم 3. هو الذي يسعى سيجد
بالنظر إلى كيفية كتابة الاختبارات على المستوى العالمي ، لاحظت مدى سهولة وبدون أي مشاكل (في الواقع ، حقول الكائنات) من entityModel
.
ثم طرحت الفكرة: لماذا لا ننشئ فصلاً تكون منهجياته قوية ومبدعة للحركة؟ سيتم تمرير المعلمات التي تم تمريرها إلى المصنع إلى مُنشئ هذه الفئة وسيتم الوصول إليها من خلال this
. يمكنك على الفور إجراء تحسين بسيط عن طريق إنشاء فصل منفصل لمنشئي الإجراءات وواحد منفصل عن thunk ، ثم يرث أحدهم عن الآخر. وبالتالي ، ستعمل هذه الفئات كواحد (عند إنشاء مثيل لفئة الوريث) ، ولكن في نفس الوقت سيكون كل فصل على حدة أسهل في القراءة والفهم والاختبار.
إليك رمز يوضح هذا النهج.
دعونا نفكر بمزيد من التفاصيل في كل ملف من الملفات التي ظهرت وتغييرت.
export class FilterActionCreators { constructor(config) { this.prefix = config.prefix; } applyFilterSuccess = payload => ({ type: `${this.prefix}APPLY_FILTER_SUCCESS`, payload, }); applyFilterError = error => ({ type: `${this.prefix}APPLY_FILTER_ERROR`, error, }); }
- في ملف
FilterActions.js
، نرث من فئة FilterActionCreators
ونحدد thunk applyFilter
كأسلوب لهذه الفئة. في هذه الحالة ، سيتم تطبيق applyFilterSuccess
و applyFilterError
من خلال this
:
import { FilterActionCreators } from '/FilterActionCreators';
- في الملف الرئيسي مع كل
FilterActions
thunk و action ، نقوم بإنشاء مثيل لفئة FilterActions
، FilterActions
إلى كائن التكوين الضروري. عند تصدير الدالات (في نهاية وظيفة actionsCreator
) ، لا تنسَ أن applyFilter
طريقة applyFilter
لتمرير التبعية fetchData
:
+ import { FilterActions } from './filter/actions/FilterActions'; - // selector - const getFilter = store => store.filter; export const actionsCreator = (prefix, getCurrentStore, entityModel) => { + const config = { prefix, getCurrentStore, entityModel }; + const filterActions = new FilterActions(config); /* --- ACTIONS BLOCK --- */ function fetchTotalCounterStart() { return { type: `${prefix}FETCH_TOTAL_COUNTER_START` }; } function fetchTotalCounterSuccess(payload) { return { type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`, payload }; } function fetchTotalCounterError(error) { return { type: `${prefix}FETCH_TOTAL_COUNTER_ERROR`, error }; } - function applyFilterSuccess(payload) { - return { type: `${prefix}APPLY_FILTER_SUCCESS`, payload }; - } - - function applyFilterError(error) { - return { type: `${prefix}APPLY_FILTER_ERROR`, error }; - } /* --- THUNKS BLOCK --- */ function fetchTotalCounter(filter) { return async dispatch => { dispatch(fetchTotalCounterStart()); try { const { data: { payload } } = await entityModel.fetchTotalCounter(filter); dispatch(fetchTotalCounterSuccess(payload)); } catch (error) { dispatch(fetchTotalCounterError(error)); } }; } function fetchData(filter, dispatch) { dispatch(fetchTotalCounter(filter)); return entityModel.fetchData(filter); } - function applyFilter(newFilter) { - return async (dispatch, getStore) => { - try { - const store = getStore(); - const currentStore = getCurrentStore(store); - // 'getFilter' comes from selectors. - const filter = newFilter || getFilter(currentStore); - const { data: { payload } } = await fetchData(filter, dispatch); - - dispatch(applyFilterSuccess(payload)); - } catch (error) { - dispatch(applyFilterError(error)); - } - }; - } return { fetchTotalCounterStart, fetchTotalCounterSuccess, fetchTotalCounterError, - applyFilterSuccess, - applyFilterError, fetchTotalCounter, fetchData, - applyFilter + ...filterActions, + applyFilter: filterActions.applyFilter({ fetchData }), }; };
import { FilterActions } from '../FilterActions'; describe('FilterActions', () => { const prefix = 'TEST_'; const getCurrentStore = store => store; const entityModel = {}; const config = { prefix, getCurrentStore, entityModel }; const actions = new FilterActions(config); const dispatch = jest.fn(); beforeEach(() => { dispatch.mockClear(); }); describe('applyFilter', () => { const getStore = () => ({}); const newFilter = {}; it('should dispatch correct actions on success', async () => { const payload = {}; const fetchData = jest.fn().mockResolvedValueOnce({ data: { payload } }); const applyFilterSuccess = jest.spyOn(actions, 'applyFilterSuccess'); await actions.applyFilter({ fetchData })(newFilter)(dispatch, getStore); expect(fetchData).toBeCalledWith(newFilter, dispatch); expect(applyFilterSuccess).toBeCalledWith(payload); }); it('should dispatch correct actions on error', async () => { const error = {}; const fetchData = jest.fn().mockRejectedValueOnce(error); const applyFilterError = jest.spyOn(actions, 'applyFilterError'); await actions.applyFilter({ fetchData })(newFilter)(dispatch, getStore); expect(fetchData).toBeCalledWith(newFilter, dispatch); expect(applyFilterError).toBeCalledWith(error); }); }); });
من حيث المبدأ ، في الاختبارات ، يمكنك استبدال الاختيار الأخير بهذه الطريقة:
- expect(applyFilterSuccess).toBeCalledWith(payload); + expect(dispatch).toBeCalledWith(applyFilterSuccess(payload)); - expect(applyFilterError).toBeCalledWith(error); + expect(dispatch).toBeCalledWith(applyFilterError(error));
عندها لن تكون هناك حاجة jest.spyOn
بـ jest.spyOn
. , , . thunk, . , ...
, , , -: , thunk- action creator- , , . , . actionsCreator
- , :
import { FilterActions } from './filter/actions/FilterActions'; import { TemplatesActions } from './templates/actions/TemplatesActions'; import { TableActions } from './table/actions/TableActions'; export const actionsCreator = (prefix, getCurrentStore, entityModel) => { const config = { prefix, getCurrentStore, entityModel }; const filterActions = new FilterActions(config); const templatesActions = new TemplatesActions(config); const tableActions = new TableActions(config); return { ...filterActions, ...templatesActions, ...tableActions, }; };
. filterActions
templatesActions
tableActions
, , , filterActions
? , . . - , , .
. , back-end ( Java), . , Java/Spring , . - ?
:
- thunk-
setDependencies
, — dependencies
:
export class FilterActions extends FilterActionCreators { constructor(config) { super(config); this.getCurrentStore = config.getCurrentStore; this.entityModel = config.entityModel; } + setDependencies = dependencies => { + this.dependencies = dependencies; + };
import { FilterActions } from './filter/actions/FilterActions'; import { TemplatesActions } from './templates/actions/TemplatesActions'; import { TableActions } from './table/actions/TableActions'; export const actionsCreator = (prefix, getCurrentStore, entityModel) => { const config = { prefix, getCurrentStore, entityModel }; const filterActions = new FilterActions(config); const templatesActions = new TemplatesActions(config); const tableActions = new TableActions(config); + const actions = { + ...filterActions, + ...templatesActions, + ...tableActions, + }; + + filterActions.setDependencies(actions); + templatesActions.setDependencies(actions); + tableActions.setDependencies(actions); + return actions; - return { - ...filterActions, - ...templatesActions, - ...tableActions, - }; };
applyFilter = newFilter => { const { fetchData } = this.dependencies; return async (dispatch, getStore) => { try { const store = getStore(); const currentStore = this.getCurrentStore(store); const filter = newFilter || getFilter(currentStore); const { data: { payload } } = await fetchData(filter, dispatch);
, applyFilter
, - this.dependencies
. , .
import { FilterActions } from '../FilterActions'; describe('FilterActions', () => { const prefix = 'TEST_'; const getCurrentStore = store => store; const entityModel = {}; + const dependencies = { + fetchData: jest.fn(), + }; const config = { prefix, getCurrentStore, entityModel }; const actions = new FilterActions(config); + actions.setDependencies(dependencies); const dispatch = jest.fn(); beforeEach(() => { dispatch.mockClear(); }); describe('applyFilter', () => { const getStore = () => ({}); const newFilter = {}; it('should dispatch correct actions on success', async () => { const payload = {}; - const fetchData = jest.fn().mockResolvedValueOnce({ data: { payload } }); + dependencies.fetchData.mockResolvedValueOnce({ data: { payload } }); const applyFilterSuccess = jest.spyOn(actions, 'applyFilterSuccess'); - await actions.applyFilter({ fetchData })(newFilter)(dispatch, getStore); + await actions.applyFilter(newFilter)(dispatch, getStore); - expect(fetchData).toBeCalledWith(newFilter, dispatch); + expect(dependencies.fetchData).toBeCalledWith(newFilter, dispatch); expect(applyFilterSuccess).toBeCalledWith(payload); }); it('should dispatch correct actions on error', async () => { const error = {}; - const fetchData = jest.fn().mockRejectedValueOnce(error); + dependencies.fetchData.mockRejectedValueOnce(error); const applyFilterError = jest.spyOn(actions, 'applyFilterError'); - await actions.applyFilter({ fetchData })(newFilter)(dispatch, getStore); + await actions.applyFilter(newFilter)(dispatch, getStore); - expect(fetchData).toBeCalledWith(newFilter, dispatch); + expect(dependencies.fetchData).toBeCalledWith(newFilter, dispatch); expect(applyFilterError).toBeCalledWith(error); }); }); });
.
, , :
import { FilterActions } from './filter/actions/FilterActions'; import { TemplatesActions } from './templates/actions/TemplatesActions'; import { TableActions } from './table/actions/TableActions'; - export const actionsCreator = (prefix, getCurrentStore, entityModel) => { + export const actionsCreator = (prefix, getCurrentStore, entityModel, ExtendedActions) => { const config = { prefix, getCurrentStore, entityModel }; const filterActions = new FilterActions(config); const templatesActions = new TemplatesActions(config); const tableActions = new TableActions(config); + const extendedActions = ExtendedActions ? new ExtendedActions(config) : undefined; const actions = { ...filterActions, ...templatesActions, ...tableActions, + ...extendedActions, }; filterActions.setDependencies(actions); templatesActions.setDependencies(actions); tableActions.setDependencies(actions); + if (extendedActions) { + extendedActions.setDependencies(actions); + } return actions; };
export class ExtendedActions { constructor(config) { this.getCurrentStore = config.getCurrentStore; this.entityModel = config.entityModel; } setDependencies = dependencies => { this.dependencies = dependencies; };
, , :
- , .
- .
- , , thunk- .
- , , thunk-/action creator- 99-100%.
action creator- ( filter
, templates
, table
), reducer- - , , actionsCreator
- , reducer- ~400-500 .
:
import isNull from 'lodash/isNull'; import { getDefaultState } from '../getDefaultState'; import { templatesReducerConfigurator } from 'src/store/templates/reducers/templatesReducerConfigurator'; import { filterReducerConfigurator } from 'src/store/filter/reducers/filterReducerConfigurator'; import { tableReducerConfigurator } from 'src/store/table/reducers/tableReducerConfigurator'; export const createTableReducer = ( prefix, initialState = getDefaultState(), entityModel, ) => { const config = { prefix, initialState, entityModel }; const templatesReducer = templatesReducerConfigurator(config); const filterReducer = filterReducerConfigurator(config); const tableReducer = tableReducerConfigurator(config); return (state = initialState, action) => { const templatesState = templatesReducer(state, action); if (!isNull(templatesState)) { return templatesState; } const filterState = filterReducer(state, action); if (!isNull(filterState)) { return filterState; } const tableState = tableReducer(state, action); if (!isNull(tableState)) { return tableState; } return state; }; };
tableReducerConfigurator
( ):
export const tableReducerConfigurator = ({ prefix, entityModel }) => { return (state, action) => { switch (action.type) { case `${prefix}FETCH_TOTAL_COUNTER_START`: { return { ...state, isLoading: true, error: null, }; } case `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`: { return { ...state, isLoading: false, counter: action.payload, }; } case `${prefix}FETCH_TOTAL_COUNTER_ERROR`: { return { ...state, isLoading: false, error: action.error, }; } default: { return null; } } }; };
:
reducerConfigurator
- action type-, «». action type case, null ().reducerConfigurator
- , null , reducerConfigurator
- !null . , reducerConfigurator
- case, reducerConfigurator
-.- ,
reducerConfigurator
- case- action type-, ( reducer-).
, actionsCreator
-, , , , .
, !
, Redux Thunk.
, Redux Thunk . , .