Testing NgRx Effect using observer-spy

Published on

Have you tried the observer-spy library by Shai Reznik?

Link

It particularly makes testing ngrx effects an easy task and keeps them readable.

To demonstrate this, I refactored the tests from book.effects.spec.ts from the ngrx example application, and here are the differences.

Testing the success path

Using marbles:

it('should return a book.SearchComplete, with the books, on success, after the de-bounce', () => {
  const book1 = { id: '111', volumeInfo: {} } as Book
  const book2 = { id: '222', volumeInfo: {} } as Book
  const books = [book1, book2]
  const action = FindBookPageActions.searchBooks({ query: 'query' })
  const completion = BooksApiActions.searchSuccess({ books })

  actions$ = hot('-a---', { a: action })
  const response = cold('-a|', { a: books })
  const expected = cold('-----b', { b: completion })
  googleBooksService.searchBooks = jest.fn(() => response)

  expect(
    effects.search$({
      debounce: 30,
      scheduler: getTestScheduler(),
    })
  ).toBeObservable(expected)
})

Using Observer-Spy makes it easier to arrange what we want to test, as we don't have to create a representation of the observables using characters, as demonstrated in the code below:

it(
  'should return a book.SearchComplete, with the books, on success, after the de-bounce',
  fakeTime((flush) => {
    const book1 = { id: '111', volumeInfo: {} } as Book
    const book2 = { id: '222', volumeInfo: {} } as Book
    const books = [book1, book2]

    actions$ = of(FindBookPageActions.searchBooks({ query: 'query' }))
    googleBooksService.searchBooks = jest.fn(() => of(books))

    const effectSpy = subscribeSpyTo(effects.search$())

    flush()
    expect(effectSpy.getLastValue()).toEqual(BooksApiActions.searchSuccess({ books }))
  })
)

Testing when the effect does not do anything

Using marbles:

it(`should not do anything if the query is an empty string`, () => {
  const action = FindBookPageActions.searchBooks({ query: '' })

  actions$ = hot('-a---', { a: action })
  const expected = cold('---')

  expect(
    effects.search$({
      debounce: 30,
      scheduler: getTestScheduler(),
    })
  ).toBeObservable(expected)
})

Using observer-spy, it is more readable because we are "acting" before the expectation, as opposed to when using marbles, where the "acting" part of the test is done inside the expect statement.

 it(`should not do anything if the query is an empty string`, fakeTime((flush) => {
        actions$ = of(FindBookPageActions.searchBooks({ query: '' }));

        const effectSpy = subscribeSpyTo(effects.search$());

        flush();
        expect(effectSpy.getLastValue()).toBeUndefined();
 })

Testing the error path of an effect

Using marbles:

it('should return a book.SearchError if the books service throws', () => {
  const action = FindBookPageActions.searchBooks({ query: 'query' })
  const completion = BooksApiActions.searchFailure({
    errorMsg: 'Unexpected Error. Try again later.',
  })
  const error = { message: 'Unexpected Error. Try again later.' }

  actions$ = hot('-a---', { a: action })
  const response = cold('-#|', {}, error)
  const expected = cold('-----b', { b: completion })
  googleBooksService.searchBooks = jest.fn(() => response)

  expect(
    effects.search$({
      debounce: 30,
      scheduler: getTestScheduler(),
    })
  ).toBeObservable(expected)
})

Using observer-spy, it is clear what action should be triggered and expected.

it(
  'should return a book.SearchError if the books service throws',
  fakeTime((flush) => {
    const error = { message: 'Unexpected Error. Try again later.' }
    actions$ = of(FindBookPageActions.searchBooks({ query: 'query' }))
    googleBooksService.searchBooks = jest.fn(() => throwError(error))

    const effectSpy = subscribeSpyTo(effects.search$())

    flush()
    expect(effectSpy.getLastValue()).toEqual(
      BooksApiActions.searchFailure({
        errorMsg: error.message,
      })
    )
  })
)

You can find the working example here:

Link