Тестирование Ngrx State в Angular с помощью Jest

Aleksandr Serenko
F.A.F.N.U.R
Published in
14 min readAug 9, 2019
Angular ngrx & jest

Update 15 марта 2020: Методы и подходы для организации state в данной статье устарели. Актуальная статья — Тестирование Ngrx store в Angular. Методы и подходы для упрощения тестирование stat’ов Ngrx в Nx.

Напишем тесты для Translation State, разработке которого было описано в предыдущей статье.

Если вы ранее не работали с Nx или Ngrx, то рекомендуется прочитать официальную документацию для данных технологий, а также посмотреть статью — О реализации State в Angular.

Для State обычно генерируются следующий список файлов тестов, которые можно увидеть в папке +state:

translation.effects.spec.ts
translation.facade.spec.ts
translation.reducer.spec.ts
translation.selectors.spec.ts

Данные файлы содержат тесты для эффектов, фасада, редъюсера и селекторов. В списке, нет только тестов для экшенов, но они не тестируются, так как экшены это просто классы с одним/двумя полями, которые не содержат логики.

От классов с экшенами можно отказаться вообще, следуя руководству по Ngrx, но данные действия вряд ли упростят State.

Основные задачи тестов

При тестировании seletor’ов, основной задачей тестов является возвращение значений из State.

При тестировании reducer’а, основной задачей тестов является установка значений в хранилище State.

При тестировании effect’ов, основной задачей тестов является вызов тех или иных action’ов, а также выполнение сопутствующей логики, такой как загрузка данных с сервера или вычисление специализированных значений и их сохранение.

При тестировании facade’а, основной задачей тестов является воспроизведение полного цикла отработки события.

Тестирование Селекторов (Selectors)

Nx генерирует тесты для селекторов, с демо данными. Удалим все тесты связанные с демо данными, а также переименуем название переменной, в которой храниться весь Store, а также зададим строгие типы:

let store: TranslationPartialState;const KEY = TRANSLATION_FEATURE_KEY;beforeEach(() => {
store = createStore(KEY, initialState);
});

Отметим, что существует множество подходов к написанию тестов, в частности Unit тестов. В данной статье, будем придерживаться идеи, что основное назначение теста проверить работоспособность основного назначения тестируемого объекта.

В Translation State хранит следующие значения, которые и будут протестированы:

  • currentLanguage
  • initialized
  • initError
  • initiating
  • language
  • languages
  • setError
  • setting

Одним из подходов, связанных с уменьшением кода в тестах, связан с созданием Mock|Stub классов и данных, которые являются урезанными объектами, каких-либо данных и сервисов. Так как в State обычно хранятся простые объекты и значения, то для данных значений создается набор данных с постфиксом, с шаблоном вида — <NAME_PROPERTY>^Stub.

В папке с библиотекой translation внутри папки testing, создадим папку stubs, в который добавим файл —translation.stub.ts:

// libs/translation/src/testing/stub/translation.stub.tsimport { TranslationConfig } from '@luxcar/translation';

export const languageEnStub = 'en';
export const languageRuStub = 'ru';
export const languagesStub = [languageEnStub, languageRuStub];

export const translationConfigStub: TranslationConfig = {
currentLanguage: languageRuStub,
language: languageEnStub,
languages: languagesStub
};

Напишем первый тест для currentLanguage:

it('getCurrentLanguage() should return current language', () => {
store = createStore(KEY, initialState, { currentLanguage: languageEnStub });
const results = translationQuery.getCurrentLanguage(store);

expect(results).toBe(languageEnStub);
});

В тесте можно увидеть, что перед выполнением запроса данных из Store, сначала устанавливаются требуемые значения.

Также в названии теста будет строка, шаблон которой:

get<NAME_STATE_PROPERTY>() should return NAME_STATE_PROPERTY

Для задания сторы, используется функция createStore, которая делает только присвоение и мерж объектов по ключу и возвращает результат:

{ [STATE_KEY]: {...initialState, ...overrideInitialProperties } }

Фактически, все тесты select’ов будут выглядеть именно так, как был описан тест.

Теперь приведем все тесты для Translation Select’ов:

import { createStore } from '@medium-stories/store/testing';

