Привет! На связи Даня, разработчик на Angular в T-Банке. Поделюсь с вами опытом использования фикстур в Playwright. Я решил поговорить об этом, потому что вместе с ростом функциональности проектов растут и сложности при тестировании, а фикстуры предоставляют удобный способ избавиться от дублирующегося кода и сложных моков.

Эта статья посвящена основам: зачем нужны фикстуры, чем они отличаются друг от друга и какую пользу приносят при тестировании веб-приложений. Мы подробно разберем устройство фикстур, посмотрим, как их создавать и грамотно внедрять в тесты. А еще рассмотрим практические примеры, которые помогут с легкостью применить полученные знания на реальном проекте. Поехали!

Погружение в проблему

Современные веб-приложения становятся все сложнее и объемнее. Они обрастают новыми функциями, страницами, вкладками и разделами словно снежный ком, катящийся с горы. Пользователи требуют больше возможностей — и желательно «еще вчера». Стремясь удовлетворить эти потребности, разработчики ускоряют процесс создания и доставки новых фич.

Вместе с увеличением скорости разработки растет и риск возникновения ошибок. Интеграционное тестирование становится неотъемлемой частью разработки приложений, помогая обеспечить качество и надежность продукта.

Тестирование сложных приложений — задача не из легких. Чтобы изолировать тестируемые функции и правильно настроить окружение для сценария, необходимо прибегать к мокированию API-запросов, сторонних сервисов и других данных. Этот процесс может стать сложным и трудоемким, особенно когда тесты переполнены моками, код дублируется и поддержка и чтение таких тестов превращаются в кошмар.

Вот пример мокирования двух небольших запросов в начале теста:

