Redux в Angular. Ngrx в действии: создание и тестирование.
В данной статье приводится реализация 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