skip to content

With an NgRx SignalStore whose state is protected, how does a unit test set the store's state directly, and when should it?

level: seniorimportance: nice to knowfreq 22%

answer

  1. protected by default
  2. a type error, not a crash
  3. @ngrx/signals/testing
  4. same instance, writable view
  5. public methods first

basics

~10 s

Wrap the store in unprotected() from @ngrx/signals/testing and call patchState on it; use that only to arrange state no public method can reach, and prefer driving the store through its own methods.

solid answer

~40 s

A SignalStore's state is protected by default: the injected instance is typed as a read-only state source, so `patchState(store, …)` from a test does not compile. `unprotected(store)` from `@ngrx/signals/testing` returns the same store typed as writable, so `patchState(unprotected(store), { country: 'DE', postcode: '10115' })` works and the store's computed signals react. I use it to arrange a state that the public API cannot produce cheaply, for example to test a `withComputed` value such as `canSubmit` in isolation. Otherwise I test through public methods, because tests that set internals couple themselves to the state shape. I would not switch the store to `protectedState: false` for tests: that loosens production code. And a component that uses the store gets a plain mock object via `useValue`, not this.

code

ts · 17 lines
ts
import { TestBed } from '@angular/core/testing';
import { patchState } from '@ngrx/signals';
import { unprotected } from '@ngrx/signals/testing';
import { ShippingFormStore } from './shipping-form.store';

describe('ShippingFormStore', () => {
  it('enables submit once country and postcode are set', () => {
    TestBed.configureTestingModule({ providers: [ShippingFormStore] });
    const store = TestBed.inject(ShippingFormStore);
    expect(store.canSubmit()).toBe(false);

    // patchState(store, ...) would not compile: the state is protected
    patchState(unprotected(store), { country: 'DE', postcode: '10115' });

    expect(store.canSubmit()).toBe(true);
  });
});

go deeper

for a junior

Recall that SignalStore state is protected by default and that tests use unprotected from @ngrx/signals/testing to patch it.

for a middle

Explain that the guard is a type contract: the store is typed read-only for patchState, and unprotected returns the same instance typed as writable.

for a senior

Use unprotected only to arrange state the public API cannot reach, keep protectedState on in production code, and mock the store with useValue in component tests.

for a principal

Set the team's SignalStore testing conventions: public API by default, unprotected as a documented exception, and store mocks for component tests.

## Protected state in a SignalStore NgRx's **SignalStore** (`signalStore` from `@ngrx/signals`) builds an injectable store out of features such as `withState`, `withComputed` and `withMethods`. Its state lives in signals, and it changes through `patchState(store, partialOrUpdater)`. By default the state is **protected**: only the store's own methods may call `patchState` on it. The NgRx guide presents this as the recommended approach, and the `signalStore` config option `protectedState: false` opts out. Reading the NgRx 22 source shows how the guard works. The store's type is a read-only `StateSource`, while `patchState` requires a `WritableStateSource`. Code outside the store therefore gets a **TypeScript compile error**; at runtime the underlying signals are ordinary writable signals. The protection is a type-level contract, not a freeze. ## The unprotected() helper `@ngrx/signals/testing` exports one function, `unprotected` (added in NgRx 19.1). It takes the store instance and returns **the same object** typed as writable. It checks at runtime that the store's state signals are writable and throws otherwise. Nothing is copied: patching the returned value patches the injected store, and every `computed` and template that reads it reacts. For a checkout shipping form: ```ts const ShippingFormStore = signalStore( withState({ country: '', postcode: '', express: false }), withComputed(({ country, postcode }) => ({ canSubmit: () => country() !== '' && postcode().length >= 3, })), ); ``` A test injects the store, patches `country` and `postcode` through `unprotected(store)` and asserts `canSubmit()`. ## When to use it, and when not to The NgRx testing guide's first principle is **public API only**: assert on what the store exposes and drive it through its methods. So: 1. **Prefer public methods.** If the store has `setAddress(country, postcode)`, call that; the test then also covers the method. 2. **Use `unprotected` to arrange** a state that no public method produces, or produces only through a long, slow sequence, such as a computed that combines many fields. 3. **Do not flip `protectedState: false`** to make tests easier; that removes the guard for application code too. 4. **Do not spy on the store's own methods**; the guide recommends extracting complex logic into a service the test can fake. ## Tests on the other side of the store | Test subject | What to provide | Tool | |---|---|---| | The SignalStore itself | the real store, fake services | `TestBed.inject`, `unprotected` for arranging | | A component using the store | a plain object with the same signals and methods | `{ provide: ShippingFormStore, useValue: mock }` | | A component using the global `Store` | a mock global store | `provideMockStore` | The last row matters because the names overlap: `provideMockStore`, `overrideSelector` and `MockStore` belong to the global `Store` in `@ngrx/store/testing` and do nothing for a SignalStore. ## Summary - Protected state is the default and is enforced by types. - `unprotected()` is a test-only writable view of the same instance. - Use it to arrange state, not as a substitute for exercising the store's methods. ## Common mistakes - **Reaching for the global Store's tools.** `provideMockStore` and `overrideSelector` belong to `@ngrx/store/testing`; they do not affect a SignalStore, so a test that uses them passes while testing nothing. - **Instantiating the store with `new`.** A SignalStore is an Angular service, and features such as `rxMethod`, `signalMethod` and `inject()` inside `withMethods` need an injection context; the NgRx guide injects it with `TestBed` instead. - **Patching keys that are not in the initial state.** `patchState` ignores unknown keys and warns in development mode, so a misspelled key leaves the store unchanged while the test author assumes otherwise. `unprotected()` keeps the state type, which catches most of these at compile time. - **Using `unprotected()` in application code.** It lives in a testing entry point for a reason; production code that needs to write state should do it through a store method. ## How it fits with the rest of the leaf The global `Store` is tested in layers: reducers and projectors as pure functions, components against `MockStore`, effects against `provideMockActions`. A SignalStore collapses state, derivation and methods into one service, so its tests look like service tests. `unprotected()` is the one NgRx-specific tool it needs, and only for arranging state.

  • If protected state is only a type-level guard, why not cast the NgRx SignalStore with 'as any' instead of unprotected()?
    A cast silences every type check on that line, including the shape of the patch. `unprotected()` keeps the state type, so a misspelled key or wrong value type still fails to compile, and it checks at runtime that the source is writable. It also documents intent: readers see a test deliberately reaching past protection.
  • How would you test a component that injects the NgRx ShippingFormStore?
    Provide a plain object with the same surface through `{ provide: ShippingFormStore, useValue: mock }`: signals for state and computed values, functions for methods. Then either let the mock's methods update its signals, or make them spies and assert they were called. The component test then does not depend on the store's internals.

saying these in an interview costs you the question

  • Set protectedState: false on the store so tests can patch it.
  • unprotected() returns a detached copy of the store for the test to modify.
  • Protected state works by freezing the state object at runtime in dev mode.
  • Use provideMockStore and overrideSelector to fake a SignalStore's computed signals.
  • Spying on the store's own methods is the recommended way to test a SignalStore.