// Внутри теста
await page.route('**/api/users', async (route) => {
  await route.fulfill({
  status: 200,
  contentType: 'application/json',
  body: JSON.stringify({
    users: [
      { id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' },
      { id: 2, name: 'Bob', email: 'bob@example.com', role: 'user' },
      // ... еще 18 пользователей
    ],
    total: 20,
    page: 1,
    pageSize: 20,
  }),
  });
});
 
// Мокирование других API-запросов
await page.route('**/api/orders', async (route) => {
  await route.fulfill({
  status: 200,
  contentType: 'application/json',
  body: JSON.stringify({
    orders: [
      { id: 101, user_id: 1, total: 99.99, status: 'shipped' },
      // ... много данных
    ],
  }),
  });
});
 
// И так далее...

Даже с двумя запросами код становится громоздким, его трудно читать и поддерживать. Дублирование моков в разных тестах увеличивает объем кода и усложняет его изменение, если нужно обновить данные.

В идеале интеграционные тесты хочется видеть как понятную последовательность шагов, где каждый шаг имеет ясное описание и приводит к конечному результату. Если добавлять в тесты большое количество сложных конструкций по подготовке данных, это ухудшает читаемость и усложняет поддержку тестов.

Как настроить тестовую среду правильно — базовые варианты решения

Часто между страницами и логическими блоками любого приложения есть большое количество одинаковых API-запросов и данных. Это значит, что во время мокирования и подготовки тестовой среды нужно обобщать эти данные, выносить их в отдельные места, вызывать как простую функцию и переиспользовать многократно.

Выносим в отдельную функцию. К примеру, у нас есть мокирование запроса к API, который необходим в каждом тесте:

await page.route('**/api/users', async (route) => {
  await route.fulfill({
  status: 200,
  contentType: 'application/json',
  body: JSON.stringify([{ id: 1, name: 'Alice' }]),
  });
});

Мы можем поступить с ним так: вынести в отдельный файл, поместить в функцию — и из файла возвращать только функцию, чтобы в тесте мы могли вызывать только ее.

/ В отдельном модуле mocks.js
export async function mockUsersApi(page) {
    // Код нашего запроса из примера выше
}
 
// В тесте
import { mockUsersApi } from './mocks'
 
await mockUsersApi(page);

Используем класс-билдер. Для более сложных моделей, требующих сложных комбинаций из большого количества данных, можно использовать классы-билдеры. Они позволяют вызывать методы цепочкой и поэтапно собирать сложную модель данных для теста.

Например, класс-билдер для сборки модели данных аккаунта пользователя accountsBuilder.ts: 

export class AccountsBuilder {
    constructor() {
      this.accounts = [];
      this.withAdminRules = false;
    }
 
    addAccount(account) {
      this.accounts.push(account);
      return this;
    }
 
    setWithAdminRules () {
      this.withAdminRules = true;
      return this;
    }
 
    build() {
      return {
        accounts: this.accounts,
        withAdminRules: this.withAdminRules,
      };
    }
  }

Стоит обратить внимание, что каждый метод класса возвращает this, а значит, можно вызывать все методы по цепочке и дополнять итоговую модель новой информацией.

Вот пример того, как это можно использовать в тесте:

import { AccountsBuilder } from './accountsBuilder'
 
const accountsData = new AccountsBuilder()
  .addAccount({
    id: ‘1’,
    name: 'User1',
    isOnline: 'true',
    status: 'don’t_worry',
    balance: 100000,
  })
  . setWithAdminRules()
  .build();
 
// Мокирование API
await mockUsersApi(page);

Если данных для теста нужно много, то даже с использованием этих инструментов код может быть громоздким.

Можно пойти дальше и выносить повторяющиеся в файле моки в блок beforeEach, тем самым убирая дублирование кода. А очищать данные, возвращая хранилище к исходному состоянию, можно в блоке afterEach. Но это тоже не идеальное решение. 

Если мы вынесем повторяющийся код в beforeEach, то в самих тестах не будет понятно, что там используется и какие данные нужны. Возможна мутация данных от теста к тесту, что повлечет за собой нежелательное поведение. Поэтому не до конца понятно, где стоит размещать логику по подготовке данных для теста.

Используем фикстуры. Фикстуры — это специальные функции или объекты, которые помогают подготовить необходимое окружение для тестов и упростить повторное использование кода между ними. Они позволяют:

  • Избежать дублирования кода: общие настройки можно вынести в фикстуры.

  • Упростить инициализацию и очистку: фикстуры могут автоматически запускать код до и после тестов.

  • Улучшить читаемость: тесты становятся короче и фокусируются на проверках, а не на настройках.

Playwright предоставляет ряд встроенных фикстур, которые облегчают работу с тестами. Рассмотрим основные из них.

browser — предоставляет экземпляр браузера (Chromium, Firefox, WebKit). Позволяет контролировать браузер на уровне теста.

test('Использование browser фикстуры', async ({ browser }) => {
  const context = await browser.newContext();
  const page = await context.newPage();
  await page.goto('https://example.com');
  // Ваши проверки...
});

context — предоставляет новый контекст браузера для каждого теста. Полезно для разделения состояния между тестами.

test('Использование context фикстуры', async ({ context }) => {
  const page = await context.newPage();
  await page.goto('https://example.com');
  // Ваши проверки...
});

page — предоставляет новую страницу (вкладку) в браузере для каждого теста. Наиболее часто используемая фикстура для взаимодействия с веб-приложением.

test('Использование page фикстуры', async ({ page }) => {
  await page.goto('https://example.com');
  // Ваши проверки...
});

request — предоставляет API для выполнения HTTP-запросов вне контекста браузера. Полезно для тестирования API или предварительной настройки данных.

test('Использование request фикстуры', async ({ request }) => {
  const response = await request.get('https://api.example.com/data');
  expect(response.status()).toBe(200);
  // Ваши проверки...
});

browserName — содержит название браузера, в котором выполняется тест ('chromium', 'firefox', 'webkit'). Позволяет писать условный код в зависимости от браузера.

test('Использование browserName фикстуры', async ({ page, browserName }) => {
  await page.goto('https://example.com');
  if (browserName === 'webkit') {
  // Специфичные проверки для WebKit
  }
});

testInfo — предоставляет информацию о текущем тесте, такую как название, статус, вложенность и так далее. Можно использовать для логирования или изменения поведения теста в зависимости от контекста.

test('Использование testInfo фикстуры', async ({ page }, testInfo) => {
  await page.goto('https://example.com');
  console.log(`Запуск теста: ${testInfo.title}`);
  // Ваши проверки...
});

trace — управляет записью трассировки для отладки тестов. Позволяет включать и выключать запись трассировки.

test('Использование trace фикстуры', async ({ page, trace }) => {
  await trace.start({ screenshots: true, snapshots: true });
  await page.goto('https://example.com');
  // Ваши проверки...
  await trace.stop();
});

parallelIndex — предоставляет индекс текущего параллельного процесса тестирования. Полезно для разделения ресурсов между параллельными тестами.

test('Использование parallelIndex фикстуры', async ({ parallelIndex }) => {
  const dbName = `test_db_${parallelIndex}`;
  // Инициализация базы данных для текущего теста
});

Создание собственных фикстур

Создание пользовательских фикстур позволяет расширить возможности тестов и адаптировать их под ваши потребности. Рассмотрим процесс создания своих кастомных фикстур.

Шаг 1. Расширим базовый класс тестов. Нужно импортировать базовый test из @playwright/test и расширить его с помощью метода test.extend(). fixtures.ts:

import base from '@playwright/test'
 
export const test = base.test.extend({
  // Здесь будут ваши фикстуры
});

import base from '@playwright/test' — импорт базового объекта test.

const test = base.test.extend({... }) — создаем новый объект test, расширяя базовый с помощью метода extend(). Это позволяет добавить новые фикстуры.

 

Шаг 2. Определим фикстуру. Фикстура определяется как свойство объекта, переданного в extend(). Ключ — имя фикстуры, значение — функция или массив, содержащий функцию и опции.

Пример простой фикстуры:

export const test = base.test.extend({
    myFixture: async ({}, use) => {
      // Инициализация фикстуры
      const data = await fetchData();
      // Передаем фикстуру в тест
      await use(data);
      // Очистка после теста
      await cleanUpData();
    },
  });

myFixture — имя фикстуры.

async ({}, use) => { ... } — функция фикстуры.

{}, первый аргумент, — это объект с доступными фикстурами. Если фикстура зависит от других фикстур, можно их деструктурировать здесь.

use — функция, которую нужно вызвать, передав в нее значение фикстуры. До вызова use тестовая функция не начнет выполняться.

await use(data) — передача значения фикстуры в тест. После этого вызова начинается выполнение теста.

Код после use — выполняется после завершения теста. Здесь можно выполнять очистку, закрытие соединений и так далее.

Шаг 3. Используем фикстуру в тесте. Вы можете получить доступ к фикстуре через параметры функции. test.spec.ts:

mport { test } from './fixtures';
 
test('Тест с myFixture', async ({ myFixture }) => {
  // Используем myFixture в тесте
  console.log(myFixture);
  // Ваши проверки...
});

({ myFixture }) — деструктурируем нашу фикстуру из параметров тестовой функции.

Если фикстура имеет auto: true, ее можно не указывать, она все равно будет инициализирована.

Логика после вызова use(): код, написанный после await use(...), выполняется после завершения теста. Это позволяет выполнять операции очистки, закрывать соединения, освобождать ресурсы и так далее.

Фикстуры сами по себе заменяют beforeEach и afterEach. Инициализация происходит до вызова use(), а очистка — после. Это упрощает структуру тестов и делает код более понятным.

При определении фикстуры можно указать дополнительные опции.

scope: определяет область действия фикстуры.

'test' (по умолчанию): 

  • Фикстура инициализируется для каждого теста отдельно.

  • Обеспечивает изоляцию между тестами.

  • Полезно, когда фикстура использует данные, которые могут изменяться от теста к тесту.

'worker': 

  • Фикстура инициализируется один раз для каждого воркера.

  • Может повысить производительность за счет уменьшения числа инициализаций.

  • Нужно быть осторожным с изменяемым состоянием, так как оно будет общим для всех тестов в воркере.

Пример фикстуры с scope: 'worker':

export const test = base.test.extend({
    sharedResource: [async ({}, use) => {
      const resource = await createResource();
      await use(resource);
      await resource.cleanup();
    }, { scope: 'worker' }],
  });

В примере стоит обратить внимание, что в качестве значения для нашей фикстуры передаем не просто функцию, а массив, где первый элемент — это функция со значением фикстуры, а второй аргумент —  объект с дополнительными опциями для работы фикстуры.

auto:

  • true: фикстура инициализируется автоматически, даже если она не указана в параметрах теста. Полезно для фикстур, которые всегда должны быть активны (например, настройки окружения).

  • false (по умолчанию): фикстура инициализируется, только если она указана в параметрах теста.

Пример фикстуры с auto: true. В этом случае environmentSetup будет выполняться для каждого теста автоматически:

export const test = base.test.extend({
    environmentSetup: [async ({}, use) => {
      await setupEnvironment();
      await use();
      await teardownEnvironment();
    }, { auto: true }],
  });

timeout: устанавливает максимальное время выполнения для фикстуры. Если фикстура не завершится в указанный срок, тест будет прерван с ошибкой.


Полезно, если фикстура выполняет длительные операции, такие как подключение к внешним сервисам или сложные сетевые запросы, и вам нужно ограничить время их выполнения.

Значение timeout указывается в миллисекундах. Пример фикстуры с timeout:

  export const test = base.test.extend({
    dbConnection: [async ({}, use) => {
      const connection = await createDatabaseConnection();
      await use(connection);
      await connection.close();
    }, { timeout: 5000 }], // Таймаут 5000 мс
  });

В этом примере, если фикстура dbConnection не завершится за 5 000 мс, тест завершится с ошибкой. Это полезно для случаев, когда инициализация может зависнуть или работать медленнее, чем обычно.

Изменения в работе фикстуры с добавлением опций

С scope: 'worker':

— Уменьшает количество инициализаций ресурса.

— Может повысить производительность.

— Нужно следить за тем, чтобы состояние фикстуры не изменялось в разных тестах.

С auto: true:

— Упрощает использование фикстуры.

— Может привести к ненужной инициализации, если фикстура не требуется в каждом тесте.

С timeout:

— Защищает тесты от зависания в случае, если фикстура выполняется слишком долго.

— Полезно для долгих операций, таких как сетевые запросы или мокация внешних API, особенно если они могут зависнуть или замедлиться.

— Устанавливает максимальное время ожидания, после которого тест будет завершен с ошибкой, если фикстура не завершится.

Применение фикстур в разных сценариях

Использование фикстур для замены базовых моков вызовов API. Мы уже разобрали пример с выносом мокирования запроса API в отдельный файл и созданием дополнительных классов для упрощения создания данных. Теперь посмотрим, как это выглядит с использованием фикстур.

Без фикстур:

test('Тест без фикстур', async ({ page }) => {
  await page.route('**/api/data', async (route) => {
  await route.fulfill({
    status: 200,
    body: JSON.stringify({ data: 'value' }),
  });
  });
 
  await page.goto('https://example.com');
  // Ваши проверки...
});

С фикстурой:

// fixtures.ts
export const test = base.test.extend({
    apiMock: async ({ page }, use) => {
      await page.route('**/api/data', async (route) => {
        await route.fulfill({
          status: 200,
          body: JSON.stringify({ data: 'value' }),
        });
      });
      await use();
    },
  });
 
  // test.spec.ts
  import { test } from './fixtures'
 
  test('Тест с фикстурой apiMock', async ({ page, apiMock }) => {
    await page.goto('https://example.com');
    // Ваши проверки...
  });

Разница и преимущества:

  • Чистота кода: тест становится короче и понятнее.

  • Повторное использование: фикстуру apiMock можно использовать в нескольких тестах.

  • Управление: изменение мока в фикстуре влияет на все тесты, которые ее используют.

  • Читаемость: мы видим, какие данные в тесте используются.

Замена билдеров. Билдеры можно использовать вместе с фикстурами, чтобы улучшить итоговый результат.

Без фикстур:

test('Тест без фикстур', async ({ page }) => {
  const dataBuilder = new DataBuilder()
  .setFieldA('valueA')
  .setFieldB('valueB')
  .build();
 
  await page.route('**/api/data', async (route) => {
  await route.fulfill({
    status: 200,
    body: JSON.stringify(dataBuilder),
  });
  });
 
  await page.goto('https://example.com');
  // Тестовая логика...
});

С фикстурой:

// fixtures.ts
export const test = base.test.extend({
    testData: async ({}, use) => {
      const data = new DataBuilder()
        .setFieldA('valueA')
        .setFieldB('valueB')
        .build();
      await use(data);
    },
 
    apiMock: async ({ page, testData }, use) => {
      await page.route('**/api/data', async (route) => {
        await route.fulfill({
          status: 200,
          body: JSON.stringify(testData),
        });
      });
      await use();
    },
  });
 
  // test.spec.ts
  import { test } from './fixtures'
 
  test('Тест с фикстурой testData', async ({ page, apiMock }) => {
    await page.goto('https://example.com');
    // Тестовая логика...
  });

Разница и преимущества:

  • Единое место для данных: изменение в билдере отражается во всех тестах.

  • Уменьшение дублирования: нет необходимости создавать билдер в каждом тесте.

  • Чистота кода: тесты становятся короче и понятнее.

 

Замена Page Object. Неотъемлемый элемент тестирования — паттерн PageObject — можно использовать совместно с фикстурами для написания более оптимизированных и стабильных тестов.

Без фикстур:

test('Тест без фикстур', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user', 'pass');
 
  // Тестовая логика...
});