import { languageEnStub, languagesStub, translationErrorStub } from '../../testing';
import { initialState, TRANSLATION_FEATURE_KEY, TranslationPartialState } from './translation.reducer';
import { translationQuery } from './translation.selectors';

describe('TranslationSelectors', () => {
const KEY = TRANSLATION_FEATURE_KEY;

let store: TranslationPartialState;

beforeEach(() => {
store = createStore(KEY, initialState);
});

describe('Translation Selectors', () => {
it('getCurrentLanguage() should return current language', () => {
store = createStore(KEY, initialState, { currentLanguage: languageEnStub });
const results = translationQuery.getCurrentLanguage(store);

expect(results).toBe(languageEnStub);
});

it('getInitialized() should return initialized', () => {
store = createStore(KEY, initialState, { initialized: true });
const results = translationQuery.getInitialized(store);

expect(results).toBeTruthy();
});

it('getInitError() should return initError', () => {
store = createStore(KEY, initialState, { initError: translationErrorStub });
const results = translationQuery.getInitError(store);

expect(results).toBe(translationErrorStub);
});

it('getInitiating() should return initiating', () => {
store = createStore(KEY, initialState, { initiating: true });
const results = translationQuery.getInitiating(store);

expect(results).toBeTruthy();
});

it('getLanguage() should return language', () => {
store = createStore(KEY, initialState, { language: languageEnStub });
const results = translationQuery.getLanguage(store);

expect(results).toBe(languageEnStub);
});

it('getLanguages() should return languages', () => {
store = createStore(KEY, initialState, { languages: languagesStub });
const results = translationQuery.getLanguages(store);

expect(results.length).toBe(languagesStub.length);
});

it('getSetError() should return setError', () => {
store = createStore(KEY, initialState, { setError: translationErrorStub });
const results = translationQuery.getSetError(store);

expect(results).toBe(translationErrorStub);
});

it('getSetting() should return setting', () => {
store = createStore(KEY, initialState, { setting: true });
const results = translationQuery.getSetting(store);

expect(results).toBeTruthy();
});
});
});

Заметим, что мы не используем сокращенные пути для тестов

import { languageEnStub, languagesStub, translationErrorStub } from '../../testing';

хотя возможно использовать и короткие пути*:

import { languageEnStub, languagesStub, translationErrorStub } from '@medium-stories/translation/testing';

Какой подход использовать — решайте сами, но короткие ссылки, могут породить ненужные зависимости в сборках проекта, таких как webpack, что в дальнейшем скажется на производительности и оптимизации приложения, поэтому используйте это осторожно.

Если вы не знакомы с Jest и тестированием в Javascript/Typescript, рекомендуется прочитать ознакомиться с документацией по Jest, а также прочитать про тестирование на сайте Nx Jest.

Теперь запустим написанные тесты:

ng test translation -t TranslationSelectors

В результате получим:

angular seletors tests with jest

Тестирование Reducer’а

Отредактируем файл с тестом, сгенерированный Nx. Удалим все функции и переменные, и добавим наш state и зададим в него initialState

let state: TranslationState;

beforeEach(() => {
state = initialState;
});

Как упоминалось ранее, все тесты reducer’а направлены на то, чтобы установить значения в State. Поэтому напишем тесты для всех action’ов, которые приводят к изменению значению State’а.

Для Translation State это:

  • InitiatingTranslation
  • TranslationInitialized
  • TranslationInitError
  • SettingLanguage
  • LanguageSet
  • LanguageSetError

Напишем тест для — InitiatingTranslation:

it('InitiatingTranslation() should set initError null and initiating true', () => {
const action = new fromTranslationActions.InitiatingTranslation();
const result = translationReducer(state, action);

expect(result.initError).toBeNull();
expect(result.initiating).toBeTruthy();
});

Для названия теста, обычно используется следующий шаблон:

ACTION_NAME() should set LIST_CHANGED PROPERTIES [WITH_VALUES]

в котором можно указать, изменяющиеся сохраняемые свойства, но обычно туда указываются значимые свойства.

Чтобы понять, какое свойство значимое, подумайте, если данный тест не пройдет, то какую информацию вы хотели бы увидеть — часто у вам будет состояние State и сообщение теста в котором написано, что initiating должен быть true, а в State он false.

