Angular series: Creating an authentication service with TDD
JPBlancoDB
Posted on November 20, 2019
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.
Angular series: Creating a Login with TDD
JPBlancoDB ・ Nov 18 '19 ・ 8 min read
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 November 20, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.