Beschreibung des Ansatzes zum Organisieren und Testen von Code mit Redux Thunk

Hallo allerseits!


In diesem Beitrag möchte ich meinen Ansatz zum Organisieren und Testen von Code mit Redux Thunk in einem React- Projekt erläutern .


Der Weg dorthin war lang und dornig, daher werde ich versuchen, den Gedankengang und die Motivation zu demonstrieren, die zur endgültigen Entscheidung geführt haben.


Beschreibung der Anwendung und Problemstellung


Zunächst ein kleiner Kontext.


Die folgende Abbildung zeigt das Layout einer typischen Seite in unserem Projekt.



In der Reihenfolge:


  • Die Tabelle (Nr. 1) enthält Daten, die sehr unterschiedlich sein können (Klartext, Links, Bilder usw.).
  • Das Sortierfeld (Nr. 2) legt die Datensortierungseinstellungen in der Tabelle nach Spalten fest.
  • Das Filterfeld (Nr. 3) stellt verschiedene Filter gemäß den Spalten der Tabelle ein.
  • Über das Spaltenfenster (Nr. 4) können Sie die Anzeige von Tabellenspalten einstellen (ein- / ausblenden).
  • Im Vorlagenfenster (Nr. 5) können Sie zuvor erstellte Einstellungsvorlagen auswählen. Vorlagen enthalten Daten aus den Bedienfeldern Nr. 2, Nr. 3, Nr. 4 sowie einige andere Daten, z. B. die Position der Spalten, ihre Größe usw.

Die Panels werden durch Klicken auf die entsprechenden Schaltflächen geöffnet.


Daten darüber, welche Spalten in einer Tabelle im Allgemeinen enthalten sein können, welche Daten darin enthalten sein können, wie sie angezeigt werden sollen, welche Werte Filter enthalten können und andere Informationen sind in den Metadaten der Tabelle enthalten, die zu Beginn des Seitenladens getrennt von den Daten selbst angefordert werden.


Es stellt sich heraus, dass der aktuelle Status der Tabelle und der darin enthaltenen Daten von drei Faktoren abhängt:


  • Daten aus den Metadaten der Tabelle.
  • Einstellungen für die aktuell ausgewählte Vorlage.
  • Benutzereinstellungen (alle Änderungen an der ausgewählten Vorlage werden in einer Art „Entwurf“ gespeichert, der entweder in eine neue Vorlage umgewandelt oder die aktuelle mit neuen Einstellungen aktualisiert oder gelöscht und die Vorlage in den ursprünglichen Zustand zurückversetzt werden kann).

Wie oben erwähnt, ist eine solche Seite typisch. Für jede dieser Seiten (oder genauer für die darin enthaltene Tabelle) wird im Redux-Repository eine separate Entität erstellt, um die Arbeit mit ihren Daten und Parametern zu vereinfachen.


Um homogene Gruppen von Thunk- und Action-Erstellern festlegen und Daten für eine bestimmte Entität aktualisieren zu können, wird der folgende Ansatz verwendet (eine Art Factory):


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

Wo:


  • prefix - Entitätspräfix im Redux-Repository. Es ist eine Zeichenfolge der Form "CATS_", "MICE_" usw.
  • getCurrentStore - Ein Selektor, der die aktuellen Daten der Entität aus dem Redux-Repository zurückgibt.
  • entityModel - Eine Instanz der Entitätsmodellklasse. Einerseits wird über das Modell auf eine API zugegriffen, um eine Anforderung an den Server zu erstellen, andererseits wird eine komplexe (oder nicht so) Datenverarbeitungslogik beschrieben.

Auf diese Weise können Sie die Verwaltung von Daten und Parametern einer bestimmten Entität im Redux-Repository flexibel beschreiben und diese der dieser Entität entsprechenden Tabelle zuordnen.


Da das Management dieses Systems viele Nuancen aufweist, kann Thunk komplex, umfangreich, verwirrend sein und sich wiederholende Teile aufweisen. Um sie zu vereinfachen und den Code wiederzuverwenden, werden komplexe Thunks in einfachere unterteilt und zu einer Komposition kombiniert. Infolgedessen kann sich nun herausstellen, dass ein Thunk einen anderen aufruft, der bereits normale applyFilter kann (wie das applyFilter Bundle - fetchTotalCounter aus dem obigen Beispiel). Und wenn alle Hauptpunkte berücksichtigt und alle erforderlichen Thunk- und Action-Ersteller beschrieben wurden, hatte die Datei mit der actionsCreator Funktion ~ 1200 Codezeilen und wurde mit großem Quietschen getestet. Die Testdatei hatte auch ungefähr 1200 Zeilen, aber die Abdeckung betrug bestenfalls 40-50%.