Для задания State, используется функция createState, которая делает только присвоение и мерж объектов и возвращает результат:

{...initialState, ...overrideInitialProperties }

Большинство тестов для reducer’а будут написаны по аналогичному алгоритму.

Приведем все тесты для Translate Reducer:

import { createState } from '@medium-stories/store/testing';

import { languageEnStub, translationConfigStub, translationErrorStub } from '../../testing';
import { fromTranslationActions } from './translation.actions';
import { initialState, translationReducer, TranslationState } from './translation.reducer';

describe('TranslationReducer', () => {
let state: TranslationState;

beforeEach(() => {
state = initialState;
});

describe('valid Translation actions ', () => {
it('InitiatingTranslation() should set initError null and initiating true', () => {
const action = new fromTranslationActions.InitiatingTranslation();
const result = translationReducer(state, action);

expect(result.initError).toBeNull();
expect(result.initiating).toBeTruthy();
});

it('TranslationInitialized() should set initiating false and set translation config', () => {
state = createState(initialState, { initiating: true });
const action = new fromTranslationActions.TranslationInitialized(translationConfigStub);
const result = translationReducer(state, action);

expect(result.currentLanguage).toBe(translationConfigStub.currentLanguage);
expect(result.language).toBe(translationConfigStub.language);
expect(result.languages.length).toBe(translationConfigStub.languages.length);
expect(result.initiating).toBeFalsy();
});

it('TranslationInitError() should set initError and initiating false', () => {
state = createState(initialState, { initiating: true });
const action = new fromTranslationActions.TranslationInitError(translationErrorStub);
const result = translationReducer(state, action);

expect(result.initError).toBe(translationErrorStub);
expect(result.initiating).toBeFalsy();
});

it('SettingLanguage() should set setError null, currentLanguage = lang and setting true', () => {
const action = new fromTranslationActions.SettingLanguage(languageEnStub);
const result = translationReducer(state, action);

expect(result.currentLanguage).toBe(languageEnStub);
expect(result.setError).toBeNull();
expect(result.setting).toBeTruthy();
});

it('LanguageSet() should set setting false', () => {
state = createState(initialState, { setting: true });
const action = new fromTranslationActions.LanguageSet();
const result = translationReducer(state, action);

expect(result.setting).toBeFalsy();
});

it('LanguageSetError() should set setError and setting false', () => {
state = createState(initialState, { setting: true });
const action = new fromTranslationActions.LanguageSetError(translationErrorStub);
const result = translationReducer(state, action);

expect(result.setError).toBe(translationErrorStub);
expect(result.setting).toBeFalsy();
});
});

describe('unknown action', () => {
it('should return the previous state', () => {
const action = {} as any;
const result = translationReducer(initialState, action);

expect(result).toBe(initialState);
});
});
});

Единственное замечание в том, что если в тесте проверяется какое либо поле, которое должно быть изменено, то нужно его установить, чтобы reducer корректно его обработал, если это необходимо.

Теперь запустим написанные тесты:

ng test translation -t TranslationReducer

Результаты работы теста для reducer’а:

angular reducer tests with jest

Тестирование Effect’ов

Наверное, тестирование эффектов, является самой важной и самой сложной частью в покрытии тестами State.

Так как Translation Effects используют AbstractEffects, вы можете ознакомиться с данным классов, в конце статьи, в разделе Утилиты и вспомогательные функции.

Если вы ранее не тестировали рекреативные методы или не знакомы с Marble testing, то рекомендуется ознакомиться с данной статьей.

Отредактируем файл теста эффектов. Сначала добавим основные объекты, с помощью которых мы будем тестировать эффекты:

let actions$: Observable<any>;
let effects: TranslationEffects;
let service: TranslationService;
let scheduler: TestScheduler;
let store: Store<TranslationPartialState>;

Настройка Angular будет следующей

