loading...

Angular series: Creating an authentication service with TDD

jpblancodb profile image JPBlancoDB ・5 min read

Let's continue with the Angular series, now is time for implementing the service for doing the authentication.

The final project could be found in my personal Github: Angular series

If you missed the previous post, we created the Login component.

Before starting, let's run our tests and verify that everything is passing:

npm run test

If everything is still green we can continue otherwise, we need to fix it first.

First step: Add a test

Let's start by adding a test in our Login component to assert that after submitting our form, we are going to call the authentication service.

  //login.component.spec.ts
  it('should invoke auth service when form is valid', () => {
    const email = component.form.controls.email;
    email.setValue('test@test.com');
    const password = component.form.controls.password;
    password.setValue('123456');
    authServiceStub.login.and.returnValue(of());

    fixture.nativeElement.querySelector('button').click();

    expect(authServiceStub.login.calls.any()).toBeTruthy();
  });

As you noticed, is broken but don't worry! What happened? We've just added authServiceStub that is not declared and of that is not imported. Let's fix it all.

Import of from rxjs by doing (probably if you use an IDE or vscode, this could be done automatically):

import { of } from 'rxjs';

Now, let's continue by fixing authServiceStub, we need to declare this in our beforeEach:

  //login.component.spec.ts

  const authServiceStub: jasmine.SpyObj<AuthService> = jasmine.createSpyObj(
    'authService',
    ['login']
  );

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [LoginComponent],
      imports: [ReactiveFormsModule],
      providers: [
        {
          provide: AuthService,
          useValue: authServiceStub
        }
      ]
    }).compileComponents();
  }));

Basically, what we are doing here is to use our stub instead of the real service when unit testing our login component.

But, why is it still failing? You're right! Because AuthService does not exist... yet.

We could use schematics for this. So, open your terminal:

ng generate service login/auth

This will generate the auth.service.ts and the base auth.service.spec.ts in our login folder.

Now, is time for importing the created service.

import { AuthService } from './auth.service';

Lastly, we'll see a new error, to fix it, we should add the login method to our authentication service.

//auth.service.ts
login(): Observable<string> {
  throw new Error('not implemented');
}

Done! We should have our failing test 😎! But, you should have an error with your auth.service test. For now, just remove the default test, we are going to come back to this later.

It's time for making our test green:

//login.component.ts
onSubmit() {
  this.submitted = true;

  if (this.form.valid) {
    this.authService.login().subscribe(
      res => console.log(res),
      error => console.log(error)
    );
  }
}

But, as you noticed, we have a green test but this service is not useful if we don't pass as a parameter to the login function the email and the password. What we could do? Yes, a test!

We have two options, or we add an extra assertion to our test or we create a new test to verify that our stub is being called with correct parameters. For simplicity, I will just add an extra assertion, so our test would look like this:

//login.component.spec.ts
it('should invoke auth service when form is valid', () => {
  const email = component.form.controls.email;
  email.setValue('test@test.com');
  const password = component.form.controls.password;
  password.setValue('123456');
  authServiceStub.login.and.returnValue(of());

  fixture.nativeElement.querySelector('button').click();

  expect(authServiceStub.login.calls.any()).toBeTruthy();
  expect(authServiceStub.login).toHaveBeenCalledWith(
    email.value,
    password.value
  );
});

Yep, again to our beautiful red test! Remember our Red, Green, Refactor: The cycles of TDD)

Hands-on! Let's fix it.

//login.component.ts
this.authService
    .login(this.form.value.email, this.form.value.password)
    .subscribe(
       res => console.log(res),
       error => console.log(error)
    );

And we need to add email and password parameters to our login function in the service.

//auth.service.ts
login(email: string, password: string): Observable<string> {
  throw new Error('not implemented');
}

Done! Check that you have all the tests passing. If this is not the case, go back and review the steps or add a comment!

Second step: Authentication Service

It's time for creating our first test in auth.service.spec.ts. One remark, in this case, to avoid confusion I will avoid using jasmine-marbles for testing observables, you could read more here: Cold Observable. But, don't worry I'll write a separate post only for explaining it in deep.

