Angular series: Creating a Login with TDD
JPBlancoDB
Posted on November 18, 2019
Let's create a Login page with Angular and TDD. The final project could be found in my personal Github: Angular series
First step: Creating the project
Let's start by creating a new angular project:
ng new [project-name]
In my case, I created ng new angular-series
and then select with routing and your file style extension of preference.
An equivalent alternative would be just adding the respective options:
ng new angular-series --style=css --routing
More options of the CLI could be found in the official docs: ng new
Now, if we run npm start
we should everything working, and npm run test
we should also see 3 tests passing.
Second step: App Component
Our goal is going to show our login page, so let's modify the current tests to reflect our intention:
We should remove the tests from src/app/app.component.spec.ts
that no longer make sense:
it(`should have as title 'angular-series'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app.title).toEqual('angular-series');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.debugElement.nativeElement;
expect(compiled.querySelector('.content span').textContent)
.toContain('angular-series app is running!');
});
And replace it with:
it('should have router-outlet', () => {
const fixture = TestBed.createComponent(AppComponent);
expect(fixture.nativeElement.querySelector('router-outlet')).not.toBeNull();
});
This way we expect that our app.component
has <router-outlet></router-outlet>
defined, and this is needed for the router to inject other components there. More information: Router Outlet
If you noticed, our test is already passing. This is because the default app.component.html
already has that directive. But now, we are going to remove the unnecessary files. Remove app.component.html
and app.component.css
. Check your console, you should see an error because app.component.ts
is referencing to those files we've just removed.
Let's first fix the compilation errors:
//app.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
template: 'hello world'
})
export class AppComponent {}
Notice the difference between templateUrl: ...
and template
If we open http://localhost:4200
we should see: "hello world", but now our test is failing (is important to first check that our test is failing and then make it "green", read more about the Red, Green, Refactor here: The cycles of TDD)
Ok, now that we have our failing test, let's fix it:
//app.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
template: '<router-outlet></router-outlet>'
})
export class AppComponent {}
Third step: Creating the Login Component
Open the terminal and run:
ng generate module login --routing
You should see:
- src/app/login/login.module.ts
- src/app/login/login-routing.module.ts
Next, create the login component:
ng generate component login
You should see:
- src/app/login/login.component.css
- src/app/login/login.component.html
- src/app/login/login.component.spec.ts
- src/app/login/login.component.ts
Finally, let's reference our newly created module into our app-routing.module.ts
const routes: Routes = [
{
path: '',
loadChildren: () => import('./login/login.module').then(m => m.LoginModule),
data: { preload: true }
}
];
End result:
//app-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
const routes: Routes = [
{
path: '',
loadChildren: () => import('./login/login.module').then(m => m.LoginModule),
data: { preload: true }
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule {}
And we should also modify our login-routing.module.ts
:
//login-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { LoginComponent } from './login.component';
const routes: Routes = [
{
path: '',
component: LoginComponent
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LoginRoutingModule {}
If you open http://localhost:4200
, you should see: "login works!"
Fourth step: Login component
Before we start, we could remove the unnecessary css file.
First, let's create our test that asserts we have a form rendered:
//login.component.spec.ts
it('should render form with email and password inputs', () => {
const element = fixture.nativeElement;
expect(element.querySelector('form')).toBeTruthy();
expect(element.querySelector('#email')).toBeTruthy();
expect(element.querySelector('#password')).toBeTruthy();
expect(element.querySelector('button')).toBeTruthy();
});
We should have our failing test 😎. Now, we need to make it pass!
Let's do that, open login.component.html
:
<form>
<input id="email" type="email" placeholder="Your email" />
<input id="password" type="password" placeholder="********" />
<button type="submit">Sign in</button>
</form>
We should see that we have 4 passing tests! Great, but still we don't have a usable form.
So, let's add a test for our form model (we're going to use Reactive forms)
//login.component.spec.ts
it('should return model invalid when form is empty', () => {
expect(component.form.valid).toBeFalsy();
});
As you could notice an error is thrown error TS2339: Property 'form' does not exist on type 'LoginComponent'.
.
Let's define our form
in our login.component.ts
:
import { Component, OnInit } from '@angular/core';
import { FormGroup } from '@angular/forms';
@Component({
selector: 'app-login',
templateUrl: './login.component.html'
})
export class LoginComponent implements OnInit {
form: FormGroup;
constructor() {}
ngOnInit() {}
}
We see that the compilation error is not there anymore, but we still have our test failing.
Why you think is still failing if we already declared form
?
That's right! Is still undefined! So, in the ngOnInit
function let's initialize our form using FormBuilder
:
import { Component, OnInit } from '@angular/core';
import { FormGroup, FormBuilder } from '@angular/forms';
@Component({
selector: 'app-login',
templateUrl: './login.component.html'
})
export class LoginComponent implements OnInit {
form: FormGroup;
constructor(private formBuilder: FormBuilder) {}
ngOnInit() {
this.form = this.formBuilder.group({});
}
}
Oh no! Now, we have more than 1 test failing!!! Everything is broken! Don't panic 😉, this is because we have added a dependency to FormBuilder
and our testing module does not know how to solve that. Let's fix it by importing ReactiveFormsModule
:
//login.component.spec.ts
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [LoginComponent],
imports: [ReactiveFormsModule] //here we add the needed import
}).compileComponents();
}));
But, we still have 2 tests failing! We need to add formGroup
to our <form>
:
<form [formGroup]="form">
Now, we should only see failing our form is invalid
test 😃.
How do you think we could make our form invalid to make the test pass?
Yes, adding our form controls with required validators. So, let's add another test to assert it:
//login.component.spec.ts
it('should validate email input as required', () => {
const email = component.form.controls.email;
expect(email.valid).toBeFalsy();
expect(email.errors.required).toBeTruthy();
});
Let's make those tests pass:
ngOnInit() {
this.form = this.formBuilder.group({
email: ['', Validators.required]
});
}
Great 😎! We need also a password property in our form with the required validator.
//login.component.spec.ts
it('should validate password input as required', () => {
const password = component.form.controls.password;
expect(password.valid).toBeFalsy();
expect(password.errors.required).toBeTruthy();
});
To make it green we need to add password property to our form declaration:
ngOnInit() {
this.form = this.formBuilder.group({
email: ['', Validators.required],
password: ['', Validators.required]
});
}
Let's verify that we should insert a valid email:
it('should validate email format', () => {
const email = component.form.controls.email;
email.setValue('test');
const errors = email.errors;
expect(errors.required).toBeFalsy();
expect(errors.pattern).toBeTruthy();
expect(email.valid).toBeFalsy();
});
For adding the correct validator, we need to add a regex pattern like this:
ngOnInit() {
this.form = this.formBuilder.group({
email: ['', [Validators.required, Validators.pattern('[^ @]*@[^ @]*')]],
password: ['', Validators.required]
});
}
We could add an extra test to validate that is working as expected:
it('should validate email format correctly', () => {
const email = component.form.controls.email;
email.setValue('test@test.com');
const errors = email.errors || {};
expect(email.valid).toBeTruthy();
expect(errors.required).toBeFalsy();
expect(errors.pattern).toBeFalsy();
});
It is time for rendering errors in our HTML. As we are getting used to, we need first to add a test.
it('should render email validation message when formControl is submitted and invalid', () => {
const elements: HTMLElement = fixture.nativeElement;
expect(elements.querySelector('#email-error')).toBeFalsy();
component.onSubmit();
fixture.detectChanges();
expect(elements.querySelector('#email-error')).toBeTruthy();
expect(elements.querySelector('#email-error').textContent).toContain(
'Please enter a valid email.'
);
});
Of course, as we didn't define an onSubmit
function it is failing. Add onSubmit() {}
in our login.component.ts
and there it is, our beautiful red test 😃.
How to make this test green? We need a submitted property as stated in our test for only showing errors after we trigger the onSubmit:
//login.component.ts
import { Component, OnInit } from '@angular/core';
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
@Component({
selector: 'app-login',
templateUrl: './login.component.html'
})
export class LoginComponent implements OnInit {
form: FormGroup;
submitted = false;
constructor(private formBuilder: FormBuilder) {}
ngOnInit() {
this.form = this.formBuilder.group({
email: ['', [Validators.required, Validators.pattern('[^ @]*@[^ @]*')]],
password: ['', Validators.required]
});
}
onSubmit() {
this.submitted = true;
}
}
And add the validation message error in the HTML
<span *ngIf="submitted && form.controls.email.invalid" id="email-error">
Please enter a valid email.
</span>
Good, now we have our test green but if we run our app we are not going to see the error message after clicking Sign in
.
What is wrong? YES, our test is calling onSubmit()
directly instead of clicking the button.
It is important to recognize this kind of errors when writing our tests to avoid "false positives". Having a green test does not necessarily mean that is working as expected.
So, if we fix our test replacing component.onSubmit()
by clicking the button, we should have again a failing test:
it('should render email validation message when formControl is submitted and invalid', () => {
const elements: HTMLElement = fixture.nativeElement;
expect(elements.querySelector('#email-error')).toBeFalsy();
elements.querySelector('button').click();
fixture.detectChanges();
expect(elements.querySelector('#email-error')).toBeTruthy();
expect(elements.querySelector('#email-error').textContent).toContain(
'Please enter a valid email.'
);
});
What is missing now to make this test green? Correct, we should call onSubmit from our form when clicking Sign In button by adding (ngSubmit)="onSubmit()"
to our form.
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input id="email" type="email" placeholder="Your email" />
<span *ngIf="submitted && form.controls.email.invalid" id="email-error">
Please enter a valid email.
</span>
<input id="password" type="password" placeholder="********" />
<button type="submit">Sign in</button>
</form>
Lastly, let's do the same for our password input.
it('should render password validation message when formControl is submitted and invalid', () => {
const elements: HTMLElement = fixture.nativeElement;
expect(elements.querySelector('#password-error')).toBeFalsy();
elements.querySelector('button').click();
fixture.detectChanges();
expect(elements.querySelector('#password-error')).toBeTruthy();
expect(elements.querySelector('#password-error').textContent).toContain(
'Please enter a valid password.'
);
});
Before proceeding, check that the test is failing.
Good, now we need the html part to make it green:
<span *ngIf="submitted && form.controls.password.invalid" id="password-error">
Please enter a valid password.
</span>
Fifth step: Styling
Now it is time to make our login form look nice! You could use plain css or your preferred css framework. In this tutorial, we are going to use TailwindCSS, and you could read this post on how to install it:
And for styling our form, we could just follow official doc:
Login Form
Our final result:
The next post is going to be the authentication service and how to invoke it using this form we've just built.
Angular series: Creating an authentication service with TDD
JPBlancoDB ・ Nov 20 '19
If you have any doubt, you could leave a comment or contact me via Twitter. I'm happy to help!
Posted on November 18, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.