beforeEach(() => {
TestBed.configureTestingModule({
imports: [
NxModule.forRoot(),
StoreModule.forRoot(
{ [TRANSLATION_FEATURE_KEY]: translationReducer },
{
initialState: { [TRANSLATION_FEATURE_KEY]: initialState },
runtimeChecks: { strictActionImmutability: false }
}
),
EffectsModule.forRoot([])
],
providers: [
TranslationEffects,
DataPersistence,
provideMockActions(() => actions$),
{
provide: TranslationService,
useValue: {
setLanguage: jest.fn(),
getConfig: jest.fn()
}
}
]
});

store = TestBed.get(Store);
effects = TestBed.get(TranslationEffects);
scheduler = getTestScheduler();
const duration = scheduler.createTime('-|');
effects.setRetryStrategyOptions({
maxRetryAttempts: 3,
scalingDuration: duration,
scheduler
});
service = TestBed.get(TranslationService);
});

Как видно из кода, был сконфигурирован тестовый компонент, в которого инициализирован Store, для корректной работы effect’ов добавлены все провайдеры, которые объявлены в конструкторе эффектов

Также была сделана установка для marble testing c созданием scheduler.

В конце, получаем имплеминтацию Translation Service из тестируемого компонента.

Теперь напишем тесты для инициализации языка. Для первого эффекта, будет два теста:

describe('init$', () => {
it('should return initiating translation', () => {
const action = new fromTranslationActions.InitTranslation();
const completion = new fromTranslationActions.InitiatingTranslation();

actions$ = hot('^-a--', { a: action });
const expected = cold('--b', { b: completion });

expect(effects.init$).toBeObservable(expected);
});

it('should return translation init canceled', () => {
store.dispatch(new fromTranslationActions.InitiatingTranslation());
const action = new fromTranslationActions.InitTranslation();
const completion = new fromTranslationActions.TranslationInitCanceled();

actions$ = hot('^-a--', { a: action });
const expected = cold('--b', { b: completion });

expect(effects.init$).toBeObservable(expected);
});
});

Первый случай, это корректный случай, когда была проверка на инициализацию, и так как ранее translation state не был инициализирован, должен запуститься процесс инициализации.

Второй случай, когда translation state уже находится в процессе инициализации. В данном случае, просто отменяется данный запрос на повторную инициализацию.

Второй эффект содержит тесты, для процесса инициализации

describe('initiating$', () => {
it('should return translation initialized', () => {
const action = new fromTranslationActions.InitiatingTranslation();
const completion = new fromTranslationActions.TranslationInitialized(translationConfigStub);

actions$ = hot('^-a--', { a: action });
const response = cold('-a|', { a: null });
const expected = cold('---b', { b: completion });
service.getConfig = jest.fn(() => translationConfigStub);
service.init = jest.fn(() => response);

expect(effects.initiating$).toBeObservable(expected);
});

it('should return translation init error', () => {
const action = new fromTranslationActions.InitiatingTranslation();
const completion = new fromTranslationActions.TranslationInitError(translationErrorStub);

actions$ = hot('^-a---', { a: action });
const response = cold('-#|', {}, translationErrorStub);
const expected = cold('---b', { b: completion });
service.getConfig = jest.fn(() => translationConfigStub);
service.init = jest.fn(() => response);

expect(effects.initiating$).toBeObservable(expected);
});
});

Как и в первом эффекте, есть два случая.

В первом случае, рассматривается кейс, когда инициализация произошла успешно, и translation state был инициализирован.

Во втором случае, в ходе процесса инициализации произошла ошибка, и эффект вызывает соответствующий action с ошибкой инициализации.

Данный алгоритм применим почти ко всем эффектам, разрабатываемым в данном приложении. Приведем оставшиеся тесты для эффектов.

import { TestBed } from '@angular/core/testing';
import { EffectsModule } from '@ngrx/effects';
import { Store, StoreModule } from '@ngrx/store';
import { provideMockActions } from '@ngrx/effects/testing';
import { DataPersistence, NxModule } from '@nrwl/angular';
import { cold, getTestScheduler, hot } from '@nrwl/angular/testing';
import { Observable } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';

import { languageEnStub, translationConfigStub, translationErrorStub } from '../../testing';
import { TranslationService } from '../interfaces/translation-service.interface';
import { fromTranslationActions } from './translation.actions';
import { TranslationEffects } from './translation.effects';
import { initialState, TRANSLATION_FEATURE_KEY, TranslationPartialState, translationReducer } from './translation.reducer';

