Testing NgRx: Store, Effects, Selectors, and Reducers in Angular with TestBed

Testing NgRx: Store, Effects, Selectors, and Reducers in Angular with TestBed

NgRx is the Redux-based state management library for Angular. Testing NgRx requires Angular's TestBed for dependency injection, along with NgRx's own testing utilities: provideMockStore, provideMockActions, and (optionally) jasmine-marbles for observable-based effects testing.

Setup

npm install @ngrx/store @ngrx/effects @ngrx/store-devtools
npm install -D @ngrx/store/testing @ngrx/effects/testing jasmine-marbles

Testing Reducers

Reducers are pure functions — test them directly without TestBed:

// store/products/products.reducer.ts
import { createReducer, on, Action } from '@ngrx/store'
import * as ProductActions from './products.actions'

export interface Product {
  id: number
  name: string
  price: number
  inStock: boolean
}

export interface ProductsState {
  products: Product[]
  selectedId: number | null
  loading: boolean
  error: string | null
}

export const initialState: ProductsState = {
  products: [],
  selectedId: null,
  loading: false,
  error: null,
}

const _productsReducer = createReducer(
  initialState,
  on(ProductActions.loadProducts, state => ({ ...state, loading: true, error: null })),
  on(ProductActions.loadProductsSuccess, (state, { products }) => ({
    ...state,
    loading: false,
    products,
  })),
  on(ProductActions.loadProductsFailure, (state, { error }) => ({
    ...state,
    loading: false,
    error,
  })),
  on(ProductActions.selectProduct, (state, { id }) => ({
    ...state,
    selectedId: id,
  })),
  on(ProductActions.clearSelection, state => ({
    ...state,
    selectedId: null,
  }))
)

export function productsReducer(state: ProductsState | undefined, action: Action) {
  return _productsReducer(state, action)
}
// store/products/products.actions.ts
import { createAction, props } from '@ngrx/store'
import { Product } from './products.reducer'

export const loadProducts = createAction('[Products] Load Products')
export const loadProductsSuccess = createAction(
  '[Products] Load Products Success',
  props<{ products: Product[] }>()
)
export const loadProductsFailure = createAction(
  '[Products] Load Products Failure',
  props<{ error: string }>()
)
export const selectProduct = createAction(
  '[Products] Select Product',
  props<{ id: number }>()
)
export const clearSelection = createAction('[Products] Clear Selection')
// store/products/products.reducer.spec.ts
import { productsReducer, initialState } from './products.reducer'
import * as ProductActions from './products.actions'

const products = [
  { id: 1, name: 'Widget', price: 9.99, inStock: true },
  { id: 2, name: 'Gadget', price: 19.99, inStock: false },
]

describe('productsReducer', () => {
  it('returns initial state for unknown action', () => {
    const action = { type: '@@INIT' } as any
    expect(productsReducer(undefined, action)).toEqual(initialState)
  })

  it('sets loading true and clears error on loadProducts', () => {
    const state = productsReducer(
      { ...initialState, error: 'previous error' },
      ProductActions.loadProducts()
    )
    expect(state.loading).toBe(true)
    expect(state.error).toBeNull()
  })

  it('populates products and sets loading false on success', () => {
    const state = productsReducer(
      { ...initialState, loading: true },
      ProductActions.loadProductsSuccess({ products })
    )
    expect(state.loading).toBe(false)
    expect(state.products).toEqual(products)
  })

  it('sets error and loading false on failure', () => {
    const state = productsReducer(
      { ...initialState, loading: true },
      ProductActions.loadProductsFailure({ error: 'Server error' })
    )
    expect(state.loading).toBe(false)
    expect(state.error).toBe('Server error')
    expect(state.products).toHaveLength(0)
  })

  it('sets selectedId on selectProduct', () => {
    const state = productsReducer(
      { ...initialState, products },
      ProductActions.selectProduct({ id: 2 })
    )
    expect(state.selectedId).toBe(2)
  })

  it('clears selectedId on clearSelection', () => {
    const state = productsReducer(
      { ...initialState, selectedId: 1 },
      ProductActions.clearSelection()
    )
    expect(state.selectedId).toBeNull()
  })
})

Testing Selectors

NgRx selectors created with createSelector are pure memoized functions — test them with plain state objects:

// store/products/products.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store'
import { ProductsState } from './products.reducer'

export const selectProductsState =
  createFeatureSelector<ProductsState>('products')

export const selectAllProducts = createSelector(
  selectProductsState,
  state => state.products
)

export const selectSelectedId = createSelector(
  selectProductsState,
  state => state.selectedId
)

export const selectSelectedProduct = createSelector(
  selectAllProducts,
  selectSelectedId,
  (products, selectedId) => products.find(p => p.id === selectedId) ?? null
)

export const selectInStockProducts = createSelector(
  selectAllProducts,
  products => products.filter(p => p.inStock)
)

