Angular Tutorial - Testing in NgRx Effects
Learn how to unit test NgRx Effects and capture the values with Observer-Spy
Unit testing observables doesn't have to be a mind pretzel.
As I start to build a membership site with the beginnings of my NX GCP Starter Project
, I'm leveraging Firebase
and NgRx
for state management. To keep code quality high and with code coverage set to 100%, this stretch goal lead to challenges I've uncovered in the past few days.
Goals
1. Install and Understand How to Use observer-spy
2. How to Unit Test NgRx Effects
3. Combine observer-spy
with the NgRx Effects
for Unit Testing
observer-spy
Start by installing observer-spy
.
npm install -D @hirez_io/observer-spy
observer-spy
wraps observables in a more understandable syntaxes than jasime-marbles
. jasmine-marbles
syntax shows frames using dashes to convey a unit of time, emitting values via a variable, pipe to complete, and a hash to signify an error. NgRx Documentation examples have syntax using jasmine-marbles
, but observer-spy
is easier to understand expected output of an Observeable
.
hot('-------a', {
a: {
type: '[Customers API] Search Customers Success',
customers: [...],
},
});
In the code above, the hot
observable included eight frames of time before emitting the value a
. The definition of a
follows the contract of an NgRx Action
with the type
and customers
as the payload.
Using observer-spy
the subscribeToSpy
subscribes to an Observable
capturing its output. Depending on the number of values you want to capture, retrieving the first, last, or a subset, ObserverSpy
exposes helper methods to read and store values from the stream. Below, the getFirstValue
retrieves the first value emitted from the authEffects.loginUserEffect$
stream.
obsSpy = subscribeSpyTo(authEffects.loginUserEffect$);
expect(obsSpy.getFirstValue()).toEqual(new UserAuthActions.LoginUserSuccessAction());
Unit Testing NgRx Effects
Effects filter the stream of actions that cause side effects. Looking through each effect created, take account of which action(s) is filtered, and the output of actions dispatched.
public loginUserEffect$: Observable<Action> = createEffect(() => this.actions$.pipe(
ofType<UserAuthActions.LoginUserAction>(UserAuthActions.ActionTypes.LOGIN_USER),
concatMap(({ payload: { authProvider, credentials } }) => {
return this.userService.login(authProvider, credentials).pipe(
map(() => new UserAuthActions.LoginUserSuccessAction()),
catchError(err => [
new UserAuthActions.LoginUserFailureAction(err)
])
);
})
));
Typically with side effects, communication with another stream such as a REST call or listening to SSE and Sockets results in side effects of changing the state. Above, the userService.login
definition returns an Observable
. This is our inner observable which will emit values to the outer stream. Successful request emit and dispatch the UserAuthActions.LoginUserSuccessfulAction
. If there's an error, the inner Observable will emit a UserAuthActions.LoginUserFailureAction(err)
.
Next, I segregate each effect into it's own describe
section to reduce cognitive load on where to fine each action / effect and only scope it to that action / effect. Also, the userService
methods to createUser
will be mocked.
Testing NgRx Effects with Observer Spy
Setting up the specific action that filters through the stream requires a utility exposed by the NgRx
library, provideMockActions
. Define the $actions
variable for the action to be tested.
describe('createUserEffect$', () => {
let authEffects: UserAuthEffects;
let userService: UserService;
let actions$: Observable<Action>;
let obsSpy: SubscriberSpy<Action>;
TestBed.configureTestingModule({
...moduleConfig,
providers: [
provideMockActions(() => actions$)
]
});
authEffects = TestBed.inject(UserAuthEffects);
userService = TestBed.inject(UserService);
actions$ = of(new UserAuthActions.CreateUserAccountAction({
email: '',
password: ''
}));
});
See the full test for this library.
it('should return CreateUserAccountSuccessAction', () => {
userService.createUser = jest.fn(() => {
return of({} as firebase.auth.UserCredential);
});
obsSpy = subscribeSpyTo(authEffects.createUserEffect$);
expect(obsSpy.getFirstValue()).toEqual(new UserAuthActions.CreateUserAccountSuccessAction());
});
it('should fail login', () => {
const err = new Error('hootie hoo!');
userService.createUser = jest.fn(() => {
return throwError(() => err)
});
obsSpy = subscribeSpyTo(authEffects.createUserEffect$);
expect(obsSpy.getFirstValue()).toEqual(new UserAuthActions.CreateUserAccountFailureAction(err));
});
afterEach(() => obsSpy?.unsubscribe());
The above code tests the UserAuthActions.ForgotUserPasswordAction
. In both tests, the userService.resetPassword
is mocked to account for both successful and error responses which the stream will dispatch two different actions.
As these are side effects of a particular action, the action stream emits for either successful or unsuccessful actions when communicating with, for instance, an HTTP service. Make sure in these cases, the inner stream handles the error actions to return rather than the outer stream. Otherwise, the outer stream will unsubscribe. See my three Pitfalls for further details.
The service requires mocking for a successful and an error response. In this way, both actions emitted will be covered.
Conclusion
observer-spy
captures values emitted from streams in a more developer friendly syntax than jasmine-marbles. Wrapping the streams in subscribeToSpy
holds the values from the stream to validate our tests.
When unit testing NgRx Effects
, be aware of the actions that filter through the stream. These will help coordinate which actions should be dispatched in the actions stream.
Finally, mock the successful and error responses for side effect. Create tests to handle both successful and unsuccessful actions emitted from the effect.