describe('TranslationEffects', () => {
let actions$: Observable<any>;
let effects: TranslationEffects;
let service: TranslationService;
let scheduler: TestScheduler;
let store: Store<TranslationPartialState>;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [
NxModule.forRoot(),
StoreModule.forRoot(
{ [TRANSLATION_FEATURE_KEY]: translationReducer },
{
initialState: { [TRANSLATION_FEATURE_KEY]: initialState },
runtimeChecks: { strictActionImmutability: false }
}
),
EffectsModule.forRoot([])
],
providers: [
TranslationEffects,
DataPersistence,
provideMockActions(() => actions$),
{
provide: TranslationService,
useValue: {
init: jest.fn(),
getConfig: jest.fn(),
setLanguage: jest.fn()
}
}
]
});

store = TestBed.get(Store);
effects = TestBed.get(TranslationEffects);
scheduler = getTestScheduler();
const duration = scheduler.createTime('-|');
effects.setRetryStrategyOptions({
maxRetryAttempts: 3,
scalingDuration: duration,
scheduler
});
service = TestBed.get(TranslationService);
});

describe('init$', () => {
it('should return initiating translation', () => {
const action = new fromTranslationActions.InitTranslation();
const completion = new fromTranslationActions.InitiatingTranslation();

actions$ = hot('^-a--', { a: action });
const expected = cold('--b', { b: completion });

expect(effects.init$).toBeObservable(expected);
});

it('should return translation init canceled', () => {
store.dispatch(new fromTranslationActions.InitiatingTranslation());
const action = new fromTranslationActions.InitTranslation();
const completion = new fromTranslationActions.TranslationInitCanceled();

actions$ = hot('^-a--', { a: action });
const expected = cold('--b', { b: completion });

expect(effects.init$).toBeObservable(expected);
});
});

describe('initiating$', () => {
it('should return translation initialized', () => {
const action = new fromTranslationActions.InitiatingTranslation();
const completion = new fromTranslationActions.TranslationInitialized(translationConfigStub);

actions$ = hot('^-a--', { a: action });
const response = cold('-a|', { a: null });
const expected = cold('---b', { b: completion });
service.getConfig = jest.fn(() => translationConfigStub);
service.init = jest.fn(() => response);

expect(effects.initiating$).toBeObservable(expected);
});

it('should return translation init error', () => {
const action = new fromTranslationActions.InitiatingTranslation();
const completion = new fromTranslationActions.TranslationInitError(translationErrorStub);

actions$ = hot('^-a---', { a: action });
const response = cold('-#|', {}, translationErrorStub);
const expected = cold('---b', { b: completion });
service.getConfig = jest.fn(() => translationConfigStub);
service.init = jest.fn(() => response);

expect(effects.initiating$).toBeObservable(expected);
});
});

describe('set$', () => {
it('should return set language', () => {
store.dispatch(new fromTranslationActions.TranslationInitialized(translationConfigStub));
const action = new fromTranslationActions.SetLanguage(languageEnStub);
const completion = new fromTranslationActions.SettingLanguage(languageEnStub);

actions$ = hot('^-a--', { a: action });
const expected = cold('--b', { b: completion });

expect(effects.set$).toBeObservable(expected);
});

it('should return language set canceled', () => {
store.dispatch(new fromTranslationActions.TranslationInitialized(translationConfigStub));
store.dispatch(new fromTranslationActions.SettingLanguage(languageEnStub));
const action = new fromTranslationActions.SetLanguage(languageEnStub);
const completion = new fromTranslationActions.LanguageSetCanceled();

actions$ = hot('^-a--', { a: action });
const expected = cold('--b', { b: completion });

expect(effects.set$).toBeObservable(expected);
});
});

describe('setting$', () => {
it('should return language set', () => {
store.dispatch(new fromTranslationActions.TranslationInitialized(translationConfigStub));
const action = new fromTranslationActions.SettingLanguage(languageEnStub);
const completion = new fromTranslationActions.LanguageSet();

actions$ = hot('^-a--', { a: action });
const response = cold('-a|', { a: null });
const expected = cold('---b', { b: completion });
service.setLanguage = jest.fn(() => response);

expect(effects.setting$).toBeObservable(expected);
});

it('should return translation save error', () => {
store.dispatch(new fromTranslationActions.TranslationInitialized(translationConfigStub));
const action = new fromTranslationActions.SettingLanguage(languageEnStub);
const completion = new fromTranslationActions.LanguageSetError(translationErrorStub);

actions$ = hot('^-a---', { a: action });
const response = cold('-#|', {}, translationErrorStub);
const expected = cold('---b', { b: completion });
service.setLanguage = jest.fn(() => response);

expect(effects.setting$).toBeObservable(expected);
});
});
});

