Mocking NGRX Store Emissions Over Time With Jasmine Marbles
Introduction: What Problem Are We Trying To Solve?
There are a few different strategies for unit testing NGRX effects, and the right solution for the job varies from one use-case to the next. One of the most useful strategies for testing NGRX effects and observables in general is to use jasmine-marbles. I won’t go in depth on marble testing itself here because it’s well-documented elsewhere on the internet. If you’re not already familiar with marble testing, then I highly recommend reading about it because it’s extremely useful.
One of the biggest limitations that I ran into in my own journey of learning how to write tests for NGRX effects using marbles is that there’s no immediately obvious way to specify which values the NGRX store itself emits over time in a jasmine-marbles-compatible manner. The main recommendations that you see on the internet for mocking up fake store emissions are to:
- Use overrideSelector or setState with the official NGRX MockStore
- Write your own custom mock NGRX Store
Using overrideSelector
works great as long as you don’t need to specify the selector’s emissions over time as an observable. Usually this takes the form of either:
- A jasmine-marbles test which sets an initial set of selector values up-front. This is fine when you’re testing that something happens based on a starting state, rather than testing that something happens based on a state change over time.
- A test in which you update your overridden selector value over fake time using fakeAsync and tick. This is very powerful and is oftentimes the best approach, but the syntax can be bulky and cumbersome when all you really want to do is just test observables using the jasmine-marbles syntax.
Obviously there’s no limit to what you can do if you choose to write a custom mock store, but for this particular problem I don’t think it’s necessary.
Solution: Change Our Way Of Thinking
This becomes an easy problem to solve if we approach it as a matter of writing testable code, rather than as a limitation of out-of-the-box testing tools. At its core, marble testing simply involves replacing a real observable with a fake observable.
In our use of store.pipe(select(someSelector...
, we already have an observable. The trick is to isolate the observable representing the selector emissions that we want to marble-ize into a separate independent observable. Once we do so, swapping the independent store.pipe(select(someSelector...
observable with a marble stream becomes very easy.
Case Study Example
Let’s say that I want to write a test to ensure that my "initialize authentication" logic doesn’t run until my config file has been loaded. I can’t perform my authentication logic until I have my config file because the config contains important details such as "issuer", thus it’s important that we don’t start before we have what we need.
Here’s what that effect looks like before refactoring:
initAuthOnConfigLoad$ = createEffect(() => this.actions$.pipe(
ofType(AuthenticationActions.effectsInit),
switchMap(() => this.store.pipe(
select(ConfigSelectors.selectConfig),
filter(config => config != null),
take(1),
map(() => AuthenticationActions.initializeStart())
))
));
My config file is being loaded by a separate effect elsewhere in the application. My authentication effect doesn’t care how it’s loaded, it only cares that it wants to wait until the config is present in the store before it does its thing.
First Try
A first pass of testing this logic with fakeAsync
and tick
might look something like this:
it('should wait until the config is loaded to init the auth logic', fakeAsync(() => {
// Arrange
const initStartSpy = jasmine.createSpy('initStart');
const actionsSubject = new Subject<Action>();
actions$ = actionsSubject.asObservable();
// Pass the action mapped from the effect to our spy so that we know when it occurred
effects.initAuthOnConfigLoad$.subscribe(action => initStartSpy(action));
// Simulate Effects Init
actionsSubject.next(AuthenticationActions.effectsInit());
// Nothing should have happened yet because we're waiting for our config to load
expect(initStartSpy).not.toHaveBeenCalled();
// Act
// Wait 200 fake milliseconds and then simulate successful config fetch via overrideSelector
tick(200);
store.overrideSelector(ConfigSelectors.selectConfig, { authConfig: { issuer: 'dummyUrl'} } as any);
store.refreshState();
// Assert
expect(initStartSpy).toHaveBeenCalledTimes(1);
}));
This does the job, but it seems like more trouble than it’s worth. I don’t really need fakeAsync
, tick
, and a spy to listen to a subscription on the effects observable. I’m only doing this because I didn’t previously know how to simulate emissions from the store over time as a marble stream.
Second Try: A Better Approach
Once we start thinking of the store as "just any old observable", then a better approach becomes immediately obvious: we’ll just abstract away our "wait for the config to load" logic into its own observable. Certainly such an observable would be useful elsewhere in our application as well.
Here’s what our refactored logic will look like:
In ConfigService
:
public appConfig$ = this.store.pipe(
select(selectConfig),
filter(config => config != null),
take(1)
);
In AuthenticationEffects
:
initAuthOnConfigLoad$ = createEffect(() => this.actions$.pipe(
ofType(AuthenticationActions.effectsInit),
switchMap(() => this.configService.appConfig$),
map(() => AuthenticationActions.initializeStart())
));
Our Rewritten Test:
it('should wait until the config is loaded to init the auth logic', () => {
// Arrange
// Simulate the standard effects initialization
actions$ = hot('a', { a: AuthenticationActions.effectsInit() });
// Simulate waiting 1 frame before the config fetch completes
configService.appConfig$ = cold('-(a|)', { a: { authConfig: { issuer: 'dummyUrl'} }});
// Act & Assert
// The passage of 1 frame before initializeStart occurs tells us that we're waiting for the config before proceeding
expect(effects.initAuthOnConfigLoad$).toBeObservable(cold('-a', { a: AuthenticationActions.initializeStart() }));
});
Our newly-refactored logic is not only easier to read, understand, and maintain, but it’s also much easier to test. Sometimes the best way to solve a problem is to take a step back and consider whether you’re approaching the problem from the wrong angle and re-evaluate accordingly.