Deskripsi pendekatan untuk mengatur dan menguji kode menggunakan Redux Thunk

Halo semuanya!


Dalam posting ini, saya ingin berbagi pendekatan saya untuk mengatur dan menguji kode menggunakan Redux Thunk dalam proyek React .


Jalan menuju ke sana panjang dan berduri, jadi saya akan mencoba menunjukkan cara berpikir dan motivasi yang mengarah pada keputusan akhir.


Deskripsi aplikasi dan pernyataan masalah


Pertama, sedikit konteks.


Gambar di bawah ini menunjukkan tata letak halaman khas dalam proyek kami.



Dalam rangka:


  • Tabel (No. 1) berisi data yang bisa sangat berbeda (teks biasa, tautan, gambar, dll.).
  • Panel sortir (No. 2) menetapkan pengaturan penyortiran data dalam tabel berdasarkan kolom.
  • Panel filtering (No. 3) mengatur berbagai filter sesuai dengan kolom tabel.
  • Panel kolom (No. 4) memungkinkan Anda untuk mengatur tampilan kolom tabel (tampilkan / sembunyikan).
  • Panel template (No. 5) memungkinkan Anda memilih template pengaturan yang dibuat sebelumnya. Template meliputi data dari panel No. 2, No. 3, No. 4, serta beberapa data lainnya, misalnya, posisi kolom, ukurannya, dll.

Panel dibuka dengan mengklik tombol yang sesuai.


Data tentang kolom apa dalam tabel dapat secara umum, data apa yang ada di dalamnya, bagaimana seharusnya ditampilkan, nilai apa yang dapat disaring oleh filter, dan informasi lain terkandung dalam meta-data dari tabel, yang diminta secara terpisah dari data itu sendiri di awal pemuatan halaman.


Ternyata kondisi tabel saat ini dan data di dalamnya tergantung pada tiga faktor:


  • Data dari meta data dari tabel.
  • Pengaturan untuk template yang saat ini dipilih.
  • Pengaturan pengguna (perubahan apa pun mengenai templat yang dipilih disimpan dalam semacam "konsep", yang dapat diubah menjadi templat baru, atau perbarui yang saat ini dengan pengaturan baru, atau hapus mereka dan kembalikan templat ke kondisi semula).

Seperti disebutkan di atas, halaman seperti itu adalah khas. Untuk setiap halaman tersebut (dan lebih tepatnya, untuk tabel di dalamnya), entitas terpisah dibuat dalam repositori Redux untuk kenyamanan pengoperasian dengan data dan parameternya.


Agar dapat menetapkan set pencipta dan tindakan yang homogen dan memperbarui data pada entitas tertentu, pendekatan berikut digunakan (semacam pabrik):


