javascript

Playwright для компонентных CAT-тестов в проекте на angular

  • воскресенье, 23 августа 2026 г. в 00:00:02
https://habr.com/ru/articles/1072918/

Когда говорят «у нас есть тесты на Playwright», обычно представляют классический e2e-сценарий: тест открывает приложение, проходит авторизацию, переходит по нескольким страницам и работает с настоящим или почти настоящим backend.

Мы используем Playwright немного иначе.

Основной пласт UI-автоматизации в нашем Angular-проекте — это CAT-тесты. CAT в данном случае расшифровывается как component autotest. Мы поднимаем отдельный компонент или целую бизнес-форму в Storybook, открываем её в настоящем Chromium, выполняем пользовательские действия, управляем HTTP-ответами и сравниваем интерфейс с эталонными снимками.

Получается тест, который находится между unit и e2e:

  • он работает в настоящем браузере;

  • Angular создаёт реальное дерево компонентов;

  • работают шаблоны, стили, формы, overlay, scroll и асинхронность;

  • Playwright кликает, вводит текст и ждёт состояния так же, как в e2e;

  • backend заменён управляемыми HTTP-маршрутами;

  • окружение приложения заменено Storybook-обвязкой;

  • визуальный результат фиксируется PNG-снимками;

  • исходящие запросы и события компонента проверяются обычными assertions.

В этой статье я разберу всю цепочку: от Storybook story и тестовых роутов до Page Object, Playwright fixtures, визуальных эталонов, Docker и CI. Без абстрактного «создайте страницу и нажмите кнопку» — с архитектурой и примерами, которые выросли из большого рабочего проекта.

1. Зачем нам еще один вид тестов

В большом frontend-проекте довольно быстро обнаруживается неприятная вещь: одним типом тестов нельзя одинаково хорошо проверить всё. Unit-тест отлично справляется с функцией, сервисом, pipe или отдельной веткой бизнес-логики. Он быстрый, точный и обычно хорошо объясняет причину падения. Но проверять unit-тестом сложную форму бывает дорого. Нужно вручную искать десятки элементов, проверять классы, атрибуты, тексты и состояния. Если перед нами грид с двадцатью колонками, несколькими фильтрами и toolbar, код проверки легко становится длиннее самого компонента. E2e-тест, наоборот, видит весь продукт. Но вместе с этим он получает:

  • авторизацию;

  • маршрутизацию приложения;

  • зависимости от окружения;

  • подготовку данных;

  • нестабильность backend;

  • длинный запуск;

  • сложную диагностику.

Мы берём не отдельный класс, а заметный UI-срез: компонент, диалог, форму, грид или небольшой бизнес-процесс. Запускаем его в реальном браузере, но не поднимаем целое приложение. Такой тест особенно полезен для:

  • сложной вёрстки;

  • больших форм;

  • диалогов и overlay;

  • переиспользуемых компонентов;

  • гридов;

  • состояний loading, empty и error;

  • разных статусов и единиц измерения;

  • проверки permissions;

  • пользовательских взаимодействий;

  • контроля отправленного API-запроса;

  • визуальной регрессии после рефакторинга.

CAT не заменяет unit и e2e. Его сила как раз в чёткой границе. Если ошибку проще и понятнее поймать unit-тестом — мы пишем unit. Если важно настоящее отображение и взаимодействие внутри изолированной формы — пишем CAT. Если смысл сценария появляется только в целом приложении или при работе нескольких систем — оставляем его для e2e.

2. Почему CAT а не CT

Playwright называет компонентные тесты CT — component testing. У нас уже были unit-файлы с суффиксом .spec.ts и e2e-файлы с суффиксом .e2e.ts. Нужен был отдельный маркер, который:

— легко найти;
— не перепутать с unit;
— удобно произнести вслух;
— можно использовать в Playwright-конфиге и Nx.

Так появился суффикс *.cat.ts:

.spec.ts — unit;
— .cat.ts — component autotest;
.e2e.ts — end-to-end;
— .page.ts — Page Object;
.stories.ts — Storybook stories;
— .routes.ts — тестовые HTTP-маршруты.

При этом наши CAT — не стандартный Playwright Component Testing. Playwright не монтирует Angular-компонент самостоятельно. Это делает Storybook. Playwright открывает story в iframe и работает с ней как с обычной веб-страницей. Для нас это оказалось удобным решением: Storybook уже умел собирать компонентное окружение, а Playwright добавил надёжную браузерную автоматизацию и визуальные снимки.

3. Архитектура одним взглядом

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

*.stories.ts
    |
    |-- импортирует Angular-модули
    |-- задаёт providers, permissions и context
    |-- объявляет args
    |-- подключает тестовые HTTP routes
    |
    v
Storybook
    |
    v
/iframe.html?id=<story-id>&args=<story-args>
    |
    v
Общий Playwright test
    |
    |-- console errors
    |-- scroll configuration
    |-- HTTP interceptor
    |-- Allure metadata
    |-- request listener
    |
    v
CAT test
    |
    |-- ожидает готовность Storybook
    |-- создаёт Page Object
    |-- выполняет действия пользователя
    |-- проверяет request body/query/path params
    |-- проверяет Storybook actions
    |-- делает toHaveScreenshot
    |
    v
Платформенный PNG-эталон
    |
    v
Playwright reporters
    |
    v
HTML / JUnit / Allure / CI artifacts

Если сократить эту схему до одной фразы: Storybook создаёт управляемое состояние компонента, Playwright играет роль пользователя и сетевого слоя, Page Object даёт тесту предметный язык, а снимки фиксируют визуальный контракт.

4. Как мы храним тесты

Тестовые файлы лежат рядом с компонентом. Типичная структура:

some-component/
  some-component.component.ts
  some-component.component.html
  some-component.module.ts

  test/
    some-component.stories.ts
    some-component.routes.ts
    some-component.page.ts
    some-component.cat.ts

    stubs/
      response.json

    screenshots/
      some-component.cat.ts-snapshots/
        linux/
          default.png
          error.png

Такой подход даёт несколько практических плюсов.
- Во-первых, при изменении компонента сразу видно его story, Page Object и тест.
- Во-вторых, эталонные снимки попадают в то же ревью, что и код.
- В-третьих, тестовые моки не превращаются в глобальный каталог, в котором никто уже не понимает, кто использует конкретный JSON.

Общая Playwright-инфраструктура при этом вынесена в отдельную библиотеку:

testing/playwright/
  test.ts
  cat.ts
  e2e.ts
  base.page.ts

  story/
  interceptor/
  console-errors/
  request-listener/
  scroll-configuration/
  clipboard/
  event-listener/
  throttling/
  config/
  utils/

5. Storybook— это не витрина

В нашем случае Storybook выполняет роль лёгкого runtime. Для простого UI-компонента story может состоять из импортированного Angular-модуля, шаблона и набора args. Например:

export default {
  title: 'Checkbox',
  decorators: [
    moduleMetadata({
      imports: [
        FormsModule,
        CheckboxModule,
      ],
    }),
  ],
};

