Creating a Modern Firebase Powered Application with TDD
Use reactive programming and tests to build a professional app
[Sprint Two] Displaying Client List from Firestore
The importance of dumb/presentational components
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
A Quick Note
I have no idea why this happened, and it might not be happening for you, but at this point I started getting this strange errors when trying to run E2E tests locally or serve the app with ng serve
:
ReferenceError: __NG_CLI_RESOURCE__0 is not defined
and the application would just display as a white page. To fix this I just had to clear the Angular cache (delete the .angular/cache
folder in the project). I have no idea why this happened, but make sure to do the same if you are running into that issue!
Sprint Two
It's time to begin out next sprint. To recap for those of you not following along with the project management module, these are the tasks that we are including in this sprint:
- #1 feat: record a client's details
- #2 feat: see a list of all clients
- #3 feat: edit a clients details
- #5 feat: access the full details of a specific client
Basically, all of the items we didn't end up getting to in the last sprint because we spent so much time setting up our development environment! With any luck, most of that will be behind us now and it will be smooth sailing from here.
It doesn't particularly matter which one we start with, but in general I like to go in an order where we are gradually adding complexity one bit at a time. For example, if we were to start with #5
:
- As a massage therapist, I want to be able to access the full details of a specific client, so that I can access all of the information that is required to complete my services
Completing this one would require that #2
was already complete, as we can't really access full details for a client if there are no clients to select in the first place.
Then we might try to decide whether to go with #2
or #1
first. Going with #1
seems obvious since it is number one. But the order of our user stories as we created them was arbitrary, we just happened to create that one first. It is the order in the backlog that is relevant, not the number of the issue.
In this case I think it makes the most sense to start with adding the ability to see a list of clients as it is going to be the first thing we see, it should be easy to implement, and we can just use some dummy data to begin with if we want.
Don't fret too much over this though, it won't matter too much in the end. If you did start with #5
you will just have a more complex E2E test and you will end up creating a lot of unit tests all at once to satisfy the functionality. Then when you come back to implement E2E tests for the simpler user stories you might find that they already pass (because you would have already need to implement a bunch of that functionality), which isn't ideal but it's not the end of the world (you should still intentionally break the functionality if the test passes by default just to make sure the test is doing what it is supposed to do).
Feature: See a list of all clients
Let's start building out the following user story:
User story: As a massage therapist, I want to be able to see a list of all clients, so that I can have a birds eye view of my entire client base
Project management
Remember to move the card for this user story to the Test
column, and create a new task branch to work on.
Then we need to add a new E2E test for this user story. Although technically it doesn't matter which file you put your test in, it's a good idea to have some sort of organisation/structure if you are going to have a significant amount of tests. We already have a home.cy.ts
, but we are going to create an additional clients.cy.ts
file.
In general, I like to create a separate spec file for each page in the application that we are testing. Since this E2E test will involve testing functionality on the clients page, it will live in the clients.cy.ts
file.
You might find it helpful to first just write comments of how you want to test this functionality:
Create a file at
cypress/e2e/clients.cy.ts
and add the following:
describe('Clients', () => {
beforeEach(() => {
cy.login();
cy.visit('/clients');
});
it('can see a list of clients', () => {
// add a test client to the firestore database
// grab items from the ion-list
// expect that the test client is in the list
});
});
We are utilising our login
command here before each test to bypass authentication. Then we want to add some test data to the firestore database, visit the clients route, and then check that the data is in the list.
IMPORTANT: We are modifying data in the Firestore database with this test. It is very important that this is happening on a test/development Firebase project or on the Firebase emulators, not your production Firebase project with your production Firestore data. Running these tests against a production database is just asking for trouble. If you have been following along with this example so far you should be safe because we set up our tests to use the emulators.
Let's replace that with the real implementation now:
Modify the test to reflect the following:
import { getItemsInList } from '../support/utils';
describe('Clients', () => {
const testDocId = 'abc123';
beforeEach(() => {
cy.login();
cy.visit('/clients');
cy.callFirestore('delete', 'clients');
});
it('can see a list of clients', () => {
cy.callFirestore('set', `clients/${testDocId}`, {
name: {
first: 'Josh',
last: 'Morony',
},
});
const listOfClients = getItemsInList();
listOfClients.should('contain.text', 'Josh Morony');
});
});
We will also need to add the following helper function:
export const getItemsInList = () => cy.get('[data-test=list] ion-item');
Since we have cypress-firebase
set up which allows us to call cy.callFirestore
the test set up is quite simple. We add some test data at the start of the test, we grab the list of clients (which does not exist yet), and then we check if it contains an item for the data we just added. Note that we are making sure to delete all the Firestore data in the beforeEach
hook - we don't want test data from one test to affect another test.
Ok, let's run our E2E tests and see what happens:
You can see that we are successfully getting to the Clients
page, but we are getting this error:
expected [data-test=list] ion-item to contain text Josh Morony, but the text was ''
Great, we have our failing test now. We can move on to creating some unit tests for our clients page.
Project management
I'll stop leaving hints like this from now on, but this would be a good time to commit what you have and push to the remote repo.
This is the part where you open up the client-dashboard.page.spec.ts
file:
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { ClientDashboardPage } from './client-dashboard.page';
describe('ClientDashboardPage', () => {
let component: ClientDashboardPage;
let fixture: ComponentFixture<ClientDashboardPage>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ ClientDashboardPage ],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(ClientDashboardPage);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});
Look at it... and say... now what? Let's think about what we are trying to do here. We are trying to display a list of clients, so we are going to need an array of clients to actually display. Since we are utilising @ngrx/component-store
for this application, we are actually going to set up this stream of client data in a store for the client-list
component.
We could immediately move our focus to creating and testing the store, but in the interest of letting the test failures guide us, let's first attempt to satisfy our failing E2E test by creating the list in the template (pretending that our store already exists).
Modify
client-dashboard.page.html
to reflect the following:
<ion-header>
<ion-toolbar>
<ion-title data-test="page-title">Clients</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-list data-test="list">
<ion-item *ngFor="let client of clientsStore.clients$ | async">
<ion-label>{{client.name.first}} {{client.name.last}}</ion-label>
</ion-item>
</ion-list>
</ion-content>
We have implemented this how we want it to work. At the moment, I am intending to have a clients store that will provide an observable stream of selected state on the clients$
class member. From previous planning, we also know the structure of the client object. The reason we have not added any new tests for this code in the template is because this is what our E2E test is specifically testing already (also note that we added a data-test
attribute for our test).
Now we are going to get errors because clientsStore
does not exist on our client list component. Now we are going to tackle creating this store.
Create a file at
clients/data-access/clients.store.ts
and add the following:
import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { ClientShellModule } from '../feature/client-shell/client-shell.module';
interface ClientName {
first: string;
last: string;
}
interface SurveyResponse {
values: string;
}
export interface Client {
name: ClientName;
email: string;
phone: string;
appointments: string[];
notes: string;
survey: SurveyResponse[];
}
export interface ClientsState {
clients: Client[];
}
@Injectable({
providedIn: ClientShellModule,
})
export class ClientsStore extends ComponentStore<ClientsState> {}
We don't want to add any functionality yet because we haven't written a test for it. We are just setting up the basic structure of the store here, as well as the definition for our Client
interface.
Also, we're doing something a little tricky here. Typically, a component store will only be provided to one component. In this case, we would not supply the providedIn
option to @Injectable()
and we would manually add the provided to the components @Component
metadata in the providers
array. However, we are actually going to use this same store for all of our client features. At the moment, we just have client-dashboard
but eventually we will have client-detail
and client-survey
as well.
Although it's key purpose is for managing local component state, component store can quite nicely be used for shared state. We could just use providedIn: root
to provide this store globally, but that isn't really our intent here. It lives inside of the clients
feature, so it should only be used by clients
features. If we wanted to share this globally then we should move it to the shared folder, and we have no reason to do that right now. Instead, we are supplying the specific module we want to provide this store in. By providing it in the ClientShellModule
it will only be accessible to our clients features.
Now let's move on to creating our first tests for the store.
Create a file at
clients/data-access/clients.store.spec.ts
and add the following:
import { TestBed } from '@angular/core/testing';
import { subscribeSpyTo } from '@hirez_io/observer-spy';
import { ClientsStore } from './clients.store';
describe('ClientsStore', () => {
let service: ClientsStore;
beforeEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
TestBed.configureTestingModule({
providers: [ClientsStore],
});
service = TestBed.inject(ClientsStore);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('selector: clients$', () => {
it('should return empty array by default', () => {
const observerSpy = subscribeSpyTo(service.clients$);
expect(observerSpy.getFirstValue()).toEqual([]);
});
});
});
Our goal here is to have clients$
be a stream of the clients data from the current state, which by default will be an empty array. This isn't going to do much for us right now because we never load anything into that state, but that's not the error we are solving at the moment.
Once again, before we can run our tests we need to make sure clients$
is actually defined in the store:
import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { EMPTY } from 'rxjs';
import { ClientShellModule } from '../feature/client-shell/client-shell.module';
interface ClientName {
first: string;
last: string;
}
interface SurveyResponse {
values: string;
}
export interface Client {
name: ClientName;
email: string;
phone: string;
appointments: string[];
notes: string;
survey: SurveyResponse[];
}
export interface ClientsState {
clients: Client[];
}
@Injectable({
providedIn: ClientShellModule,
})
export class ClientsStore extends ComponentStore<ClientsState> {
clients$ = EMPTY;
}
Again, we are just using EMPTY
from rxjs
so that clients$
is an observable but we don't have any implementation details. If we run our unit tests now we can see that it fails:
FAIL src/app/clients/data-access/clients.store.spec.ts
● ClientsStore - selector: clients$ - should return empty array by default
expect(received).toEqual(expected) // deep equality
Expected: []
Received: undefined
NOTE: We will also have a failing test for the client list page because of what we added to the template, but we are going to ignore that for now.
Now let's add the implementation to the store:
@Injectable({
providedIn: ClientShellModule,
})
export class ClientsStore extends ComponentStore<ClientsState> {
readonly clients$ = this.select((state) => state.clients);
constructor() {
super({ clients: [] });
}
}
and now it should pass. Now let's focus on that other failing test:
FAIL src/app/clients/feature/client-dashboard/client-dashboard.page.spec.ts
● ClientDashboardPage - should create
TypeError: Cannot read properties of undefined (reading 'clients$')
Our client list page is trying to access the store but we aren't injecting it yet, let's do that now.
Modify
client-dashboard.page.ts
to reflect the following:
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ClientsStore } from '../../data-access/clients.store';
@Component({
selector: 'app-client-dashboard',
templateUrl: './client-dashboard.page.html',
styleUrls: ['./client-dashboard.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ClientDashboardPage implements OnInit {
constructor(public clientsStore: ClientsStore) {}
ngOnInit() {}
}
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).