Redux в Angular. Ngrx в действии: создание и тестирование.

Aleksandr Serenko
F.A.F.N.U.R
Published in
9 min readNov 24, 2022

В данной статье приводится реализация feature store для сайта новостей. В статье разбираются моменты связанные с хранением коллекций в state, а также приводятся примеры тестирования ngrx.

Ngrx первая реализация redux в Angular, которая достаточно близко реализовала описываемые концепты классического redux.

Чего нельзя сказать про первую реализацию в vue.

Реализация redux в Ngrx выглядит следующим образом:

Установка Ngrx

Для того чтобы использовать ngrx необходимо установить зависимости, запустив команду:

yarn add @ngrx/store @ngrx/router-store @ngrx/entity @ngrx/effects @ngrx/component-store

После выполнения команды необходимо добавить 2 модуля в AppModule:

imports: [
StoreModule.forRoot({}),
EffectsModule.forRoot([]),
...
]

Этого достаточно чтобы начать пользоваться ngrx.

Логирование событий навигации в приложении.

Ngrx позволяет сохранять в store события навигации в приложении.

Так как снапшот события навигации достаточно большой можно использовать собственный сериализер, который будет хранить в state только то, что нужно:

RootRouterStateSerializer — всего лишь делает срез данных и сохраняет только необходимые данные.

Root Store

Как было написано в разделе установки, для работы ngrx необходимо подключит Store.forRoot(), Effects.forRoot() в AppModule. Однако если включать сериализацию роутера, а также логирование экшенов, то можно вынести все настройки в отдельный модуль, назвав его RootStore.

В данном проекте это выглядит следующим образом:

@NgModule({
imports: [
StoreModule.forRoot(rootReducers, { initialState: rootInitialState }),
EffectsModule.forRoot([]),
StoreRouterConnectingModule.forRoot({
serializer: RootRouterStateSerializer,
}),
],
})
export class RootStoreModule {}

@NgModule({
imports: [
StoreModule.forRoot(rootReducers, {
initialState: rootInitialState,
runtimeChecks: {
strictActionImmutability: true,
strictStateImmutability: true,
},
}),
EffectsModule.forRoot([]),
StoreRouterConnectingModule.forRoot({
serializer: RootRouterStateSerializer,
}),
StoreDevtoolsModule.instrument({ logOnly: false }),
],
})
export class RootStoreDevelopmentModule {}

и подключены в AppModule:

@NgModule({
imports: [
...
environment.production
? RootStoreModule,
: RootStoreDevelopmentModule
],
...
})
export class AppModule {}

Как видно из реализации есть две версии модулей: RootStoreModule и RootStoreDevelopmentModule. Первая версия используется в prod, вторая в режиме разработки. В режиме разработки включено логирование и строгая проверка типов и состояний стейта и экшенов.

Стоит отметить, что все state можно хранить в RootStore, но это не рекомендуется. Одна из ключевых особенностей ngrx в создании lazy loading feature, которые будут инициализированы только тогда, когда будет инициализирован требуемый компонент.

Другими словами, хорошей практикой является создание ngrx store из множества маленьких, атомарных, ленивых feature store.

Создание Feature Store

Для генерации новой фичи используется следующая команда:

nx g @nrwl/angular:ngrx feature-name --module=path-to-module.ts

Команда принимает аргумент feature-name — имя фичи, а также параметр module, который является относительным путем до модуля, куда будут добавлены импорты модулей Ngrx.

После выполнения команды будут сгенерированы следующие файлы:

feature
├── +state
│ ├── feature.actions.ts
│ ├── feature.effects.ts
│ ├── feature.facade.ts
│ ├── feature.reducer.ts
│ └── feature.selectors.ts
├── feature.module.ts
└── ...

Стоит отметить, что facade создается только если используются схематики Nx.

Каждый файл содержит конкретные абстракции redux:

  • feature.actions.ts — файл, который содержит все экшены;
  • feature.effects.ts — файл, который содержит все эффекты;
  • feature.facade.ts — файл, который скрывает прямое обращение к конкретной реализации redux;
  • feature.reducer.ts — файл, который содержит интерфейс state и reducer;
  • feature.selectors.ts — файл, который содержит все селекторы.

