DEV Community

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

Discussion (9)

pic
Editor guide
Collapse
thecodingalpaca profile image
Carlos Trapet

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

Collapse
jpblancodb profile image
JPBlancoDB Author

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

Collapse
drvanon profile image
Robin Alexander Dorstijn

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!

Collapse
padnevici profile image
Andrei Padnevici

Awesome !!!, thank you

Collapse
jpblancodb profile image
JPBlancoDB Author

Thanks Andrei!

Collapse
frankfullstack profile image
frankfullstack

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.

Collapse
jpblancodb profile image
JPBlancoDB Author

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!

Collapse
jpblancodb profile image
JPBlancoDB Author

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