export const actionsCreator = (prefix, getCurrentStore, entityModel) => { /* --- 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, }; }; 

Dimana:


  • prefix - prefix entitas dalam repositori Redux. Ini adalah string dari bentuk "CATS_", "MICE_", dll.
  • getCurrentStore - pemilih yang mengembalikan data saat ini pada entitas dari repositori Redux.
  • entityModel - Sebuah instance dari kelas model entitas. Di satu sisi, api diakses melalui model untuk membuat permintaan ke server, di sisi lain, beberapa logika pemrosesan data yang kompleks (atau tidak begitu) dijelaskan.

Dengan demikian, pabrik ini memungkinkan Anda untuk secara fleksibel menggambarkan pengelolaan data dan parameter entitas tertentu dalam repositori Redux dan mengaitkannya dengan tabel yang sesuai dengan entitas ini.


Karena ada banyak nuansa dalam pengelolaan sistem ini, thunk bisa menjadi rumit, banyak, membingungkan dan memiliki bagian berulang. Untuk menyederhanakan mereka, serta untuk menggunakan kembali kode, thunks kompleks dipecah menjadi yang lebih sederhana dan digabungkan menjadi komposisi. Sebagai akibatnya, sekarang mungkin salah satu pemanggil memanggil yang lain, yang sudah dapat mengirimkan applyFilter biasa (seperti bundle fetchTotalCounter - fetchTotalCounter dari contoh di atas). Dan ketika semua poin utama diperhitungkan, dan semua pencetus tindakan dan tindakan yang diperlukan dijelaskan, file yang berisi fungsi actionsCreator memiliki ~ 1200 baris kode dan diuji dengan mencicit hebat. File tes juga memiliki sekitar 1.200 baris, tetapi cakupan terbaik 40-50%.


Di sini, contohnya, tentu saja, sangat disederhanakan, baik dalam hal jumlah thunk dan logika internal mereka, tetapi ini akan cukup untuk menunjukkan masalahnya.


Perhatikan 2 jenis thunk pada contoh di atas:


  • fetchTotalCounter - tindakan pengiriman saja.
  • applyFilter - di samping pengiriman applyFilter miliknya ( applyFilterSuccess , applyFilterError ), pengiriman-itu juga merupakan fetchTotalCounter lain ( fetchTotalCounter ).
    Kami akan kembali lagi nanti.

Semua ini diuji sebagai berikut (kerangka kerja digunakan untuk menguji 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 = [ // fetchTotalCounter actions { type: `${prefix}FETCH_TOTAL_COUNTER_START` }, { type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`, payload: counter, }, // applyFilter actions { type: `${prefix}APPLY_FILTER_SUCCESS`, payload, } ]; return store.dispatch(actions.applyFilter(newFilter)).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); it('should dispatch correct actions on error', () => { const error = {}; const counter = 0; const newFilter = {}; const store = mockStore(defaultState); entityModel.fetchData.mockRejectedValueOnce(error); entityModel.fetchTotalCounter.mockResolvedValueOnce({ data: { payload: counter }, }); const expectedActions = [ // fetchTotalCounter actions { type: `${prefix}FETCH_TOTAL_COUNTER_START` }, { type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`, payload: counter, }, // applyFilter actions { type: `${prefix}APPLY_FILTER_ERROR`, error, } ]; return store.dispatch(actions.applyFilter(newFilter)).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); }); }); 

Seperti yang Anda lihat, tidak ada masalah dengan pengujian tipe pertama thunk - Anda hanya perlu mengaitkan entityModel model entityModel, tetapi tipe kedua lebih rumit - Anda harus menghapus data untuk seluruh rantai yang disebut thunk dan metode model yang sesuai. Kalau tidak, tes akan jatuh pada penghancuran data ( {data: {payload}} ), dan ini dapat terjadi baik secara eksplisit atau implisit (itu sedemikian rupa sehingga tes berhasil lulus, tetapi dengan penelitian yang cermat diketahui bahwa pada detik / ketiga Link rantai ini ada penurunan dalam tes karena kurangnya data yang terkunci). Juga buruk bahwa unit test fungsi individu berubah menjadi semacam integrasi, dan menjadi terkait erat.


Timbul pertanyaan: mengapa di fungsi applyFilter memeriksa bagaimana fungsi fetchTotalCounter jika tes rinci terpisah telah ditulis untuk itu? Bagaimana saya bisa membuat pengujian tipe kedua thunk lebih mandiri? Akan sangat bagus untuk mendapatkan kesempatan untuk menguji bahwa thunk (dalam hal ini fetchTotalCounter ) baru saja dipanggil dengan parameter yang tepat , dan tidak perlu mengurus maw agar dapat bekerja dengan benar.


Tetapi bagaimana cara melakukannya? Keputusan yang jelas terlintas dalam pikiran: untuk mengaitkan fungsi fetchData, yang disebut dalam applyFilter , atau untuk mengunci fetchTotalCounter (karena sering kali thunk lain dipanggil langsung, dan tidak melalui beberapa fungsi lain seperti fetchData ).


Ayo kita coba. Misalnya, kami hanya akan mengubah skrip yang berhasil.



 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); }); }); }); 

Di sini, metode jest.spyOn menggantikan kira-kira (dan mungkin persis) implementasi berikut:


 actions.fetchData = jest.fn(actions.fetchData); 

Ini memungkinkan kita untuk "memantau" fungsi dan memahami apakah itu dipanggil dan dengan parameter apa.


Kami mendapatkan kesalahan berikut:


 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", + }, ] 

Aneh, kami agak menyembunyikan fungsi fetchData, fetchData implementasi kami


 fetchData.mockResolvedValueOnce({ data: { payload } }) 

tetapi fungsinya sama persis seperti sebelumnya, yaitu, mock tidak berfungsi! Mari kita coba secara berbeda.



 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); }); }); }); 

Kami mendapatkan kesalahan yang persis sama. Untuk beberapa alasan, moka kami tidak menggantikan implementasi fungsi yang asli.


Setelah menjelajahi masalah ini sendiri dan menemukan beberapa informasi di Internet, saya menyadari bahwa masalah ini tidak hanya ada pada diri saya, dan masalah ini diselesaikan (menurut saya) dengan sangat buruk. Selain itu, contoh-contoh yang dijelaskan dalam sumber-sumber ini baik sampai mereka menjadi bagian dari sesuatu yang menghubungkan mereka ke dalam satu sistem tunggal (dalam kasus kami, ini adalah pabrik dengan parameter).


Pada proyek kami di pipeline Jenkins ada pemeriksaan kode dari SonarQube, yang membutuhkan mencakup file yang dimodifikasi (yang ada dalam permintaan gabungan / tarik) > 60% . Karena cakupan pabrik ini, seperti yang dikatakan sebelumnya, tidak memuaskan, dan kebutuhan untuk menutupi file semacam itu hanya menyebabkan depresi, sesuatu harus dilakukan dengannya, jika tidak, pengiriman fungsionalitas baru dapat melambat seiring berjalannya waktu. Hanya cakupan uji file lain (komponen, fungsi) dalam permintaan gabungan / tarik yang sama yang disimpan, untuk mencapai cakupan% ke tanda yang diinginkan, tetapi, pada kenyataannya, itu adalah solusi, bukan solusi untuk masalah tersebut. Dan suatu saat yang baik, setelah mengalokasikan sedikit waktu dalam sprint, saya mulai berpikir bagaimana masalah ini dapat diselesaikan.


Upaya untuk memecahkan masalah nomor 1. Saya mendengar sesuatu tentang Redux-Saga ...


... dan mereka mengatakan kepada saya bahwa pengujian sangat disederhanakan ketika menggunakan middleware ini.


Memang, jika Anda melihat dokumentasi , Anda akan terkejut betapa mudahnya kode diuji. Jus itu sendiri terletak pada kenyataan bahwa dengan pendekatan ini tidak ada masalah sama sekali dengan fakta bahwa beberapa saga dapat memanggil saga lain - kita bisa basah dan "mendengarkan" fungsi yang disediakan oleh middleware ( put , take , dll.), Dan verifikasi bahwa mereka dipanggil (dan dipanggil dengan parameter yang benar). Artinya, dalam hal ini, fungsi tersebut tidak mengakses fungsi lain secara langsung, tetapi merujuk ke fungsi dari perpustakaan, yang kemudian memanggil fungsi / sagas yang diperlukan lainnya.


"Kenapa tidak mencoba middleware ini?" Saya berpikir, dan mulai bekerja. Dia memulai sejarah teknis di Jira, menciptakan beberapa tugas di dalamnya (dari penelitian hingga implementasi dan deskripsi arsitektur seluruh sistem ini), menerima "lampu hijau" dan mulai membuat salinan minimal dari sistem saat ini dengan pendekatan baru.


Pada awalnya, semuanya berjalan dengan baik. Atas saran salah satu pengembang, bahkan dimungkinkan untuk membuat hikayat global untuk memuat data dan penanganan kesalahan pada pendekatan baru. Namun, pada beberapa titik ada masalah dengan pengujian (yang, kebetulan, belum terselesaikan sejauh ini). Saya pikir ini dapat menghancurkan semua tes yang tersedia saat ini dan menghasilkan banyak bug, jadi saya memutuskan untuk menunda pekerjaan pada tugas ini sampai ada beberapa solusi untuk masalah ini, dan mulai mengerjakan tugas produk.


Satu atau dua bulan berlalu, tidak ada solusi yang ditemukan, dan pada titik tertentu, setelah berdiskusi dengan mereka. memimpin (tidak ada) kemajuan dalam tugas ini, mereka memutuskan untuk meninggalkan implementasi Redux-Saga dalam proyek, karena pada saat itu sudah terlalu mahal dalam hal biaya tenaga kerja dan kemungkinan jumlah bug. Jadi kami akhirnya memutuskan untuk menggunakan Redux Thunk.


Upaya untuk memecahkan masalah nomor 2. Modul thunk


Anda dapat mengurutkan semua thunk ke dalam file yang berbeda, dan dalam file-file di mana satu thunk memanggil yang lain (diimpor), Anda dapat menghapus import ini baik menggunakan metode jest.mock atau menggunakan jest.spyOn sama. Dengan demikian, kita akan mencapai tugas di atas untuk memverifikasi bahwa beberapa pukulan eksternal dipanggil dengan parameter yang diperlukan, tanpa khawatir tentang masalah itu. Selain itu, akan lebih baik untuk memecahkan semua pukulan sesuai dengan tujuan fungsionalnya, agar tidak menyimpan semuanya dalam satu tumpukan. Jadi tiga spesies tersebut dibedakan:


  • Terkait dengan bekerja dengan templat - templates .
  • Terkait dengan bekerja dengan filter (menyortir, menampilkan kolom) - filter .
  • Terkait dengan bekerja dengan tabel (memuat data baru saat menggulir, karena tabel memiliki gulir virtual, memuat meta-data, memuat data dengan penghitung catatan dalam tabel, dll.) - table .

Folder dan struktur file berikut ini diusulkan:


 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) 

Contoh arsitektur ini ada di sini .


Dalam file uji untuk applyFilter, Anda dapat melihat bahwa kami telah mencapai tujuan yang kami perjuangkan - Anda tidak dapat menulis mokas untuk mempertahankan operasi fetchData / fetchTotalCounter . Tetapi berapa biayanya ...



 import { applyFilterSuccess, applyFilterError } from '../'; import { fetchData } from '../../../table/actions'; // selector const getFilter = store => store.filter; export function applyFilter(prefix, getCurrentStore, entityModel) { return newFilter => { return async (dispatch, getStore) => { try { const store = getStore(); const currentStore = getCurrentStore(store); const filter = newFilter || getFilter(currentStore); const { data: { payload } } = await fetchData(prefix, entityModel)(filter, dispatch); dispatch(applyFilterSuccess(prefix)(payload)); } catch (error) { dispatch(applyFilterError(prefix)(error)); } }; }; } 


 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) }; }; 

Kami harus membayar modularitas tes dengan duplikasi kode dan ketergantungan yang sangat kuat pada satu sama lain. Perubahan sekecil apa pun dalam rantai panggilan akan menyebabkan refactoring berat.


Pada contoh di atas, contoh untuk table dan filter diperlihatkan untuk menjaga konsistensi dari contoh yang diberikan. Bahkan, refactoring dimulai dengan templates (ternyata lebih sederhana), dan di sana, selain refactoring di atas, konsep bekerja dengan templat sedikit berubah. Sebagai asumsi, dapat diterima bahwa hanya ada satu panel template pada halaman (seperti tabel). Pada saat itu hanya itu, dan ini kelalaian asumsi memungkinkan kita untuk menyederhanakan kode sedikit dengan menghilangkan prefix .
Setelah perubahan dituangkan ke cabang pengembangan utama dan diuji, saya pergi berlibur dengan jiwa yang tenang untuk melanjutkan mentransfer sisa kode ke pendekatan baru setelah kembali.


Setelah kembali dari liburan, saya terkejut menemukan bahwa perubahan saya dibatalkan. Ternyata sebuah halaman muncul di mana mungkin ada beberapa tabel independen, yaitu asumsi yang dibuat sebelumnya merusak segalanya. Jadi semua pekerjaan dilakukan dengan sia-sia ...


Yah, hampir. Bahkan, akan mungkin untuk melakukan kembali semua tindakan yang sama (manfaat permintaan gabungan / tarik tidak hilang di mana pun, tetapi tetap dalam sejarah), membiarkan pendekatan ke arsitektur template tidak berubah, dan mengubah hanya pendekatan untuk mengatur thunk-s. Tetapi pendekatan ini masih tidak menginspirasi kepercayaan karena koherensi dan kerumitannya. Tidak ada keinginan untuk kembali ke sana, meskipun ini memecahkan masalah yang ditunjukkan dengan pengujian. Itu perlu untuk menghasilkan sesuatu yang lain, lebih sederhana dan lebih dapat diandalkan.


Upaya untuk memecahkan masalah nomor 3. Dia yang mencari akan menemukan


Melihat secara global bagaimana tes ditulis untuk thunk, saya perhatikan betapa mudah dan tanpa masalah metode (pada kenyataannya, bidang objek) dari entityModel .


Kemudian muncul ide: mengapa tidak membuat kelas yang metodenya adalah pencipta aksi dan pukulan? Parameter yang diteruskan ke pabrik akan diteruskan ke konstruktor kelas ini dan akan dapat diakses melalui this . Anda dapat segera membuat optimasi kecil dengan membuat kelas terpisah untuk pembuat tindakan dan yang terpisah untuk pencuri, lalu mewarisi satu dari yang lain. Dengan demikian, kelas-kelas ini akan berfungsi sebagai satu (saat membuat instance dari kelas pewaris), tetapi pada saat yang sama setiap kelas secara individual akan lebih mudah untuk dibaca, dipahami, dan diuji.


Berikut adalah kode yang menunjukkan pendekatan ini.


Mari kita pertimbangkan secara lebih rinci setiap file yang muncul dan diubah.



 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, }); } 

  • Dalam file FilterActions.js , FilterActions.js mewarisi dari kelas FilterActionCreators dan mendefinisikan thunk applyFilter sebagai metode kelas ini. Dalam hal ini, applyFilterSuccess tindakan applyFilterSuccess dan applyFilterError akan tersedia di dalamnya melalui this :

 import { FilterActionCreators } from '/FilterActionCreators'; // selector const getFilter = store => store.filter; export class FilterActions extends FilterActionCreators { constructor(config) { super(config); this.getCurrentStore = config.getCurrentStore; this.entityModel = config.entityModel; } applyFilter = ({ fetchData }) => { return newFilter => { 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); // Comes from FilterActionCreators dispatch(this.applyFilterSuccess(payload)); } catch (error) { // Comes from FilterActionCreators dispatch(this.applyFilterError(error)); } }; }; }; } 

  • Di file utama dengan semua FilterActions aksi dan FilterActions , kami membuat instance dari kelas FilterActions , meneruskannya objek konfigurasi yang diperlukan. Saat mengekspor fungsi (di akhir fungsi actionsCreator ), jangan lupa untuk mengganti metode applyFilter untuk meneruskan ketergantungan fetchData ke 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 }), }; }; 

  • Tes menjadi sedikit lebih mudah baik dalam implementasi maupun dalam membaca:

 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); }); }); }); 

Pada prinsipnya, dalam tes, Anda dapat mengganti cek terakhir dengan cara ini:


 - expect(applyFilterSuccess).toBeCalledWith(payload); + expect(dispatch).toBeCalledWith(applyFilterSuccess(payload)); - expect(applyFilterError).toBeCalledWith(error); + expect(dispatch).toBeCalledWith(applyFilterError(error)); 

Maka tidak perlu mengoleskan mereka dengan 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, - }; }; 

  • this.dependencies :

 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); // Comes from FilterActionCreators dispatch(this.applyFilterSuccess(payload)); } catch (error) { // Comes from FilterActionCreators dispatch(this.applyFilterError(error)); } }; }; 

, 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; }; 

  • ExtendedActions , :

 export class ExtendedActions { constructor(config) { this.getCurrentStore = config.getCurrentStore; this.entityModel = config.entityModel; } setDependencies = dependencies => { this.dependencies = dependencies; }; // methods to re-define } 

, , :


  • , .
  • .
  • , , thunk- .
  • , , thunk-/action creator- 99-100%.


action creator- ( filter , templates , table ), reducer- - , , actionsCreator - , reducer- ~400-500 .


:


  • reducer-:

 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; } } }; }; 

:


  1. reducerConfigurator - action type-, ยซยป. action type case, null ().
  2. reducerConfigurator - , null , reducerConfigurator - !null . , reducerConfigurator - case, reducerConfigurator -.
  3. , reducerConfigurator - case- action type-, ( reducer-).

, actionsCreator -, , , , .


, !
, Redux Thunk.


, Redux Thunk . , .

Source: https://habr.com/ru/post/id469371/


All Articles