export const Default = {
  render: args => ({
    template: `
      <app-checkbox
        [label]="label"
        [disabled]="disabled"
        [(ngModel)]="model"
      ></app-checkbox>
    `,
    props: args,
  }),

  args: {
    model: true,
    label: 'Simple text label',
    disabled: false,
  },
};

Для бизнес-формы story обычно заметно сложнее. Ей могут понадобиться:

— Angular-модули;
— сервисы;
— permissions;
— пользовательский context;
— wrapper-компонент;
— тестовые реализации зависимостей;
— HTTP interceptor;
— фиктивные router states.

Пример общей идеи:

@NgModule({
  imports: [
    BusinessFormModule,
    SummaryModule,
  ],
  providers: [
    StoryInterceptorProvider,
    {
      provide: STORY_INTERCEPTOR_CONFIG,
      useFactory: () => interceptorConfig,
    },
    {
      provide: STORY_INTERCEPTOR_INPUT,
      useFactory: () => interceptorInput,
    },
    {
      provide: STORY_INTERCEPTOR_ROUTES,
      useFactory: () => businessFormRoutes,
    },
  ],
  declarations: [
    StoryOfBusinessFormComponent,
  ],
})
class StoryOfBusinessFormModule {}

Wrapper-компонент воспроизводит минимально необходимое окружение формы:

@Component({
  selector: 'story-of-business-form',
  template: `
    <app-summary [summary]="'Проверка промокода'"></app-summary>
    <app-business-form></app-business-form>
  `,
})
class StoryOfBusinessFormComponent {}

Story не должна превращаться во второе приложение. Мы добавляем только то, что необходимо тестируемой границе. Это важный принцип. Чем больше настоящего приложения мы пытаемся протащить внутрь Storybook, тем ближе CAT становится к дорогому и хрупкому e2e.

6. Одна story — два сценария использования

Хорошая story полезна и без автоматического теста. Разработчик или QA может открыть её руками, менять controls и проверять состояние компонента. В этом режиме HTTP-запросы обрабатывает Angular-интерцептор самой story. CAT открывает ту же story, но включает другой режим: Angular пропускает запросы наружу, а ответы формирует уже Playwright. В render-функции мы синхронизируем args с конфигурацией:

const StoryTemplate = args => {
  Object.keys(args).forEach(key => {
    interceptorInput[key] = args[key];
  });

  interceptorConfig.useRealApi = args.useRealApi;

  return {
    props: args,
    template: `
      <story-of-business-form
        [serviceProvider]="serviceProvider"
      ></story-of-business-form>
    `,
  };
};

В обычном Storybook:
useRealApi = false
В CAT:
useRealApi = true

Название useRealApi здесь немного обманчиво. Оно не означает, что тест обязательно идёт в настоящий backend. Оно означает: «Не отвечай внутри Angular-интерцептора. Выпусти запрос в браузерную сеть». А там запрос уже ловит Playwright.

7. Как из srory получается url

CAT не открывает левое меню Storybook и не ищет нужный пункт. Он сразу переходит в iframe:

/iframe.html?id=checkbox–default
/iframe.html?id=business-form–default
/iframe.html?id=business-contracts-list–default

Story ID формируется из title и имени экспорта:

title: ‘Checkbox’ + Default -> checkbox–default
title: ‘Business/ContractsList’ + Default -> business-contractslist–default

Args можно передать через URL: /iframe.html?id=checkbox–default&args=disabled:true;model:false

Для этого у нас есть маленький helper:

export function joinArgsValuesToString(args): string {
  return Object.entries(args)
    .map(([key, value]) => `${key}:${value}`)
    .join(';');
}

В тесте:

const args = {
  serviceProvider: 'PROVIDER_A',
  useRealApi: true,
};

await page.goto(
  `${componentUrl}--default&args=${joinArgsValuesToString(args)}`
);

На двух параметрах helper кажется необязательным. На пяти-шести он заметно улучшает читаемость и убирает ошибки с разделителями.

8. Почему мы не импортируем test напрямую

В CAT-файле expect импортируется из Playwright, а test — из нашей обвязки:

import { expect } from '@playwright/test';
import { test } from '@project/testing/playwright/cat';

Это принципиальный момент. Проектный test подключает общие fixtures и меняет поведение page.goto. После навигации мы дополнительно ждём готовность Storybook:

page.goto = async (...args) => {
  const response = await originalGoto.apply(page, args);

  await page
    .locator('.sb-show-main')
    .waitFor({
      state: 'visible',
    });

  return response;
};

Без этого легко напороться на асинхронный гонку и получить баг:

— браузер уже закончил навигацию;
— iframe существует;
— Angular story ещё не готова;
— тест уже пытается найти поле или нажать кнопку.

Маленькое ожидание в общей fixture убирает необходимость повторять эту защиту в сотнях тестов. Если случайно импортировать test из голого @playwright/test, тест потеряет это ожидание вместе с остальной проектной инфраструктурой.

9. Story args можно менять без перезагрузки

Для работы со Storybook у нас есть StoryFixture. Она обращается к внутреннему каналу Storybook preview и умеет:

— получить ID текущей story;
— обновить часть args;
— заменить args целиком;
— сбросить args к значениям по умолчанию;
— принудительно remount story;
— ждать Storybook action;
— записывать несколько вызовов action.

Пример:

test(
  'Состояние disabled fieldset',
  async ({ page, story }) => {
    const component = new CheckboxPage(page);

    await page.goto(
      '/iframe.html?id=checkbox--in-fieldset'
    );

    await story.setArgs({
      fieldsetDisabled: true,
      disabled: true,
      model: true,
    });

    await expect(component.host)
      .toHaveScreenshot(
        'disabled-checked.png'
      );

    await story.updateArgs({
      model: false,
    });

    await expect(component.host)
      .toHaveScreenshot(
        'disabled-unchecked.png'
      );
  }
);

Это быстрее, чем делать новый page.goto для каждого изменения. У некоторых stories подключён autoReload. Декоратор следит за изменением значимых args и при необходимости принудительно пересоздаёт Angular-компонент. Получается несколько вариантов:

— updateArgs — поменять часть входов;
— setArgs — заменить набор входов;
— forceReload — явно пересоздать story;
— page.goto — открыть другую story или получить полностью чистое состояние.

10. Story actions: проверяем выход компонента

Storybook полезен не только входными args. Он позволяет проверить output компонента без настоящего router. В story:

onDone = action('onDone');

В тесте:

const onDone = story.waitForStoryAction('onDone');

await component.addButton.click();

expect(await onDone).toEqual({
  action: 'GO',
  state: 'business.registration',
  params: {
    source: 'CONTACT',
  },
});

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

await story.trackStoryAction('onChange');

// Несколько действий пользователя.

expect(
  await story.getStoryActionCallCount('onChange')
).toBe(2);

expect(
  await story.getStoryActionCallArgs('onChange', 0)
).toEqual(expectedFirstPayload);

11. Что мы называем роутами

В контексте CAT «роут» обычно означает HTTP-маршрут: URL или RegExp + функция, возвращающая тестовый ответ. Это не Angular Router. Тип маршрута выглядит примерно так:

type InterceptorRoute<T> =
  | [
      string | RegExp,
      InterceptorHandler<T>
    ]
  | [
      string | RegExp,
      InterceptorHandler<T>,
      RouteOptions
    ]
  | {
      url: string | RegExp;
      handler: InterceptorHandler<T>;
      options?: RouteOptions;
    };

Handler получает:
— params — изменяемое состояние моков;
— request — method, body, headers, query и path params;
— options — настройки конкретного route.

Handler может вернуть:
— обычный объект — он станет JSON-ответом;
— { status, json } — ответ с нужным HTTP-статусом;
— undefined — пропустить обработчик.

12. Зачем нужны два интерсептера

Это одна из самых важных частей архитектуры. Один массив *.routes.ts используется в двух местах.

Ручной Storybook:

Angular HttpClient
    |
    v
Story Angular Interceptor
    |
    |-- useRealApi = false
    |
    v
Ответ из *.routes.ts


CAT:

Angular HttpClient
    |
    v
Story Angular Interceptor
    |
    |-- useRealApi = true
    |
    v
Браузерная сеть
    |
    v
Playwright page.route
    |
    v
Ответ из тех же *.routes.ts

Плюс этого подхода в переиспользовании. Story можно открыть вручную без backend. CAT получает полный контроль над запросом на уровне браузера:

— может дождаться его;
— прочитать body;
— проверить query;
— получить path params;
— изменить ответ на конкретном шаге;
— добавить задержку;
— вернуть ошибку.

Типичная настройка:

test.use({
  interceptorConfig: {
    params: defaultBusinessFormParams,
    routes: businessFormRoutes,
    delay: 200,
  },
});

И переход:

await page.goto(
  `${componentUrl}--default&args=useRealApi:true`
);

Если забыть useRealApi:true, Angular может ответить раньше Playwright. Тогда интерфейс даже способен отобразиться правильно, но waitForRequest будет ждать запрос, который до браузерного слоя не дошёл.

13. Как устроен Playwright interceptor

InterceptorFixture создаётся автоматически для каждого теста. Упрощённая настройка:

interceptorConfig: [
  {},
  {
    option: true,
  },
],

interceptor: [
  interceptorSetup,
  {
    auto: true,
  },
],

Setup:

  1. Создаёт InterceptorFixture.

  2. Передаёт начальные params.

  3. Регистрирует routes.

  4. Устанавливает общую задержку.

  5. После теста удаляет обработчики.

Playwright выполняет последним зарегистрированный page.route первым. Чтобы порядок маршрутов в исходном массиве оставался понятным, при регистрации мы разворачиваем его:

for (let index = 0; index < routes.length; index++) {
  const route = routes[routes.length - 1 - index];
  await interceptor.addRoute(...route);
}

Строковый маршрут преобразуется в RegExp.
Плейсхолдер: /api/v2/customers/:customerId/contracts/search становится именованной группой: request.pathParams.customerId

У такого удобства есть ограничение. Если шаблон использует \w+, он не поймает UUID с дефисами. Для подобных значений лучше сразу передать собственный RegExp.

14. Моки - это не только статичные json

Большую часть данных удобно хранить в JSON-stubs. Но отдельный JSON на каждую вариацию быстро приводит к дублированию. Поэтому routes у нас работают как маленькая модель состояния backend. Например:

interface PromoCodeInterceptorParams {
  searchError: boolean;
  unlimitedAllowance: boolean;
  megabyteUnit: boolean;
  serviceProvider: string;
  promoStatus: string;
}

Значения по умолчанию:

const defaultParams = {
  searchError: false,
  unlimitedAllowance: true,
  megabyteUnit: false,
  serviceProvider: 'PROVIDER_A',
  promoStatus: 'ACTIVE',
};

Handler:

const promoCodeRoutes = [
  [
    /\/api\/v1\/promo-codes\/search/,
    params => {
      if (params.searchError) {
        return {
          status: 500,
          json: {},
        };
      }

      let stub = params.unlimitedAllowance
        ? unlimitedOfferStub
        : limitedOfferStub;

      if (params.megabyteUnit) {
        stub = megabyteOfferStub;
      }

      const offer = structuredClone(
        stub.items[0]
      );

      offer.status = params.promoStatus;
      offer.serviceProvider =
        params.serviceProvider;

      return {
        items: [offer],
      };
    },
  ],
];

Тест переключает backend-состояние одной командой:

interceptor.setParams({
  serviceProvider: 'PROVIDER_B',
  promoStatus: 'INACTIVE',
  megabyteUnit: true,
});

Это гораздо понятнее, чем файлы:
promo-response-1.json
promo-response-2.json
promo-response-final.json
promo-response-final-2.json

JSON хранит содержательные данные, а TypeScript-параметры описывают сценарии.

15. Ошибки, задержки и одноразовые ответы

Вернуть ошибку:

return {
  status: 500,
  json: {
    code: 'INTERNAL_ERROR',
  },
};

Изменить задержку во время теста:

interceptor.setDelay(5000);

Задать задержку только одному route:

{
  url: searchUrl,
  handler: searchHandler,
  options: {
    delay: 1000,
  },
}

Ограничить число срабатываний:

{
  url: saveUrl,
  handler: firstSaveHandler,
  options: {
    times: 1,
  },
}

Пропустить обработчик:

[
  searchUrl,
  params => {
    if (!params.useSpecialResponse) {
      return;
    }

    return specialResponse;
  },
]

Если handler ничего не вернул, Playwright вызывает fallback и ищет следующий подходящий route.

16. Проверяем не только ответ, но и запрос

Снимок отвечает на вопрос: «Что увидел пользователь?». Но он не доказывает, что форма отправила правильный API-контракт. Поэтому хороший CAT обычно сочетает визуальную проверку и assertion запроса.

const requestPromise =
  interceptor.waitForRequest(searchUrl);

await component.promoCodeInput.fill(
  'PROMO-2026'
);

await component.checkButton.click();

const request = await requestPromise;

expect(
  request.body.promoCode
).toBe('PROMO-2026');

expect(
  request.params.zoneIds
).toEqual([10, 20]);

await expect(
  component.result
).toHaveScreenshot(
  'active-promo-code.png'
);

Ожидание создаётся до клика. Если сначала нажать кнопку, а потом вызвать waitForRequest, быстрый запрос может успеть уйти. Наш объект запроса содержит:

— url;
— urlWithParams;
— method;
— query params;
— path params;
— body;
— headers.

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

const requestPromise =
  interceptor.waitForRequest(searchUrl);

await grid.header.sortByColumn(
  'Дата договора'
);

const request = await requestPromise;

expect(
  request.params.sort
).toBe('signingDate');

Для обратной сортировки:

expect(
  request.params.sort
).toBe('-signingDate');

Снимок при этом проверит стрелку и состояние заголовка, а assertion — настоящий query-параметр.

17. waitForRequest и requestListener

У нас есть два похожих инструмента. interceptor.waitForRequest подходит для одного ожидаемого запроса:

const requestPromise =
  interceptor.waitForRequest(refreshUrl);

await component.refreshButton.click();

const request = await requestPromise;

