Playwright: Simplify Tests by Using a Main Page Object Class
Tags: playwright, testing, typescript, e2e
Published: 2024-03-21 16:38:26

Tests often need to use many page objects. Instantiating all required page objects inside each test creates a lot of boilerplate. Let’s review a pattern for instantiating all the application page objects inside a main class or container and fixtures to reduce verboseness in the tests using Playwright in Typescript.

Let’s start with a basic test that uses many page objects:

**tests/checkoutProduct.spec.ts**
```javascript
import { test, expect } from '@playwright/test';
import Home from '../pages/home.page';
import SignIn from '../pages/signIn.page';
import Checkout from '../pages/checkout.page';
import Confirmation from '../pages/confirmation.page';

test('Checkout Product', async ({ page }) => {
  await page.goto('https://www.bstackdemo.com/');
  const home = new Home(page);
  const signIn = new SignIn(page);
  const checkout = new Checkout(page);
  const confirmation = new Confirmation(page);

  await home.addProductToCart();
  await signIn.doSignIn();
  await checkout.submit('John', 'Doe');
  await expect(confirmation.confirmationMessage).toBeVisible();
});
```
And, for reference, a page object class looks like this:

**pages/checkout.page.ts**

```typescript
import { type Locator, type Page } from '@playwright/test';

export default class Checkout {

  readonly page: Page;
  readonly firstName: Locator;
  readonly lastName: Locator;
  readonly submitButton: Locator;

  constructor(page: Page) {
    this.firstName = page.locator('#firstNameInput');
    this.lastName = page.locator('#lastNameInput');
    this.submitButton = page.getByRole('button', { name: 'Submit' });
  }
  
  async submit(firstName: string, lastName: string) {
    await this.firstName.fill(firstName);
    await this.lastName.fill(lastName);
    await this.submitButton.click();
  }
}
```

## Main Page Object

To reduce the verboseness, a better pattern is to create a main class that instantiates every other page object we use for our application inside of it. Then, we need to instantiate only one  per test.

First, we create a main class that receives the page and passes it to each page object class.

**pages/app.page.ts**

```typescript
import { type Page } from '@playwright/test';
import Home from './home.page';
import SignIn from './signIn.page';
import Checkout from './checkout.page';
import Confirmation from './confirmation.page';

export default class App {

  readonly page: Page;
  readonly home: Home;
  readonly signIn: SignIn;
  readonly checkout: Checkout;
  readonly confirmation: Confirmation;

  constructor(page: Page) {
    this.page = page;
    this.home = new Home(page);
    this.signIn = new SignIn(page);
    this.checkout = new Checkout(page);
    this.confirmation = new Confirmation(page);
  }
}
```
We pass the page fixture to this class and through it, we can access the page objects we have defined for our application.

**tests/checkoutProduct.spec.ts**
```typescript
import { test, expect } from '@playwright/test';
import App from '../pages/app.page';

test('Checkout Product', async ({ page }) => {
  await page.goto('https://www.bstackdemo.com/');
  const app = new App(page); // <-- instantiate main class

  await app.home.addProductToCart();
  await app.signIn.doSignIn();
  await app.checkout.submit('John', 'Doe');
  await expect(app.confirmation.confirmationMessage).toBeVisible();
});
```
## Using Playwright Fixtures
Another improvement we can do is to use dependency injection and use this “app” class directly in our tests without needing to instantiate it manually in every test, thus reducing even more the verboseness.

We add a fixture for the app object.

**config/fixtures.ts**

```typescript
import { test as base } from '@playwright/test';
import App from '../pages/app.page';

type Fixtures = {
  app: App;
};

export const test = base.extend<Fixtures>({
  app: async ({ page }, use) => {
    const myApp = new App(page);
    await page.goto('https://www.bstackdemo.com/');
    await use(myApp);
  },
});

export { expect } from '@playwright/test';
```

This fixture returns the instantiated master app object.

Now in our tests, we can pass directly the app fixture as a parameter, leaving the final result like this:

**tests/checkoutProduct.spec.ts**

```typescript
import { test, expect } from '../config/fixtures';

test('Checkout Product', async ({ app }) => {
  await app.home.addProductToCart();
  await app.signIn.doSignIn();
  await app.checkout.submit('John', 'Doe');
  await expect(app.confirmation.confirmationMessage).toBeVisible();
});
```

If we need to access the page inside the test, it’s in app.page

Following this pattern, we greatly reduce boilerplate code and the test is much more readable and concise. The final result has 355 fewer characters and 9 fewer lines of code.