С фикстурой:

// fixtures.ts
export const test = base.test.extend({
    loginPage: async ({ page }, use) => {
      const loginPage = new LoginPage(page);
      await use(loginPage);
    },
  });
 
  // test.spec.ts
  import { test } from './fixtures'
 
  test('Тест с фикстурой loginPage', async ({ loginPage }) => {
    await loginPage.goto();
    await loginPage.login('user', 'pass');
 
    // Тестовая логика...
  });

Разница и преимущества:

  • Упрощение тестов: нет необходимости создавать объект PageObject в каждом тесте.

  • Единообразие: все тесты используют одну и ту же фикстуру.

  • Легкость изменения: изменения в фикстуре отражаются во всех тестах.

 

Переопределение базовых фикстур. Можно переопределять встроенные фикстуры, чтобы изменить их поведение или добавить дополнительную функциональность.

Пример переопределения фикстуры page:

  // fixtures.ts
export const test = base.test.extend({
    page: async ({ page }, use) => {
      // Настраиваем страницу перед использованием
      await page.setViewportSize({ width: 1280, height: 720 });
      await page.addInitScript(() => {
        // Дополнительные настройки или полифилы
      });
      await use(page);
      // Можно добавить логику после использования страницы
    },
  });
 
  // test.spec.ts
  import { test } from './fixtures'
 
  test('Тест с переопределенной page фикстурой', async ({ page }) => {
    await page.goto('https://example.com');
    // Страница уже настроена
    // Тестовая логика...
  });