requestListener нужен, когда важна история:
— сколько раз ушёл запрос;
— какой запрос был первым;
— что отправилось после нескольких действий;
— какие запросы выполнил длинный сценарий.

requestListener.listen([
  searchUrl,
  saveUrl,
]);

// Несколько действий.

expect(
  requestListener.getRequestsCount(searchUrl)
).toBe(2);

const saveRequest =
  requestListener.getRequestData(saveUrl);

expect(
  saveRequest?.postDataJSON()
).toEqual(expectedBody);

Если запрос полностью обработан внутри Angular StoryInterceptor, браузерный listener его не увидит. Запрос должен пройти до сетевого уровня Playwright.

18. Page object как язык теста

По нашим правилам доступ к UI выносится в *.page.ts. Базовый класс:

abstract class BasePage {
  static selector: string;

  readonly host: Locator;

  protected constructor(
    public readonly page: Page,
    selectorOrLocator: string | Locator
  ) {
    this.host =
      typeof selectorOrLocator === 'string'
        ? page.locator(selectorOrLocator)
        : selectorOrLocator;
  }

  async moveMouseAway(): Promise<void> {
    await this.page.mouse.move(-1, -1);
  }
}

Каждый Page Object получает:
— root selector;
— host;
— локаторы значимых элементов;
— вложенные Page Objects;
— методы для действительно нетривиальных действий.

19. Композиция page object

Дерево pages повторяет дерево компонентов.

class PromoCodePage extends BasePage {
  static selector = 'app-promo-code';

  readonly form: PromoCodeFormPage;
  readonly description:
    PromoCodeDescriptionPage;
  readonly result: PromoCodeResultPage;

  constructor(
    page: Page,
    host: string | Locator =
      PromoCodePage.selector
  ) {
    super(page, host);

    this.form = new PromoCodeFormPage(
      page,
      this.host.locator(
        PromoCodeFormPage.selector
      )
    );

    this.description =
      new PromoCodeDescriptionPage(
        page,
        this.host.locator(
          PromoCodeDescriptionPage.selector
        )
      );

    this.result = new PromoCodeResultPage(
      page,
      this.host.locator(
        PromoCodeResultPage.selector
      )
    );
  }
}

Код теста после этого читается на уровне формы:

await component.form
  .promoCodeField
  .input
  .fill('PROMO-2026');

await component.form
  .checkButton
  .click();

Тест не знает, из каких div и CSS-классов собрано поле. Но понимает структуру интерфейса.

20. Page object не является fixture

Page Objects у нас остаются обычными классами.

let component: ContractsListPage;

test.beforeEach(({ page }) => {
  component = new ContractsListPage(page);
});

Через Playwright fixtures мы передаём инфраструктуру:

— interceptor;
— consoleErrors;
— story;
— requestListener;
— clipboard.

Через new создаём модель UI. Это разделение оказалось простым и предсказуемым: fixture управляет жизненным циклом окружения; Page Object описывает доступ к интерфейсу.

21. Какие локаторы мы используем

В Playwright-конфиге задан собственный test id:

use: {
  testIdAttribute: 'data-element',
}

Поэтому:

this.refreshButton =
  this.host.getByTestId('refresh-button');

ищет:

<button data-element="refresh-button">
  Обновить
</button>

Обычно приоритет такой:

  1. getByRole — если элемент имеет устойчивую пользовательскую семантику.

  2. getByText — если текст является частью контракта.

  3. getByTestId — для точного технического якоря.

  4. CSS или tag locator — когда это действительно стабильная структура компонента.

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

22. Не нужно оборачивать каждый click

Такой метод почти ничего не даёт:

async registerCustomer(): Promise<void> {
  await this.registerButton.click();
}

В тесте можно написать напрямую:

await component.registerButton.click();

Метод Page Object оправдан, если скрывает последовательность или знание о внутреннем устройстве:

async selectOption(
  name: string
): Promise<void> {
  await this.trigger.click();

  await this.list
    .getOptionByName(name)
    .click();

  await this.list.host.waitFor({
    state: 'hidden',
  });
}

То есть мы выносим в Page Object локаторы и полезную абстракцию, но не создаём бессмысленный слой прокси-методов.

23. Story-only page object

Иногда wrapper story добавляет искусственные элементы:
— кнопку для изменения входа;
— индикатор события;
— тестовый контейнер;
— вспомогательный переключатель.

Такие локаторы мы выносим в отдельный *-story.page.ts. Иначе Page Object настоящего компонента начинает знать о тестовом стенде и становится непереиспользуемым.

24. ОБЩИЙ НАБОР FIXTURES

Наша базовая Playwright-обвязка расширяет стандартный test.

Автоматически подключаются:
— consoleErrors;
— scrollConfiguration;
— allureMeta;
— interceptor;
— cpuThrottling, если задана специальная переменная окружения.

По запросу доступны:
— requestListener;
— gesture;
— clipboard;
— eventListener;
— story.

В результате тест может выглядеть так:

test(
  'Сценарий',
  async ({
    page,
    interceptor,
    consoleErrors,
    story,
    requestListener,
  }) => {
    // Всё уже создано.
    // После теста инфраструктура
    // будет корректно очищена.
  }
);

25. Контроль ошибок в консоли

Совпавший снимок не означает, что компонент работает без ошибок. Поэтому consoleErrors автоматически слушает браузерную консоль и после теста выполняет мягкую проверку:

expect.soft(
  consoleErrors.errors.join(';\n'),
  'should be no errors in the console'
).toBe('');

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

consoleErrors.skip('500');

interceptor.setParams({
  searchError: true,
});

// Проверяем error state.

consoleErrors.unskip('500');

Так тест документирует:
— какая ошибка ожидается;
— на каком участке;
— когда контроль снова включается.

Глобально отключать console errors опасно. Ошибка может появиться в совершенно другом месте, а тест всё равно останется зелёным.

26. Стабилизация scroll

До загрузки страницы fixture добавляет:

window.env_test = {
  RUN_CAT_TEST: true,
  SCROLL_CONFIG: {
    duration: 0,
  },
};

Это решает две задачи. Во-первых, scroll-анимация становится мгновенной. Тест не снимает случайный промежуточный кадр. Во-вторых, story может понять, что запущена в CAT:

const isCatTest =
  !!window.env_test?.RUN_CAT_TEST;

export default {
  parameters: {
    layout: isCatTest
      ? 'fullscreen'
      : 'centered',
  },
};

CAT-флаг стоит использовать только для тестового окружения. Он не должен включать отдельную бизнес-логику компонента.

27. Дополнительные fixtures

Clipboard позволяет проверять вставку и чтение буфера обмена. EventListener собирает пользовательские DOM events через тестовый канал в window. Gesture выполняет touch, swipe и другие действия через Chrome DevTools Protocol. CPU throttling помогает искать гонки и flaky:
PW_CPU_RATE=4
workers=1
repeat-each=10

Когда процессор замедлен, места с неверными ожиданиями проявляются заметно чаще. Также у нас есть helper ожидания Angular stability. Он полезен в редких случаях, когда встроенного auto-waiting Playwright недостаточно. Но мы не используем его вместо нормальных locator assertions. Чем точнее тест ждёт конкретное состояние, тем понятнее его смысл.

