В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для чтения благодаря своей простоте. Кроме того, мы всегда хотим проверить самые последние возможности в наших проектах!
Это совершенно нормально, ведь новые возможности появляются не просто так. Цель - облегчить жизнь разработчикам, но за это всегда приходится платить. Мы должны начать исследовать неизведанные воды, и на нашем пути могут встретиться препятствия, это точно.
В случае функциональных эффектов наши юнит-тесты могут несколько отличаться от классовых, но в принципе цель одна и та же.
Если мы познакомимся с разными подходами, нас уже ничто не будет сдерживать.
И да, давайте исследовать неизведанные области!
Начнем с базового примера.
Если вы хотите узнать больше о функциональных эффектах, посмотрите одну из моих статей (в ней я подробно рассказываю о точно таком же эффекте :-).
Как я настроил NgRx в Angular 16 с отдельными компонентами
Вот как выглядит наш функциональный эффект:
import { inject } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { deleteMessage, deleteMessageError, deleteMessageSuccess } from './messages.actions'; import { catchError, map, mergeMap } from 'rxjs'; import { MessagesApiService } from '../../shared'; export const deleteMessage$ = createEffect( ( actions$: Actions = inject(Actions), messagesApiService: MessagesApiService = inject(MessagesApiService) ) => actions$.pipe( ofType(deleteMessage), mergeMap(({ id }) => messagesApiService.deleteMessage(id).pipe( map(() => deleteMessageSuccess()), catchError(() => [deleteMessageError()]) ) ) ), { functional: true } );
Функционально мы используем наш сервис MessagesApiService для запуска запроса httpDELETE. Если вызов API прошел успешно, мы хотим вернуть deleteMessageSuccess, в противном случае будет возвращена deleteMessageError. Мы хотим протестировать оба пути, успешный и неудачный.
Наш MessageApiService не может быть проще, он выглядит следующим образом:
import { inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class MessagesApiService { private readonly http: HttpClient = inject(HttpClient); deleteMessage(id: string): Observable<void> { return this.http.delete<void>(`/${id}`); } }
Мы будем использовать подход Observable<Action> для тестирования обоих сценариев, поскольку нет необходимости диспетчеризировать несколько действий для установки сцены. Мы просто хотим отправить действие deleteMessage с некоторым фиктивным идентификатором и проверить результат.
Начнем с начальных настроек на нашей стороне:
import { Observable } from 'rxjs'; import { MessagesApiService } from './../../shared/services'; import { Action } from '@ngrx/store'; describe('Messages Effects', (): void => { let messagesApiServiceMock: MessagesApiService; let actions$: Observable<Action>; });
Теперь пришло время создать успешный тестовый пример, как показано ниже:
import { Observable, of, take, throwError } from 'rxjs'; import { MessagesApiService } from './../../shared/services'; import { deleteMessage, deleteMessageError, deleteMessageSuccess, deleteMessage$ } from './index'; import { TestBed } from '@angular/core/testing'; import { Action } from '@ngrx/store'; describe('Messages Effects', (): void => { let messagesApiServiceMock: MessagesApiService; let actions$: Observable<Action>; it('should dispatch success action when message deletion succeed', (done: jest.DoneCallback): void => { messagesApiServiceMock = { deleteMessage: () => of(null), } as unknown as MessagesApiService; actions$ = of(deleteMessage({ id: '23' })); TestBed.runInInjectionContext((): void => { deleteMessage$(actions$, messagesApiServiceMock) .pipe(take(1)) .subscribe(action => { expect(action).toEqual(deleteMessageSuccess()); done(); }); }); }); });
Давайте подробнее рассмотрим сам тестовый пример. Мы будем использовать обратный вызов done(), чтобы указать нужный момент времени, когда наш тестовый пример должен быть завершен.
Мы присваиваем значение нашей переменной messagesApiServiceMock, в которой объявляем метод deleteMessage, для типизации мы должны привести тип, так как это практически все, что нам нужно в нашем тесте. Если вы хотите поиздеваться над всем сервисом, вы можете это сделать. Но сейчас это не тот случай.
Далее мы присваиваем фактическому вызову действия, расположенному чуть ниже, некий фиктивный идентификатор .
Поскольку мы используем inject в нашем MessagesApiService, нам необходимо запускать наш тест в InjectionContext, поэтому мы оборачиваем нашу подписку на эффект методом TestBed.runInInjectionContext.
Внутри контекста инъекции нам необходимо подписаться на наш функциональный эффект deleteMessage$ . Поскольку нам нужен только один выброс, используется оператор take(1). В рамках подписки мы сравниваем полученное действие с ожидаемым результатом deleteMessageSuccess. Как только утверждение произошло, мы готовы вызвать оператор done() и завершить тест.
Наша следующая цель - проверить, решается ли наш сценарий ошибки так, как задумано.
import { Observable, of, take, throwError } from 'rxjs'; import { MessagesApiService } from './../../shared/services'; import { deleteMessage, deleteMessageError, deleteMessageSuccess, deleteMessage$ } from './index'; import { TestBed } from '@angular/core/testing'; import { Action } from '@ngrx/store'; describe('Messages Effects', (): void => { let messagesApiServiceMock: MessagesApiService; let actions$: Observable<Action>; it('should dispatch error action when message deletion has failed', (done: jest.DoneCallback): void => { messagesApiServiceMock = { deleteMessage: (id: string): Observable<void> => throwError(() => new Error()), } as MessagesApiService; actions$ = of(deleteMessage({ id: '23' })); TestBed.runInInjectionContext(() => { deleteMessage$(actions$, messagesApiServiceMock) .pipe(take(1)) .subscribe(action => { expect(action).toEqual(deleteMessageError()); done(); }); }); }); });
Реализация тестового случая ошибки не будет сильно отличаться от успешного сценария, нам просто нужно будет бросать ошибку (throwError) при вызове метода deleteMessage. Также может отличаться наше утверждение, поскольку мы ожидаем возврата действия deleteMessageError.
Вот и весь тест с двумя случаями:
import { Observable, of, take, throwError } from 'rxjs'; import { MessagesApiService } from './../../shared/services'; import { deleteMessage, deleteMessageError, deleteMessageSuccess, deleteMessage$ } from './index'; import { TestBed } from '@angular/core/testing'; import { Action } from '@ngrx/store'; describe('Messages Effects', (): void => { let messagesApiServiceMock: MessagesApiService; let actions$: Observable<Action>; it('should dispatch success action when message deletion succeed', (done: jest.DoneCallback): void => { messagesApiServiceMock = { deleteMessage: () => of(null), } as unknown as MessagesApiService; actions$ = of(deleteMessage({ id: '23' })); TestBed.runInInjectionContext((): void => { deleteMessage$(actions$, messagesApiServiceMock) .pipe(take(1)) .subscribe(action => { expect(action).toEqual(deleteMessageSuccess()); done(); }); }); }); it('should dispatch error action when message deletion has failed', (done: jest.DoneCallback): void => { messagesApiServiceMock = { deleteMessage: (id: string): Observable<void> => throwError(() => new Error()), } as MessagesApiService; actions$ = of(deleteMessage({ id: '23' })); TestBed.runInInjectionContext(() => { deleteMessage$(actions$, messagesApiServiceMock) .pipe(take(1)) .subscribe(action => { expect(action).toEqual(deleteMessageError()); done(); }); }); }); });
Это не единственный способ тестирования функциональных эффектов. Мы также можем использовать ActionsSubject из библиотеки @ngrx/store. Этот способ особенно полезен, если нам нужно отправить еще несколько действий, чтобы настроить наш тестовый этап для какого-то более точного случая использования.
Вот как выглядит наш функциональный эффект:
import { Actions, createEffect, ofType } from '@ngrx/effects'; import { inject } from '@angular/core'; import { BlogApiService } from '../../blog'; import { addPosts, loadPosts } from './posts.actions'; import { map, mergeMap } from 'rxjs'; import { Post } from '../../blog/models'; export const loadPosts$ = createEffect( ( actions$: Actions = inject(Actions), blogApiService: BlogApiService = inject(BlogApiService)) => actions$.pipe( ofType(loadPosts), mergeMap(() => blogApiService.getRssData() .pipe(map((posts: Post[]) => addPosts({ posts })))) ), { functional: true } );
В данном случае мы используем другой ApiService, который имеет публичный метод getRssData(), так как случай ошибки будет выглядеть точно так же, мы будем тестировать успешный сценарий.
import { ActionsSubject } from '@ngrx/store'; import { of, take } from 'rxjs'; import { loadPosts$, addPosts, loadPosts } from './index'; import { TestBed } from '@angular/core/testing'; import { BlogApiService } from './../../blog/services'; describe('Posts Effects', (): void => { describe('loadPosts$', (): void => { let blogApiServiceMock: BlogApiService; const actions: ActionsSubject = new ActionsSubject(); it('should dispatch addPosts action when api call was successful', (done: jest.DoneCallback): void => { blogApiServiceMock = { getRssData: () => of([{ id: '123' }]), } as unknown as BlogApiService; actions.next(loadPosts()); TestBed.runInInjectionContext((): void => { loadPosts$(actions, blogApiServiceMock) .pipe(take(1)) .subscribe((action): void => { expect(action).toEqual({ type: addPosts.type, posts: [{ id: '123' }], }); done(); }); }); }); }); });
Вместо того чтобы использовать подход Observable<Action>, мы создаем экземпляр ActionsSubject, который затем будем вызывать из нашего тестового примера.
Мы должны сымитировать наш ответ BlogApiService.getRssData() и затем сравнить его с реальными значениями атрибутов записей, переданными в случае успеха addPosts. Небольшое отличие заключается в том, что в случае с ActionsSubject мы вызываем метод actions.next() и передаем в качестве аргумента желаемое действие. В нашем случае это loadPosts().
Примечание:
Я использовал два описания одно в другом, так как в моем файле posts.effects.ts больше эффектов, поэтому имеет смысл сделать различие и немного упростить управление.
Для справки, мы не использовали никаких внешних библиотек тестирования. Ничего такого, что уже было бы в нашей кодовой базе. Многие разработчики продолжают использовать множество различных библиотек, чтобы сделать тестирование "проще". Но я предпочитаю придерживаться "родного" подхода. Поскольку дополнительная библиотека - это еще один слой абстракции, который нужно преодолевать/поддерживать. Но приносят ли они реальную пользу?
Нужна консультация по вашему проекту? Не испортите свое приложение и свяжитесь с нами :-)
Камиль Конопка - тренер
20.08.2023 18:21
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в 2023-2024 годах? Или это полная лажа?".
20.08.2023 17:46
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
19.08.2023 18:39
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в частности, магию поплавков и гибкость flexbox.
18.08.2023 20:33
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий их языку и культуре.
14.08.2023 14:49
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип предназначен для представления неделимого значения.
05.08.2023 16:43
В компьютерном программировании биты играют важнейшую роль в представлении и манипулировании данными на двоичном уровне. Побитовые операции обеспечивают мощный способ выполнения операций на битовом уровне, предлагая более эффективные решения для определенных задач. В этом блоге мы рассмотрим, как...