Hier ist das Beispiel natürlich stark vereinfacht, sowohl in Bezug auf die Anzahl der Thunk als auch in Bezug auf ihre interne Logik, aber dies wird ausreichen, um das Problem zu demonstrieren.


Achten Sie im obigen Beispiel auf zwei Arten von Thunk:


  • fetchTotalCounter - fetchTotalCounter Versand- fetchTotalCounter .
  • applyFilter - Neben dem Versand seiner applyFilterSuccess ( applyFilterSuccess , applyFilterError ) wird auch ein weiterer Thunk ( fetchTotalCounter ) fetchTotalCounter .
    Wir werden etwas später darauf zurückkommen.

All dies wurde wie folgt getestet (das Framework wurde zum Testen von Jest verwendet ):


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

Wie Sie sehen, gibt es keine Probleme beim Testen des ersten Thunk-Typs - Sie müssen nur die entityModel-Modellmethode einbinden, der zweite Typ ist jedoch komplizierter - Sie müssen die Daten für die gesamte Kette des aufgerufenen Thunk und die entsprechenden Modellmethoden löschen. Andernfalls wird der Test auf die Störung der Daten ( {data: {payload}} ) fallen, und dies kann entweder explizit oder implizit geschehen (es war so, dass der Test erfolgreich bestanden wurde, aber bei sorgfältiger Recherche wurde festgestellt, dass im zweiten / dritten Glied dieser Kette gab es einen Rückgang im Test aufgrund des Fehlens gesperrter Daten). Es ist auch schlecht, dass Unit-Tests einzelner Funktionen zu einer Art Integration werden und eng miteinander verbunden werden.


Es stellt sich die Frage: Warum überprüfen Sie in der Funktion applyFilter , wie sich die Funktion fetchTotalCounter wenn bereits separate detaillierte Tests dafür geschrieben wurden? Wie kann ich das Testen des zweiten Thunk-Typs unabhängiger machen? Es wäre großartig, die Gelegenheit zu bekommen, zu testen, dass Thunk (in diesem Fall fetchTotalCounter ) nur mit den richtigen Parametern aufgerufen wird , und es wäre nicht erforderlich, sich um die Moks zu kümmern, damit es richtig funktioniert.


Aber wie geht das? Die offensichtliche Entscheidung fällt mir ein: die in applyFilter aufgerufene Funktion fetchData zu applyFilter oder den fetchTotalCounter zu sperren (da häufig ein anderer Thunk direkt aufgerufen wird und nicht über eine andere Funktion wie fetchData ).


Lass es uns versuchen. Zum Beispiel werden wir nur ein erfolgreiches Skript ändern.



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

Hier ersetzt die Methode jest.spyOn ungefähr (und möglicherweise genau) die folgende Implementierung:


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

Dies ermöglicht es uns, die Funktion zu "überwachen" und zu verstehen, ob und mit welchen Parametern sie aufgerufen wurde.


Wir erhalten folgenden Fehler:


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

Seltsamerweise haben wir die Funktion fetchData versteckt und unsere Implementierung eingeschlossen


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

aber die Funktion funktioniert genauso wie zuvor, das heißt, der Mock hat nicht funktioniert! Lass es uns anders versuchen.



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

Wir bekommen genau den gleichen Fehler. Aus irgendeinem Grund ersetzen unsere Mokas nicht die ursprüngliche Implementierung von Funktionen.


Nachdem ich dieses Problem selbst untersucht und einige Informationen im Internet gefunden hatte, stellte ich fest, dass dieses Problem nicht nur bei mir besteht und (meiner Meinung nach) ziemlich kritisch gelöst ist. Darüber hinaus sind die in diesen Quellen beschriebenen Beispiele gut, bis sie Teil von etwas werden, das sie zu einem einzigen System verbindet (in unserem Fall handelt es sich um eine Fabrik mit Parametern).


