Skip to content

Commit 01c2327

Browse files
feat(signals): add unprotected testing helper (#4725)
1 parent 32f4e81 commit 01c2327

File tree

12 files changed

+190
-1
lines changed

12 files changed

+190
-1
lines changed

modules/signals/spec/state-source.spec.ts

+22
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,21 @@ import {
33
effect,
44
EnvironmentInjector,
55
Injectable,
6+
signal,
67
} from '@angular/core';
78
import { TestBed } from '@angular/core/testing';
89
import {
910
getState,
11+
isWritableStateSource,
1012
patchState,
1113
signalState,
1214
signalStore,
15+
StateSource,
1316
watchState,
1417
withHooks,
1518
withMethods,
1619
withState,
20+
WritableStateSource,
1721
} from '../src';
1822
import { STATE_SOURCE } from '../src/state-source';
1923
import { createLocalService } from './helpers';
@@ -32,6 +36,24 @@ describe('StateSource', () => {
3236
[SECRET]: 'secret',
3337
};
3438

39+
describe('isWritableStateSource', () => {
40+
it('returns true for a writable StateSource', () => {
41+
const stateSource: StateSource<typeof initialState> = {
42+
[STATE_SOURCE]: signal(initialState),
43+
};
44+
45+
expect(isWritableStateSource(stateSource)).toBe(true);
46+
});
47+
48+
it('returns false for a readonly StateSource', () => {
49+
const stateSource: StateSource<typeof initialState> = {
50+
[STATE_SOURCE]: signal(initialState).asReadonly(),
51+
};
52+
53+
expect(isWritableStateSource(stateSource)).toBe(false);
54+
});
55+
});
56+
3557
describe('patchState', () => {
3658
[
3759
{

modules/signals/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export {
1212
} from './signal-store-models';
1313
export {
1414
getState,
15+
isWritableStateSource,
1516
PartialStateUpdater,
1617
patchState,
1718
StateSource,

modules/signals/src/state-source.ts

+12
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
DestroyRef,
44
inject,
55
Injector,
6+
isSignal,
67
Signal,
78
untracked,
89
WritableSignal,
@@ -29,6 +30,17 @@ export type StateWatcher<State extends object> = (
2930
state: NoInfer<State>
3031
) => void;
3132

33+
export function isWritableStateSource<State extends object>(
34+
stateSource: StateSource<State>
35+
): stateSource is WritableStateSource<State> {
36+
return (
37+
'set' in stateSource[STATE_SOURCE] &&
38+
'update' in stateSource[STATE_SOURCE] &&
39+
typeof stateSource[STATE_SOURCE].set === 'function' &&
40+
typeof stateSource[STATE_SOURCE].update === 'function'
41+
);
42+
}
43+
3244
export function patchState<State extends object>(
3345
stateSource: WritableStateSource<State>,
3446
...updaters: Array<

modules/signals/testing/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './src/index';
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"lib": {
3+
"entryFile": "index.ts"
4+
}
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const compilerOptions = () => ({
2+
moduleResolution: 'node',
3+
target: 'ES2022',
4+
baseUrl: '.',
5+
experimentalDecorators: true,
6+
strict: true,
7+
noImplicitAny: true,
8+
paths: {
9+
'@ngrx/signals': ['./modules/signals'],
10+
'@ngrx/signals/testing': ['./modules/signals/testing'],
11+
},
12+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { expecter } from 'ts-snippet';
2+
import { compilerOptions } from './helpers';
3+
4+
describe('unprotected', () => {
5+
const expectSnippet = expecter(
6+
(code) => `
7+
import { computed, inject } from '@angular/core';
8+
import { signalStore, withState, withComputed } from '@ngrx/signals';
9+
import { unprotected } from '@ngrx/signals/testing';
10+
11+
${code}
12+
`,
13+
compilerOptions()
14+
);
15+
16+
it('replaces StateSource with WritableStateSource', () => {
17+
const snippet = `
18+
const CounterStore = signalStore(
19+
withState({ count: 0 }),
20+
withComputed(({ count }) => ({
21+
doubleCount: computed(() => count() * 2),
22+
})),
23+
);
24+
25+
const store = inject(CounterStore);
26+
const unprotectedStore = unprotected(store);
27+
`;
28+
29+
expectSnippet(snippet).toSucceed();
30+
expectSnippet(snippet).toInfer(
31+
'unprotectedStore',
32+
'{ count: Signal<number>; doubleCount: Signal<number>; [STATE_SOURCE]: WritableSignal<{ count: number; }>; }'
33+
);
34+
});
35+
36+
it('does not affect the store with an unprotected state', () => {
37+
const snippet = `
38+
const CounterStore = signalStore(
39+
{ protectedState: false },
40+
withState({ count: 0 }),
41+
);
42+
43+
const store = inject(CounterStore);
44+
const unprotectedStore = unprotected(store);
45+
`;
46+
47+
expectSnippet(snippet).toSucceed();
48+
expectSnippet(snippet).toInfer(
49+
'unprotectedStore',
50+
'{ count: Signal<number>; [STATE_SOURCE]: WritableSignal<{ count: number; }>; }'
51+
);
52+
});
53+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { signal } from '@angular/core';
2+
import { TestBed } from '@angular/core/testing';
3+
import { patchState, signalStore, StateSource, withState } from '@ngrx/signals';
4+
import { STATE_SOURCE } from '../../src/state-source';
5+
import { unprotected } from '../src';
6+
7+
describe('unprotected', () => {
8+
it('returns writable state source', () => {
9+
const CounterStore = signalStore(
10+
{ providedIn: 'root' },
11+
withState({ count: 0 })
12+
);
13+
14+
const counterStore = TestBed.inject(CounterStore);
15+
patchState(unprotected(counterStore), { count: 1 });
16+
17+
expect(counterStore.count()).toBe(1);
18+
});
19+
20+
it('throws error when provided state source is not writable', () => {
21+
const readonlySource: StateSource<{ count: number }> = {
22+
[STATE_SOURCE]: signal({ count: 0 }).asReadonly(),
23+
};
24+
25+
expect(() => unprotected(readonlySource)).toThrowError(
26+
'@ngrx/signals: The provided source is not writable.'
27+
);
28+
});
29+
});

modules/signals/testing/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { unprotected } from './unprotected';
+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import {
2+
isWritableStateSource,
3+
Prettify,
4+
StateSource,
5+
WritableStateSource,
6+
} from '@ngrx/signals';
7+
8+
type UnprotectedSource<Source extends StateSource<object>> =
9+
Source extends StateSource<infer State>
10+
? Prettify<
11+
Omit<Source, keyof StateSource<State>> & WritableStateSource<State>
12+
>
13+
: never;
14+
15+
export function unprotected<Source extends StateSource<object>>(
16+
source: Source
17+
): UnprotectedSource<Source> {
18+
if (isWritableStateSource(source)) {
19+
return source as UnprotectedSource<Source>;
20+
}
21+
22+
throw new Error('@ngrx/signals: The provided source is not writable.');
23+
}

projects/ngrx.io/content/guide/signals/signal-store/testing.md

+30-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ A key concern in testing is maintainability. The more tests are coupled to inter
1919

2020
For example, when testing the store in a loading state, avoid directly setting the loading property. Instead, trigger a loading method and assert against an exposed computed property or slice. This approach reduces dependency on internal implementations, such as properties set during the loading state.
2121

22-
From this perspective, private properties or methods of the SignalStore should not be accessed. Additionally, avoid running `patchState` if the state is protected.
22+
From this perspective, private properties or methods of the SignalStore should not be accessed.
2323

2424
---
2525

@@ -118,6 +118,35 @@ describe('MoviesStore', () => {
118118

119119
</code-example>
120120

121+
### `unprotected`
122+
123+
The `unprotected` function from the `@ngrx/signals/testing` plugin is used to update the protected state of a SignalStore for testing purposes.
124+
This utility bypasses state encapsulation, making it possible to test state changes and their impacts.
125+
126+
```ts
127+
// counter.store.ts
128+
const CounterStore = signalStore(
129+
{ providedIn: 'root' },
130+
withState({ count: 1 }),
131+
withComputed(({ count }) => ({
132+
doubleCount: computed(() => count() * 2),
133+
})),
134+
);
135+
136+
// counter.store.spec.ts
137+
import { TestBed } from '@angular/core/testing';
138+
import { unprotected } from '@ngrx/signals/testing';
139+
140+
describe('CounterStore', () => {
141+
it('recomputes doubleCount on count changes', () => {
142+
const counterStore = TestBed.inject(CounterStore);
143+
144+
patchState(unprotected(counterStore), { count: 10 });
145+
expect(counterStore.doubleCount()).toBe(20);
146+
});
147+
});
148+
```
149+
121150
### `withComputed`
122151

123152
Testing derived values of `withComputed` is also straightforward.

tsconfig.json

+1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"@ngrx/signals": ["./modules/signals"],
4747
"@ngrx/signals/entities": ["./modules/signals/entities"],
4848
"@ngrx/signals/rxjs-interop": ["./modules/signals/rxjs-interop"],
49+
"@ngrx/signals/testing": ["./modules/signals/testing"],
4950
"@ngrx/signals/schematics-core": ["./modules/signals/schematics-core"],
5051
"@ngrx/store": ["./modules/store"],
5152
"@ngrx/store-devtools": ["./modules/store-devtools"],

0 commit comments

Comments
 (0)