Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions modules/signals/spec/state-source.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@ import {
effect,
EnvironmentInjector,
Injectable,
signal,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
getState,
isWritableStateSource,
patchState,
signalState,
signalStore,
StateSource,
watchState,
withHooks,
withMethods,
withState,
WritableStateSource,
} from '../src';
import { STATE_SOURCE } from '../src/state-source';
import { createLocalService } from './helpers';
Expand All @@ -32,6 +36,24 @@ describe('StateSource', () => {
[SECRET]: 'secret',
};

describe('isWritableStateSource', () => {
it('returns true for a writable StateSource', () => {
const stateSource: StateSource<typeof initialState> = {
[STATE_SOURCE]: signal(initialState),
};

expect(isWritableStateSource(stateSource)).toBe(true);
});

it('returns false for a readonly StateSource', () => {
const stateSource: StateSource<typeof initialState> = {
[STATE_SOURCE]: signal(initialState).asReadonly(),
};

expect(isWritableStateSource(stateSource)).toBe(false);
});
});

describe('patchState', () => {
[
{
Expand Down
1 change: 1 addition & 0 deletions modules/signals/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {
} from './signal-store-models';
export {
getState,
isWritableStateSource,
PartialStateUpdater,
patchState,
StateSource,
Expand Down
12 changes: 12 additions & 0 deletions modules/signals/src/state-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
DestroyRef,
inject,
Injector,
isSignal,
Signal,
untracked,
WritableSignal,
Expand All @@ -29,6 +30,17 @@ export type StateWatcher<State extends object> = (
state: NoInfer<State>
) => void;

export function isWritableStateSource<State extends object>(
stateSource: StateSource<State>
): stateSource is WritableStateSource<State> {
return (
'set' in stateSource[STATE_SOURCE] &&
'update' in stateSource[STATE_SOURCE] &&
typeof stateSource[STATE_SOURCE].set === 'function' &&
typeof stateSource[STATE_SOURCE].update === 'function'
);
}

export function patchState<State extends object>(
stateSource: WritableStateSource<State>,
...updaters: Array<
Expand Down
1 change: 1 addition & 0 deletions modules/signals/testing/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './src/index';
5 changes: 5 additions & 0 deletions modules/signals/testing/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "index.ts"
}
}
12 changes: 12 additions & 0 deletions modules/signals/testing/spec/types/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const compilerOptions = () => ({
moduleResolution: 'node',
target: 'ES2022',
baseUrl: '.',
experimentalDecorators: true,
strict: true,
noImplicitAny: true,
paths: {
'@ngrx/signals': ['./modules/signals'],
'@ngrx/signals/testing': ['./modules/signals/testing'],
},
});
53 changes: 53 additions & 0 deletions modules/signals/testing/spec/types/uprotected.types.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { expecter } from 'ts-snippet';
import { compilerOptions } from './helpers';

describe('unprotected', () => {
const expectSnippet = expecter(
(code) => `
import { computed, inject } from '@angular/core';
import { signalStore, withState, withComputed } from '@ngrx/signals';
import { unprotected } from '@ngrx/signals/testing';

${code}
`,
compilerOptions()
);

it('replaces StateSource with WritableStateSource', () => {
const snippet = `
const CounterStore = signalStore(
withState({ count: 0 }),
withComputed(({ count }) => ({
doubleCount: computed(() => count() * 2),
})),
);

const store = inject(CounterStore);
const unprotectedStore = unprotected(store);
`;

expectSnippet(snippet).toSucceed();
expectSnippet(snippet).toInfer(
'unprotectedStore',
'{ count: Signal<number>; doubleCount: Signal<number>; [STATE_SOURCE]: WritableSignal<{ count: number; }>; }'
);
});

it('does not affect the store with an unprotected state', () => {
const snippet = `
const CounterStore = signalStore(
{ protectedState: false },
withState({ count: 0 }),
);

const store = inject(CounterStore);
const unprotectedStore = unprotected(store);
`;

expectSnippet(snippet).toSucceed();
expectSnippet(snippet).toInfer(
'unprotectedStore',
'{ count: Signal<number>; [STATE_SOURCE]: WritableSignal<{ count: number; }>; }'
);
});
});
29 changes: 29 additions & 0 deletions modules/signals/testing/spec/unprotected.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore, StateSource, withState } from '@ngrx/signals';
import { STATE_SOURCE } from '../../src/state-source';
import { unprotected } from '../src';

describe('unprotected', () => {
it('returns writable state source', () => {
const CounterStore = signalStore(
{ providedIn: 'root' },
withState({ count: 0 })
);

const counterStore = TestBed.inject(CounterStore);
patchState(unprotected(counterStore), { count: 1 });

expect(counterStore.count()).toBe(1);
});

it('throws error when provided state source is not writable', () => {
const readonlySource: StateSource<{ count: number }> = {
[STATE_SOURCE]: signal({ count: 0 }).asReadonly(),
};

expect(() => unprotected(readonlySource)).toThrowError(
'@ngrx/signals: The provided source is not writable.'
);
});
});
1 change: 1 addition & 0 deletions modules/signals/testing/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { unprotected } from './unprotected';
23 changes: 23 additions & 0 deletions modules/signals/testing/src/unprotected.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {
isWritableStateSource,
Prettify,
StateSource,
WritableStateSource,
} from '@ngrx/signals';

type UnprotectedSource<Source extends StateSource<object>> =
Source extends StateSource<infer State>
? Prettify<
Omit<Source, keyof StateSource<State>> & WritableStateSource<State>
>
: never;

export function unprotected<Source extends StateSource<object>>(
source: Source
): UnprotectedSource<Source> {
if (isWritableStateSource(source)) {
return source as UnprotectedSource<Source>;
}

throw new Error('@ngrx/signals: The provided source is not writable.');
}
31 changes: 30 additions & 1 deletion projects/ngrx.io/content/guide/signals/signal-store/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ A key concern in testing is maintainability. The more tests are coupled to inter

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.

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

---

Expand Down Expand Up @@ -118,6 +118,35 @@ describe('MoviesStore', () => {

</code-example>

### `unprotected`

The `unprotected` function from the `@ngrx/signals/testing` plugin is used to update the protected state of a SignalStore for testing purposes.
This utility bypasses state encapsulation, making it possible to test state changes and their impacts.

```ts
// counter.store.ts
const CounterStore = signalStore(
{ providedIn: 'root' },
withState({ count: 1 }),
withComputed(({ count }) => ({
doubleCount: computed(() => count() * 2),
})),
);

// counter.store.spec.ts
import { TestBed } from '@angular/core/testing';
import { unprotected } from '@ngrx/signals/testing';

describe('CounterStore', () => {
it('recomputes doubleCount on count changes', () => {
const counterStore = TestBed.inject(CounterStore);

patchState(unprotected(counterStore), { count: 10 });
expect(counterStore.doubleCount()).toBe(20);
});
});
```

### `withComputed`

Testing derived values of `withComputed` is also straightforward.
Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@ngrx/signals": ["./modules/signals"],
"@ngrx/signals/entities": ["./modules/signals/entities"],
"@ngrx/signals/rxjs-interop": ["./modules/signals/rxjs-interop"],
"@ngrx/signals/testing": ["./modules/signals/testing"],
"@ngrx/signals/schematics-core": ["./modules/signals/schematics-core"],
"@ngrx/store": ["./modules/store"],
"@ngrx/store-devtools": ["./modules/store-devtools"],
Expand Down