Пример переопределения фикстуры context:

// fixtures.ts
export const test = base.test.extend({
    context: async ({ browser }, use) => {
      const context = await browser.newContext({
        locale: 'ru-RU',
        geolocation: { longitude: 37.618423, latitude: 55.751244 },
        permissions: ['geolocation'],
      });
      await use(context);
      await context.close();
    },
  });
 
  // test.spec.ts
  import { test } from './fixtures'
 
  test('Тест с переопределенной context фикстурой', async ({ page }) => {
    await page.goto('https://example.com');
    // Контекст браузера настроен с нужной локалью и геолокацией
    // Тестовая логика...
  });

Использование цепочки фикстур. Фикстуры могут зависеть друг от друга, и вы можете использовать одну фикстуру внутри другой.

Пример использования встроенной фикстуры внутри пользовательской:

// fixtures.ts
export const test = base.test.extend({
    customPage: async ({ page }, use) => {
      // Дополнительная настройка страницы
      await page.setExtraHTTPHeaders({ 'X-Custom-Header': 'value' });
      await use(page);
    },
  });
 
  // test.spec.ts
  import { test } from './fixtures'
 
  test('Тест с кастомной customPage фикстурой', async ({ customPage }) => {
    await customPage.goto('https://example.com');
    // Ваши проверки...
  });

