Angular series: Creating a Login with TDD

jpblancodb

JPBlancoDB

Posted on November 18, 2019

Angular series: Creating a Login with TDD

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!');
});

Enter fullscreen mode Exit fullscreen mode

And replace it with:

it('should have router-outlet', () => {
    const fixture = TestBed.createComponent(AppComponent);

    expect(fixture.nativeElement.querySelector('router-outlet')).not.toBeNull();
});
Enter fullscreen mode Exit fullscreen mode

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 {}

Enter fullscreen mode Exit fullscreen mode

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 {}

Enter fullscreen mode Exit fullscreen mode

Third step: Creating the Login Component

Open the terminal and run:

ng generate module login --routing
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
  }
];
Enter fullscreen mode Exit fullscreen mode

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 {}

Enter fullscreen mode Exit fullscreen mode

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 {}

Enter fullscreen mode Exit fullscreen mode

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();
  });
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
  });
Enter fullscreen mode Exit fullscreen mode

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() {}
}
Enter fullscreen mode Exit fullscreen mode

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({});
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }));

Enter fullscreen mode Exit fullscreen mode

But, we still have 2 tests failing! We need to add formGroup to our <form>:

<form [formGroup]="form">
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

Let's make those tests pass:

ngOnInit() {
  this.form = this.formBuilder.group({
    email: ['', Validators.required]
  });
}
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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]
  });
}
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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]
  });
}
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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.'
  );
});
Enter fullscreen mode Exit fullscreen mode

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;
  }
}

Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.'
  );
});
Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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.'
  );
});

Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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!

💖 💪 🙅 🚩
jpblancodb
JPBlancoDB

Posted on November 18, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related