How do we start? Exactly! By creating the test, and here I'll cheat a little bit because I already know that we need HttpClient dependency, so:

//auth.service.spec.ts
import { AuthService } from './auth.service';
import { HttpClient } from '@angular/common/http';
import { of } from 'rxjs';

describe('AuthService', () => {
    it('should perform a post to /auth with email and password', () => {
      const email = 'email';
      const password = 'password';
      const httpClientStub: jasmine.SpyObj<HttpClient> = jasmine.createSpyObj(
        'http',
        ['post']
      );
      const authService = new AuthService(httpClientStub);
      httpClientStub.post.and.returnValue(of());

      authService.login(email, password);

      expect(httpClientStub.post).toHaveBeenCalledWith('/auth', { email, password });
    });
});

This will cause some errors. We first need to inject HttpClient into AuthService:

//auth.service.ts
constructor(private httpClient: HttpClient) {}

Try again! What did you see? Our red test! Once more 😃.
This implementation is quite easy, let's do it:

  //auth.service.ts
  login(email: string, password: string): Observable<string> {
    return this.httpClient.post<string>('/auth', {
      email,
      password
    });
  }

And that is it! We should have our working service with all our tests green! 🎉🎉🎉

If you want to manually try this and to avoid creating the server, we could just add an interceptor (remember to add it as a provider in your app.module):

import { Injectable } from '@angular/core';
import {
  HttpInterceptor,
  HttpEvent,
  HttpHandler,
  HttpRequest,
  HttpResponse,
  HTTP_INTERCEPTORS
} from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';

@Injectable()
export class FakeServerInterceptor implements HttpInterceptor {
  intercept(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    if (req.url.endsWith('/auth')) {
      return this.authenticate();
    }

    return next.handle(req);
  }

  authenticate(): Observable<HttpResponse<any>> {
    return of(
      new HttpResponse({
        status: 200,
        body: 'jwt-token'
      })
    ).pipe(delay(1000));
  }
}

export const fakeServerProvider = {
  provide: HTTP_INTERCEPTORS,
  useClass: FakeServerInterceptor,
  multi: true
};

Lastly, if you were wondering how to do it with jasmine-marbles, would be something like this:

//auth.service.spec.ts
  it('should perform a post to /auth with email and password', () => {
    const serverResponse = 'jwt-token';
    const email = 'email';
    const password = 'password';
    const httpClientStub: jasmine.SpyObj<HttpClient> = jasmine.createSpyObj(
      'http',
      ['post']
    );
    const authService = new AuthService(httpClientStub);
    httpClientStub.post.and.returnValue(cold('a', {a: serverResponse}));

    const response = authService.login(email, password);

    expect(response).toBeObservable(cold('a', {a: serverResponse}));
    expect(httpClientStub.post).toHaveBeenCalledWith('/auth', { email, password });
  });

If you have any doubt you could add a comment or ask me via Twitter

Posted on by:

jpblancodb profile

JPBlancoDB

@jpblancodb

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

Discussion

markdown guide
 

Props on actually doing TDD :)
The only thing I'd say is, since this is clearly directed to people not familiar with Angular, maybe I'd have explained what RxJs, 'of' & Observables are.

But then again, that might take you down a rabbit hole trying to explain async operators hah

 

Hi Carlos! Thanks for your suggestion, actually I thought about it too but then decided to better write something specific for it 😉

 

You can't believe how helpful the httpClient stub was. I have up to this point only worked with the HttpClientTestingModule, but that does not work together with marbles, which makes some things very hard to debug.

Goed gedaan man!

 

A big thanks to you mate!
I heard about TDD but this is the first time i am seeing it in action and it feels very great & satisfying that our code is working as it's intended to. Would love to see a complete app tutorial !

 

Hi Iva! Thanks for your comment, I really appreciate it!

 
 

Hi, great post :). I found that you need to import in your auth.service.spec.ts the HttpClientTestingModule in order to use the HttpClient on the spyObj.

 

Hi Frank! Thanks for reading! Could you put your test file? I'm curious why you needed to import that. Is it possible that you are using TestBed.configureTestingModule in your service tests? If that is the case, indeed you need to import HttpClientTestingModule, if not is not needed.

Thank you again!