28. Простой cat-тест

Начнём с UI-компонента.

import {
  expect,
} from '@playwright/test';

import {
  test,
} from '@project/testing/playwright/cat';

import {
  CheckboxPage,
} from './checkbox.page';


test.describe(
  'CheckboxComponent',
  () => {
    const componentUrl =
      '/iframe.html?id=checkbox';

    test.use({
      viewport: {
        width: 400,
        height: 200,
      },
    });

    const stories = [
      {
        id: 'default',
        title: 'Default',
      },
      {
        id: 'large',
        title: 'Large',
      },
      {
        id: 'in-dark-form',
        title: 'In dark form',
      },
    ];

    for (const {
      id,
      title,
    } of stories) {
      test(
        title,
        async ({ page }) => {
          await page.goto(
            `${componentUrl}--${id}`
          );

          const component =
            new CheckboxPage(page);

          await expect(
            component.host
          ).toHaveScreenshot(
            `${id}.png`
          );
        }
      );
    }
  }
);

Одна тестовая логика прогоняется для нескольких stories. Дальше добавляем взаимодействие:

test(
  'При клике меняется checked',
  async ({ page }) => {
    const component =
      new CheckboxPage(page);

    await page.goto(
      `${componentUrl}--default`
      + '&args=model:false'
    );

    await component.icon.click();

    await expect(
      component.host
    ).toHaveScreenshot(
      'checked.png'
    );

    await component.icon.click();

    await expect(
      component.host
    ).toHaveScreenshot(
      'unchecked.png'
    );
  }
);

Один снимок проверяет:
— иконку;
— цвет;
— label;
— размеры;
— отступы;
— взаимное расположение элементов;
— disabled или checked state.

29. Бизнес-cat

Для бизнес-формы обычно есть четыре основных файла:

business-form.stories.ts
business-form.routes.ts
business-form.page.ts
business-form.cat.ts

Story собирает Angular-окружение. Routes моделируют backend. Page Object описывает UI. CAT выполняет пользовательский сценарий.

test.describe(
  'Проверка промокода '
    + '@allureID=123456',
  () => {
    test.use({
      interceptorConfig: {
        params: defaultParams,
        routes: promoCodeRoutes,
        delay: 200,
      },
    });

    const componentUrl =
      '/iframe.html?id=promo-code';

    test(
      'Активный промокод',
      async ({
        page,
        interceptor,
      }) => {
        const component =
          new PromoCodePage(page);

        interceptor.setParams({
          serviceProvider:
            'PROVIDER_A',
          promoStatus:
            'ACTIVE',
        });

        const args = {
          serviceProvider:
            'PROVIDER_A',
          useRealApi: true,
        };

        await page.goto(
          `${componentUrl}--default`
          + `&args=${
            joinArgsValuesToString(args)
          }`
        );

        const searchPromise =
          interceptor.waitForRequest(
            promoSearchUrl
          );

        await component.form
          .promoCodeInput
          .fill('PROMO-2026');

        await component.form
          .checkButton
          .click();

        const request =
          await searchPromise;

        expect(
          request.body.promoCode
        ).toBe('PROMO-2026');

        await expect(
          component.result.host
        ).toHaveScreenshot(
          'active-promo-code.png'
        );
      }
    );
  }
);

Это уже не просто screenshot test. Он проверяет:

— загрузку story;
— работу Angular-формы;
— ввод пользователя;
— реакцию на кнопку;
— исходящий HTTP-запрос;
— request body;
— обработку ответа;
— итоговый интерфейс;
— отсутствие неожиданных console errors.

30. Большой сценарий и test.step

Для бизнес-кейсов мы стараемся использовать один test с несколькими test.step.

test(
  'Работа с формой '
    + '@allureID=123456',
  async ({ page }) => {
    await page.goto(componentUrl);

    await test.step(
      'Форма открыта',
      async () => {
        await expect(
          component.host
        ).toHaveScreenshot(
          'initial.png'
        );
      }
    );

    await test.step(
      'Пользователь применил фильтр',
      async () => {
        await component.filter
          .fill('123');

        await component
          .refreshButton
          .click();

        await expect(
          component.host
        ).toHaveScreenshot(
          'filtered.png'
        );
      }
    );

    await test.step(
      'Пользователь очистил фильтр',
      async () => {
        await component
          .clearFiltersButton
          .click();

        await expect(
          component.host
        ).toHaveScreenshot(
          'cleared.png'
        );
      }
    );
  }
);

Причина не только в красивом отчёте. Каждый page.goto заново загружает Storybook и Angular story. В большом наборе лишние переходы заметно увеличивают время. Несколько отдельных test оправданы, если каждому сценарию нужен:

— другой story ID;
— другой набор начальных args;
— чистое состояние страницы;
— отдельный lifecycle;
— независимая параллелизация.

31. Как мы связываем cat с allure

Часть CAT автоматизирует готовые ручные сценарии. ID добавляется в название:

test(
  'Список доступных продуктов '
    + '@allureID=123456',
  async () => {
    // ...
  }
);

Если один кейс разбит на несколько Playwright tests, ID можно разместить в describe:

test.describe(
  'Кеширование формы '
    + '@allureID=123456',
  () => {
    // ...
  }
);

Автоматическая fixture:
— ищет тег во всём title path;
— добавляет ссылку на исходный кейс;
— задаёт suite; — передаёт metadata в reporter;
— помечает успешный retry как flaky.

Код теста по возможности повторяет структуру ручного сценария, но мы не копируем номера шагов:

await test.step(
  'Открыт диалог подтверждения',
  async () => {
    await expect(
      dialog.host
    ).toHaveScreenshot(
      'confirmation-dialog.png'
    );
  }
);

Имя снимка описывает состояние, а не называется step-7.png.

32. Как мы фиксируем границы автоматизации

CAT не должен притворяться полным e2e. Если шаг уже проверяется unit-тестом:

test.info().annotations.push({
  type: 'skip',
  description:
    'Задержка запроса проверяется '
    + 'в unit-тесте сервиса',
});

Если шаг требует целого приложения:

test.info().annotations.push({
  type: 'e2e',
  description:
    'Смена статуса требует '
    + 'сквозного сценария',
});

Если есть технический блокер:

test.info().annotations.push({
  type: 'blocked',
  description:
    'Операция выполняется '
    + 'во внешней системе',
});

Такая разметка честно показывает, какая часть исходного бизнес-кейса покрыта CAT, а какая осталась за его границей.

33. Что проверяет toHaveScreenshot

Для отдельного компонента:

await expect(
  component.host
).toHaveScreenshot(
  'default.png'
);

Для всей страницы:

await expect(
  page
).toHaveScreenshot(
  'business-form.png'
);

Снимок host предпочтительнее, когда внешний фон и wrapper Storybook не являются частью контракта. Снимок всей страницы нужен, если важны:

— overlay;
— выпадающий список вне host;
— fullscreen layout;
— взаимное расположение нескольких областей.

В глобальной конфигурации мы отключаем анимации и задаём строгий порог сравнения:

expect: {
  toHaveScreenshot: {
    threshold: 0.017,
    maxDiffPixels: 1,
    animations: 'disabled',
  },
  timeout: 10_000,
}

Эталон лежит рядом с тестом:

test/
  screenshots/
    business-form.cat.ts-snapshots/
      linux/
        initial.png
        filtered.png
        error.png

34. Почему windows png не равен linux png

Один и тот же Chromium на разных ОС немного по-разному рендерит:
— шрифты;
— сглаживание;
— метрики текста;
— некоторые графические примитивы.

Человеку изображения кажутся одинаковыми. Pixel diff может увидеть тысячи отличий. Поэтому в пути эталона есть platform:

linux/
win32/
darwin/

Разрабатывать и отлаживать тест можно на любой поддерживаемой ОС. Эталоны для Linux CI мы снимаем в Docker.

npm run cat:update:snapshots:docker -– business-form.cat.ts

Локальное обновление:

npm run cat:update:snapshots -– business-form.cat.ts

Эти команды создают снимки разных платформ и не являются взаимозаменяемыми.

35. Как сделать снимок стабильным

Визуальный тест требует большей детерминированности, чем обычный DOM assertion. Отключаем анимации: animations: ‘disabled’. Фиксируем время:

await page.clock.install({
  time: new Date(
    '2026-06-19T12:00:00'
  ),
});

Ждём содержательное состояние:

await component.grid.waitForLoad();

await expect(
  component.emptyState
).toBeVisible();

Уводим курсор:

await component.moveMouseAway();

Задаём viewport:

test.use({
  viewport: {
    width: 1280,
    height: 500,
  },
});

Маскируем только неизбежную динамику:

await expect(
  component.host
).toHaveScreenshot(
  'result.png',
  {
    mask: [
      component.loader,
    ],
  }
);

Стандартный цвет mask — magenta. Это полезно: на эталоне сразу видно, какая область исключена из сравнения. Но mask не должна быть лекарством от flaky. Если можно зафиксировать дату, дождаться loader или отключить анимацию, лучше сделать это. Большое розовое поле уже почти ничего не защищает.

36. Снимок не должен быть единственной проверкой

Визуальное сравнение отлично ловит:

— пропавший элемент;
— неверный текст;
— неправильный цвет;
— сломанную сетку;
— лишний scroll;
— неправильный размер;
— неверное состояние кнопки.

Но снимок не всегда хорошо объясняет логическую ошибку. Поэтому мы комбинируем проверки.
DOM:

await expect(
  component.saveButton
).toBeDisabled();

Текст:

await expect(
  component.title
).toHaveText(
  'Проверка промокода'
);

HTTP:

expect(
  request.body
).toEqual(expectedBody);

expect(
  request.params.sort
).toBe('-contractNumber');

expect(
  request.pathParams.customerId
).toBe('100500');

Output:

expect(
  await story.waitForStoryAction(
    'onDone'
  )
).toEqual(expectedAction);

Console:
Автоматическая fixture гарантирует отсутствие неожиданных console.error.
Визуальный контракт:

await expect(
  component.host
).toHaveScreenshot(
  'filtered.png'
);

Именно сочетание этих проверок делает CAT действительно полезным.

37. Playwright-конфигурация

У нас несколько Storybook-host и несколько Playwright-конфигов. Общие настройки:

— testDir указывает на библиотеки;
— testMatch выбирает *.cat.ts;
— браузер — Chromium;
— базовый viewport — 1280 x 900;
— тесты полностью параллельны;
— локально retries выключены;
— на CI retries включены;
— trace сохраняется на первом retry;
— video сохраняется при падении;
— Storybook поднимается через webServer;
— data-element настроен как test id.

Упрощённый пример:

const config = {
  testDir: workspaceLibraries,

  testMatch: '**/*.cat.ts',

  snapshotPathTemplate:
    '{testDir}/{testFileDir}'
    + '/screenshots'
    + '/{testFileName}-snapshots'
    + '/{platform}/{arg}{ext}',

  fullyParallel: true,

  forbidOnly: Boolean(process.env.CI),

  retries: process.env.CI
    ? 2
    : 0,

  workers: process.env.CI
    ? 4
    : undefined,

  use: {
    baseURL,
    trace: 'on-first-retry',
    testIdAttribute: 'data-element',
  },

  projects: [
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        viewport: {
          width: 1280,
          height: 900,
        },
        screenshot:
          'only-on-failure',
        video:
          'retain-on-failure',
      },
    },
  ],

  webServer: {
    command:
      'nx serve-storybook storybook',
    url: baseURL,
    reuseExistingServer: true,
  },
};

38. NX и собственный executor

CAT оформлен как Nx target.

{
  "cat": {
    "executor":
      "@project/schematics:playwright",
    "inputs": [
      "storybook-cats",
      "^storybook-cats"
    ],
    "outputs": [
      "{workspaceRoot}/dist/cat"
    ],
    "options": {
      "configPath":
        "{projectRoot}/playwright.config.ts"
    },
    "configurations": {
      "docker": {
        "remote": true
      },
      "update:snapshots": {
        "update-snapshots": "changed"
      },
      "update:snapshots:docker": {
        "remote": true,
        "update-snapshots": "changed"
      }
    }
  }
}

В обычном режиме executor вызывает:

npx playwright test -c <playwright.config.ts>

В Docker-режиме:

  1. Проверяет Docker.

  2. Находит или создаёт контейнер.

  3. Запускает Playwright server.

  4. Передаёт WebSocket endpoint.

  5. Оставляет runner на host.

  6. Запускает Chromium внутри Linux.

Конфиг видит удалённый endpoint:
— выбирает platform = linux;
— обращается к Storybook на host-машине;
— не запускает второй webServer.

Так мы получаем стабильное Linux-окружение, не перенося весь frontend-проект внутрь контейнера.

39. Основные команды

Установить Chromium: npm run playwright:install

Запустить CAT: npm run cat

Запустить один файл: npm run cat –– business-form.cat.ts

Запустить по grep: npm run cat -- --grep=“@allureID=123456”

Обновить снимки текущей ОС: npm run cat:update:snapshots –– business-form.cat.ts

Обновить Linux-снимки: npm run cat:update:snapshots:docker –– business-form.cat.ts

Открыть HTML-отчёт: npm run cat:report

Запустить с inspector: npm run cat –– business-form.cat.ts ––debug

Собрать trace: npm run cat –– business-form.cat.ts ––trace=on

40. Что происходит на ci

На CI CAT не запускается одним длинным процессом. Сначала Nx определяет affected-проекты. Для CAT учитываются:
— исходный код;
— stories;
— routes;
— Page Objects;
— CAT-файлы;
— screenshots;
— зависимости Storybook-host.

Unit и e2e при этом не должны без причины инвалидировать CAT-cache. Дальше большой набор делится на shards:
nx cat storybook --shard=1/6
nx cat storybook --shard=2/6
nx cat storybook --shard=3/6

Каждый shard создаёт blob report:
dist/cat/blob/report-shard-1.zip
dist/cat/blob/report-shard-2.zip

После завершения нод:

npx playwright merge-reports ––config=merge.config.ts dist/cat/blob