export const selectProductsLoading = createSelector(
  selectProductsState,
  state => state.loading
)
// store/products/products.selectors.spec.ts
import {
  selectAllProducts,
  selectSelectedProduct,
  selectInStockProducts,
} from './products.selectors'

const products = [
  { id: 1, name: 'Widget', price: 9.99, inStock: true },
  { id: 2, name: 'Gadget', price: 19.99, inStock: false },
]

function makeState(overrides: Partial<any> = {}) {
  return {
    products: {
      products,
      selectedId: null,
      loading: false,
      error: null,
      ...overrides,
    },
  }
}

describe('products selectors', () => {
  it('selectAllProducts returns all products', () => {
    expect(selectAllProducts(makeState())).toEqual(products)
  })

  it('selectSelectedProduct returns null when no selection', () => {
    expect(selectSelectedProduct(makeState())).toBeNull()
  })

  it('selectSelectedProduct returns the correct product', () => {
    expect(selectSelectedProduct(makeState({ selectedId: 2 }))).toEqual(
      products[1]
    )
  })

  it('selectInStockProducts filters out unavailable products', () => {
    const inStock = selectInStockProducts(makeState())
    expect(inStock).toHaveLength(1)
    expect(inStock[0].name).toBe('Widget')
  })

  it('returns null for selectedProduct when id does not match any product', () => {
    expect(selectSelectedProduct(makeState({ selectedId: 999 }))).toBeNull()
  })
})

Testing Effects

NgRx effects are Angular services that react to dispatched actions using RxJS. Test them with provideMockActions:

// store/products/products.effects.ts
import { Injectable } from '@angular/core'
import { Actions, createEffect, ofType } from '@ngrx/effects'
import { catchError, map, switchMap } from 'rxjs/operators'
import { of } from 'rxjs'
import * as ProductActions from './products.actions'
import { ProductsService } from '../../services/products.service'

@Injectable()
export class ProductsEffects {
  loadProducts$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ProductActions.loadProducts),
      switchMap(() =>
        this.productsService.getAll().pipe(
          map(products => ProductActions.loadProductsSuccess({ products })),
          catchError(err =>
            of(ProductActions.loadProductsFailure({ error: err.message }))
          )
        )
      )
    )
  )

  constructor(
    private actions$: Actions,
    private productsService: ProductsService
  ) {}
}
// store/products/products.effects.spec.ts
import { TestBed } from '@angular/core/testing'
import { provideMockActions } from '@ngrx/effects/testing'
import { Observable, of, throwError } from 'rxjs'
import { cold, hot } from 'jasmine-marbles'
import { ProductsEffects } from './products.effects'
import { ProductsService } from '../../services/products.service'
import * as ProductActions from './products.actions'

const products = [
  { id: 1, name: 'Widget', price: 9.99, inStock: true },
]

describe('ProductsEffects', () => {
  let actions$: Observable<any>
  let effects: ProductsEffects
  let productsService: jasmine.SpyObj<ProductsService>

  beforeEach(() => {
    productsService = jasmine.createSpyObj('ProductsService', ['getAll'])

    TestBed.configureTestingModule({
      providers: [
        ProductsEffects,
        provideMockActions(() => actions$),
        { provide: ProductsService, useValue: productsService },
      ],
    })

    effects = TestBed.inject(ProductsEffects)
  })

  describe('loadProducts$', () => {
    it('dispatches loadProductsSuccess on service success (marble)', () => {
      const action = ProductActions.loadProducts()
      const completion = ProductActions.loadProductsSuccess({ products })

      // Hot observable: action fires at frame 0
      actions$ = hot('-a', { a: action })
      // Cold observable: service returns products at frame b
      productsService.getAll.and.returnValue(cold('-b|', { b: products }))

      // Expected: completion fires at frame --(a+b) = --c
      const expected = cold('--c', { c: completion })
      expect(effects.loadProducts$).toBeObservable(expected)
    })

    it('dispatches loadProductsFailure on service error (marble)', () => {
      const action = ProductActions.loadProducts()
      const error = new Error('API down')
      const completion = ProductActions.loadProductsFailure({ error: 'API down' })

      actions$ = hot('-a', { a: action })
      productsService.getAll.and.returnValue(cold('-#', {}, error))

      const expected = cold('--c', { c: completion })
      expect(effects.loadProducts$).toBeObservable(expected)
    })

    it('dispatches loadProductsSuccess without marbles (simpler)', done => {
      productsService.getAll.and.returnValue(of(products))
      actions$ = of(ProductActions.loadProducts())

      effects.loadProducts$.subscribe(result => {
        expect(result).toEqual(
          ProductActions.loadProductsSuccess({ products })
        )
        done()
      })
    })

    it('dispatches loadProductsFailure without marbles on error', done => {
      productsService.getAll.and.returnValue(
        throwError(() => new Error('Network error'))
      )
      actions$ = of(ProductActions.loadProducts())

      effects.loadProducts$.subscribe(result => {
        expect(result).toEqual(
          ProductActions.loadProductsFailure({ error: 'Network error' })
        )
        done()
      })
    })
  })
})