Теперь запустим написанные тесты

ng test translation -t TranslationEffects

Результаты работы теста для эффектов:

angular effects tests with jest

Тестирование Translation Facade

Последней частью является тестирование facade. Но так как все основные тесты были сделаны ранее, тесты для facade чаще всего просто является дополнительным слоем защиты на работоспособность State и не являются достаточно подробными, но тут стоит отметить, что все зависит сколько и какую логику вы закладываете в facade. В данных статьях это всего лишь вызов новых action’ов и обращение к observable свойствам state через селекторы.

Содержимое файла теста почти аналогично файлу теста эффектов, за исключением значения теста:

import { NgModule } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { EffectsModule } from '@ngrx/effects';
import { StoreModule, Store } from '@ngrx/store';
import { NxModule } from '@nrwl/angular';
import { readFirst } from '@nrwl/angular/testing';
import { of } from 'rxjs';

import { translationConfigStub } from '../../testing';
import { TranslationService } from '../interfaces/translation-service.interface';
import { TranslationEffects } from './translation.effects';
import { TranslationFacade } from './translation.facade';
import { initialState, TRANSLATION_FEATURE_KEY, TranslationPartialState, translationReducer } from './translation.reducer';

describe('TranslationFacade', () => {
let facade: TranslationFacade;
let store: Store<TranslationPartialState>;

beforeEach(() => {});

describe('used in NgModule', () => {
beforeEach(() => {
@NgModule({
imports: [
StoreModule.forFeature(TRANSLATION_FEATURE_KEY, translationReducer, { initialState }),
EffectsModule.forFeature([TranslationEffects])
],
providers: [
TranslationFacade,
{
provide: TranslationService,
useValue: {
init: jest.fn(() => of(null)),
getConfig: jest.fn(() => translationConfigStub),
setLanguage: jest.fn(() => of(null))
}
}
]
})
class CustomFeatureModule {}

@NgModule({
imports: [
NxModule.forRoot(),
StoreModule.forRoot(
{},
{
runtimeChecks: { strictActionImmutability: false }
}
),
EffectsModule.forRoot([]),
CustomFeatureModule
]
})
class RootModule {}

TestBed.configureTestingModule({ imports: [RootModule] });

store = TestBed.get(Store);
facade = TestBed.get(TranslationFacade);
});

it('init() should initialized language on app', async done => {
try {
let initialized = await readFirst(facade.initialized$);
expect(initialized).toBeFalsy();

facade.init();
initialized = await readFirst(facade.initialized$);
expect(initialized).toBeTruthy();

done();
} catch (err) {
done.fail(err);
}
});
});
});

В данном случае реализуем, был реализован один тест для инициализации языка.

Теперь запустим написанные тесты:

ng test translation -t TranslationFacade

После запуска увидим:

angular facade tests with jest

Теперь запустим все тесты в translation, и удостоверимся, что все тесты проходят:

ng test translation
angular translation alltests with jest

Все тесты прошли! :)

Утилиты и вспомогательные функции

Abstract Effects

AbstractEffects — абстрактный класс эффектов, в которых будут методы для задания общих свойств, а также обертка над Store.

Отметим, что наследование зачастую является плохим решением, и данную технику ООП стоит использовать осторожно. Автор статьи, придерживается мнения, если можно обойтись без наследования, то лучше так и сделать.

