Angular, Angular Material, and TDD [Part 3] — Create an Account using the Authentication Service
On this part of the journey (Part 3), I’m going to hook up the Login page to the authentication service that was created in Part 1.
1. Create the Create Account Button
Update the login-form.component.spec.ts
A new test will be added to look for the Create Account button.
The new test for login-component.spec.ts
is below.
it('has a create account button', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('button.create-account')).not.toEqual(null);
});
Update login-form.component.html
Now that there’s a failing test, the Create Account button can be added to the login-form.component.html
template, and that simple change gets the tests to green.
Changes to login-form.component.html
are in bold, below.
<div class="container">
<h1 class="header">Welcome</h1>
<form class="login-form">
<mat-form-field appearance="outline" class="email">
<input matInput placeholder="Email">
</mat-form-field>
<mat-form-field appearance="outline" class="password">
<input type="password" matInput placeholder="Password">
</mat-form-field>
<button mat-raised-button class="login" color="primary">Login</button>
<button mat-raised-button class="create-account" color="primary">Create Account</button>
</form>
</div>
All tests are passing once again. 🎉
2. Display a success message when the create account button is clicked
Since the Create Account button will eventually create an account, I need an indication that an action has occurred when it’s clicked. The happy path for this scenario is that an account is created successfully when the button is clicked. So, I will begin by writing a test that verifies that it happens.
Note: The first iteration of this solution will be simple. It won’t verify validity of an email address or a password. It will only verify that a message is displayed after the Create Account button is clicked.
Below is the new test for login-form.component.spec.ts
. It looks for Your account was created successfully.
to be displayed after the Create Account button is clicked.
it('successfully creates an account', () => {
const compiled = fixture.nativeElement;
const createAccountButton =
compiled.querySelector('button.create-account'); createAccountButton.onclick =
(event:any) => { event.preventDefault(); }
createAccountButton.click();
expect(compiled.querySelector('.message').textContent)
.toEqual('Your account was created successfully.');
});
Note: Overriding the onclick
behavior for the createAccountButton
will prevent the button from refreshing the page as the tests run.
Now, there’s a failing test 🎉.
Get the test to green
To get the test to green, the only thing that is required is a string which returns the message listed in the test. So, I will add a <div>
which displays that string.
These are the code changes for login-form.component.html
. The changes are in bold.
<div class="container">
<h1 class="header">Welcome</h1>
<form class="login-form">
<div class="message">
Your account was created successfully.
</div>
<mat-form-field appearance="outline" class="email">
<input matInput placeholder="Email">
</mat-form-field>
<mat-form-field appearance="outline" class="password">
<input type="password" matInput placeholder="Password">
</mat-form-field>
<button mat-raised-button class="login" color="primary">
Login
</button>
<button mat-raised-button class="create-account"
color="primary">
Create Account
</button>
</form>
</div>
The tests are green, but there’s a problem. The message is always displayed. It should only be displayed when the Create Account button is clicked. So, I’m going to introduce a new test that verifies that the message doesn’t appear when it shouldn’t.
it('does not display a success message if an account is not created.', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.message')).toEqual(null);
});
Conditionally display the success message
The ngIf
directive will allow me to conditionally display the success message. So, I will add the ngIf
directive to the message div
, and it will be set to a new variable isAccountCreated
. The value for isAccountCreated
will be changed when the Create Account button is clicked by calling a new function called onCreateAccountClicked
.
Changes to login-form.component.html
are in bold below.
<div class="container">
<h1 class="header">Welcome</h1>
<form class="login-form">
<div class="message" ngIf="isAccountCreated">
Your account was created successfully.
</div>
<mat-form-field appearance="outline" class="email">
<input matInput placeholder="Email">
</mat-form-field>
<mat-form-field appearance="outline" class="password">
<input type="password" matInput placeholder="Password">
</mat-form-field>
<button mat-raised-button class="login" color="primary">
Login
</button>
<button mat-raised-button
class="create-account"
color="primary"
(click)="onCreateAccountClicked($event)"
>
Create Account
</button>
</form>
</div>
Changes to login-form.component.ts
are below in bold.
import { Component, OnInit } from '@angular/core';@Component({
selector: 'app-login-form',
templateUrl: './login-form.component.html',
styleUrls: ['./login-form.component.css']
})
export class LoginFormComponent implements OnInit {
isAccountCreated = false; constructor() { } ngOnInit(): void {
} onCreateAccountClicked(e:Event) {
e.preventDefault();
this.isAccountCreated = true;
}}
Note: Do not forget the e.preventDefault()
or your test will fail. Html buttons, by default, will cause a page refresh as it attempts to submit the form data to a server. This will cause the success message to appear when the button is clicked, then disappear after the page refreshes.
Update the other test
Now, the test for 'does not display a success message if an account is not created.'
is green, but the test for 'successfully creates an account'
fails because that test needs to check for changes on the page using fixture.detectChanges()
. Also, the override set for createAccountButton.onclick
can be deleted since the onCreateAccountClicked
calls e.preventDefault()
.
Changes to the login-form.component.ts
are in bold below. Note that a line containing createAccountButton.onclick = (event:any) => { event.preventDefault(); }
has been deleted.
it('successfully creates an account', () => {
const compiled = fixture.nativeElement; const createAccountButton =
compiled.querySelector('button.create-account');
createAccountButton.click();
fixture.detectChanges(); expect(compiled.querySelector('.message').textContent)
.toEqual('Your account was created successfully.');
});
Now all the tests are green! 🎉
Tip: When debugging tests put a f
in front of the it
or describe
function to run a single test or set of tests. Example:
fit(‘successfully creates an account’, () => { //debug this test });fdescribe(‘LoginFormComponent’, () => { // debug these tests });`
3. Add email and password verification
An account should only be created when an email address and password are supplied. The LoginFormComponent
should reflect this behavior. So, it's time for a new test.
The new test for login-form.component.spec.ts
is below.
it('requires an email and password for account creation', () => {
const compiled = fixture.nativeElement;
const createAccountButton =
compiled.querySelector('button.create-account');
createAccountButton.click(); expect(document.querySelector('.email-validation-message')?.textContent).toEqual('An email is required.'); expect(document.querySelector('.password-validation-message')?.textContent).toEqual('A password is required.');
});
Update login-form.component.ts
I’ll add validation to the email and password fields. A FormControl
will be used to achieve this goal.
Updates to login-form.component.ts
are below in bold.
import { Component, OnInit } from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';@Component({
selector: 'app-login-form',
templateUrl: './login-form.component.html',
styleUrls: ['./login-form.component.css']
})
export class LoginFormComponent implements OnInit {
email = new FormControl('', [Validators.required]);
password = new FormControl('', [Validators.required]); constructor(public dialog: MatDialog) { } ngOnInit(): void {
} onCreateAccountClicked(e:Event) {
e.preventDefault();
this.isAccountCreated = true;
}}
Update login-form.component.html
The LoginFormComponent
template needs to be updated to use the new form controls. Also, an Angular Material component, <mat-error>
will be used to display the error messages.
When the email is invalid mat-error
will display the error message, and the same will happen when the password is invalid. Currently, the only validation is that a value is supplied for both fields.
Updates to login-form.component.html
are in bold below.
<div class="container">
<h1 class="header">Welcome</h1>
<form class="login-form">
<mat-form-field appearance="outline" class="email">
<input matInput placeholder="Email"
[formControl]="email" required>
<mat-error class="email-validation-message"
*ngIf="email.invalid">
An email is required.
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="password">
<input type="password" matInput placeholder="Password"
[formControl]="password" required>**
<mat-error class="password-validation-message"
*ngIf="password.invalid">
A password is required.
</mat-error>
</mat-form-field>
<button mat-raised-button class="login"
color="primary">Login</button>
<button
mat-raised-button
class="create-account"
color="primary"
(click)="createAccount($event)"
>
Create Account
</button>
</form>
</div>
Update login-form.component.spec.ts
The tests continue to fail and there are a few errors in the console.
ERROR: 'NG0303: Can't bind to 'formControl' since it isn't a known property of 'input'.'
To fix this error, both ReactiveFormsModule
and FormsModule
need to be added to the test imports since FormControls
are being used in the html form.
Updates to login-form.component.ts
are in bold.
// Additional imports are aboveimport { FormsModule, ReactiveFormsModule } from '@angular/forms';describe('LoginFormComponent', () => {
let component: LoginFormComponent;
let fixture: ComponentFixture<LoginFormComponent>; beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ LoginFormComponent ],
imports: [
BrowserAnimationsModule,
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
],
}).overrideModule(BrowserDynamicTestingModule, {
set: {
entryComponents: [
MessageComponent
]
}
}).compileComponents();
});// Tests are below
The test will continue to fail. Why? In the test environment, mat-error
never displays the error message, even when the email field is blank. After spending hours wading through articles and forums, I found this wonderful solution. It's a collection of tests used in the Angular repo to test the Angular framework's components. The test at line 1087 was a life saver. Two lines of code were the key to success.
To get the test to pass, the test needs to mark the email and password field as being touched by the user, by calling component.email.markAsTouched()
and component.password.markAsTouched()
, then the test needs to force a change detection by calling fixture.detectChanges()
this will cause the error to be displayed 🎉.
After those changes are added, the test will continue to fail because the of differences in whitespace. Some whitespace appears before and after each message, thus failing the toEqual()
check. So, the test has also been updated to use toContain()
instead of toEqual()
Updates to login-form.component.spec.ts
are below in bold.
it('requires an email and password for account creation', () => {
const compiled = fixture.nativeElement; component.email.markAsTouched();
component.password.markAsTouched();
fixture.detectChanges(); const createAccountButton = compiled.querySelector('button.create-account');
createAccountButton.click(); expect(document.querySelector('.email-validation-message')?.textContent).toContain('An email is required.'); expect(document.querySelector('.password-validation-message')?.textContent).toContain('A password is required.');
});
Now that an email and password are required before creating an account, the test for it('successfully creates an account')
fails. So, it needs to be updated.
Changes are in bold, below.
it('successfully creates an account', () => {
const randomString = () =>
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15); const email = `${randomString()}@${randomString()}.test`;
const password = randomString(); component.email.setValue(email);
component.password.setValue(password);
fixture.detectChanges(); const compiled = fixture.nativeElement;
const createAccountButton =
compiled.querySelector('button.create-account');
createAccountButton.click();
fixture.detectChanges();
expect(compiled.querySelector('.message').textContent)
.toEqual('Your account was created successfully.');
});
All the tests are passing once again! 🎉
Don’t display the success message when the email and password are invalid
I want to add another test. The success message shouldn’t be displayed when the email and password are invalid.
Updates to login-form.component.spec.ts
are in bold, below.
it('requires an email and password for account creation', () => {
const compiled = fixture.nativeElement; component.email.markAsTouched();
component.password.markAsTouched();
fixture.detectChanges(); const createAccountButton =
compiled.querySelector('button.create-account'); createAccountButton.onclick =
(event:any) => {event.preventDefault()}
createAccountButton.click(); expect(compiled.querySelector('.email-validation-message')?.textContent).toContain('An email is required.'); expect(compiled.querySelector('.password-validation-message')?.textContent).toContain('A password is required.'); expect(document.querySelector('.message')).toEqual(null);
});
Now, there is one failing test.
Update login-form.component.html
The easiest way to solve this problem is to disable the create account button until a valid email and password are supplied.
Changes to login-form.component.html
are below in bold.
<div class="container">
<h1 class="header">Welcome</h1>
<form class="login-form">
<mat-form-field appearance="outline" class="email">
<input matInput class="email" placeholder="Email"
[formControl]="email" required>
<mat-error class="email-validation-message">
An email is required.
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="password">
<input type="password" matInput placeholder="Password"
[formControl]="password" required>
<mat-error class="password-validation-message"
*ngIf="password.invalid">
A password is required.
</mat-error>
</mat-form-field>
<button mat-raised-button class="login" color="primary">
Login
</button>
<button
mat-raised-button
class="create-account"
color="primary"
(click)="createAccount($event)"
[disabled]="email.invalid || password.invalid"
>
Create Account
</button>
</form>
</div>
All the tests are green again!
4. Create an account using the Firebase API
I’m going to use the AuthenticationService
that I created earlier, to create an account when the Create Account button is clicked. The click event will call the AuthenticationService.createAccount
function, and since the createAccount
function is asynchronous, the LoginFormComponent.onCreateAccountClicked
will need to be asynchronous as well.
Changes to login-form.component.ts
are below in bold.
import { Component, OnInit } from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { AuthenticationService } from 'src/app/services/authentication.service';@Component({
selector: 'app-login-form',
templateUrl: './login-form.component.html',
styleUrls: ['./login-form.component.css']
})
export class LoginFormComponent implements OnInit {
isAccountCreated = false;
email = new FormControl('', [Validators.required]);
password = new FormControl('', [Validators.required]); constructor(
public authenticationService: AuthenticationService
) { } ngOnInit(): void {
} async onCreateAccountClicked(e:Event) {
e.preventDefault();
this.isAccountCreated = await this.authenticationService
.createAccount(this.email.value, this.password.value);
}
}
I’ve injected the AuthenticationService
as a dependency for the LoginFormComponent
, so that the AuthenticationService.createAccount
can be made available to the component.
The email
and password
arguments are retrieved from the FormControls
that were defined earlier in the LoginFormComponent
.
The test for the LoginFormComponent
needs to be updated so that it has a reference to AngularFireModule.initializeApp(environment.firebase)
and AngularFirestoreModule
in its imports.
The test for successfully creates an account
will need to be an asynchronous since the LoginFormComponent.onCreateAccountClicked
function.
Changes to login-form.component.spec.ts
are below.
// more imports are aboveimport { AngularFireModule } from '@angular/fire';
import { AngularFirestoreModule } from '@angular/fire/firestore';
import { environment } from 'src/environments/environment';describe('LoginFormComponent', () => {
let component: LoginFormComponent;
let fixture: ComponentFixture<LoginFormComponent>; beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ LoginFormComponent ],
imports: [
AngularFireModule.initializeApp(environment.firebase),
AngularFirestoreModule,
BrowserAnimationsModule,
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
],
})
.compileComponents();
});// Additional test code has been omittedit('successfully creates an account', async() => {
const randomString = () => Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); const email = `${randomString()}@${randomString()}.test`;
const password = randomString(); component.email.setValue(email);
component.password.setValue(password);
fixture.detectChanges(); const compiled = fixture.nativeElement;
const createAccountButton =
compiled.querySelector('button.create-account');
createAccountButton.click(); fixture.whenStable().then(() => {
fixture.detectChanges();
expect(compiled.querySelector('.message').textContent)
.toEqual('Your account was created successfully.');
});
});
Now, other tests need to be updated because the LoginFormComponent
is rendered as part of other components, so its changes affect the tests of those components. The AppComponent
test will need to include AngularFireModule.initializeApp(environment.firebase)
and AngularFirestoreModule
in its imports.
// more imports are aboveimport { environment } from 'src/environments/environment';
import { AngularFirestoreModule } from '@angular/fire/firestore';describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
**AngularFireModule.initializeApp(environment.firebase),
AngularFirestoreModule,**
RouterTestingModule,
MatDialogModule,
],
declarations: [
AppComponent,
LoginFormComponent,
],
}).compileComponents();
});
Now, all the tests are green!
5. Create an account from the app
Now that everything is hooked up, and all the tests are passing, I can create an account from the app.
I’m going to run ng serve
to get the app running, but there's an error in the terminal
error NG8002: Can't bind to 'formControl' since it isn't a known property of 'input'.
This is the exact error I saw for the tests earlier. To fix the error, I just need to add ReactiveFormsModule
and FormsModule
to the app.module.ts
.
Updates are in bold, below.
// Additional imports are aboveimport { FormsModule, ReactiveFormsModule } from '@angular/forms';@NgModule({
declarations: [
AppComponent,
LoginFormComponent,
],
imports: [
AngularFireModule.initializeApp(environment.firebase),
AngularFirestoreModule,
AppRoutingModule,
BrowserAnimationsModule,
BrowserModule,
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatTableModule,
MatToolbarModule,
ReactiveFormsModule,
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Now, the app is running without any issues.
I noticed that the Create Account button is not styled the same as the Login button, so I’ve made a small css and html change.
Updates to login-form.component.css
and login-form.component.html
are below, in bold.
login-form.component.css
.container, .login-form, .header {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
button.login, button.create-account {
width: 100%;
border-radius: 18px;
}
.header {
margin-bottom: 2em;
font-size: 3em;
}
.login-form {
border: 1px solid;
padding: 6vh;
border-radius: 8px;
}
mat-form-field, .login-form {
width: 45vw;
}
login-form.component.html
<div class="container">
<h1 class="header">Welcome</h1>
<form class="login-form">
<div class="message" *ngIf="isAccountCreated">
Your account was created successfully.
</div>
<mat-form-field appearance="outline" class="email">
<input matInput placeholder="Email"
[formControl]="email" required>
<mat-error class="email-validation-message"
*ngIf="email.invalid">
An email is required.
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="password">
<input type="password" class="create-account"
matInput placeholder="Password"
[formControl]="password" required>
<mat-error class="password-validation-message"
*ngIf="password.invalid">
A password is required.
</mat-error>
</mat-form-field>
<button mat-raised-button class="login" color="primary">
Login
</button>
<button mat-raised-button
class="create-account"
color="primary"
(click)="onCreateAccountClicked($event)"
[disabled]="email.invalid || password.invalid"
>
Create Account
</button>
</form>
</div>
Now, I’m ready to enter an email and password into the login form.
I’ve created an account and got a success message back. Now, I’m going to check the Firebase console for the newly created account.
Voila! It’s there! Along with dozens of other test emails, which will need to be deleted.
The Final Result
The completed code can be found at https://stackblitz.com/edit/angular-ivy-zlguwt?file=src/app/app.component.ts
Note: The app won’t work on stackblitz.com because it is not configured to run a live firebase instance.