Testing with provideMockStore

provideMockStore sets up a mock store where you control the initial state and can override selectors:

// components/product-list/product-list.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { provideMockStore, MockStore } from '@ngrx/store/testing'
import { By } from '@angular/platform-browser'
import { ProductListComponent } from './product-list.component'
import {
  selectAllProducts,
  selectProductsLoading,
} from '../../store/products/products.selectors'
import * as ProductActions from '../../store/products/products.actions'

const products = [
  { id: 1, name: 'Widget', price: 9.99, inStock: true },
  { id: 2, name: 'Gadget', price: 19.99, inStock: false },
]

describe('ProductListComponent', () => {
  let component: ProductListComponent
  let fixture: ComponentFixture<ProductListComponent>
  let store: MockStore

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ProductListComponent],
      providers: [
        provideMockStore({
          initialState: {
            products: {
              products: [],
              selectedId: null,
              loading: false,
              error: null,
            },
          },
        }),
      ],
    }).compileComponents()

    store = TestBed.inject(MockStore)
    fixture = TestBed.createComponent(ProductListComponent)
    component = fixture.componentInstance
  })

  afterEach(() => store.resetSelectors())

  it('shows loading indicator when loading is true', () => {
    store.overrideSelector(selectProductsLoading, true)
    store.overrideSelector(selectAllProducts, [])
    store.refreshState()
    fixture.detectChanges()

    const spinner = fixture.debugElement.query(By.css('.loading-spinner'))
    expect(spinner).toBeTruthy()
  })

  it('renders product list when loaded', () => {
    store.overrideSelector(selectProductsLoading, false)
    store.overrideSelector(selectAllProducts, products)
    store.refreshState()
    fixture.detectChanges()

    const items = fixture.debugElement.queryAll(By.css('.product-item'))
    expect(items).toHaveLength(2)
    expect(items[0].nativeElement.textContent).toContain('Widget')
  })

  it('dispatches loadProducts on init', () => {
    const dispatchSpy = spyOn(store, 'dispatch')
    fixture.detectChanges()

    expect(dispatchSpy).toHaveBeenCalledWith(ProductActions.loadProducts())
  })

  it('dispatches selectProduct when item is clicked', () => {
    store.overrideSelector(selectProductsLoading, false)
    store.overrideSelector(selectAllProducts, products)
    store.refreshState()
    fixture.detectChanges()

    const dispatchSpy = spyOn(store, 'dispatch')
    const firstItem = fixture.debugElement.query(By.css('.product-item'))
    firstItem.triggerEventHandler('click', null)

    expect(dispatchSpy).toHaveBeenCalledWith(
      ProductActions.selectProduct({ id: 1 })
    )
  })
})

Testing Selector Memoization

// Verify createSelector memoizes correctly
import { selectSelectedProduct } from './products.selectors'
import { MemoizedSelector } from '@ngrx/store'

describe('selector memoization', () => {
  it('does not recompute when state is unchanged', () => {
    const state = makeState({ selectedId: 1 })
    const first = selectSelectedProduct(state)
    const second = selectSelectedProduct(state)
    expect(first).toBe(second) // same reference
  })

  it('recomputes when relevant state slice changes', () => {
    const state1 = makeState({ selectedId: 1 })
    const state2 = makeState({ selectedId: 2 })
    const first = selectSelectedProduct(state1)
    const second = selectSelectedProduct(state2)
    expect(first).not.toBe(second)
  })
})

Common Mistakes

Not calling store.refreshState() after overrideSelector() — the component won't pick up the new value until state is refreshed.

Not calling store.resetSelectors() in afterEach() — overridden selectors leak between tests.

Using real store in effects tests — use provideMockActions instead. Real store effects tests are integration tests; they need more setup and are slower.

Marble syntax confusion- is 10ms, | is completion, # is error. Each character is one frame. cold = starts when subscribed; hot = emits regardless of subscription.

End-to-End Validation with HelpMeTest

NgRx unit tests verify that your store logic is correct, but they can't verify that your Angular components connect to the store correctly or that the UI responds to state changes. HelpMeTest completes the picture:

Go to /products
Verify loading spinner appears
Verify product list shows "Widget" and "Gadget"
Click on "Widget"
Verify product detail panel shows "Widget" and price "$9.99"
Click "Back to list"
Verify product detail panel is dismissed

These browser-level tests run against your deployed Angular app and catch Angular change detection bugs, routing issues, and component wiring problems that unit tests can't reach.

Summary

NgRx testing strategy by layer:

  • Reducers — pure functions, test without TestBed, cover all action types
  • Selectors — test with plain state objects, verify memoization and edge cases
  • Effects — use provideMockActions + TestBed, test with marbles for timing-sensitive RxJS
  • Components — use provideMockStore + overrideSelector to control state; spy on dispatch

Keep reducer and selector tests fast (no Angular overhead). Effects tests need TestBed but should still use mocked services. Reserve full integration tests for browser-level validation.

Read more