Пример использования одной пользовательской фикстуры внутри другой:

// fixtures.ts
export const test = base.test.extend({
    testData: async ({}, use) => {
      const data = { value: 42 };
      await use(data);
    },
 
    apiMock: async ({ page, testData }, use) => {
      await page.route('**/api/value', async (route) => {
        await route.fulfill({
          status: 200,
          body: JSON.stringify({ result: testData.value }),
        });
      });
      await use();
    },
  });
 
  // test.spec.ts
  import { test } from './fixtures'
 
  test('Тест с цепочкой фикстур', async ({ page }) => {
    await page.goto('https://example.com');
    // Тестовая логика...
  });

Рассмотрим подробнее этот пример:

  • Фикстура apiMock зависит от testData и page.

  • Playwright автоматически инициализирует фикстуры в правильном порядке.

Итоги

Использование фикстур в Playwright позволяет значительно улучшить структуру и читаемость ваших тестов:

  • Тесты становятся короче и фокусируются на проверках, а не на настройках.

  • Снижается дублирование кода: общие настройки выносятся в фикстуры и могут быть переиспользованы.

  • Упрощается поддержка: изменения в фикстурах автоматически применяются ко всем тестам, которые их используют.

  • Повышается гибкость и расширяемость: вы можете создавать сложные цепочки фикстур, переопределять встроенные и адаптировать их под свои нужды.

