Testing
When an @ionic/angular application is generated using the Ionic CLI, it is automatically set up for unit testing and end-to-end testing of the application:
- Unit tests run with Vitest through the Angular CLI's
@angular/build:unit-testbuilder, in a jsdom environment. Refer to the Angular Testing Guide for detailed information on testing Angular applications. - End-to-end tests run with Playwright against the app, which Playwright starts with
npm start.
The generated package.json includes scripts for running them:
| Script | Description |
|---|---|
npm test | Runs the unit tests once, then the end-to-end tests. |
npm run test:unit | Runs the unit tests once. |
npm run test:unit:watch | Runs the unit tests and re-runs them when files change. |
npm run test:e2e | Runs the end-to-end tests, starting the dev server for them. |
Playwright downloads its browsers separately from the npm package. Run npx playwright install once before running the end-to-end tests for the first time.
Testing Principles
When testing an application, it is best to keep in mind that testing can show if defects are present in a system. However, it is impossible to prove that any non-trivial system is completely free of defects. For this reason, the goal of testing is not to verify that the code is correct but to find problems within the code. This is a subtle but important distinction.
If we set out to prove that the code is correct, we are more likely to stick to the happy path through the code. If we set out to find problems, we are more likely to more fully exercise the code and find the bugs that are lurking there.
It is also best to begin testing an application from the very start. This allows defects to be found early in the process when they are easier to fix. This also allows code to be refactored with confidence as new features are added to the system.
Unit Testing
Unit tests exercise a single unit of code (component, page, service, pipe, etc) in isolation from the rest of the system. Isolation is achieved through the injection of mock objects in place of the code's dependencies. The mock objects allow the test to have fine-grained control of the outputs of the dependencies. The mocks also allow the test to determine which dependencies have been called and what has been passed to them.
Well-written unit tests are structured such that the unit of code and the features it contains are described via describe() callbacks. The requirements for the unit of code and its features are tested via it() callbacks. When the descriptions for the describe() and it() callbacks are read, they make sense as a phrase. When the descriptions for nested describe()s and a final it() are concatenated together, they form a sentence that fully describes the test case.
Since unit tests exercise the code in isolation, they are fast, robust, and allow for a high degree of code coverage.
Vitest's test functions (describe, it, expect, beforeEach, vi, and so on) are available as globals in spec files, so they do not need to be imported. Global setup for the unit tests goes in src/test-setup.ts, which already polyfills window.matchMedia for jsdom because Ionic needs it. The Vitest configuration that the builder uses is in vitest.config.mts.
Using Mocks
Unit tests exercise a code module in isolation. To facilitate this, Vitest can create mock functions to take the place of dependencies while testing. When a mock is used, the test can control the values returned by calls to that dependency, making the current test independent of changes made to the dependency. This also makes the test setup easier, allowing the test to only be concerned with the code within the module under test.
Using mocks also allows the test to query the mock to determine if it was called and how it was called via the toHaveBeenCalled* set of functions. Tests should be as specific as possible with these functions, favoring calls to toHaveBeenCalledTimes over calls to toHaveBeenCalled when testing that a method has been called. That is, expect(mock.foo).toHaveBeenCalledTimes(1) is better than expect(mock.foo).toHaveBeenCalled(). The opposite advice should be followed when testing that something has not been called (expect(mock.foo).not.toHaveBeenCalled()).
There are two common ways to create mocks in Vitest. Mock functions can be created from scratch using vi.fn(), or spies can be installed onto existing objects using vi.spyOn(). Refer to Vitest's Mocking guide for more details.
Using vi.fn()
vi.fn() creates a stand-alone mock function. Combining several of them into an object creates a full mock object from scratch, which can be provided in place of a real dependency. This is useful in that it is very simple. Nothing needs to be constructed or injected into the test. The disadvantage of this approach is that it allows the creation of objects that may not match the real objects.
Using vi.spyOn()
vi.spyOn() installs the spy on an existing object. The advantage of using this technique is that if an attempt is made to spy on a method that does not exist on the object, an exception is raised. This prevents the test from mocking methods that do not exist. The disadvantage is that the test needs a fully formed object to begin with, which may increase the amount of test setup required.
vi.spyOn() can also spy on a getter or setter by passing 'get' or 'set' as its third argument.
General Testing Structure
Unit tests are contained in spec files with one spec file per entity (component, page, service, pipe, etc.). The spec files live side-by-side with and are named after the source that they are testing. For example, if the project has a service called WeatherService, the code for it is in a file named weather.service.ts with the tests in a file named weather.service.spec.ts. Both of those files are in the same folder.
The spec files themselves contain a single describe call that defines that overall test. Nested within it are other describe calls that define major areas of functionality. Each describe call can contain setup and teardown code (generally handled via beforeEach and afterEach calls), more describe calls forming a hierarchical breakdown of functionality, and it calls which define individual test cases.
The describe and it calls also contain a descriptive text label. In well-formed tests, the describe and it calls combine with their labels to form proper phrases and the full label for each test case, formed by combining the describe and it labels, creates a full sentence.
For example:
describe('Calculation', () => {
describe('divide', () => {
it('calculates 4 / 2 properly', () => {});
it('cowardly refuses to divide by zero', () => {});
// ...
});
describe('multiply', () => {
// ...
});
});
The outer describe call states that the Calculation service is being tested, the inner describe calls state exactly what functionality is being tested, and the it calls state what the test cases are. When run, the full label for each test case is a sentence that makes sense (Calculation divide cowardly refuses to divide by zero).
Pages and Components
Pages are just Angular components. Thus, pages and components are both tested using Angular's Component Testing guidelines.
Since pages and components contain both TypeScript code and HTML template markup it is possible to perform both component class testing and component DOM testing. When a page is created with ionic generate page, the spec that is generated looks like this:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LoginPage } from './login.page';
describe('LoginPage', () => {
let component: LoginPage;
let fixture: ComponentFixture<LoginPage>;
beforeEach(() => {
fixture = TestBed.createComponent(LoginPage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
The files that ionic generate page creates do not pass the project's lint rules yet. Run npm run lint:fix to fix their imports, then add code to the page's empty ngOnInit() method or remove it along with implements OnInit.
When doing component class testing, the component object is accessed using the component object defined via component = fixture.componentInstance;. This is an instance of the component class. When doing DOM testing, the fixture.nativeElement property is used. This is the actual HTMLElement for the component, which allows the test to use standard HTML API methods such as HTMLElement.querySelector in order to examine the DOM.
If the page or component needs providers, such as a router for a page that contains links, configure them with TestBed.configureTestingModule before creating the component:
import type { ComponentFixture } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { TabsPage } from './tabs.page';
describe('TabsPage', () => {
let component: TabsPage;
let fixture: ComponentFixture<TabsPage>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TabsPage],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(TabsPage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
Waiting for Components
When you need to wait for an Ionic component to render before asserting against its DOM, use the componentOnReady helper exported from @ionic/core. Do not call el.componentOnReady() directly. @ionic/angular uses Stencil's custom elements build, where that method does not exist on the element; it only exists in apps that use the lazy-loaded build from @ionic/angular/lazy. The helper handles both. It awaits the element's own componentOnReady() promise when that exists, and otherwise waits one animation frame, giving the component's inner contents a chance to render. Wait for the callback before asserting against the rendered DOM or running accessibility tests.
import type { ComponentFixture } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { componentOnReady } from '@ionic/core';
import { HomePage } from './home.page';
describe('HomePage', () => {
let fixture: ComponentFixture<HomePage>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HomePage],
}).compileComponents();
fixture = TestBed.createComponent(HomePage);
fixture.detectChanges();
});
it('renders the submit button', async () => {
const button = fixture.nativeElement.querySelector('ion-button');
await new Promise<void>((resolve) => componentOnReady(button, () => resolve()));
expect(button.textContent).toContain('Submit');
});
});
Services
Services often fall into one of two broad categories: utility services that perform calculations and other operations, and data services that perform primarily HTTP operations and data manipulation.
Basic Service Testing
The suggested way to test most services is to provide mocks for any dependency the service has, then get the service from Angular's TestBed. This way, the code can be tested in isolation.
Let's say that there is a service with a method that takes an array of timecards and calculates net pay. Let's also assume that the tax calculations are handled via another service that the current service depends on, which it gets with inject(TaxService). This payroll service could be tested as such:
import { TestBed } from '@angular/core/testing';
import type { Mock } from 'vitest';
import { PayrollService } from './payroll.service';
import { TaxService } from './tax.service';
describe('PayrollService', () => {
let service: PayrollService;
let taxServiceMock: { [K in keyof TaxService]: Mock };
beforeEach(() => {
taxServiceMock = {
federalIncomeTax: vi.fn().mockReturnValue(0),
stateIncomeTax: vi.fn().mockReturnValue(0),
socialSecurity: vi.fn().mockReturnValue(0),
medicare: vi.fn().mockReturnValue(0),
};
TestBed.configureTestingModule({
providers: [{ provide: TaxService, useValue: taxServiceMock }],
});
service = TestBed.inject(PayrollService);
});
describe('net pay calculations', () => {
it('subtracts the federal income tax from the gross pay', () => {
taxServiceMock.federalIncomeTax.mockReturnValue(73.24);
expect(service.netPay([{ hours: 40, rate: 10 }])).toBeCloseTo(326.76);
expect(taxServiceMock.federalIncomeTax).toHaveBeenCalledTimes(1);
expect(taxServiceMock.federalIncomeTax).toHaveBeenCalledWith(400);
});
});
});
This allows the test to control the values returned by the various tax calculations via mock setup such as taxServiceMock.federalIncomeTax.mockReturnValue(73.24). This allows the "net pay" tests to be independent of the tax calculation logic. When the tax codes change, only the tax service related code and tests need to change. The tests for the net pay can continue to operate as they are since these tests do not care how the tax is calculated, just that the value is applied properly.
To keep the real tax service and only replace one of its methods, get both services from the TestBed and spy on the method instead. This works without listing any providers because both services are @Injectable({ providedIn: 'root' }), which is how ionic generate service creates them:
import { TestBed } from '@angular/core/testing';
import { PayrollService } from './payroll.service';
import { TaxService } from './tax.service';
describe('PayrollService', () => {
let service: PayrollService;
let taxService: TaxService;
beforeEach(() => {
service = TestBed.inject(PayrollService);
taxService = TestBed.inject(TaxService);
});
it('uses the federal income tax from the tax service', () => {
vi.spyOn(taxService, 'federalIncomeTax').mockReturnValue(73.24);
service.netPay([{ hours: 40, rate: 10 }]);
expect(taxService.federalIncomeTax).toHaveBeenCalledTimes(1);
});
});
Testing HTTP Data Services
Most services that perform HTTP operations will use Angular's HttpClient service in order to perform those operations. For such tests, it is suggested to provide HttpClient with provideHttpClient() and replace its backend with provideHttpClientTesting(). The test then uses HttpTestingController to check the requests that were made and to respond to them. For detailed documentation, please refer to Angular's Testing HTTP requests guide.
This basic setup for such a test looks like this:
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { IssTrackingDataService } from './iss-tracking-data.service';
describe('IssTrackingDataService', () => {
let httpTestingController: HttpTestingController;
let service: IssTrackingDataService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
httpTestingController = TestBed.inject(HttpTestingController);
service = TestBed.inject(IssTrackingDataService);
});
afterEach(() => {
httpTestingController.verify();
});
describe('location', () => {
it('gets the location of the ISS now', () => {
let location: unknown;
service.location().subscribe((value) => (location = value));
const req = httpTestingController.expectOne('http://api.open-notify.org/iss-now.json');
expect(req.request.method).toEqual('GET');
req.flush({
iss_position: { longitude: '-138.1719', latitude: '44.4423' },
timestamp: 1525950644,
message: 'success',
});
expect(location).toEqual({ longitude: -138.1719, latitude: 44.4423 });
});
});
});
Pipes
A pipe is like a service with a specifically defined interface. It is a class that contains one public method, transform, which manipulates the input value (and other optional arguments) in order to create the output that is rendered on the page. To test a pipe: instantiate the pipe, call the transform method, and verify the results.
As a simple example, let's consider a pipe that takes a Person object and formats the name. For the sake of simplicity, let's say a Person consists of an id, firstName, lastName, and middleInitial. The requirements for the pipe are to print the name as "Last, First M." handling situations where a first name, last name, or middle initial do not exist. Such a test might look like this:
import type { Person } from '../models/person';
import { NamePipe } from './name.pipe';
describe('NamePipe', () => {
let pipe: NamePipe;
let testPerson: Person;
beforeEach(() => {
pipe = new NamePipe();
testPerson = {
id: 42,
firstName: 'Douglas',
lastName: 'Adams',
middleInitial: 'N',
};
});
it('formats a full name properly', () => {
expect(pipe.transform(testPerson)).toBe('Adams, Douglas N.');
});
it('handles having no middle initial', () => {
delete testPerson.middleInitial;
expect(pipe.transform(testPerson)).toBe('Adams, Douglas');
});
it('handles having no first name', () => {
delete testPerson.firstName;
expect(pipe.transform(testPerson)).toBe('Adams N.');
});
it('handles having no last name', () => {
delete testPerson.lastName;
expect(pipe.transform(testPerson)).toBe('Douglas N.');
});
});
It is also beneficial to exercise the pipe via DOM testing in the components and pages that utilize the pipe.
End-to-end Testing
End-to-end testing is used to verify that an application works as a whole and often includes a connection to live data. Whereas unit tests focus on code units in isolation and thus allow for low-level testing of the application logic, end-to-end tests focus on various user stories or usage scenarios, providing high-level testing of the overall flow of data through the application. Whereas unit tests try to uncover problems with an application's logic, end-to-end tests try to uncover problems that occur when those individual units are used together. End-to-end tests uncover problems with the overall architecture of the application.
Since end-to-end tests exercise user stories and cover the application as a whole rather than individual code modules, they run in a real browser against the running application. Most end-to-end tests operate by automating common user interactions with the application and examining the DOM to determine the results of those interactions.
Test Structure
When an @ionic/angular application is generated, it is set up to run end-to-end tests with Playwright. The setup consists of:
playwright.config.ts- the Playwright configuration file. It starts the app withnpm startbefore the tests run and emulates a mobile device (a Pixel 7) in Chromium.e2e/app.spec.ts- a testing script that loads the app and checks its first page.
Refer to Playwright's Writing tests guide for an introduction to the test API.
Page Objects
End-to-end tests operate by automating common user interactions with the application, waiting for the application to respond, and examining the DOM to determine the results of the interaction. This involves a lot of DOM manipulation and examination. If this were all done manually, the tests would be very brittle and difficult to read and maintain.
Page objects encapsulate the HTML for a single page in a TypeScript class, providing an API that the test scripts use to interact with the application. The encapsulation of the DOM manipulation logic in page objects makes the tests more readable and far easier to reason about, lowering the maintenance costs of the test. Creating well-crafted page objects is the key to creating high quality and maintainable end-to-end tests. Refer to Playwright's Page object models guide for more details.
Playwright's locators wait for elements automatically, so a page object can mostly return locators and leave the waiting to the assertions that use them.
Here is an example page object for the tabs in an app created from the tabs starter:
import type { Locator, Page } from '@playwright/test';
export class TabsPage {
constructor(private readonly page: Page) {}
async load(): Promise<void> {
await this.page.goto('/');
}
// Only match the title of the page that is showing.
title(): Locator {
return this.page.locator('ion-title').filter({ visible: true }).first();
}
tabButton(name: string): Locator {
return this.page.getByRole('tab', { name });
}
async selectTab(name: string): Promise<void> {
await this.tabButton(name).click();
}
}
Ionic keeps pages in the navigation stack, and tabs that have been visited, in the DOM after navigating away from them, hiding them instead of removing them. Use filter({ visible: true }) when the same element, such as ion-title, can appear on more than one page.
Testing Scripts
Similar to unit tests, end-to-end test scripts consist of nested test.describe() and test() functions. In the case of end-to-end tests, the test.describe() functions generally denote specific scenarios with the test() functions denoting specific behaviors that should be exhibited by the application as actions are performed within that scenario.
Also similar to unit tests, the labels used in the test.describe() and test() functions should make sense when concatenated together to form the complete test case.
Here is a sample end-to-end test script that uses the page object above to exercise the tab navigation:
import { expect, test } from '@playwright/test';
import { TabsPage } from './page-objects/tabs.po';
test.describe('Tabs', () => {
let tabs: TabsPage;
test.beforeEach(async ({ page }) => {
tabs = new TabsPage(page);
await tabs.load();
});
test.describe('on first load', () => {
test('shows the first tab', async ({ page }) => {
await expect(page).toHaveURL(/\/tabs\/tab1$/);
await expect(tabs.title()).toHaveText('Tab 1');
});
test('marks the first tab as selected', async () => {
await expect(tabs.tabButton('Tab 1')).toHaveAttribute('aria-selected', 'true');
});
});
test.describe('when switching tabs', () => {
test('navigates to the selected tab', async ({ page }) => {
await tabs.selectTab('Tab 2');
await expect(page).toHaveURL(/\/tabs\/tab2$/);
await expect(tabs.title()).toHaveText('Tab 2');
});
test('navigates back to a previous tab', async ({ page }) => {
await tabs.selectTab('Tab 3');
await tabs.selectTab('Tab 1');
await expect(page).toHaveURL(/\/tabs\/tab1$/);
await expect(tabs.title()).toHaveText('Tab 1');
});
});
});
Configuration
The default configuration uses the same environment.ts file that is used for development. In order to provide better control over the data used by the end-to-end tests, it is often useful to create a specific environment for testing and use that environment for the tests. This section shows one possible way to create this configuration.
Testing Environment
Setting up a testing environment involves creating a new environment file that uses a dedicated testing backend, updating the angular.json file to use that environment, and modifying the webServer command in playwright.config.ts to serve the app with it.
Create the environment.e2e.ts File
The Angular environment.ts and environment.prod.ts files are often used to store information such as the base URL for the application's backend data services. Create an environment.e2e.ts that provides the same information, only connecting to backend services that are dedicated to testing rather than the development or production backend services. Here is an example:
export const environment = {
production: false,
apiUrl: 'https://e2e-test-api.my-great-app.com',
};
Modify the angular.json File
The angular.json file needs to be modified to use this file. Follow the paths listed below to add the configuration that is required.
Add a configuration at projects.app.architect.build.configurations called e2e that does the file replacement:
"e2e": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.e2e.ts"
}
]
}
Add a configuration at projects.app.architect.serve.configurations called e2e that points the build target at the development build configuration combined with the e2e build configuration defined above:
"e2e": {
"buildTarget": "app:build:development,e2e"
}
Modify the playwright.config.ts File
Modify playwright.config.ts so that npm run test:e2e serves the app with the e2e configuration. Serve it on a different port than the default 4200, so Playwright never reuses a development server that is already running, which would send the tests to the development backend:
const baseURL = 'http://localhost:4300';
export default defineConfig({
// ...existing config...
webServer: {
command: 'npm start -- --configuration=e2e --port 4300',
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
Test Cleanup
If the end-to-end tests modify data in any way, it is helpful to reset the data to a known state once the tests complete. One way to do that is to:
- Create an endpoint that performs the cleanup.
- Add a global teardown file that calls the endpoint, and point the
globalTeardownoption inplaywright.config.tsat it.
Here is an example:
async function globalTeardown(): Promise<void> {
const response = await fetch('https://e2e-test-api.my-great-app.com/purgeDatabase', { method: 'POST' });
console.log(await response.text());
}
export default globalTeardown;
export default defineConfig({
// ...existing config...
globalTeardown: './e2e/global-teardown.ts',
});