QA Interview Hub · Knowledge base · Quizzes

Patterns

Strategy

Strategy: varying behaviour behind a shared interface

Strategy defines a family of interchangeable algorithms behind one interface, with the choice made at runtime. In a test framework typical examples are: different ways of logging in (UI, API, SSO), different payment methods, data generation for different regions.

interface LoginStrategy {
  login(page: Page, user: User): Promise<void>;
}

class UiLogin implements LoginStrategy {
  async login(page: Page, user: User) {
    await page.goto('/login');
    await page.getByLabel('Email').fill(user.email);
    await page.getByLabel('Password').fill(user.password);
    await page.getByRole('button', { name: 'Sign in' }).click();
  }
}

class ApiLogin implements LoginStrategy {
  async login(page: Page, user: User) {
    const res = await page.request.post('/api/login', { data: user });
    expect(res.ok()).toBeTruthy();
  }
}

page.request shares cookies with the page's context, so after an API login the page is already authenticated (if the session is kept in cookies, not in localStorage).

The choice of strategy can be made through a fixture option set by test.use or a project in the config. Then the test doesn't know how exactly the login happened.

The advantage over if/else or switch: a new way is added with a new class, without changes in tests and in existing strategies (Open/Closed).

In an interview: I use Strategy when there are several interchangeable ways to do the same thing, for example login through the UI or the API. The test works with the interface, and the concrete implementation is chosen through a fixture option.

Fixture options as Strategy and when the pattern is unnecessary

Playwright Test has a ready mechanism for choosing behaviour: option fixtures. The value is set in projects or through test.use.

type Opts = { loginMode: 'ui' | 'api' };

export const test = base.extend<Opts & { loggedIn: void }>({
  loginMode: ['api', { option: true }],
  loggedIn: async ({ page, loginMode }, use) => {
    const strategy = loginMode === 'ui' ? new UiLogin() : new ApiLogin();
    await strategy.login(page, defaultUser);
    await use();
  },
});
test.describe('login flow', () => {
  test.use({ loginMode: 'ui' });
  // ...
});

This is a combination of Strategy and DI: the test depends on loggedIn, and the concrete strategy is substituted by configuration.

When Strategy is NOT needed:

  • There are only two variants and no new ones are expected. A simple if in a fixture is more honest than an interface and three classes.
  • The difference between the "strategies" is one parameter. Then it is enough to pass a parameter.
  • For different browsers and devices. Playwright itself already does this through projects and devices, so you don't need to write your own browser strategy.

A typical interview mistake: calling any switch a Strategy. Strategy is about polymorphism: different implementations of one contract that can be swapped independently.

In an interview: In Playwright I implement Strategy through an option fixture: the value comes from projects or test.use, and the fixture picks the implementation. If there are two variants and there won't be more, I get by with a simple if.