Testing a Page in Ionic 6

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.


Erik August Johnson is a software developer working with JavaScript to build useful things. Follow them on Twitter.