В следующей части мы рассмотрим продвинутые темы, такие как область действия фикстур (scope), параллелизация тестов, организация фикстур в больших проектах и многое другое. Оставайтесь с нами, будет интересно!

P. S. Фикстуры — это как невидимые супергерои ваших тестов: они работают за кулисами, чтобы мы могли сосредоточиться на самом важном.

Комментарии (5)


  1. oracle_schwerpunkte
    29.01.2025 10:43

    К сожалению, на практике вещь досататочно бесполезная, так как в fixture нельзя передать параметры.
    Вот здесь хотелось бы задавать разное имя пользователя для каждого теста , но это невозможно сделать адекватным способом.
    test('Тест с фикстурой loginPage', async ({ loginPage })

    Хак с test.use({ login: 'aloha' })
    не не будет работать с несколькими тестами.

    Хак с передачей парамертов через tag больше похож на злую шутку.


    1. jQwery Автор
      29.01.2025 10:43

      В этом моменте согласен

      Однако что насчет создания фикстур через фабрику, куда в свою очередь можно прокидывать параметры

      Я собираюсь подробнее описать этот кейс во второй части статьи

      А если рассматривать глобально фикстуры, то не соглашусь что это бесполезно

      В большинстве кейсов они полезны и существенно уменьшают время написания тестов, а если рассмотреть их возможности настройки, в частности настройки для запуска с каждым тестом отдельно, или один раз для всего скопа тестов - тут уже фикстуры могут быть мощной штукой


    1. QALiberum
      29.01.2025 10:43

      1. Можно сделать столько фикстур, сколько пользователей.

      2. Юзать POM и метод логина в нем + фикстуры, как в примере автора.


    1. LionMuzzle
      29.01.2025 10:43

      Не совсем понял, что значит нельзя передать параметры? При вызове фикстуры это делается легко. Так же как и при регистрации фикстуры. Вот пример, очень упрощенный:

      const loginManager = (page: Page, api: string) => {
        // Возвращаем функцию, которую будем вызывать в тесте.
        // Она принимает аргумент, персональный для каждого вызова.
        return async (userId: number) => {
          await page.request.fetch(`${api}/login/${userId}`)
        }
      }
      
      // Playwright мощно задействует typescript, используем это:
      export const test = base.extend<{
        loginUser: (userId: number) => Promise<void>
      }>({
        async loginUser({ page }, use) {
          // Подготавливаем фикстуру.
          // Передаем аргумент, общий для всех тестов.
          await use(loginManager(page, 'https://service.ru'))
        },
      })
      
      test('Тест страницы', async ({ page, loginUser }) => {
        await loginUser(100500)
      })



  1. Noah1
    29.01.2025 10:43

    Пользователи требуют больше возможностей — и желательно «еще вчера».

    Нет. Этого требуют менеджеры. Пользователям 95% изменений не нужны, более того - неприятны.