Bei unserem Projekt in Jenkins Pipline gibt es eine Codeprüfung von SonarQube, bei der geänderte Dateien (die sich in der Merge / Pull-Anforderung befinden) > 60% abgedeckt werden müssen. Da die Abdeckung dieser Fabrik, wie bereits erwähnt, unbefriedigend war und die Notwendigkeit, eine solche Datei abzudecken, nur zu Depressionen führte, musste etwas damit unternommen werden, da sich sonst die Bereitstellung neuer Funktionen mit der Zeit verlangsamen könnte. Es wurde nur die Testabdeckung anderer Dateien (Komponenten, Funktionen) in derselben Zusammenführungs- / Pull-Anforderung gespeichert, um die prozentuale Abdeckung bis zur gewünschten Marke zu erreichen. Tatsächlich war dies jedoch eine Problemumgehung und keine Lösung für das Problem. Und einen schönen Moment, nachdem ich ein wenig Zeit im Sprint eingeplant hatte, begann ich zu überlegen, wie dieses Problem gelöst werden kann.


Ein Versuch, das Problem Nummer 1 zu lösen. Ich habe etwas über Redux-Saga gehört ...


... und sie sagten mir, dass das Testen bei Verwendung dieser Middleware erheblich vereinfacht wird.


Wenn Sie sich die Dokumentation ansehen , werden Sie überrascht sein, wie einfach der Code getestet wird. Der Saft selbst liegt in der Tatsache, dass es bei diesem Ansatz überhaupt kein Problem gibt, dass eine Saga eine andere Saga aufrufen kann - wir können nass werden und die Funktionen der Middleware ( put , take usw.) „hören“ und Stellen Sie sicher, dass sie aufgerufen wurden (und mit den richtigen Parametern aufgerufen wurden). Das heißt, in diesem Fall greift die Funktion nicht direkt auf eine andere Funktion zu, sondern verweist auf eine Funktion aus der Bibliothek, die dann andere erforderliche Funktionen / Sagen aufruft.


"Warum nicht diese Middleware ausprobieren?" Dachte ich und machte mich an die Arbeit. Er begann eine technische Geschichte in Jira, erstellte darin mehrere Aufgaben (von der Recherche bis zur Implementierung und Beschreibung der Architektur dieses gesamten Systems), erhielt die Genehmigung und begann, eine minimale Kopie des aktuellen Systems mit einem neuen Ansatz zu erstellen.


Am Anfang lief alles gut. Auf Anraten eines Entwicklers war es sogar möglich, eine globale Saga zum Laden von Daten und zur Fehlerbehandlung nach einem neuen Ansatz zu erstellen. Irgendwann gab es jedoch Probleme beim Testen (die übrigens bisher noch nicht gelöst wurden). Ich dachte, dass dies alle derzeit verfügbaren Tests zerstören und eine Reihe von Fehlern hervorrufen könnte. Deshalb habe ich beschlossen, die Arbeit an dieser Aufgabe zu verschieben, bis eine Lösung für das Problem gefunden ist, und mich den Produktaufgaben zuzuwenden.


Ein oder zwei Monate vergingen, es wurde keine Lösung gefunden, und irgendwann wurde mit ihnen diskutiert. Sie führten (fehlende) Fortschritte bei dieser Aufgabe und beschlossen, die Implementierung von Redux-Saga im Projekt abzubrechen, da es zu diesem Zeitpunkt im Hinblick auf die Arbeitskosten und die mögliche Anzahl von Fehlern zu teuer geworden war. Also haben wir uns endlich für Redux Thunk entschieden.


Ein Versuch, das Problem Nummer 2 zu lösen. Thunk-Module


Sie können alle Thunks in verschiedene Dateien sortieren und in den Dateien, in denen ein Thunk einen anderen aufruft (importiert), diesen Import entweder mit der Methode jest.mock oder mit derselben jest.spyOn . Somit werden wir die obige Aufgabe erfüllen, zu überprüfen, ob ein externer Thunk mit den erforderlichen Parametern aufgerufen wurde , ohne uns um Moks zu kümmern. Außerdem wäre es besser, alle Thunk entsprechend ihrem funktionalen Zweck zu zerbrechen, um sie nicht alle auf einem Haufen zu halten. So wurden drei solcher Arten unterschieden:


  • Bezogen auf die Arbeit mit Vorlagen - templates .
  • Bezogen auf die Arbeit mit dem Filter (Sortieren, Anzeigen von Spalten) - filter .
  • Bezogen auf die Arbeit mit der Tabelle (Laden neuer Daten beim Scrollen, da die Tabelle einen virtuellen Bildlauf hat, Laden von Metadaten, Laden von Daten über den Zähler der Datensätze in der Tabelle usw.) - table .

Die folgende Ordner- und Dateistruktur wurde vorgeschlagen:


 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) 

Ein Beispiel für diese Architektur finden Sie hier .


