DEV Community

nsimonoski
nsimonoski

Posted on

Testing the 7 Signal Store Features

Every store that uses withLoading() doesn't need to re-test that setLoading(true) sets isLoading to true. That's the feature's job. Test the feature once, and every store that composes it gets that behavior for free.

These features were not built with TDD. They went through multiple API changes before settling - writing tests during that phase means rewriting them every time the API shifts. Once the APIs stabilized, it was time to write tests. Time better spent on the feature itself.

These tests don't verify framework behavior. Angular's template binding, change detection, signal reactivity - that's already tested by the Angular team. If a signal value changes in the store, the template updates. That's Angular's contract, not ours. Tests here cover the store logic only: if setLoading(true) was called, the component is guaranteed to reflect it - that's already tested in the feature store.

This post shows how to test each of the 7 features from the previous post and what consuming store tests look like after that.


The Setup: A Minimal Test Store

Features can't run on their own - they need a host store. Every test creates a minimal one:

const TestStore = signalStore(
  { providedIn: 'root' },
  withState({ name: 'test' }),
  withLoading(),
);

function setup() {
  return TestBed.inject(TestStore);
}
Enter fullscreen mode Exit fullscreen mode

No component, no template.


Testing withLoading

Pure state - no dependencies to mock.

describe('withLoading', () => {
  const TestStore = signalStore(
    { providedIn: 'root' },
    withState({ name: 'test' }),
    withLoading(),
  );

  function setup() {
    return TestBed.inject(TestStore);
  }

  it('should initialize with loading false and no error', () => {
    const store = setup();
    expect(store.isLoading()).toBe(false);
    expect(store.errorMessage()).toBe('');
  });

  it('should set loading to true by default when called without args', () => {
    const store = setup();
    store.setLoading();
    expect(store.isLoading()).toBe(true);
  });

  it('should set error message', () => {
    const store = setup();
    store.setLoading(false, 'Something went wrong');
    expect(store.errorMessage()).toBe('Something went wrong');
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing withBrowserStorage

Tests interact with real localStorage - no need to mock the Web Storage API.

describe('withBrowserStorage', () => {
  const STORAGE_KEY = 'test-store';

  const TestStore = signalStore(
    { providedIn: 'root' },
    withState({ count: 0, label: 'default' }),
    withBrowserStorage({ key: STORAGE_KEY }),
  );

  function setup() {
    localStorage.clear();
    sessionStorage.clear();
    return TestBed.inject(TestStore);
  }

  it('should return false when no saved data exists', () => {
    const store = setup();
    expect(store.loadFromStorage()).toBe(false);
  });

  it('should load saved data and patch state', () => {
    const store = setup();
    localStorage.setItem(STORAGE_KEY, JSON.stringify({ count: 42 }));
    expect(store.loadFromStorage()).toBe(true);
    expect(store.count()).toBe(42);
  });

  it('should not overwrite fields that were not saved', () => {
    const store = setup();
    localStorage.setItem(STORAGE_KEY, JSON.stringify({ count: 10 }));
    store.loadFromStorage();
    expect(store.count()).toBe(10);
    expect(store.label()).toBe('default');
  });

  it('should save data to localStorage and patch state', () => {
    const store = setup();
    store.saveToStorage({ count: 99 });
    expect(store.count()).toBe(99);
    expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual({ count: 99 });
  });

  it('should merge with existing saved data', () => {
    const store = setup();
    localStorage.setItem(STORAGE_KEY, JSON.stringify({ count: 1 }));
    store.saveToStorage({ label: 'updated' });
    const saved = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
    expect(saved).toEqual({ count: 1, label: 'updated' });
  });

  it('should preserve specified keys on clearAll', () => {
    const store = setup();
    localStorage.setItem('keep-me', '"value"');
    localStorage.setItem('remove-me', '"gone"');
    store.clearAllStorage(['keep-me']);
    expect(localStorage.getItem('keep-me')).toBe('"value"');
    expect(localStorage.getItem('remove-me')).toBeNull();
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing withSnackbar

Injects SnackbarService - one mock via TestBed:

describe('withSnackbar', () => {
  const TestStore = signalStore(
    { providedIn: 'root' },
    withState({ name: 'test' }),
    withSnackbar(),
  );

  function setup() {
    TestBed.configureTestingModule({
      providers: [{ provide: SnackbarService, useValue: mockSnackbarService() }],
    });
    return TestBed.inject(TestStore);
  }

  it('should show success snackbar', () => {
    const store = setup();
    store.showSuccess('Saved!');
    expect(TestBed.inject(SnackbarService).success).toHaveBeenCalledWith('Saved!', undefined);
  });

  it('should show error snackbar', () => {
    const store = setup();
    store.showError('Failed!');
    expect(TestBed.inject(SnackbarService).error).toHaveBeenCalledWith('Failed!', undefined);
  });

  it('should dismiss snackbar', () => {
    const store = setup();
    store.dismissSnackbar();
    expect(TestBed.inject(SnackbarService).dismiss).toHaveBeenCalled();
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing withDialog

Pure state. The callback is closure-scoped - tested by the consuming store, not here.

describe('withDialog', () => {
  const TestStore = signalStore(
    { providedIn: 'root' },
    withState({ name: 'test' }),
    withDialog(),
  );

  function setup() {
    return TestBed.inject(TestStore);
  }

  it('should initialize with dialog closed', () => {
    const store = setup();
    expect(store.dialogOpen()).toBe(false);
    expect(store.dialogTitle()).toBe('');
    expect(store.dialogMessage()).toBe('');
  });

  it('should open dialog with title, message, and data', () => {
    const store = setup();
    store.openDialog('Delete File', 'Are you sure?', { filePath: '/src/main.ts' });
    expect(store.dialogOpen()).toBe(true);
    expect(store.dialogTitle()).toBe('Delete File');
    expect(store.dialogData()).toEqual({ filePath: '/src/main.ts' });
  });

  it('should close dialog and reset all fields', () => {
    const store = setup();
    store.openDialog('Title', 'Message', { id: 1 });
    store.closeDialog();
    expect(store.dialogOpen()).toBe(false);
    expect(store.dialogTitle()).toBe('');
    expect(store.dialogData()).toBeNull();
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing withRouting

Router and ActivatedRoute mocks are needed here. Every store that composes withRouting() skips them - mock factories handle it.

describe('withRouting', () => {
  const routerEvents$ = new Subject<NavigationEnd>();

  const mockRouter = {
    url: '/initial',
    events: routerEvents$.asObservable(),
    navigateByUrl: vi.fn(),
    createUrlTree: vi.fn().mockReturnValue({}),
    serializeUrl: vi.fn().mockReturnValue('/serialized'),
  };

  const mockActivatedRoute = {
    snapshot: {
      params: { id: '123' },
      queryParams: { tab: 'files' },
      data: { title: 'Test' },
    },
  };

  const TestStore = signalStore(
    { providedIn: 'root' },
    withState({ name: 'test' }),
    withRouting(),
  );

  function setup() {
    TestBed.configureTestingModule({
      providers: [
        { provide: Router, useValue: mockRouter },
        { provide: ActivatedRoute, useValue: mockActivatedRoute },
      ],
    });
    return TestBed.inject(TestStore);
  }

  it('should expose current URL signal with initial value', () => {
    const store = setup();
    expect(store.currentUrl()).toBe('/initial');
  });

  it('should navigate to a route', () => {
    const store = setup();
    store.navigate('/editor');
    expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/editor');
  });

  it('should return route params and query params', () => {
    const store = setup();
    expect(store.getRouteParams()).toEqual({ id: '123' });
    expect(store.getQueryParams()).toEqual({ tab: 'files' });
  });

  it('should open a new tab with serialized URL', () => {
    const store = setup();
    const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
    store.openInNewTab('/preview', { mode: 'dark' });
    expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['/preview'], {
      queryParams: { mode: 'dark' },
    });
    expect(openSpy).toHaveBeenCalledWith('/serialized', '_blank');
    openSpy.mockRestore();
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing withGitActions (Domain Feature)

withGitActions uses withSnackbar internally, so the test provides SnackbarService.

describe('withGitActions', () => {
  const TestStore = signalStore(
    { providedIn: 'root' },
    withState({ name: 'test' }),
    withGitActions(),
  );

  function setup() {
    const git = mockGitService();
    const snackbar = mockSnackbarService();
    TestBed.configureTestingModule({
      providers: [
        { provide: GitService, useValue: git },
        { provide: SnackbarService, useValue: snackbar },
        { provide: GitStatusStore, useValue: mockGitStatusStore() },
      ],
    });
    return { store: TestBed.inject(TestStore), git, snackbar };
  }

  it('should call commit and show success', () => {
    const { store, git, snackbar } = setup();
    store.gitCommit('feat: add feature');
    expect(git.commit).toHaveBeenCalledWith('/workspace/repo', 'feat: add feature');
    expect(snackbar.success).toHaveBeenCalledWith('Committed!', undefined);
  });

  it('should show error on failure', () => {
    const { store, git, snackbar } = setup();
    git.commit.mockReturnValue(of({ success: false, error: 'Commit failed' }));
    store.gitCommit('bad commit');
    expect(snackbar.error).toHaveBeenCalledWith('Commit failed', undefined);
  });

  it('should stage files', () => {
    const { store, git } = setup();
    store.gitStage(['src/main.ts', 'src/app.ts']);
    expect(git.stage).toHaveBeenCalledWith('/workspace/repo', ['src/main.ts', 'src/app.ts']);
  });
});
Enter fullscreen mode Exit fullscreen mode

What Consuming Store Tests Look Like

IdeLayoutStore uses withBrowserStorage and withRouting. Mock factories handle those dependencies:

describe('IdeLayoutStore', () => {
  function setup() {
    localStorage.clear();
    TestBed.configureTestingModule({
      providers: [
        { provide: Router, useValue: mockRouter() },
        { provide: ActivatedRoute, useValue: mockActivatedRoute() },
        { provide: CodeEditorStore, useValue: mockCodeEditorStore() },
      ],
    });
    return TestBed.inject(IdeLayoutStore);
  }

  it('should toggle terminal and persist', () => {
    const store = setup();
    store.toggleTerminal();
    expect(store.terminalOpen()).toBe(true);
    const saved = JSON.parse(localStorage.getItem('ide-layout')!);
    expect(saved.terminalOpen).toBe(true);
  });

  it('should clamp terminal height between 100 and 600', () => {
    const store = setup();
    store.setTerminalHeight(50);
    expect(store.terminalHeight()).toBe(100);
    store.setTerminalHeight(800);
    expect(store.terminalHeight()).toBe(600);
  });
});
Enter fullscreen mode Exit fullscreen mode

Same with AiChatStore:

describe('AiChatStore', () => {
  function setup() {
    localStorage.clear();
    const ai = mockAiService();
    TestBed.configureTestingModule({
      providers: [
        { provide: AiService, useValue: ai },
        { provide: SnackbarService, useValue: mockSnackbarService() },
        { provide: CodeEditorStore, useValue: mockCodeEditorStore() },
      ],
    });
    return { store: TestBed.inject(AiChatStore), ai };
  }

  it('should send message and create user + assistant entries', () => {
    const { store, ai } = setup();
    ai.sendMessage.mockReturnValue(of({ delta: 'Hello!', done: true }));
    store.sendMessage({ userMessage: 'Hi' });
    expect(store.messages().length).toBe(2);
    expect(store.messages()[0].role).toBe('user');
    expect(store.messages()[1].role).toBe('assistant');
    expect(store.messages()[1].content).toBe('Hello!');
  });

  it('should compute questionCount', () => {
    const { store } = setup();
    expect(store.questionCount()).toBe(0);
  });
});
Enter fullscreen mode Exit fullscreen mode

What Makes This Work

No async or fakeAsync. Signal stores are synchronous. rxMethod with mockReturnValue(of(...)) completes inline.

Mock factories live in a shared testing lib. mockGitService(), mockSnackbarService(), mockRouter() - defined once, used everywhere.

// @org/angular-testing
export function mockSnackbarService() {
  return {
    success: vi.fn(),
    error: vi.fn(),
    info: vi.fn(),
    dismiss: vi.fn(),
  };
}
Enter fullscreen mode Exit fullscreen mode

The Bigger Picture

When withBrowserStorage changes internally, one test file updates. The stores that use it don't change.

Same applies to components. Stores own all the logic - HTTP calls, state, routing, dialogs. Components inject a store, read signals, call methods. They don't know if the data comes from a REST API, a WebSocket, or localStorage. Testing a component means mocking the store and checking the template. No HttpClientTestingModule, no RouterTestingModule.

The code in this post comes from the same portfolio project as the previous posts. If you want to see how these features fit into a larger monorepo, check out the architecture post. The features themselves are covered in 7 Signal Store Features You Only Need to Write Once.

Top comments (0)