Angular series: Creating an authentication service with TDD

jpblancodb

JPBlancoDB

Posted on November 20, 2019

Angular series: Creating an authentication service with TDD

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

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

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

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

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

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

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

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

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

Enter fullscreen mode Exit fullscreen mode

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

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

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

Enter fullscreen mode Exit fullscreen mode

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

//auth.service.ts
constructor(private httpClient: HttpClient) {}
Enter fullscreen mode Exit fullscreen mode

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

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

Enter fullscreen mode Exit fullscreen mode

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

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

💖 💪 🙅 🚩
jpblancodb
JPBlancoDB

Posted on November 20, 2019

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

Sign up to receive the latest update from our blog.

Related