В результате появляются:
— единый HTML report;
— JUnit XML;
— Allure results, если включена интеграция;
— traces;
— videos;
— screenshots падений.

41. Зачем нужен собственный screenshots reporter

Когда toHaveScreenshot падает, Playwright создаёт:
— expected;
— actual;
— diff.

Но actual лежит внутри каталога артефактов конкретного test run. Наш reporter находит attachments с окончанием -actual.png и копирует их в отдельный каталог:
dist/screenshots/ <путь-к-эталону>

Структура повторяет ожидаемый snapshot path. Это позволяет CI:
— собрать все новые actual в одном месте;
— упаковать их в artifact;
— подготовить обновление golden;
— не разыскивать файлы внутри каждого test-results.

При этом reporter не должен автоматически решать, что новый снимок правильный. Обновление эталона всё равно требует человеческого ревью.

42. Как читать падение cat

Изменился ожидаемый интерфейс. Нужно посмотреть diff и после ревью обновить golden.

Произошла визуальная регрессия. Эталон обновлять нельзя. Нужно исправить компонент.

На actual остался loader. Тест не дождался содержательного состояния.

Playwright не увидел запрос. Проверяем:

— useRealApi:true;
— ожидание создано до действия;
— URL совпадает;
— Angular interceptor не ответил раньше;
— path params подходят под RegExp;
— запрос не пойман более приоритетным route.

Снимок отличается только шрифтами. Проверяем платформу golden.

На снимке случайный hover. Уводим мышь или снимаем focus.

Плавает текущее время. Используем page.clock.

Тест зелёный визуально, но всё равно упал. Смотрим consoleErrors и soft assertions в teardown.

43. Как мы ищем flaky

Для подозрительного теста полезна комбинация:

PW_CPU_RATE=4 workers=1 repeat-each=10

Замедленный CPU делает flaky заметнее. Один worker исключает влияние параллельного выполнения. Repeat-each собирает несколько попыток в один отчёт. Дальше изучаем:
— trace;
— network;
— screenshot;
— DOM snapshot;
— console;
— время появления loader;
— порядок создания Promise и действия.

Чаще всего flaky оказывается не «магией Playwright», а неверной точкой ожидания.

44. Генераторы

Для стандартного CAT у нас есть schematic, который создаёт:
— *.cat.ts;
— Page Object;
— при необходимости routes;
— параметризованный блок базового отображения.

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

import {
  expect,
} from '@playwright/test';

import {
  test,
} from '@project/testing/playwright/cat';

И правильную структуру:

for (const {
  id,
  title,
} of stories) {
  test(
    title,
    async ({ page }) => {
      await page.goto(
        `${componentUrl}--${id}`
      );

      const component =
        new ComponentPage(page);

      await expect(
        component.host
      ).toHaveScreenshot(
        `${id}.png`
      );
    }
  );
}

Для сценариев Allure используется отдельный шаблон. Генератор не проектирует тест вместо разработчика, но убирает рутину и снижает шанс забыть обязательную обвязку.

45. Как мы пишем новый cat: пошагово

Шаг 1. Определяем границу.
Отвечаем:
— что именно тестируем;
— что дешевле проверить unit-тестом;
— что останется e2e;
— какие состояния важны визуально;
— какие HTTP-контракты нужно проверить.

Шаг 2. Создаём или актуализируем story.
Она должна:
— открываться вручную;
— не зависеть от настоящего backend;
— иметь понятные args;
— воспроизводить permissions и context;
— иметь реалистичный размер;
— не содержать случайные данные.

Шаг 3. Описываем routes.
Данные выносим в JSON, вариативность — в typed params.
Хорошо:

{
  searchError: false,
  customerType: 'ORGANIZATION',
  emptyResult: false,
}

Плохо:

{
  useStub2: true,
}

Шаг 4. Создаём Page Object.
Добавляем:
— host;
— устойчивые локаторы;
— вложенные pages;
— методы для нетривиальных действий.

Шаг 5. Подключаем interceptor.

test.use({
  interceptorConfig: {
    params: defaultParams,
    routes,
    delay: 200,
  },
});

Шаг 6. Открываем story.

await page.goto(
  '/iframe.html'
  + '?id=business-form--default'
  + '&args=useRealApi:true'
);

Шаг 7. Действуем как пользователь.

await component.nameField
  .input
  .fill('Иван');

await component.saveButton.click();

Шаг 8. Проверяем запрос.

const request =
  await requestPromise;

expect(
  request.body
).toEqual(expectedBody);

Шаг 9. Проверяем интерфейс.

await expect(
  component.host
).toHaveScreenshot(
  'customer-created.png'
);

Шаг 10. Запускаем на host и в Linux.
На host удобно отлаживать. В Docker создаём CI-compatible golden.

Шаг 11. Смотрим diff глазами.
Команда update snapshots не исправляет тест. Она меняет ожидаемый результат.

46. Частые анти-паттерны

Импорт test из @playwright/test. Тест теряет общие fixtures и Storybook wait.

Сложная логика внутри *.cat.ts. Техническую сложность лучше вынести в routes, fixtures или Page Objects.

CSS-селектор на пол-экрана. Лучше role, text или data-element.

Page Object с сотней однострочных click-методов. Локаторы выносим, бессмысленные прокси — нет.

Новый page.goto на каждый шаг. Если состояние можно продолжить, используем test.step.

Фиксированный sleep:

await page.waitForTimeout(2000);

Такой тест либо медленный, либо flaky, а иногда и то и другое. Ждём request, locator или состояние компонента.

Безусловное обновление golden. Сначала читаем diff и выясняем причину.

Огромная mask. Если половина формы закрыта magenta, снимок почти потерял смысл.

Проверка сортировки только картинкой. Стрелку проверяем снимком, query param — assertion запроса.

Отдельный JSON для каждого небольшого состояния. Используем typed params и setParams.

Попытка протащить e2e внутрь Storybook. Если сценарий требует настоящей авторизации, нескольких страниц или внешней системы, честно отмечаем границу e2e.

47. Что в этой архитектуре оказалось удачным

Story полезна сама по себе. Её можно открыть руками, показать QA или дизайнеру, использовать для отладки.

Routes переиспользуются. Один набор ответов работает в ручном Storybook и Playwright.

Тест видит настоящий браузер. Работают layout, focus, scroll, overlay, clipboard и пользовательские события.

Backend-состояние управляется из теста. Редкий статус или ошибка создаётся одним параметром.

Page Object повторяет компонентную архитектуру. Переиспользуемые компоненты получают переиспользуемые pages.

Снимки лежат рядом с тестом. На ревью видно, какой визуальный контракт изменился.

CI учитывает специфику visual regression. Есть Linux-окружение, shards, merge reports и сбор actual-снимков.

48. Что cat покрывает хорошо

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

Например, мы можем проверить, что форма действительно открывается и переходит в корректное loading state, а при возникновении ошибки пользователь получает ожидаемое сообщение. Аналогично проверяется доступность кнопок в зависимости от permission, отображение нужных колонок в гриде и корректная работа фильтрации и сортировки.

