はじめに
先日、NgRxのブログが更新され、ついに @ngrx/signals
が安定版としてリリースされました!これまで、@ngrx/signals
は登場直後に少し触っただけだったので、今回の正式リリースを機に改めて使ってみました。そして、その過程でテストの書き方についても探ってみました。まだまだ荒削りではありますが、その内容を共有したいと思います。
NgRxのおさらい
早速進める前に、まずはNgRxとは何かをおさらいしておきましょう。
NgRxは、Angularアプリケーションで利用できる状態管理用のライブラリで、ReduxのアーキテクチャをAngularに適用したものです。
状態管理のライブラリは他にもたくさんありますが、NgRxは初期の頃からあり、メンテナンスもよくされているため、多くのAngularプロジェクトで利用されています。
リリース当初は @ngrx/store
のみでしたが、その後、コンポーネントの状態管理をより簡単に行うことができる @ngrx/component-store
が登場し、個人的には「これを待っていた!」と思ったのを覚えています。
そして今回の @ngrx/signals
は、 @ngrx/component-store
のさらなる進化版として登場しました。その名の通り、AngularのSignalsをベースにしています。
主な違いをざっくりとまとめると以下のようになります。
-
@ngrx/store
: グローバルな状態管理に特化し、Reduxパターンを採用。 -
@ngrx/component-store
: 特定のコンポーネント内の状態管理を行い、コンポーネントのライフサイクルと同期。グローバルな状態管理も可能。 -
@ngrx/signals
:@ngrx/component-store
の後継で、Signalsとの統合によりローカルおよびグローバルの状態管理が可能。
使ってみる
それでは、実際に使ってみましょう。今回は実装したTodoアプリの一部を抜粋しながら説明します。
ただ、一つ一つ説明していたらいつまでもテストにたどり着かないので、ここでは実装のコードを紹介するだけにとどめます。(実装方法などもそのうち紹介できたら良いなと思っています)
import { signalStore, withComputed } from '@ngrx/signals';
import { withEntities } from '@ngrx/signals/entities';
export const TodoStore = signalStore(
// NOTE: テストを書くときに patchState で初期を渡すための設定
{ protectedState: false },
withEntities<Todo>(),
withComputed(({ entities }) => ({
todos: entities,
})),
withMethods((store, todoService = inject(TodoService)) => ({
addTodo: rxMethod<CreateTodoRequest>(
pipe(
switchMap((todo) =>
todoService.create(todo).pipe(
tapResponse({
next: (todo) => patchState(store, addEntity(todo!)),
error: () => EMPTY,
})
)
)
)
),
completeTodo: rxMethod<Todo['id']>(
pipe(
switchMap((id) =>
todoService.complete(id).pipe(
tapResponse({
next: (todo) =>
patchState(store, updateEntity({ id, changes: { ...todo } })),
error: () => EMPTY,
})
)
)
)
),
_loadTodos: rxMethod<void>(
pipe(
switchMap(() =>
todoService.loadAll().pipe(
tapResponse({
next: (todos) => patchState(store, setAllEntities(todos)),
error: () => EMPTY,
})
)
)
)
),
})),
withHooks((store) => {
return {
onInit() {
store._loadTodos();
},
};
})
);
@ngrx/signals
の実装を見たことがない人は「なんだこれ?」と思うかもしれません。僕も最初はそう思いました(笑)。とはいえ、なんとなく眺めていると今までNgRxで使われていた言葉に似ている部分が多いので、意外と理解できるのではないでしょうか?
今回は詳しく説明しませんので、気になった方は公式ドキュメントをご参照ください。
テストの書き方
ここから本題のテストの書き方について説明していきます。コードを紹介しつつ、困ったポイントも一緒に紹介できればと思います。
まずはStore以外の初期設定から始めます。
describe('TodoStore', () => {
let todoService: TodoService;
beforeEach(() =>
TestBed.configureTestingModule({
imports: [],
providers: [
{
provide: TodoService,
useValue: {
loadAll: jest.fn(),
create: jest.fn(),
complete: jest.fn(),
},
},
],
})
);
beforeEach(() => {
todoService = TestBed.inject(TodoService);
});
});
beforeEachで初期化出来ない
ここで早速問題が起きました。いつもは let store: TodoStore;
を宣言して、beforeEach
で初期化していたのですが、今回は signalStore
で作成したStoreが関数なので、beforeEach
で初期化することができません。
describe('TodoStore', () => {
let todoService: TodoService;
+ // let store: typeof TodoStore;
beforeEach(() =>
TestBed.configureTestingModule({
imports: [],
providers: [
+ TodoStore,
{
provide: TodoService,
useValue: {
loadAll: jest.fn(),
create: jest.fn(),
},
},
],
})
);
beforeEach(() => {
todoService = TestBed.inject(TodoService);
+ // NOTE: 型が一致せず、エラーになってしまう。
+ // store = TestBed.inject(TodoStore);
});
+
+ it('should be created', () => {
+ // NOTE: 都度初期化が必要
+ const store = TestBed.inject(TodoStore);
+
+ expect(store).toBeTruthy();
+ });
});
都度初期化をすれば良いのですが、少し面倒だなーと思ってしまいました。
ここでは、setup関数を用意して、初期化処理をまとめてしまうのも一つの手かもしれません。
withHookのテスト
次に、withHooks
で定義した onInit
のテストを書いてみます。これはAngularのコンポーネントの ngOnInit
のテストと同じような感じです。
describe('_loadTodos (onInit)', () => {
const todoOne: Todo = {
id: 1,
title: 'title',
completed: false,
};
const todoTwo: Todo = {
id: 2,
title: 'title',
completed: false,
};
it('should call TodoService.loadAll', () => {
TestBed.inject(TodoStore);
expect(todoService.loadAll).toHaveBeenCalledWith();
});
it('should set response to todos on success', () => {
jest
.spyOn(todoService, 'loadAll')
.mockImplementation(() => of([todoOne, todoTwo]));
const store = TestBed.inject(TodoStore);
expect(store.todos()).toContainEqual(todoOne);
expect(store.todos()).toContainEqual(todoTwo);
});
});
toHaveBeenCalled
を利用した呼び出しテストはいつもどおり書けたので、特に困ることはありませんでした。
実行結果のテストはObservableのときと比べてかなりシンプルになったと思います。MarbleやSubscribeがないだけで、かなり読みやすくなりました。
patchStateの呼び出しテストが出来ない
とはいえ、個人的には少し微妙なポイントがあります。
expect(store.todos()).toContainEqual(todoOne);
の箇所は、おそらく一般的な書き方だと思います。しかし、expect(ngrxSignal.patchState).toHaveBeenCalledWith(store, addEntities([todoOne, todoTwo]));
でも良いのでは・・・?と思ってしまいました。
patchState
が正しく動くかはライブラリ側で保証してくれているので、呼ばれてさえいればうまくいくという考え方もできます。初期データの用意などが不要なこちらの書き方ができたら、より嬉しかったポイントでした(ちゃんとデータが更新されたかまで見ろ、と怒られるかもしれませんが・・・笑)。
初期ステートが必要なテスト
addTodo
のテストは _loadTodos
とほとんど変わらないので割愛し、ここでは completeTodoのテストを書いてみます。
describe('completeTodo', () => {
const id = 1;
const todo: Todo = {
id,
title: 'title',
completed: false,
};
const updated: Todo = {
...todo,
completed: true,
};
it('should call TodoService.complete', () => {
const store = TestBed.inject(TodoStore);
store.completeTodo(id);
expect(todoService.complete).toHaveBeenCalledWith(id);
});
it("should update todo's completed on success", () => {
jest.spyOn(todoService, 'complete').mockImplementation(() => of(updated));
const store = TestBed.inject(TodoStore);
// NOTE: patchStateを呼んで初期ステートをセット
patchState(store, addEntity(todo));
store.completeTodo(id);
expect(store.todos()).toContainEqual(updated);
});
});
patchState(store, addEntity(todo));
の部分で、これから更新するデータをステートにセットしています。
これ自体は特に問題があるわけではないのですが、この patchState
を呼ぶためには signalStore({protectedState: false})
という設定が必要になります。この設定は本来不要なはずなのにテストのためだけに追加しているので、かなり違和感があります。
とはいえ、 store.addTodo
を毎度呼ぶのも(それ自体がテスト対象なので)なんとも言えない気持ちになりました。
まとめ
今回は、安定版がリリースされた @ngrx/signals
を使って、Todoアプリのテストの書き方を紹介しました。
@ngrx/signals
はまだ新しいライブラリであり、テストの書き方も定まっていない部分が多いです。実際に使ってみて、初期化の方法や patchState の呼び出しに少し工夫が必要でしたが、全体としてはシンプルで読みやすいコードが書けました。
皆さんもぜひ @ngrx/signals
を試してみて、最適なテストの書き方を模索してみてください。公式ドキュメントを参考にしながら、学びを深めましょう!こんな書き方良いよ〜というおすすめの方法があれば、ぜひ教えてもらえると嬉しいです。
また、今回は実装の一部を抜粋していますが、全体の実装はこちらで公開しているのでぜひ見てみてください。