Ionic Angular Testing: Component Testing with Ionic Test Utilities and ion-* Component Mocks
Testing Ionic Angular applications requires navigating a collision between two opinionated frameworks, each with their own component lifecycle and dependency injection patterns. Angular's TestBed assumes synchronous or promise-based initialization; Ionic's components wrap Web Components and initialize asynchronously. Modal controllers, alert controllers, and navigation all introduce services that need mocking before a single test can run.
This guide cuts through that friction with concrete patterns for TestBed configuration, controller mocking, ion-* element queries, and form validation testing.
TestBed Setup with IonicModule
The most common mistake is forgetting IonicModule.forRoot() in the test module imports. Without it, Ionic's dependency injection tokens — including all controller services — are undefined, and your tests fail with cryptic NullInjectorError messages.
// src/app/home/home.component.spec.ts
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { HomeComponent } from './home.component';
describe('HomeComponent', () => {
let component: HomeComponent;
let fixture: ComponentFixture<HomeComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [HomeComponent],
imports: [
IonicModule.forRoot(),
],
}).compileComponents();
fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});waitForAsync is essential here. Ionic registers custom elements during IonicModule.forRoot(), and those registrations are asynchronous. Using waitForAsync ensures the zone has settled before assertions run.
CUSTOM_ELEMENTS_SCHEMA vs Full IonicModule Imports
You have two strategies for handling ion-* elements in templates.
Strategy 1: CUSTOM_ELEMENTS_SCHEMA — fastest to set up, weakest type safety
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
TestBed.configureTestingModule({
declarations: [HomeComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
});Angular will stop complaining about unknown elements. But ion-* components won't actually render or respond to events — they're treated as opaque HTML elements. This is fine for testing component TypeScript logic, but it breaks tests that query ion-* element state.
Strategy 2: IonicModule.forRoot() — slower compilation, real behavior
import { IonicModule } from '@ionic/angular';
TestBed.configureTestingModule({
declarations: [HomeComponent],
imports: [IonicModule.forRoot()],
});Ion-* components register as Web Components and render their shadow DOM. You can interact with them through DebugElement queries and Ionic-specific testing utilities.
For most teams, the pragmatic split is: use CUSTOM_ELEMENTS_SCHEMA for logic-heavy unit tests, and use IonicModule.forRoot() for tests that directly interact with ion-* behavior.
Querying ion-* Elements
Angular's DebugElement.query() works on ion-* elements, but the shadow DOM internals are not directly accessible. Query by CSS selector or By.css():
import { By } from '@angular/platform-browser';
it('renders the submit button', () => {
const button = fixture.debugElement.query(By.css('ion-button[data-cy="submit"]'));
expect(button).toBeTruthy();
expect(button.nativeElement.disabled).toBeFalsy();
});
it('renders the input with correct placeholder', () => {
const input = fixture.debugElement.query(By.css('ion-input[formControlName="email"]'));
expect(input).toBeTruthy();
expect(input.nativeElement.getAttribute('placeholder')).toBe('Enter your email');
});For attributes bound via Angular, access them through nativeElement.getAttribute(). For event simulation on ion-* components, dispatch custom events:
it('responds to ionChange on the input', () => {
const input = fixture.debugElement.query(By.css('ion-input'));
const changeSpy = jest.spyOn(component, 'onEmailChange');
input.nativeElement.dispatchEvent(
new CustomEvent('ionChange', { detail: { value: 'user@example.com' } })
);
fixture.detectChanges();
expect(changeSpy).toHaveBeenCalledWith('user@example.com');
});Mocking ModalController
ModalController is one of the most frequently used and most awkward services to test. The key is to mock it before the component is instantiated so Angular's DI picks up the mock.
// src/app/profile/profile.component.spec.ts
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ModalController, IonicModule } from '@ionic/angular';
import { ProfileComponent } from './profile.component';
describe('ProfileComponent', () => {
let component: ProfileComponent;
let fixture: ComponentFixture<ProfileComponent>;
let modalCtrlSpy: jest.Mocked<ModalController>;
const mockModal = {
present: jest.fn().mockResolvedValue(undefined),
dismiss: jest.fn().mockResolvedValue(undefined),
onDidDismiss: jest.fn().mockResolvedValue({ data: null, role: 'cancel' }),
};
beforeEach(waitForAsync(() => {
modalCtrlSpy = {
create: jest.fn().mockResolvedValue(mockModal),
dismiss: jest.fn().mockResolvedValue(undefined),
getTop: jest.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<ModalController>;
TestBed.configureTestingModule({
declarations: [ProfileComponent],
imports: [IonicModule.forRoot()],
providers: [
{ provide: ModalController, useValue: modalCtrlSpy },
],
}).compileComponents();
fixture = TestBed.createComponent(ProfileComponent);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('opens the edit modal when edit button is clicked', async () => {
await component.openEditModal();
expect(modalCtrlSpy.create).toHaveBeenCalledWith(
expect.objectContaining({ component: expect.any(Function) })
);
expect(mockModal.present).toHaveBeenCalled();
});
it('reloads data when modal returns with saved role', async () => {
mockModal.onDidDismiss.mockResolvedValue({ data: { updated: true }, role: 'saved' });
const reloadSpy = jest.spyOn(component, 'loadProfile');
await component.openEditModal();
expect(reloadSpy).toHaveBeenCalled();
});
it('does not reload data when modal is cancelled', async () => {
mockModal.onDidDismiss.mockResolvedValue({ data: null, role: 'cancel' });
const reloadSpy = jest.spyOn(component, 'loadProfile');
await component.openEditModal();
expect(reloadSpy).not.toHaveBeenCalled();
});
});Mocking AlertController
// src/app/settings/settings.component.spec.ts
import { AlertController, IonicModule } from '@ionic/angular';
describe('SettingsComponent — delete account flow', () => {
let alertCtrlSpy: jest.Mocked<AlertController>;
const mockAlert = {
present: jest.fn().mockResolvedValue(undefined),
dismiss: jest.fn().mockResolvedValue(undefined),
onDidDismiss: jest.fn().mockResolvedValue({ role: 'confirm' }),
};
beforeEach(waitForAsync(() => {
alertCtrlSpy = {
create: jest.fn().mockResolvedValue(mockAlert),
} as unknown as jest.Mocked<AlertController>;
TestBed.configureTestingModule({
declarations: [SettingsComponent],
imports: [IonicModule.forRoot()],
providers: [
{ provide: AlertController, useValue: alertCtrlSpy },
],
}).compileComponents();
}));
it('shows confirmation alert before deleting account', async () => {
await component.confirmDeleteAccount();
expect(alertCtrlSpy.create).toHaveBeenCalledWith(
expect.objectContaining({
header: expect.stringContaining('Delete'),
buttons: expect.arrayContaining([
expect.objectContaining({ role: 'confirm' }),
expect.objectContaining({ role: 'cancel' }),
]),
})
);
});
it('calls deleteAccount service when user confirms', async () => {
const deleteSpy = jest.spyOn(component['userService'], 'deleteAccount')
.mockResolvedValue(undefined);
await component.confirmDeleteAccount();
expect(deleteSpy).toHaveBeenCalled();
});
it('does not delete when user cancels', async () => {
mockAlert.onDidDismiss.mockResolvedValue({ role: 'cancel' });
const deleteSpy = jest.spyOn(component['userService'], 'deleteAccount');
await component.confirmDeleteAccount();
expect(deleteSpy).not.toHaveBeenCalled();
});
});Mocking NavController
Navigation is another common failure point. Mock NavController and verify navigation calls:
import { NavController, IonicModule } from '@ionic/angular';
import { Router } from '@angular/router';
describe('LoginComponent navigation', () => {
let navCtrlSpy: jest.Mocked<NavController>;
let routerSpy: { navigate: jest.Mock };
beforeEach(waitForAsync(() => {
navCtrlSpy = {
navigateForward: jest.fn().mockResolvedValue(true),
navigateBack: jest.fn().mockResolvedValue(true),
navigateRoot: jest.fn().mockResolvedValue(true),
back: jest.fn(),
} as unknown as jest.Mocked<NavController>;
routerSpy = { navigate: jest.fn().mockResolvedValue(true) };
TestBed.configureTestingModule({
declarations: [LoginComponent],
imports: [IonicModule.forRoot()],
providers: [
{ provide: NavController, useValue: navCtrlSpy },
{ provide: Router, useValue: routerSpy },
],
}).compileComponents();
}));
it('navigates to dashboard on successful login', async () => {
jest.spyOn(component['authService'], 'login').mockResolvedValue({ token: 'abc123' });
component.form.setValue({ email: 'user@example.com', password: 'password123' });
await component.onSubmit();
expect(navCtrlSpy.navigateRoot).toHaveBeenCalledWith('/dashboard', expect.any(Object));
});
it('stays on login page when credentials are wrong', async () => {
jest.spyOn(component['authService'], 'login')
.mockRejectedValue(new Error('Invalid credentials'));
component.form.setValue({ email: 'wrong@example.com', password: 'wrong' });
await component.onSubmit();
expect(navCtrlSpy.navigateRoot).not.toHaveBeenCalled();
expect(component.errorMessage).toBe('Invalid credentials');
});
});Testing Form Validation
Ionic forms use Angular Reactive Forms under the hood. Testing them is standard Angular — but ion-input triggers ionChange and ionBlur rather than change and blur, so some validation triggers differ.
// src/app/register/register.component.spec.ts
import { ReactiveFormsModule, FormBuilder } from '@angular/forms';
import { RegisterComponent } from './register.component';
describe('RegisterComponent form validation', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [RegisterComponent],
imports: [IonicModule.forRoot(), ReactiveFormsModule],
providers: [FormBuilder],
}).compileComponents();
}));
it('is invalid when email is empty', () => {
component.form.controls['email'].setValue('');
expect(component.form.controls['email'].valid).toBeFalsy();
expect(component.form.controls['email'].errors?.['required']).toBeTruthy();
});
it('is invalid with malformed email', () => {
component.form.controls['email'].setValue('not-an-email');
expect(component.form.controls['email'].errors?.['email']).toBeTruthy();
});
it('is invalid when password is too short', () => {
component.form.controls['password'].setValue('abc');
expect(component.form.controls['password'].errors?.['minlength']).toBeTruthy();
});
it('form is valid with correct values', () => {
component.form.setValue({
email: 'user@example.com',
password: 'securePassword123',
confirmPassword: 'securePassword123',
});
expect(component.form.valid).toBeTruthy();
});
it('submit button is disabled when form is invalid', () => {
component.form.controls['email'].setValue('');
fixture.detectChanges();
const button = fixture.debugElement.query(By.css('ion-button[type="submit"]'));
expect(button.nativeElement.disabled).toBeTruthy();
});
it('shows validation error message for email field', () => {
component.form.controls['email'].setValue('bad');
component.form.controls['email'].markAsTouched();
fixture.detectChanges();
const errorEl = fixture.debugElement.query(By.css('[data-cy="email-error"]'));
expect(errorEl.nativeElement.textContent).toContain('valid email');
});
});Testing with HttpClientTestingModule
Components that make HTTP calls through Angular services need the testing HTTP module:
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
describe('UserListComponent', () => {
let httpMock: HttpTestingController;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [UserListComponent],
imports: [IonicModule.forRoot(), HttpClientTestingModule],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
}));
afterEach(() => {
httpMock.verify(); // ensure no outstanding requests
});
it('loads and displays user list on init', () => {
fixture.detectChanges(); // triggers ngOnInit
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush([
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
]);
fixture.detectChanges();
const items = fixture.debugElement.queryAll(By.css('ion-item'));
expect(items.length).toBe(2);
});
it('shows error state on HTTP failure', () => {
fixture.detectChanges();
const req = httpMock.expectOne('/api/users');
req.error(new ProgressEvent('error'), { status: 500 });
fixture.detectChanges();
const errorMsg = fixture.debugElement.query(By.css('[data-cy="error-state"]'));
expect(errorMsg).toBeTruthy();
});
});Component Lifecycle with Ionic Navigation
Ionic uses its own lifecycle hooks — ionViewWillEnter, ionViewDidEnter, ionViewWillLeave, ionViewDidLeave — in addition to Angular's. These do not fire automatically in TestBed. Call them directly:
it('reloads data every time the page becomes visible', async () => {
const loadSpy = jest.spyOn(component, 'loadData').mockResolvedValue(undefined);
// Simulate Ionic navigation lifecycle
component.ionViewWillEnter();
expect(loadSpy).toHaveBeenCalledTimes(1);
});
it('cancels pending requests when navigating away', () => {
const cancelSpy = jest.spyOn(component['subscriptions'], 'unsubscribe');
component.ionViewWillLeave();
expect(cancelSpy).toHaveBeenCalled();
});Running the Full Suite
Keep your test command simple and integrate it into CI:
# Run all tests with coverage
npx ng <span class="hljs-built_in">test --watch=<span class="hljs-literal">false --code-coverage --browsers=ChromeHeadless
<span class="hljs-comment"># Run a specific spec file
npx ng <span class="hljs-built_in">test --include=<span class="hljs-string">"**/home.component.spec.ts" --watch=<span class="hljs-literal">falseA complete Ionic Angular test suite should cover every controller interaction, every form validation path, and every navigation transition — without requiring a physical device or a browser running your full app.
HelpMeTest integrates with your Angular test pipeline to run your Ionic component tests automatically on every pull request, surfacing regressions before they reach code review.