При этом CAT позволяет проверять не только внешний вид. Можно убедиться, что фильтр сформировал правильный request body, сортировка — ожидаемый query, а открытие диалога приводит к нужному состоянию интерфейса. Для компонентов с args можно дополнительно проверить, что изменение входных параметров действительно меняет состояние компонента ожидаемым образом.

Таким образом, CAT хорошо подходит для проверки поведения компонента на границе UI: от действий пользователя и отображения состояния до сформированных HTTP-запросов и полученных данных.

49. Чего cat не доказывает

При этом у такого подхода есть вполне естественная граница. CAT не доказывает, что настоящий backend в production действительно вернёт именно такой ответ. В тесте мы контролируем HTTP-слой и сознательно задаём необходимые данные, поэтому проверяем реакцию компонента на конкретный контракт, а не работоспособность самого backend.

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

Наконец, CAT не проверяет бизнес-процесс целиком. Например, успешное завершение формы ещё не означает, что операция действительно завершилась на стороне внешней системы или что все последующие этапы процесса были выполнены.

И это нормально. Сила CAT как раз заключается не в попытке проверить всё одним тестом, а в чётко определённой и хорошо контролируемой границе. За пределами этой границы остаются интеграционные, E2E и другие виды тестов.

50. Checklist для ревью

Чтобы CAT-тесты оставались предсказуемыми и не превращались со временем в отдельный сложно поддерживаемый фреймворк, полезно проверять не только сами assertions, но и устройство всей тестовой инфраструктуры.

Начинать стоит со Story. Она должна открываться вручную, иметь детерминированное начальное состояние, а args должны описывать состояние компонента, а не детали реализации. context и permissions лучше воспроизводить явно. HTTP при этом должен работать через общие routes, а Story не должна содержать отдельную бизнес-реализацию компонента только ради теста.

В HTTP routes стоит обратить внимание на отсутствие мутаций импортированных stub-данных. Если данные необходимо изменить для конкретного сценария, безопаснее создать копию через structuredClone. Ошибки должны возвращаться с явным HTTP status, а path parameters — соответствовать реальному формату приложения. Хороший route описывает бизнес-состояние, а не просто копирует несколько почти одинаковых JSON-ответов.

Page Object должен наследоваться от базового page и иметь устойчивый host. Если внутри него создаются вложенные pages, их область действия должна быть ограничена родительским host. Для поиска элементов предпочтительны role, текст и testId. Не стоит создавать бессмысленные обёртки над простыми действиями вроде click, если они не добавляют дополнительного смысла. Элементы, существующие только для Storybook или CAT, лучше держать отдельно от основной модели страницы.

Сам тест должен импортировать test из CAT-обвязки. Если сценарий ожидает HTTP-запрос, promise для него лучше создавать до пользовательского действия, которое этот запрос инициирует. Для сценариев с Playwright interceptor должен быть явно включён useRealApi.

Хороший тест при этом читается как последовательность действий пользователя. Если сценарий становится длинным, его стоит разбить на несколько test.step. HTTP-контракт лучше проверять отдельно от визуальной части, а ожидаемые console errors разрешать максимально узко. Имена snapshot-файлов должны описывать проверяемое состояние, а случайные waitForTimeout в стабильных тестах лучше не использовать.

Для screenshot-тестов важно снимать минимально достаточный host, осознанно задавать viewport и стабилизировать дату и тестовые данные. Состояния hover и focus не должны попадать в golden случайно, а mask стоит использовать только там, где она действительно необходима. Linux golden должен создаваться в Linux, а полученный diff — проходить визуальное ревью человеком.

Наконец, в CI и отчётности стоит проверить, что Allure ID находится непосредственно в test или describe, границы между skip, E2E и blocked-сценариями явно обозначены, а golden проходит ревью. При падении теста должны сохраняться trace и actual screenshot — без них разбор визуальных и поведенческих проблем быстро превращается в угадывание.

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

51. Совет команде, которая только начинает

Если команда только начинает внедрять CAT, я бы не советовал сразу брать огромную бизнес-форму со множеством зависимостей. Гораздо проще начать с небольшого переиспользуемого компонента, у которого есть несколько понятных состояний. На нём можно постепенно выстроить всю инфраструктуру и при этом сразу увидеть пользу от подхода.

На первом этапе достаточно одного Storybook host, одного Playwright config, базового CAT fixture, Page Object, платформенного snapshot path и запуска в Linux. Этого уже хватит, чтобы получить первый работающий тест и HTML report.

Когда базовый сценарий стабильно работает, можно постепенно добавлять HTTP interceptor, проверку console errors, управление Storybook args, CI sharding, Allure и специализированные fixtures. Важно именно сохранять эту последовательность: инфраструктура должна расти из реальных тестов, а не наоборот.

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

Отдельно я бы заранее договорился с командой о правилах работы с golden. Изменение golden — это изменение ожидаемого поведения, а не просто техническое исправление красного CI. Snapshot нельзя обновлять автоматически только потому, что тест упал. Сначала нужно понять причину изменения, убедиться, что новое состояние действительно корректно, и только после этого принимать новый golden.

И ещё один практический момент. Если в проекте используется ИИ, эту статью вполне можно использовать как исходный материал для подготовки базовой конфигурации CAT под конкретный проект. Модель может помочь собрать первоначальный Playwright config, fixtures, routes, Page Object и структуру тестов, опираясь на описанный здесь подход. Но получившуюся инфраструктуру всё равно стоит адаптировать под реальные ограничения проекта, а не воспринимать сгенерированный код как готовый универсальный фреймворк.

52. Итог

В нашей архитектуре Playwright — не просто инструмент, который нажимает кнопки и сравнивает картинки. Он соединяет несколько слоёв:

  • Storybook создаёт изолированное Angular-окружение;

  • args задают начальное состояние;

  • StoryFixture меняет входы и наблюдает outputs;

  • routes моделируют backend;

  • Playwright interceptor управляет ответами и раскрывает HTTP-контракт;

  • Page Object превращает DOM в понятный язык формы;

  • общие fixtures стабилизируют scroll, console и окружение;

  • toHaveScreenshot фиксирует визуальный контракт;

  • Nx и собственный executor обеспечивают локальный и Docker-запуск;

  • CI параллелит тесты и объединяет отчёты;

  • Allure связывает автоматизацию с бизнес-сценариями.

CAT занимает полезную середину между unit и e2e. Он достаточно близок к пользователю, чтобы находить реальные UI-регрессии. И достаточно изолирован, чтобы тесты оставались управляемыми и воспроизводимыми. Особенно хорошо этот подход защищает большие формы, диалоги, гриды и старый код перед рефакторингом.
CAT неважно, сколько сервисов и компонентов находится внутри формы. Пока внешний визуальный и поведенческий контракт сохраняется, тест остаётся зелёным. Если контракт изменился, мы получаем не абстрактный процент покрытия, а конкретные данные:

— шаг, на котором сломался сценарий;
— actual и diff; — исходящий запрос;
— console error;
— trace;
— video;
— состояние DOM.

Именно это стало главной ценностью Playwright CAT-тестов: они превращают UI-покрытие из дорогого финального ритуала в обычный рабочий инструмент разработчика.