loading...

Angular series: Creating a Login with TDD

jpblancodb profile image JPBlancoDB Updated on ・8 min read

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.

Screenshot of the CLI

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:

Login result screenshot

The next post is going to be the authentication service and how to invoke it using this form we've just built.

If you have any doubt, you could leave a comment or contact me via Twitter. I'm happy to help!

Posted on by:

jpblancodb profile

JPBlancoDB

@jpblancodb

Developer, writing tech articles. Football fan and terrible defender. He/him.

Discussion

markdown guide