Для сайта новостей понадобятся следующие экшены:

  • Загрузка списка новостей;
  • Загрузка новости по id;
  • Создание новой новости;
  • Изменение новости;
  • Удаление новости;
  • Удаление всех новостей;
  • Восстановление новостей (сброс новостей до дефолтного списка новостей.

Обычно при работе с API используются цепочки событий. Например, для загрузки списка новостей будет использоваться цепочка:

  • load — экшен, который вызывает загрузку списка новостей путем выполнения запроса к серверу;
  • loadSuccess — экшен, который испускается только в том случае, если список новостей был успешно загружен;
  • loadFailure — экшен, который испускается в случае если получили ошибку от сервера при попытке загрузить список новостей.

Аналогично создаются другие цепочки событий: change, changeSuccess, changeFailure, remove, removeSuccess, removeFailure и так далее.

Список экшенов для новостей будет следующим:

Как видно из списка экшенов, почти все экшены представлены в виде цепочек кроме экшена init. Данный экшен при инициализации feature store вызывает загрузку списка новостей.

После того как были созданы все экшены необходимо сформировать стейт и хранить требуемые данные. Так как новости это коллекция данных, то пакет @ngrx/entity идеально подойдет для управления и изменения массива сущностей.

PostState и postReducer примут вид:

PostState представляет собой интерфейс, который расширяет EntityState:

export interface PostState extends EntityState<Post> {
loaded: boolean;
}
  • loaded — свойство, которое говорит о том, был ли загружен список новостей.

EntityState это способ хранения коллекции сущностей в виде двух объектов:

export interface EntityState<T> {
ids: string[] | number[];
entities: Record<string, T>;
}

Это позволяет быстро обращаться к элементу коллекции по id.

export const postAdapter = createEntityAdapter<Post>({
selectId: (entity) => entity.uuid,
});

PostAdapter предоставляет набор функций для быстрого изменения коллекции сущностей.

Например, для того чтобы изменить одну сущность:

postAdapter.updateOne({ id: post.uuid, changes: post }, state)

Адаптер найдет сущность по id, изменит ее, а затем изменит состояние state и selector, который возвращает список новостей получит новую коллекцию новостей с измененной новостью.

PostReducer обычно представляется в виде списка экшенов и соответствующих мутаций:

const reducer = createReducer(
initialPostState,
on(
PostActions.loadSuccess,
(state, { posts }): PostState =>
postAdapter.setAll(posts, {
...state,
loaded: true,
})
),
on(
PostActions.clearSuccess,
(state): PostState => postAdapter.removeAll(state)
),
on(
PostActions.loadOneSuccess, PostActions.createSuccess,
(state, { post }): PostState => postAdapter.upsertOne(post, state)
),
on(
PostActions.removeSuccess,
(state, { uuid }): PostState => postAdapter.removeOne(uuid, state)
),
on(
PostActions.changeSuccess,
(state, { post }): PostState =>
postAdapter.updateOne(
{
id: post.uuid,
changes: post,
},
state
)
)
);

Например, при успешной загрузке списка новостей, редьюсер изменит сущности с помощью адаптера:

on(
PostActions.loadSuccess,
(state, { posts }): PostState =>
postAdapter.setAll(posts, {
...state,
loaded: true,
})
),

Для обращения к свойствам state используются селекторы. Для новостного feature store используются селекторы:

  • selectLoaded — признак загрузки новостей;
  • selectPosts — полный список новостей;
  • selectPromoPosts — список промо новостей;
  • selectPopularPosts — список популярных новостей;
  • selectLastPosts — список последних новостей;
  • selectPostsEntities — словарь новостей;
  • selectPostById — получение новости по id.

Помимо списка новостей(selectPosts) и признака загрузки новостей (selectLoaded), также есть отфильтрованные списки новостей с определенными характеристиками: selectPromoPosts, selectPopularPosts, selectLastPosts.

Для создания асинхронных экшенов используются эффекты:

  • init$ — эффект, который вызывает загрузку списка новостей при инициализации feature store;
  • load$ — эффект, который выполняет загрузку новостей;
  • create$ — эффект, который создает новую новость;
  • change$ — эффект, который изменяет новость;
  • remove$ — эффект, который удаляет новость;
  • clear$ — эффект, который удаляет все новости.

Пример загрузки списка новостей:

load$ = createEffect(() => {
return this.actions$.pipe(
ofType(PostActions.load),
fetch({
id: () => 'load',
run: () => this.postApiService.get().pipe(
map((posts) => PostActions.loadSuccess({ posts }))
),
onError: (action, error) => PostActions.loadFailure({ error }),
})
);
});

Если происходит событие load, то тогда выполняется API запрос с помощью сервиса postApiService.get(). И если был получен успех, то тогда диспатчится экшен loadSuccess, который принимает список новостей, иначе испускается экшен loadFailure, который принимает ошибку.

Стоит отметить, что используется специальный оператор fetch из Nx.

Данный оператор позволяет по особому обрабатывать запросы. Подробнее в документации — Using Data Persistence operators.

Последней частью является facade. Данный сервис используется в прямом своем назначении — скрывает внутреннюю реализацию redux, предоставляя прямой доступ к селекторам, а также позволяет создавать новые экшены.

Так как в проекте используется три реализации redux’а, то facade имеет абстрактный класс, который содержит в себе все требуемые свойства и методы:

Подробнее с методами и свойствами можно ознакомиться в предыдущей статье данного цикла — Создание базовых классов.

В ngrx фасад будет иметь следующую реализацию:

Для диспача событий используется сервис Store, а для подписки на экшены сервис Actions.

Тестирование Ngrx

В Ngrx тестируют селекторы, редьюсер, эффекты и фасад.

Так как часто приходится задавать значение state можно создать пару утилит, которые упростят создание и установку значений в state.

  • createGetState — типизированная функция для задания значения в state;
  • createGetEntityState — типизированная функция для задания значений в state, который расширяет EntityState;

Тестирование селекторов

Основное задача тестов в селекторах — убедиться, что селектор связан или возвращает нужное значение из state.

Пример тестирования селекторов:

Для задания значения state создается константа getState:

const getState = createGetEntityState(initialPostState, postAdapter);

Для того чтобы получить state с сущностями, достаточно вызвать getState передав туда массив тестовых новостей POSTS_STUB:

state = getState({}, POSTS_STUB);

Тест задается следующим образом:

it('selectPosts() should return posts', () => {
state = getState({}, POSTS_STUB);
const result = PostSelectors.selectPosts.projector(state);

expect(result.length).toBe(POSTS_STUB.length);
});

Так как селектор выбирает значение из глобального стора, то для того чтобы не создавать тестовый стор целиком { feature: PostState }, достаточно вызвать projector у селектора, куда передать значение state.

Данный подход позволяет немного сократить количество кода и немного упростить тестирование.

Стоит отметить что нет явной потребности объявлять внутри describe еще один describe и создавать отдельные группы.

Чем более плоская структура тестов, тем проще ее поддерживать.

Тестирование редьюсера

Основное назначение тестов в редьюсере — убедиться, что конкретный экшен правильно меняет состояние стейта.

Пример тестирования редьюсера:

Как и в случае с селекторами, для задания значения стейта создается константа:

const getState = createGetEntityState(initialPostState, postAdapter);

Для каждого теста сбрасываем начальное состояние:

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

Простой тест выглядит следующим образом:

it('loadSuccess() should set posts', () => {
const action = PostActions.loadSuccess({ posts: POSTS_STUB });
const result = postReducer(state, action);

expect(result.ids.length).toBe(POSTS_STUB.length);
});

Данный тест проверяет состояние в случае успешной загрузки списка новостей. В данном случае должны установиться entities и ids.

В идеале нужно проверять что и entities соответствуют требуемому словарю. Однако это функциональность внешнего пакета — @ngrx/entity, и тестировать внешний пакет большого смысла не имеет. Смысл теста убедиться что сущности будут записаны в хранилище.

Тестирование эффектов

Основная задача при тестировании эффектов это убедиться, что вызываются требуемые экшены.

Примет тестирования эффектов:

Сначала задаются все требуемые сервисы для работы эффектов:

let actions: Observable<Action>;
let effects: PostEffects;
let postApiServiceMock: PostApiService;

beforeAll(() => {
postApiServiceMock = mock(postApiServiceMock);

TestBed.configureTestingModule({
providers: [
PostEffects,
provideMockActions(() => actions),
provideMockStore({
initialState: { [POST_FEATURE_KEY]: initialPostState },
}),
providerOf(PostApiService, postApiServiceMock),
],
});

effects = TestBed.inject(PostEffects);
});
  • actions — объект, который будет являтся источником испускаемых экшенов;
  • effects — сервис эффектов, который должен быть протестирован;
  • postApiServiceMock — мок для API сервиса новостей;
  • provideMockActions — утилита, которая связывает actions с испускаемыми экшенами;
  • provideMockStore — мок сервис для хранилища ngrx;
  • providerOf — утилита для установки ts-mockito мок сервисов как сервисов Angular.

Тест для проверки успешной загрузки списка новостей:

it('load$ should return loadSuccess()', () => {
actions = hot('a', { a: PostActions.load() });

const expected = hot('a', { a: PostActions.loadSuccess({ posts: POSTS_STUB }) });
when(postApiServiceMock.get()).thenReturn(hot('a', { a: POSTS_STUB }));

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

Сначала в actions задается поток экшенов. Затем создается expected — экшен который должен быть испущен в случае успешного получения списка новостей. Для API запроса postApiServiceMock.get() задаем ответ в виде списка новостей. В конце сравниваем результат с ожидаемым ответом.

Для случая с ошибкой, меняется только ответ от апи сервиса:

it('load$ should return loadFailure()', () => {
actions = hot('a', { a: PostActions.load() });

const expected = hot('a', { a: PostActions.loadFailure({ error: ERROR_STUB }) });
when(postApiServiceMock.get()).thenReturn(hot('#', {}, ERROR_STUB));

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

Тестирование фасада

Дефолтные тесты для фасада от Nx слишком громоздкие. В подходе от nx проверяется весь redux, начиная от экшенов, заканчивая эффектами.

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

Пример тестирования фасада новостей:

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

let actions: Observable<Action>;
let mockStore: MockStore;
let facade: NgrxPostFacade;
let dispatchSpy: jest.SpyInstance;

beforeAll(() => {
TestBed.configureTestingModule({
providers: [
NgrxPostFacade,
provideMockActions(() => actions),
provideMockStore({
initialState: { [POST_FEATURE_KEY]: initialPostState },
selectors: [
{ selector: PostSelectors.selectPostState, value: { entities: POSTS_ENTITIES_STUB } },
{ selector: PostSelectors.selectLoaded, value: true },
{ selector: PostSelectors.selectPosts, value: POSTS_STUB },
{ selector: PostSelectors.selectLastPosts, value: POSTS_STUB },
{ selector: PostSelectors.selectPopularPosts, value: POSTS_STUB },
{ selector: PostSelectors.selectPromoPosts, value: POSTS_STUB },
{ selector: PostSelectors.selectPostById(POST_STUB.uuid), value: POST_STUB },
],
}),
],
});

facade = TestBed.inject(NgrxPostFacade);
mockStore = TestBed.inject(MockStore);
dispatchSpy = jest.spyOn(mockStore, 'dispatch');
});

Пример тестирования свойств, которые ссылаются на селекторы:

it('should return loaded$', () => {
const expected = hot('a', { a: true });

expect(facade.loaded$).toBeObservable(expected);
});

Значение берется из store, где в моке задано значение для loaded:

{ selector: PostSelectors.selectLoaded, value: true }

Тестирование подписок на экшены:

it('should emit loadSuccess$', () => {
const action = PostActions.loadSuccess({ posts: POSTS_STUB });

actions = hot('a', { a: action });
const expected = hot('a', { a: POSTS_STUB });

expect(facade.loadSuccess$).toBeObservable(expected);
});

Подход тестирования совпадает с тестированием эффектов.

Пример тестирования вызова экшенов:

it('load() should dispatch action', () => {
facade.load();

expect(dispatchSpy).toHaveBeenCalledWith(PostActions.load());
});

dispatchSpy — это jest.SpyInstance, который связывается со стором:

mockStore = TestBed.inject(MockStore);
dispatchSpy = jest.spyOn(mockStore, 'dispatch');

Выше описанное позволяет тестировать все части redux в ngrx.

Ссылки

Оглавление

Предыдущая статья — Рекомендации по организации state.

Следующая статья — Использование Ngxs.

Все исходники находятся на github, в репозитории:

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

Подписывайтесь на блог, чтобы не пропустить новые статьи про Angular, и веб-разработку. Medium | Telegram| VK |Tw| Ln

--

--

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

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