import { DEFAULT_GENERIC_RETRY_STRATEGY, GenericRetryStrategyOptions } from '../utils/generic-retry-strategy.util';
import { md5 } from '../utils/md5.util';
/**
* Abstract Effects
*/
export abstract class AbstractEffects<T = any> {
/**
* Generic retry strategy options
*/
protected genericRetryStrategyOptions = DEFAULT_GENERIC_RETRY_STRATEGY;
protected constructor(protected readonly key: string) {} /**
* Return effect id by payload
*
@param payload payload
*/
getIdByPayload(payload: any): string {
return md5(JSON.stringify(payload));
}
/**
* Return state from "partial" store
*
@param state State
*
@param key External state key
*/
getState<S = any>(state: S, key?: string): T {
return state[key || this.key];
}
/**
* Set generic retry strategy options
* Notice: Required only for testing
*
*
@param options options
*/
setRetryStrategyOptions(options: Partial<GenericRetryStrategyOptions>): void {
this.genericRetryStrategyOptions = { ...this.genericRetryStrategyOptions, ...options };
}
}

Метод — getState возвращает из глобального Store, локальный State по ключу.

Метод — getIdByPayload позволяет задать Id effect’у по payload’у, который нужен для того, чтобы DataPersistence смог отменить effect, если он был вызван повторно.

Метод — setRetryStrategyOptions устанавливает параметры для GenericRetryStrategy. Данный метод нужен формально только для тестирования.

GenericRetryStrategy — это повторные запросы, для доступа к серверу или данным. Например, из-за недоступности сервера (502), вместо показа ошибки, GenericRetryStrategy позволяет делать повторные запросы через 1 секунду, увеличивая ожидание на 1 секунду после каждой попытки. Если после 3 неудачных попыток, не было получено ответа, то только в этом случае показываем ошибку.

import { Observable, SchedulerLike, throwError, timer } from 'rxjs';
import { mergeMap } from 'rxjs/operators';
export interface GenericRetryStrategyOptions {
maxRetryAttempts?: number;
scalingDuration?: number;
excludedStatusCodes?: number[];
scheduler?: SchedulerLike;
}
export const DEFAULT_GENERIC_RETRY_STRATEGY: GenericRetryStrategyOptions = {
maxRetryAttempts: 3,
scalingDuration: 1000,
excludedStatusCodes: [403],
scheduler: null
};
export const genericRetryStrategy = ({
maxRetryAttempts = 3,
scalingDuration = 1000,
excludedStatusCodes = [403],
scheduler = null
}: GenericRetryStrategyOptions = {}) => (attempts: Observable<any>) => {
return attempts.pipe(
mergeMap((error, i) => {
const retryAttempt = i + 1;
if (retryAttempt > maxRetryAttempts || excludedStatusCodes.find(e => e === error.status)) {
return throwError(error);
}
return timer(retryAttempt * scalingDuration, scheduler);
})
);
};

Также можно увидеть функцию — md5. В данном случае, это простой клон функции md5 без вендоров, но возможно подключить библиотеку для реализации md5, или вообще отказаться от md5 и генерировать единый хеш для Id самостоятельно.

CreateStore и CreateState

Для тестирования будем использовать две простые функции:

/**
* Create store
*
*
@param key State feature key
*
@param initialState Initial state
*
@param params Extend initial params
*/
export function createStore<S = object, P = object>(key: string, initialState: S, params: Partial<S> = {}): P {
return {
[key]: { ...initialState, ...params }
} as any;
}
/**
* Create state
*
*
@param initialState Initial state
*
@param params Extend initial params
*/
export function createState<S = object>(initialState: S, params: Partial<S> = {}): S {
return { ...initialState, ...params };
}

Функция createStore — позволяет создать Partial<RootState> по заданному ключу.

Функция createState — позволяет создать State из initialState с переопределением/добавлением свойств.

Исходники

Все исходники находятся на github, в репозитории https://github.com/Fafnur/medium-stories

Для того, чтобы посмотреть состояние проекта на момент написания статьи, нужно выбрать соответствующий tag — translation-state-tests.

git checkout translation-state-tests

Предыдущие статьи:

  1. Статья о настройке Prettier, tslint и eslint в Angular
  2. Статья про LocalStorage, SessionStorage в Angular Universal
  3. Статья про мультиязычность в Angular & Universal с помощью ngx-translate
  4. Статья про Redux в Angular с помощью Ngrx. Создание Store в Angular
  5. Статья про Организацию Stat’ов в Angular c Ngrx и Nx

--

--

Aleksandr Serenko
F.A.F.N.U.R

Senior Front-end Developer, Angular evangelist, Nx apologist, NodeJS warlock