Creating a Modern Firebase Powered Application with TDD
Use reactive programming and tests to build a professional app
[Sprint One] Component Store
Utilising component store to make the app more reactive
PROModule Outline
- Source Code & Resources PRO
- Lesson 1: Introduction PUBLIC
- Lesson 2: The Structure of this Module PUBLIC
- Lesson 3: [Sprint One] Setting up Firebase PUBLIC
- Lesson 4: [Sprint One] Creating Security Rules with TDD PRO
- Lesson 5: [Sprint One] Testing Authentication PRO
- Lesson 6: [Sprint One] Component Store PRO
- Lesson 7: [Sprint One] Circumventing Firebase Authentication for E2E Tests PRO
- Lesson 8: [Sprint Two] Displaying Client List from Firestore PRO
- Lesson 9: [Sprint Two] - Adding Clients PRO
- Lesson 10: [Sprint Two] - Editing Clients PRO
- Lesson 11: [Sprint Two] - Client Details PRO
- Lesson 12: Preparing for Delivery PRO
- Lesson 13: Configuring for Production PRO
- Lesson 14: [Sprint Three] - Refactoring PRO
- Lesson 15: [Sprint Three] Setting up PWA PRO
- Lesson 16: [Sprint Three] Logout PRO
- Lesson 17: [Sprint Three] Delete a Client PRO
- Lesson 18: [Sprint Three] - Feedback Mechanism PRO
- Lesson 19: [Sprint Three] View Feedback PRO
- Lesson 20: More Styling PRO
- Lesson 21: [Sprint Four] - Refactoring Feedback PRO
- Lesson 22: [Sprint Four] - Feedback Dates PRO
- Lesson 23: [Sprint Four] - Client Survey PRO
- Lesson 24: [Sprint Four] - View Survey PRO
- Lesson 25: Final Touches PRO
- Lesson 26: Conclusion PRO
Lesson Outline
Setting up Component Store
We're going to take a different path to implementing the navigation functionality - it's not the most direct one, but it is going to make our application more robust. What I want to do is utilise @ngrx/component-store
to manage local state for this component (and for any other components that may end up needing some kind of state management). This isn't the full NgRx/Redux approach, component-store
is a lightweight and local state management solution. It's kind of similar to just using a service to manage state, with some nice helpers built in that can manage observables for us.
I won't go into detail here, but if you feel like you want a bit of a primer on @ngrx/component-store
I have a video on it here.
The reason I reach for a solution like @ngrx/component-store
in a situation like this is that, for things like a login process, there are going to be multiple states involved, e.g:
- before the auth process has begun/before the user has clicked the login button
- during the auth process
- after the auth process completes with either a success or failure
Now we could just ignore these states, trigger the auth, and be done with it for now. But if we take a haphazard approach now it might create more work for us later when we inevitably have to refactor it. What if we want to display an error message if the login fails? We will need some way to track the error state. What if we want to display a loading animation/spinner whilst the auth is in process? We will need some way to track the "authenticating" state.
We could implement all of this ourselves, but @ngrx/component-store
just provides us with an easy way to reactively handle this situation. Let's install it now:
npm install @ngrx/component-store
Then we will create two new files inside of our home
directory:
data-access/home.store.ts
data-access/home.store.spec.ts
The first file is the actual component store file that we will use to manage the state, and the second is a separate file for testing our store. Let's just set up some empty boilerplate for our store:
import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
@Injectable()
export class HomeStore extends ComponentStore<never> {}
Now let's consider what tests we need to write by first determining what this store should do. If we select the login state from this store it should:
- Return a
pending
state by default - Return an
authenticating
state after the authentication has been triggered - Return a
success
state if the authentication succeeds - Return a
error
state if the authentication fails
Let's see if we can write some tests for these in home.store.spec.ts
. To ease into this, we are just going to start with the default pending state:
import { TestBed } from '@angular/core/testing';
import { subscribeSpyTo } from '@hirez_io/observer-spy';
import { HomeStore } from './home.store';
describe('HomeStore', () => {
let service: HomeStore;
beforeEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
TestBed.configureTestingModule({
providers: [HomeStore],
});
service = TestBed.inject(HomeStore);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('login selector should initially return: pending', () => {
const status$ = service.select((state) => state.loginStatus);
const observerSpy = subscribeSpyTo(status$);
expect(observerSpy.getFirstValue()).toBe('pending');
});
});
NOTE: We also need to supply HomeStore
as a provider because in our @Injectable
declaration for the store we do not use the providedIn
option to inject it into root
. We just want this store to be available to the home component, not globally.
You will likely notice that loginStatus
is erroring because our component store does not have the correct type information yet, so let's add that to the store:
import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
export interface HomeState {
loginStatus: 'pending' | 'authenticating' | 'success' | 'error';
}
@Injectable()
export class HomeStore extends ComponentStore<HomeState> {}
Now let's see how our tests go:
FAIL src/app/home/data-access/home.store.spec.ts
● HomeStore - login selector should initially return: pending
expect(received).toBe(expected) // Object.is equality
Expected: "pending"
Received: undefined
Great, now let's try to implement the functionality:
import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
export interface HomeState {
loginStatus: 'pending' | 'authenticating' | 'success' | 'error';
}
const defaultState: HomeState = {
loginStatus: 'pending',
};
@Injectable()
export class HomeStore extends ComponentStore<HomeState> {
constructor() {
super(defaultState);
}
}
Now that we are supplying some default state, our simple test passes. Now let's get into the more interesting stuff. We are going to create an effect
that will modify the state in response to the observable stream from our AuthService
. Let's write some more tests:
import { TestBed } from '@angular/core/testing';
import { subscribeSpyTo } from '@hirez_io/observer-spy';
import { AuthService } from '../../shared/data-access/auth.service';
import { HomeStore } from './home.store';
jest.mock('../../shared/data-access/auth.service');
describe('HomeStore', () => {
let service: HomeStore;
let authService: AuthService;
beforeEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
TestBed.configureTestingModule({
providers: [HomeStore, AuthService],
});
service = TestBed.inject(HomeStore);
authService = TestBed.inject(AuthService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('login selector should initially return: pending', () => {
const status$ = service.select((state) => state.loginStatus);
const observerSpy = subscribeSpyTo(status$);
expect(observerSpy.getFirstValue()).toBe('pending');
});
it('login effect should change state to authenticating once triggered', () => {
const status$ = service.select((state) => state.loginStatus);
const observerSpy = subscribeSpyTo(status$);
service.login();
expect(observerSpy.getLastValue()).toBe('authenticating');
});
});
We are now mocking and injecting the AuthService
. Since our login
method doesn't exist yet, we will need to add that to run the test:
import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { EMPTY } from 'rxjs';
export interface HomeState {
loginStatus: 'pending' | 'authenticating' | 'success' | 'error';
}
const defaultState: HomeState = {
loginStatus: 'pending',
};
@Injectable()
export class HomeStore extends ComponentStore<HomeState> {
login = this.effect(() => EMPTY);
constructor() {
super(defaultState);
}
}
Again, just returning an empty observable here just to get the test running. An effect
in @ngrx/component-store
actually creates an observable stream from whatever values we call it with, and then returns some modified stream. For example, this effect isn't going to have any inputs so it is hard to see, but let's imagine we had a login form that required a username/password. If we called login(credentials)
from our home component, the argument supplied to this.effect((credentials$) => {})
would actually be an observable stream of the values login()
is called with. So, if we call login()
three times with different credentials, the effect
will have a stream of those three values. Again, I'd recommend watching that video for a bit more context.
Now our test should fail with this error:
FAIL src/app/home/data-access/home.store.spec.ts
● HomeStore - login effect should change state to authenticating once triggered
expect(received).toBe(expected) // Object.is equality
Expected: "authenticating"
Received: "pending"
Perfect, now the implementation:
import { tap } from 'rxjs/operators';
login = this.effect(($) =>
$.pipe(tap(() => this.setState({ loginStatus: 'authenticating' })))
);
Now I'm sorry to give such a weird example if this is your first experience with component store. As I mentioned, usually we would be passing values like credentials
into our effect. So, we would typically have something like this.effect((credentials$))
to represent the stream of values being passed in. In this case, login()
isn't passed any values, so we just have this empty observable which we call $
. This observable is still important, as it is going to be triggered every time we call this login
effect.
If we check our test, we will see that it is successfully passing now. Let's continue with more tests! We are just going to keep adding one at a time because this is still our first user story, but typically I would probably just create all of these tests at once and solve them all at once.
it('login effect should trigger the loginWithGoogle method from the AuthService', () => {
service.login();
expect(authService.loginWithGoogle).toHaveBeenCalled();
});
Again, our set up here is kind of weird. Usually with an authentication process we would call some method, and it would return an observable with the success/failure. However, since we are using Firebase Authentication it is split up into two parts. We actually just listen for the authState
changes that our getLoggedIn
provides to see when the user has successfully authenticated, but this is not what triggers the authentication. It is the signInWithPopup
that triggers the authentication, which is triggered by our loginWithGoogle
method. So, what we need to do is add a test that checks that this method is called.
We should see that fail now:
● HomeStore - login effect should trigger the loginWithGoogle method from the AuthService
expect(jest.fn()).toHaveBeenCalled()
Expected number of calls: >= 1
Received number of calls: 0
and now the implementation:
import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { tap } from 'rxjs/operators';
import { AuthService } from '../../shared/data-access/auth.service';
export interface HomeState {
loginStatus: 'pending' | 'authenticating' | 'success' | 'error';
}
const defaultState: HomeState = {
loginStatus: 'pending',
};
@Injectable()
export class HomeStore extends ComponentStore<HomeState> {
login = this.effect(($) =>
$.pipe(
tap(() => {
this.setState({ loginStatus: 'authenticating' });
this.authService.loginWithGoogle();
})
)
);
constructor(private authService: AuthService) {
super(defaultState);
}
}
Again, it is quite atypical to call a method inside of tap
like this, but in this case we do want this to be triggered as a side effect of calling login
. The test for this should now pass.
A Quick Refactor
Thanks for checking out the preview of this lesson!
You do not have the appropriate membership to view the full lesson. If you would like full access to this module you can view membership options (or log in if you are already have an appropriate membership).