Introduction
This article could be named “Testing a Component in Angular” as there is nothing particularly special about testing a “page” versus a “component” in Ionic.
Generating a page in Ionic (versus a component) will create additional routing (routing.module.ts
and module.ts
), and lazy loading will be the default.
That stated, in this tutorial we will test a page in Ionic - covering how we test the component logic as well as the HTML template. In addition, we will also see how to mock dependencies and any child components.
Writing Good Unit Tests
Let’s step back and discuss a little bit about how we should write good tests. Or at least tests with a defined structure. For this, we will use the AAA Pattern.
AAA Pattern
The AAA Pattern is to:
- Arrange all necessary preconditions and inputs
- Act on the object or class under test
- Assert that the expected results have occured
From Mews:
“The arrange step sets up the test’s input values. The act step prompts the primary function being tested. And finally, the assert step verifies that the output of the function is what was expected.”
DAMP versus DRY
Good tests are more DAMP than DRY. DRY meaning Don’t Repeat Yourself, a hallmark of programming. DAMP meaning we can repeat ourselves if necessary.
Finally, a good test should tell a story.
Karma and Jasmine
By default, Ionic uses Angular’s testing setup utilizing Karma and Jasmine. Karma runs the tests. Jasmine is the way we define the tests themselves. This is the set up we will be using.
To run our tests, we use the npm test
command. Karma will be looking for files to run that have spec
in the name.
For example, in this tutorial we will be testing the birthdays.page
, therefore our test file will be birthdays.page.spec.ts
.
Kicking Off Testing
Ionic, by default, creates a test spec for the page with some boilerplate:
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { BirthdaysPage } from './birthdays.page';
describe('BirthdaysPage', () => {
let component: BirthdaysPage;
let fixture: ComponentFixture<BirthdaysPage>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [BirthdaysPage],
imports: [IonicModule.forRoot()]
}).compileComponents();
fixture = TestBed.createComponent(BirthdaysPage);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});
For the sake of learning, let’s go ahead and remove this code, rebuilding our test file piece by piece.
Describe
Now that we have a blank test file, the first thing to do is define what will be our suite of tests for the BirthdaysPage
by using describe
.
describe
takes two arguments, a string for the name, and a function Jasmine will invoke that contain the inner specs. A test suite is just a function.
describe('BirthdaysPage', () => {
});
Fixture
A test fixture (also known as a test context) is the set of preconditions or state needed to run a test. We can set up a variable for it, and since we’re in TypeScript, assign it a type:
import { ComponentFixture } from '@angular/core/testing';
import { BirthdaysPage } from './birthdays.page';
describe('BirthdaysPage', () => {
let fixture: ComponentFixture<BirthdaysPage>;
});
TestBed
TestBed
is “the primary API for writing unit tests for Angular applications and libraries.” Angular.io
We will use TestBed to do a lot of the heavy lifting of creating our context (the state needed). A couple steps exist before we initialize our fixture.
We could go without TestBed
if our component had no dependencies, but it has several.
beforeEach()
We are going to have some common code that should run fresh for each spec. We can use Jasmine’s beforeEach()
to achieve this goal:
import { ComponentFixture } from '@angular/core/testing';
import { BirthdaysPage } from './birthdays.page';
describe('BirthdaysPage', () => {
let fixture: ComponentFixture<BirthdaysPage>;
beforeEach(() => {
});
});
As the name implies, code inside beforeEach()
will execute before each spec.
Testing Module
The page we are testing has dependencies.
Before we go ahead and create the fixture using TestBed
, we will need to inject our component, child component, as well as service dependencies in the TestBed
configuration:
import { ComponentFixture } from '@angular/core/testing';
import { BirthdaysPage } from './birthdays.page';
describe('BirthdaysPage', () => {
let fixture: ComponentFixture<BirthdaysPage>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [BirthdaysPage, FakeCountdownComponent],
providers: [
{ provide: BirthdayService, useValue: mockBirthdayService },
{ provide: ActivatedRoute, useValue: mockActivatedRoute,
{ provide: Router, useValue: mockRouter },
{ provide: ToastController, useValue: mockToastController }
]
});
fixture = TestBed.createComponent(BirthdaysPage);
});
});
The fixture is also initalized using the BirthdaysPage
component. Now it’s time to add our mock providers.
Mocking Dependencies
First, let’s start off with Router
. We will import Router
and provide a mock for it using Jasmine’s createSpy()
on the router’s navigate
method which is the only method being used.
const mockRouter = {
navigate: jasmine.createSpy('navigate')
};
Note that the jasmine
object is injected, and there’s no need for importing it.
Next up is ActivatedRoute
:
const mockActivatedRoute = {
snapshot: { params: {uuid: '123'} }
}
This will be enough to satisfy the behavior of ActivatedRoute
.
Now we will mock a Ionic dependency in ToastController
:
const toast = jasmine.createSpyObj('Toast', [
'present'
]);
const mockToastController = jasmine.createSpyObj('ToastController', ['create']);
mockToastController.create.and.returnValue(toast);
Here we are covering the ToastController
create
method, but also the present
instance method as we need both to satsify the usage inside the component.
Finally, let’s mock the BirthdayService
which powers the functionality of the component:
const mockBirthdayService = jasmine.createSpyObj(['update', 'getBirthdays']);
Now we have a mock set up for each dependency:
describe('BirthdaysPage', () => {
let fixture: ComponentFixture<BirthdaysPage>;
beforeEach(() => {
// Mock ToastController:
const toast = jasmine.createSpyObj('Toast', ['present']);
mockToastController = jasmine.createSpyObj('ToastController', ['create']);
mockToastController.create.and.returnValue(toast);
// Mock Activated Route
mockActivatedRoute = { snapshot: { params: {uuid: '123'} } } ;
// Mock Router
mockRouter = { navigate: jasmine.createSpy('navigate') };
// Mock Birthday Service
mockBirthdayService = jasmine.createSpyObj(['update', 'getBirthdays']);
TestBed.configureTestingModule({
declarations: [BirthdaysPage, FakeCountdownComponent],
providers: [
{ provide: BirthdayService, useValue: mockBirthdayService },
{ provide: ActivatedRoute, useValue: mockActivatedRoute,
{ provide: Router, useValue: mockRouter },
{ provide: ToastController, useValue: mockToastController }
]
});
fixture = TestBed.createComponent(BirthdaysPage);
});
});
Now, finally, we can get to writing some tests!
Mocking Child Component
BirthdayPage
has a single child component, CountdownComponent
. We will create a bare bones mock component:
@Component({
selector: 'app-countdown',
template: '<div></div>'
})
class FakeCountdownComponent {
@Input() moment;
}
We have add FakeCountdownComponent
to the TestBed
declarations and we are good to go.
Writing Tests
Let’s start off super simple and re-create the test that is generated by Ionic when a page is created:
it('should create the component', () => {
// Arrange
mockBirthdayService.getBirthdays.and.returnValue(of([BIRTHDAY]));
const component = fixture.componentInstance;
// Act
fixture.detectChanges();
// Assert
expect(component).toBeTruthy();
});
As you can see it
is similar to describe
and takes two arguments, one for a string for naming purposes and a callback function.
Inside of the block, we see an example of arrange as we set up the preconditions, then act on them by forcing the fixture to detect changes, and finally an assert using expect
. expect
has many methods, and you can check them out on this Jasmine cheat sheet.
So, what else should we test? Let’s have a look inside the component:
ngOnInit() {
this.uuid = this.route.snapshot.params.uuid;
this.getBirthday();
}
getBirthday() {
this.birthdayService.getBirthdays().subscribe((birthdays) => {
this.birthday = birthdays.find(person => person.uuid === this.uuid);
});
}
Both the retrieval of the birthday information as well as the UUID look like good places to start, as they are what kick off the component.
Let’s write two simple tests to verify we are receiving birthday info and the UUID:
it('should set a UUID to be used to find person', () => {
// Arrange
mockBirthdayService.getBirthdays.and.returnValue(of([BIRTHDAY]));
// Act
fixture.detectChanges();
// Assert
expect(fixture.componentInstance.uuid).toBe('123');
});
As you can see we need to arrange a return value from getBirthdays
. We may want to put this line of code in our beforeEach
although some duplication in testing is alright.
it('should set the birthday information from service', () => {
// Arrange
mockBirthdayService.getBirthdays.and.returnValue(of([BIRTHDAY]));
// Act
fixture.detectChanges();
// Assert
expect(fixture.componentInstance.birthday.firstName).toEqual('Mock');
});
Here we are confirming we get back birthday information, namely the first name property.
Testing the Template
How about verifying the template is correct? We have the tools to be able to do just that. Here is the following code we want to cover:
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="/"></ion-back-button>
</ion-buttons>
<ion-title>{{ birthday?.firstName }} {{ birthday?.lastName }}</ion-title>
</ion-toolbar>
</ion-header>
As you can see, we want to be able to see the first and last name of the person whose birthday it is. Here’s how we do this:
it('should display the selected person\'s first and last name', () => {
// Arrange
mockBirthdayService.getBirthdays.and.returnValue(of([BIRTHDAY]));
// Act
fixture.detectChanges();
// Assert
expect(fixture.debugElement.query(By.css('ion-title')).nativeElement.textContent).toContain('Mock Person');
});
fixture
contains a debugElement
which allows us to query for an element. By
also needs to be imported:
import { By } from '@angular/platform-browser';
Conclusion
In conclusion, we are given some powerful tools by Ionic/Angular: Karma, Jasmine, and TestBed. When combined we can really test our page’s component logic as well as the HTML template in isolation. We could perhaps keep our code a bit DRYer but that’s good place for a future refactor.