In der Testdatei für applyFilter können Sie sehen, dass wir das angestrebte Ziel erreicht haben - Sie können keine Mokas schreiben, um den korrekten Betrieb von fetchData / fetchTotalCounter . Aber zu welchem ​​Preis ...



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

Wir mussten für die Modularität der Tests mit doppelter Codierung und einer sehr starken Abhängigkeit von Thunk voneinander bezahlen. Die geringste Änderung in der Anrufkette führt zu einem starken Refactoring.


Im obigen Beispiel wurde das Beispiel für table und filter demonstriert, um die Konsistenz der angegebenen Beispiele aufrechtzuerhalten. Tatsächlich wurde das Refactoring mit templates gestartet (da es sich als einfacher herausstellte), und zusätzlich zum obigen Refactoring wurde das Konzept der Arbeit mit Vorlagen geringfügig geändert. Als Annahme wurde angenommen, dass es auf einer Seite (wie einer Tabelle) nur ein Vorlagenfenster geben kann. Zu dieser Zeit war es genau das und das Auslassung Die Annahme erlaubte es uns, den Code ein wenig zu vereinfachen, indem wir das prefix eliminierten.
Nachdem die Änderungen in den Hauptentwicklungszweig eingegossen und getestet wurden, machte ich mit ruhiger Seele Urlaub, um den Rest des Codes nach meiner Rückkehr auf einen neuen Ansatz zu übertragen.


Nach meiner Rückkehr aus dem Urlaub stellte ich überrascht fest, dass meine Änderungen rückgängig gemacht wurden. Es stellte sich heraus, dass eine Seite erschien, auf der sich möglicherweise mehrere unabhängige Tabellen befinden, dh die zuvor getroffene Annahme hat alles gebrochen. Die ganze Arbeit wurde also vergebens erledigt ...


Na ja, fast. Tatsächlich wäre es möglich, dieselben Aktionen erneut auszuführen (der Vorteil der Zusammenführungs- / Pull-Anforderung verschwand nirgendwo, sondern blieb in der Historie), wobei der Ansatz für die Vorlagenarchitektur unverändert blieb und nur der Ansatz für die Organisation von Thunk-s geändert wurde. Dieser Ansatz hat jedoch aufgrund seiner Kohärenz und Komplexität immer noch kein Vertrauen geweckt. Es bestand kein Wunsch, darauf zurückzukommen, obwohl dies das angegebene Problem beim Testen löste. Es war notwendig, sich etwas anderes auszudenken, das einfacher und zuverlässiger war.


Ein Versuch, das Problem Nummer 3 zu lösen. Wer sucht, wird finden


Als ich global betrachtete, wie Tests für Thunk geschrieben werden, bemerkte ich, wie einfach und ohne Probleme Methoden (in der Tat Objektfelder) von entityModel .


Dann kam die Idee: Warum nicht eine Klasse erstellen, deren Methoden Thunk- und Action-Ersteller sind? An die Factory übergebene Parameter werden an den Konstruktor dieser Klasse übergeben und sind über this zugänglich. Sie können sofort eine kleine Optimierung vornehmen, indem Sie eine separate Klasse für Aktionsersteller und eine separate Klasse für Thunk erstellen und dann eine von einer anderen erben. Somit funktionieren diese Klassen als eine Klasse (beim Erstellen einer Instanz der Erbenklasse), aber gleichzeitig ist jede Klasse einzeln leichter zu lesen, zu verstehen und zu testen.


Hier ist ein Code, der diesen Ansatz demonstriert.


Lassen Sie uns jede der Dateien, die angezeigt und geändert wurden, genauer betrachten.



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

  • In der Datei FilterActions.js erben FilterActions.js von der FilterActionCreators Klasse und definieren applyFilter als Methode dieser Klasse. In diesem Fall sind die Aktionsersteller applyFilterSuccess und applyFilterError folgendermaßen verfügbar:

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

  • In der Hauptdatei mit allen Thunk- und Aktionserstellern erstellen wir eine Instanz der FilterActions Klasse und übergeben ihr das erforderliche Konfigurationsobjekt. Vergessen Sie beim Exportieren von Funktionen (ganz am Ende der actionsCreator Funktion) nicht, die applyFilter Methode zu überschreiben, um die fetchData Abhängigkeit an 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 }), }; }; 

  • Tests sind sowohl in der Implementierung als auch beim Lesen etwas einfacher geworden:

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

Grundsätzlich können Sie in Tests die letzte Prüfung folgendermaßen ersetzen:


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

Dann wäre es nicht nötig, sie mit 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%.

Bonus


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/de469371/


All Articles