Skip to main content

Writing Tests

The @ionic/e2e package comes with an API that makes test writing as easy as possible, with smart features meant for testing apps built with Ionic and Capacitor (or Cordova).

Ionic E2E is based on top of several popular E2E testing technologies, including WebdriverIO and Appium, but provides additional tooling and libraries to make testing Ionic and Capacitor apps much easier.

While we've done our best to make getting started with E2E testing as easy as possible, at the end of the day you and your team must develop the tests that will properly test your app, and these tests can be challenging to write especially for those new to E2E testing. The goal of this guide is to make writing your tests easier and to drill home key concepts that will reduce frustration when writing your E2E tests.

Key Concepts#

There are some key concepts that must be followed to write successful E2E tests, starting with Element visibility. It's important to keep these in mind before starting your tests to avoid frustration.

Element visibility#

Perhaps one of the most important concepts you'll need to master is that elements must be visible on screen before writing tests against them. This can be tricky especially with modern frameworks like Ionic that often render asynchronously for performance reasons and have elements off-screen to enable smooth animations and transitions.

The number one thing to keep in mind is that, when querying elements on the page, you must do it relative to the most active, currently visible page on screen.

Luckily, @ionic/e2e comes with utilities to make this easy, and will scope element queries to the most active page. However, when using WebdriverIO element calls directly, you won't have this protection, so be careful when using them.

Everything is Async#

Because Ionic E2E uses WebdriverIO under the hood, which eventually calls a "driver" that controls your app as if a user was using it directly, every call is asynchronous. It's easy to forget that even an element query like Ionic$.$() must have an await before it to make sure the call correctly resolves.

It's helpful to think about your tests as ultimately contacting a remote "server" that controls your app instead of running directly inside your app. The benefit to this approach is your actual app is being tested, including testing your app as a fully built production native app that will test exactly what your users will experience. The downside is your test code does not run directly in your app context so everything must be done through an asynchronous server.

So, when in doubt, add await to most calls to @ionic/e2e objects and any calls to the lower-level WebdriverIO objects.

Page Object Pattern#

Tests in Ionic E2E are best architected by following the Page Object Pattern. Applied to Ionic apps, this pattern basically means you'll create Page classes that collect the most important elements on the page you'll be interacting with, and then exposing useful functionality that your test spec will then invoke.

It's important to note that the ionic-e2e node script provides a generate command that will create test files based around the Page Object pattern. It supports generating the initial test files based on your existing project, and generating new ones as you add new pages to your app.

For example, say we have a Login page in our app that we will test against, and we ran npm run ionic-e2e generate page, we will then have these files: tests/pageobjects/login.page.ts and tests/specs/app.login.ts. In pageobjects/login.page.ts we will build a class that represents our page and exposes the elements and actions that we will test against. For example, a fully built-out Login Page Object might look like:

import { IonicInput, IonicButton, IonicRouting, Ionic$ } from '@ionic/e2e';import Page from './page';
class Login extends Page {  get username() { return new IonicInput('#username')); }  get password() { return new IonicInput('#password'); }  get loginButton() { return IonicButton.withTitle('Login'); }  get errorMessage() { return Ionic$.$('div.error') }  get successMessage() { return Ionic$.$('div.success') }
  async open() {    return IonicRouting.open('/login');  }
  async login(username: string, password: string) {    await this.username.setValue(username);    await this.password.setValue(password);    await this.loginButton.tap();  }}
export default new Login();

Then, in our specs/app.login.ts, we use this Page Object when writing tests:

import Login from '../pageobjects/Login';
describe('Login page', () => {  beforeEach(() => {    await IonicE2E.web();    await Login.open();  });
  it('should show an error with incorrect login', () => {    await Login.login('badusername', 'badpassword');
    await expect(await Login.errorMessage).toHaveText('Incorrect username or password');  });
  it('should succeed', () => {    await Login.login('badusername', 'badpassword');
    await expect(await Login.successMessage).toHaveText('Loading app...');  });});