Have you tried the observer-spy library by Shai Reznik?
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: