Catalin Ciubotaru avatar

Test Driven Development in an Angular World - Part 2

Before reading any further, I recommend reading part 1 for some context.

When you're done and want to know how to test a Smart Component, maybe part 3 will help.

Why the Http Service?

So, last time we talked about the Angular Component and how to test it. Now, that was a "dumb" component but hey, we have to start somewhere. I promise I'll show you how to test a "smart" component in the nearby future.

So, back to our WHY question. I think next to components, services make for a big part of our Angular applications. Then, out of all services, I noticed that people have issues testing the ones that do HTTP calls. So, without further ado, here is my take on it.

Don't worry, this will still be done in a Test Driven Development way.

Where we left off

If you remember from last time, we relied on the existence of a FavoriteMoviesService. Since that was beyond the purpose at the time, it was just a shell for the actual functionality. This is how it ended up looking like:


export class FavoriteMoviesService {
  constructor() {}

  getFavoriteMovies(): Observable<Movie[]> {
    return of([]);
  }
}}
  

Create a service spec file

So, first things first: we need a spec file for our service. This is where we will write our tests. So, let’s create it:


const moviesToUse = [
  { title: 'Interstellar' } as Movie,
  { title: 'The Green Book' } as Movie,
  { title: 'Dark Knight' } as Movie
];

describe('FavoriteMoviesService', () => {
  let serviceUnderTest: FavoriteMoviesService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [FavoriteMoviesService]
    });

    serviceUnderTest = TestBed.get(FavoriteMoviesService);
  });

  it('should be created', () => {
    expect(serviceUnderTest).toBeTruthy();
  });
});
  

What do we have here? This is the boilerplate for testing any service in Angular. It contains the initialization and a test that checks that the service is created.

Then, we add a variable for our serviceUnderTest and we initialize it in the beforeEach so we don’t have to do it in every single test.

Also, we added a list of movies that we will use as mocked data for the tests. More on that, later.

That’s about it.

So, we run our test now, what does it do?

Image of the test passing

Great! It passes.

How to start?

Now that that is out of the way, what do we do? Well, we want to make sure that our service returns what we expect it to. How can we achieve that? We will write a test for it:


it('should return the favorite movies', () => {
  let result: Movie[] = [];
  serviceUnderTest.getFavoriteMovies().subscribe(data => {
    result = data;
  });

  expect(result).toEqual(moviesToUse);
});
  

So, here we have a new test that subscribes to our method and checks that what it got back is the actual list of movies that we expect.

Let’s run it:

Image of the tests failing

Well, would you look at that; it failed!

Now, following the red-green approach, let’s fix it. How do we fix it? Doing the minimum amount of work necessary. Remember, we’re lazy.


getFavoriteMovies(): Observable<Movie[]> {
  return of([
    { title: 'Interstellar' } as Movie,
    { title: 'The Green Book' } as Movie,
    { title: 'Dark Knight' } as Movie
  ]);
}
  

So, we just return the list we know it’s expected, wrapped in an Observable. You might think it’s a hack, which it kinda is, but it’s fine. Don’t worry about it. It’s a work in progress.

Now, when we run our tests:

Image of the tests passing

Check!

What about the HTTP part?

Why are we here? Yes, testing an HTTP service. So, far, there is no HTTP. Let’s change that.

The first step is to make sure our environment has a variable with the URL that we expect to be called. This is just good practice.


const favoriteUrl = 'test.favorite.com';

export const environment = {
  production: false,
  favoriteUrl
};
  

This makes our service configurable so we can have different URLs for DEV, TEST and PROD.

Now, back to our test. We want to make sure that our service does an HTTP call to our endpoint and it returns whatever that call returns. This will require some changes in the test:


describe('FavoriteMoviesService', () => {
  let serviceUnderTest: FavoriteMoviesService;
  let http: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [FavoriteMoviesService]
    });

    serviceUnderTest = TestBed.get(FavoriteMoviesService);
    http = TestBed.get(HttpTestingController);
  });

  it('should return the favorite movies from the backend', () => {
    let result: Movie[] = [];
    serviceUnderTest.getFavoriteMovies().subscribe(data => {
     result = data;
    });

    const req = http.expectOne(environment.favoriteUrl);
    expect(req.request.method).toEqual('GET');

    req.flush(moviesToUse);
    http.verify();
  });
})
  

First, we import the HttpClientTestingModule. This will help us test/mock/intercept/analyze HTTP traffic. Then, we create a new variable for the http helper because we will need this in more tests and it’s cleaner than to do it in each and every test.

Now, to the actual test. First, we changed the name to reflect that we expect the data to come from the backend. After that, there are a couple of things happening:

  • We use the new http variable to check that one and only one call was made to our endpoint. This will return the request that was made by our service, if any.
  • We check the HTTP method of this request to make sure it’s a GET request
  • We use this request object to flush data. This is our way of mocking the response that would otherwise come from the backend
  • Last but not least, we call the verify() method to make sure that there are no pending HTTP calls. This is to make sure that we don’t do more requests than we expect to or that we don’t have any unhandled requests.

Now, let’s run it!

Image of the tests failing

Of course, it fails. Red-green, remember? So, it says that it expected one request for our URL, but found none. Let’s fix that.


export class FavoriteMoviesService {
  constructor(private http: HttpClient) {}

  getFavoriteMovies(): Observable<Movie[]> {
    return this.http.get<Movie[]>(environment.favoriteUrl);
  }
}
  

So, in order to make our test pass, we inject the HttpClient in our service and we use it to make a call to our favoriteUrl endpoint. We return the response of that. Let’s try to run our tests again.

Image of the tests passing

Check!

What other things should we test?

Another thing I like to test is error handling. We want to make sure that our errors are handled properly and are not swallowed.

What is that I hear? Time for another test!


it('should throw if the backend returns an error ', () => {
  let bubblesUpTheError = false;
  serviceUnderTest.getFavoriteMovies().subscribe(() => {}, () => (bubblesUpTheError = true));

  const req = http.expectOne(environment.favoriteUrl, 'expected to make a request');
  expect(req.request.method).toEqual('GET');
  req.flush('ERROR', { status: 500, statusText: 'Internal server error' });

  expect(bubblesUpTheError).toBeTruthy();
  http.verify();
});
  

So, here we use the second parameter of the subscribe method, the error callback. That method is called in case of an error. We want to check that if the backend returns an error, that error is not swallowed by our service but is bubbled up, so the consumer can do the handling.

Then we just tell our http to return a 500 ERROR.

Now, this is highly subjective. You can also have a logger injected in your service, and here instead of checking the bubblesUpTheError variable, you would check if the logger was asked to log an error(remember spies from lesson 1?). Whatever works for you. For simplicity, I went with this approach. I just wanted to show HOW to test this scenario.

Now, let’s run the test:

Image of the tests passing

Check!

Let’s get fancy. RETRY

Now that the basic functionality is tested, let’s make things interesting.

We have a new feature request. Since our backend is flaky, let’s make sure that our service tries to get the data 3 times before actually failing. Interesting right?

Now, let’s change our previous test to check for that functionality:


it('should fail if the backend returns an error 3 times in a row', done => {
  let bubblesUpTheError = false;
  serviceUnderTest.getFavoriteMovies().subscribe(() => {}, () => (bubblesUpTheError = true);

  const req = http.expectOne(environment.favoriteUrl, 'expected to make an initial request');
  expect(req.request.method).toEqual('GET');

  req.flush('ERROR', { status: 500, statusText: 'Internal server error' });
  const req2 = http.expectOne(environment.favoriteUrl, 'expected to make a second request');
  expect(req2.request.method).toEqual('GET');

  req2.flush('ERROR', { status: 500, statusText: 'Internal server error' });
  const req3 = http.expectOne(environment.favoriteUrl, 'expected to make a third request');
  expect(req3.request.method).toEqual('GET');

  req3.flush('ERROR', { status: 500, statusText: 'Internal server error' });
  expect(bubblesUpTheError).toBeTruthy();
  http.verify();
});
  

Here we created 3 more request-checks. Each check comes with its own error message so is easier for us to know what failed. So, we expect 3 HTTP GET calls to go to our endpoint and if ALL of them fail, we expect an error.

Let’s run our test:

Image of the tests failing

You see? Our helper error message came in handy. It failed but we know why. It did not make a second request. Now, let’s make this test pass:


getFavoriteMovies(): Observable<Movie[]> {
  return this.http.get<Movie[]>(environment.favoriteUrl).pipe(retry(2));
}
  

The way we make this pass is by using the retry operator. We tell it: if the request fails, retry 2 more times before actually passing the error.

And, running our test now:

Image of the tests passing

Check!

One last test for the road

Now, for a sanity check, we might want to write a test that checks the situation when the backend fails twice but works the third time. In that case, we want to make sure that the data is returned and there is no error. How would that test look like?


it('should return the list of favorite movies if the backend returns an error 2 times and the succeeds', () => {
  let favoriteMovies: Movie[] = [];
  serviceUnderTest.getFavoriteMovies().subscribe(data => {
    favoriteMovies = data;
  });

  const req = http.expectOne(environment.favoriteUrl, 'expected to make an initial request');
  expect(req.request.method).toEqual('GET');
  req.flush('ERROR', { status: 500, statusText: 'Internal server error' });

  const req1 = http.expectOne(environment.favoriteUrl, 'expected to make a second request');
  expect(req1.request.method).toEqual('GET');
  req1.flush('ERROR', { status: 500, statusText: 'Internal server error' });

  const req2 = http.expectOne(environment.favoriteUrl, 'exected to make a third request');
  expect(req2.request.method).toEqual('GET');
  req2.flush(moviesToUse);

  expect(favoriteMovies).toEqual(moviesToUse);
  http.verify();
});
  

This looks very similar to our previous test. The difference is with the last request that we intercept. We tell that one to return data instead of failing. Then, we check that data was actually returned. Easy peasy.

Let’s run it:

Image of the tests passing

It’s ALIVE!!

Summary

What did we learn?

  • Testing an HTTP Service is important
  • Test that an actual HTTP call was made. Test the HTTP method and check the data that was returned
  • Test that errors are handled properly
  • Test whatever extra logic you have handling the response from the server

And remember: RED => GREEN => SUCCESS

Be kind to each other! 🧡

Over and out

My Twitter avatar
Catalin Ciubotaru 🚀

Test Driven Development in an Angular World - Part 2

Sep 21, 2019
55 people are talking about this

Wanna read more?