Skip to main content

How to test components and services that use RxState

Goal

Unit-test state and its transformations when using @rx-angular/state. There are two levels: test the behaviour of a whole component through its DOM, or test the state and its transformations in isolation. This page uses jest, but the mechanics carry to any runner.

info

Make sure you are familiar with the basics of testing Angular applications: the official testing guide and component testing basics.

tip
  • Keep tests unrelated to implementation details as much as possible.
  • Testing a component through its DOM nodes rather than the component instance is a best practice.

Test a component through the DOM

Drive the component through its rendered output. Under zoneless change detection (default since Angular v21), await fixture.whenStable() to flush pending change detection instead of calling fixture.detectChanges() synchronously.

/src/counter.component.ts
import { rxState } from '@rx-angular/state';
import { Component } from '@angular/core';
import { Subject, map, merge } from 'rxjs';

@Component({
selector: 'rx-counter',
template: `
<button id="increment" (click)="increment.next()">Increment</button>
<button id="decrement" (click)="decrement.next()">Decrement</button>
<div id="counter">{{ count() }}</div>
`,
})
export class CounterComponent {
readonly increment = new Subject<void>();
readonly decrement = new Subject<void>();

private readonly state = rxState<{ count: number }>(({ connect, set }) => {
set({ count: 0 });
connect('count', merge(this.increment.pipe(map(() => 1)), this.decrement.pipe(map(() => -1))), ({ count }, slice) => count + slice);
});

// signals-first template surface
readonly count = this.state.signal('count');
}

Assert the state signal directly

When you do not need the DOM, assert the state's signal surface on the component instance. No fixture stabilisation is required, since a signal read returns the current value synchronously.

/src/counter.component.spec.ts
it('should expose the incremented count as a signal', () => {
const fixture = TestBed.createComponent(CounterComponent);
const component = fixture.componentInstance;

fixture.debugElement.query(By.css('#increment')).triggerEventHandler('click', null);

// read the signal directly — no whenStable(), no async pipe
expect(component.count()).toBe(1);
});

Test state transformations in isolation

To test the state and its transformations on their own, use the TestScheduler from rxjs/testing with marble diagrams against the select-backed Observable.

/src/counter.service.ts
import { rxState } from '@rx-angular/state';
import { Injectable } from '@angular/core';
import { Subject, map, merge } from 'rxjs';

@Injectable()
export class CounterService {
readonly increment = new Subject<void>();
readonly decrement = new Subject<void>();

private readonly state = rxState<{ count: number }>(({ connect, set }) => {
set({ count: 0 });
connect('count', merge(this.increment.pipe(map(() => 1)), this.decrement.pipe(map(() => -1))), ({ count }, slice) => count + slice);
});

readonly count$ = this.state.select('count');
}

Result

Both test styles pass without touching RxState internals: the DOM test proves the wired-up behaviour, the signal assertion proves the state surface, and the marble test proves the transformation in isolation.

See also