In the previous tutorial in this series, we introduced the concept of Test Driven Development and how those concepts can be applied to Ionic/Angular application development. I won’t be introducing the concepts again here, but in short: Test Driven Development, or TDD, is a method of development that involves writing tests before writing your application code, and then writing code to satisfy those tests.
We have already covered the basic concepts, how to set up testing, and some basic tests which ensured our components were being created successfully and that views were being updated with the correct data. In this tutorial, we are going to build on the existing application to include a more complex scenario.
In the previous tutorial we had a Products service that would supply some product data, and then we would use that service to display some data in a ProductPage. We have written tests for these and all of those tests pass successfully.
However, the Products service is only using static data set up through its constructor
:
constructor() {
this.products = [
{ title: "Cool shoes", description: ""Isn't it obvious?", price: "39.99" },"
{
title: "Broken shoes",
description: ""You should probably get the other ones","
price: "89.99"
},
{ title: "Socks", description: ""The essential footwear companion", price: "2.99" }"
];
}
This was done for the sake of making the first tutorial more simple, but this isn’t likely how you would want to set this service up in a real world scenario. You would likely load this data in from somewhere using the HttpClient service, but if you try to modify this service to make an HTTP request, you will quickly find you run into trouble with your tests.
In this tutorial, we will be modifying the Products service to make an HTTP request to load the data. We will discuss why it is beneficial to “fake” the request to the backend, and why it is useful to use “mocks”.
Before We Get Started
IMPORTANT: This tutorial follows on from Test Driven Development in Ionic: An Introduction to TDD, if you want to follow along with the example you will need to have completed the previous tutorial first.
1. Modify the Products Service to make an HTTP Request
In the previous tutorial, we discussed that the general approach to Test Driven Development is the following:
- Write a test
- Run the test (it should fail)
- Write code to satisfy the test and run it again (it should pass)
- Refactor
We already have our tests and the code to satisfy those tests, and now we are looking at the refactor step. Refactoring code just means modifying it to make it better in some way. Since we have our tests, we know that if we make any breaking changes as we are attempting to refactor, our tests will let us know about it. This gives us a much greater degree of confidence in modifying existing code.
Let’s see what this process might look like as we refactor our Products service to make an HTTP request.
Create a file at src/assets/data/products.json and add the following:
{
"products": [
{"title": "Cool shoes", "description": "Isn"t it obvious?", "price": "39.99"},
{"title": "Broken shoes", "description": "You should probably get the other ones", "price": "89.99"},
{"title": "Socks", "description": "The essential footwear companion", "price": "2.99"}
]
}
This is the data that we will load with an HTTP request, but we are just going to store it locally. You could just as easily make this request to a server instead, either way, by loading through an HTTP request we are making the load process asynchronous (meaning the data is not instantly available to our application, and it will go on doing its own thing instead until the data is available).
Modify src/app/services/products.service.ts to reflect the following:
import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
@Injectable({
providedIn: "root"
})
export class ProductsService {
public products: any[];
constructor(private http: HttpClient) {}
load() {
this.http.get("assets/data/products.json").subscribe((data: any) => {
this.products = data.products;
});
}
}
We’re importing the HttpClient
service now, and we have made a load
method that will make a request to the JSON file we created. Once the data has been retrieved we will assign that data to the products
member variable.
Let’s see what happens if we run our tests now with npm test
:
An argument for 'http' was not provided.
Executed 0 of 0 ERROR (0.033 secs / 0 secs)
Our tests can’t even run at this point because we are running into some compilation errors. This is fine and often expected - ideally we are looking for failing tests, but a test that won’t even run is still a type of failing test - now let’s investigate how we can fix this.
2. Modify the Products Service Test
This is what our test file looks like for the Products service right now:
import { ProductsService } from './products.service';
describe('Provider: Products', () => {
let productsService;
beforeEach(() => {
productsService = new ProductsService();
});
afterEach(() => {
productsService = null;
});
it('should have a non empty array called products', () => {
let products = productsService.products;
expect(Array.isArray(products)).toBeTruthy();
expect(products.length).toBeGreaterThan(0);
});
});
This is a nice and simple test. At the time, this service did not have any dependencies so we could just instantiate a new object using:
productsService = new Products();
instead of configuring a test component with TestBed. But now that we are making an HTTP request, we are setting up the HttpClient
service through dependency injection, which means we are going to need to start using TestBed to configure an Angular testing module.
Modify src/app/services/products.spec.ts to reflect the following:
import { TestBed, inject, async } from '@angular/core/testing';
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing';
import { ProductsService } from './products.service';
describe('Provider: Products', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [],
providers: [ProductsService],
imports: [HttpClientTestingModule],
}).compileComponents();
}));
it('should have a non empty array called products', inject(
[ProductsService, HttpTestingController],
(productsService, httpMock) => {
const mockResponse = {
products: [
{
title: 'Cool shoes',
description: ""Isn't it obvious?","
price: '39.99',
},
{
title: 'Broken shoes',
description: "'You should probably get the other ones',"
price: '89.99',
},
{
title: 'Socks',
description: "'The essential footwear companion',"
price: '2.99',
},
],
};
productsService.load();
// Expect a request to the URL
const mockReq = httpMock.expectOne('assets/data/products.json');
// Execute the request using the mockResponse data
mockReq.flush(mockResponse);
expect(productsService.products.length).toBeGreaterThan(0);
}
));
});
We’ve set up TestBed now, and we have discussed the purpose for that previously, but we also have a bunch of other shenanigans going on now like the inclusion of HttpClientTestingModule
and HttpTestingController
.
We need to make use of the HttpClient service, and so we would want to provide that service to this test configuration. However, when creating unit tests we generally want to create an isolated test case. We want to test this service and only this service, so we don’t want to have to rely on an external server to provide us with the correct data (we will just assume that it gives us the correct data). This is something you may cover in different types of tests, but not in a unit test.
So, instead of providing HttpClient in its normal form, we provide “fake” responses by injecting the HttpRestingController
into our test as httpMock
. This is a service supplied by Angular for the purpose of testing, and it allows you to create a “fake” response from the backend. The important part is that these fake responses are still performed asynchronously similar to the way a normal HTTP request would be in Angular.
We can then use any response we like for testing this service, as you will see we are hard coding a JSON string to use as the response, and then we set it up as a mock response with the following code:
const mockResponse = {
products: [
{ title: 'Cool shoes', description: ""Isn't it obvious?", price: '39.99' },"
{
title: 'Broken shoes',
description: "'You should probably get the other ones',"
price: '89.99',
},
{
title: 'Socks',
description: "'The essential footwear companion',"
price: '2.99',
},
],
};
// ... snip
// Execute the request using the mockResponse data
mockReq.flush(mockResponse);
This has two main benefits: it allows us to test this service in isolation, and it will allow the tests to run faster since we don’t need to wait for a response from a server.
Let’s take a look at where we stand with our tests now:
NullInjectorError: StaticInjectorError(DynamicTestModule)[ProductsService -> HttpClient]:
StaticInjectorError(Platform: core)[ProductsService -> HttpClient]:
NullInjectorError: No provider for HttpClient!
We are getting some errors complaining that there is no provider for HttpClient
. This is because we are making use of our products service, which uses the HttpClient
, inside of our Product Page. However, the testing module for our product page tests don’t include the HttpClientModule
or HttpClientTestingModule
that is necessary to inject HttpClient
.
We are going to fix this the obvious way - by adding one of the required modules - but it is worth noting that this is a bit of a failure of our test design at the moment. I am attempting to introduce new concepts slowly, and as a result our tests are a bit simpler than perhaps they should be. I mentioned that unit tests should test things in isolation. However, our product page is making calls to the real product service. Although we will not be dealing with this properly just yet, it is worth noting that our product page should also be making use of a fake implementation of the product service, not the real thing. We will get into this more later in this tutorial and in the next tutorial.
Modify src/app/product/product.page.spec.ts to include the
HttpClientTestingModule
:
import { HttpClientTestingModule } from '@angular/common/http/testing';
// ...snip
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ProductPage],
imports: [HttpClientTestingModule],
providers: [ProductsService],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();
}));
Great! Our Products service test is passing again now, but our ProductPage test is failing:
Page: Product Page displays products containing a title, description, and price in the list FAILED
TypeError: Cannot read property '0' of undefined
Let’s see if we can ge to the bottom of that.
3. Modify the Products Page and Test
We’ve changed the implementation of the Products service now, rather than loading in its data automatically it won’t load until the load
function is called, so our ProductPage will need to be updated to reflect this change.
Modify src/pages/product/product.page.ts to reflect the following:
import { Component, OnInit } from "@angular/core";
import { ProductsService } from "../services/products.service";
@Component({
selector: "app-product",
templateUrl: "./product.page.html",
styleUrls: ["./product.page.scss"]
})
export class ProductPage implements OnInit {
constructor(public productsService: ProductsService) {}
ngOnInit() {
this.productsService.load();
}
}
This will cause the Products service to load in its data as soon as the view has loaded. Let’s see if this sorts out our problem with the test:
Page: Product Page displays products containing a title, description, and price in the list FAILED
TypeError: Cannot read property '0' of undefined
Nope! Still getting an issue there. Let’s consider the test itself:
it('displays products containing a title, description, and price in the list', () => {
let productsService = fixture.debugElement.injector.get(ProductsService);
let firstProduct = productsService.products[0];
fixture.detectChanges();
de = fixture.debugElement.query(By.css('ion-list ion-item'));
el = de.nativeElement;
expect(el.textContent).toContain(firstProduct.title);
expect(el.textContent).toContain(firstProduct.description);
expect(el.textContent).toContain(firstProduct.price);
});
Right now we just grab a reference to the Products service and check its data immediately. The data isn’t available immediately, though, because we are loading it asynchronously, so our test is going to fail.
This isn’t even really the correct approach anyway. As I mentioned in the last section, we want to isolate our unit tests as much as possible, but in this test, we are relying on the Products service to supply the data. Instead, we are going to “mock” the Products service. A mock is a “fake” implementation of a real class for the purpose of testing. It means we can take the approach of assuming that our dependencies work, and focus on only testing this one specific component.
Add the following inside of a new file at src/mocks.ts:
export class ProductsMock {
public products: any = [{ title: "Cool shoes", description: ""Isnt it obvious?", price: "39.99" }];"
public load: Function = () => {};
}
This sets up a simple mock with a products
member variable that will return some dummy data, and an implementation of the load
function so that calls to the load
function won’t cause errors. Now we just need to use that mock in our ProductPage.
Modify src/app/product/product.page.spec.ts to reflect the following:
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
import { ProductsService } from '../services/products.service';
import { ProductsMock } from '../../mocks';
import { ProductPage } from './product.page';
describe('Page: Product Page', () => {
let comp: ProductPage;
let fixture: ComponentFixture<ProductPage>;
let de: DebugElement;
let el: HTMLElement;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ProductPage],
imports: [HttpClientTestingModule],
providers: [
{
provide: ProductsService,
useClass: ProductsMock,
},
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ProductPage);
comp = fixture.componentInstance;
});
afterEach(() => {
fixture.destroy();
comp = null;
de = null;
el = null;
});
it('is created', () => {
expect(fixture).toBeTruthy();
expect(comp).toBeTruthy();
});
it('displays products containing a title, description, and price in the list', () => {
let productsService = fixture.debugElement.injector.get(ProductsService);
let firstProduct = productsService.products[0];
fixture.detectChanges();
de = fixture.debugElement.query(By.css('ion-list ion-item'));
el = de.nativeElement;
expect(el.textContent).toContain(firstProduct.title);
expect(el.textContent).toContain(firstProduct.description);
expect(el.textContent).toContain(firstProduct.price);
});
});
The important change here is this:
providers: [
{
provide: ProductsService,
useClass: ProductsMock
}
],
Instead of just providing the ProductsService service, as usual, we take a similar approach to using the fake HTTP backend and swap out the real service with a fake ProductsMock by using useClass
. This means that our simple dummy class we just created will be used instead. Everything else remains the same, we can still access the Products service throughout the tests as we were before, except that it will have been replaced by its fake clone.
Let’s try the tests again:
6 specs, 0 failures, randomized with seed 56803
AppComponent
- should create the app
- should initialize the app
Provider: Products
- should have a non empty array called products
HomePage
- should create
Page: Product Page
- displays products containing a title, description, and price in the list
- is created
Hooray! All green text again, all of our tests are passing.
Summary
We haven’t written a single new test in this tutorial, but we have continued developing our application using a Test Driven Development approach. As you can see, sometimes our tests will need to be modified as we go, and there are different ways that you can test the same functionality. The changes we have made in this tutorial to use mocks instead of the dependencies themselves conforms to a more best practice approach of testing components in isolation.