Тестирование с playwright. Компонентный тест с Vuetify

Продолжаем тему предыдущей статьи и пробуем приблизиться к проблемам реальных проектов. В них ведь часто используются готовые наборы UI-элементов. Посмотрим как запустить компонентный тест, но с поддержкой Vuetify. Ранее мы пробовали подобное с Cypress. Сделаем похожим способом пример и создадим тесты на Playwright.

Содержание

Что нужно сделать

  • Создать проект на базе Vuetify
  • Установить Playwright Component Testing ( Playwright CT )
  • Корректно подключить Vuetify

Немного подробнее по последнему пункту. В приложении у нас, обычно, есть main.ts, в котором мы создаем объект vuetify через createVuetify и подключаем через app.use(vuetify).

Для компонентных тестов для этого служит playwright/index.ts, подключаемый в playwright/index.html. В нем надо вызвать createVuetify, передать ему необходимые для теста настройки и затем вызвать app.use(vuetify).

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

Новое приложение и Playwright CT

И так создадим проект:

npm create vuetify@latest

Need to install the following packages:
create-vuetify@2.7.0
Ok to proceed? (y) 


> npx
> create-vuetify


Vuetify.js - Material Component Framework for Vue

✔ Project name: … vuetify-playwright-ct
✔ Which preset would you like to install? › Barebones (Only Vue & Vuetify)
✔ Use TypeScript? … No / Yes
✔ Would you like to install dependencies with yarn, npm, pnpm, or bun? › npm
✔ Install Dependencies? … No / Yes

◌ Generating scaffold...
◌ Installing dependencies with npm...

vuetify-playwright-ct has been generated at /home/igor/projects/myblog/examples/p9298/vuetify-playwright-ct

Discord community: https://community.vuetifyjs.com
Github: https://github.com/vuetifyjs/vuetify
Support Vuetify: https://github.com/sponsors/johnleider

Сразу установим Playwright Component Testing

npm init playwright@latest -- --ct  

Подключаем Vietify к Playwright CT

Как Vuetify подключен в приложении

Так в main.ts у нас сгенерирован такой код:

// Plugins
import { registerPlugins } from '@/plugins'

// Components
import App from './App.vue'

// Composables
import { createApp } from 'vue'

// Styles
import 'unfonts.css'

const app = createApp(App)

registerPlugins(app)

app.mount('#app')

В src/plugins/index.ts подключен наш vuetify.

// Plugins
import vuetify from './vuetify'

// Types
import type { App } from 'vue'

export function registerPlugins (app: App) {
  app.use(vuetify)
}

Который настраивается так:

import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'

// Composables
import { createVuetify } from 'vuetify'

// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
  theme: {
    defaultTheme: 'system',
  },
})

Подключаем Vuetify к компонентным тестам

Так будет выглядеть наш index.ts для Playwright.

// Import styles, initialize component theme here.
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'

// Playwright CT hooks
import { beforeMount } from '@playwright/experimental-ct-vue/hooks'

// Import Vuetify
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'

// Create Vuetify instance
const vuetify = createVuetify({
  components,
  directives,
  theme: {
    defaultTheme: 'light',
  },
})

// Configure Vue app for each component test
beforeMount(async ({ app }) => {
  app.use(vuetify)
})

// Export Vuetify instance for direct use in tests if needed
export { vuetify }

Чтобы подключить Vuetify нам пришлось передать components и directives. Они нужны, чтобы Vuetify-компоненты (v-btn, v-card и так далее) и директивы работали в окружении компонентных тестов без “магии” сборки.

В обычном приложении, как правило, работает связка vite-plugin-vuetify и unplugin-vue-components. Она автоматически подтягивает нужные Vuetify-компоненты/директивы и делает “tree-shaking” (в сборку попадает только используемое). Поэтому там достаточно createVuetify({ theme: … }).

В компонентных тестах Vite-конфиг другой (см. ctViteConfig ниже в playwright-ct.config.ts), и эти плагины, обычно, не подключены

Ниже приведен сгенерированный playwright-ct.config.ts с указанием ctTemplateDir и ctViteConfig:

import { defineConfig, devices } from '@playwright/experimental-ct-vue';
import { fileURLToPath, URL } from 'node:url';

/**
 * See https://playwright.dev/docs/test-configuration.
 */
export default defineConfig({
  testDir: './',
  testMatch: /.*\.test\.(ts|tsx)/,
  /* The base directory, relative to the config file, for snapshot files created with toMatchSnapshot and toHaveScreenshot. */
  snapshotDir: './__snapshots__',
  /* Maximum time one test can run for. */
  timeout: 10 * 1000,
  /* Run tests in files in parallel */
  fullyParallel: true,
  /* Fail the build on CI if you accidentally left test.only in the source code. */
  forbidOnly: !!process.env.CI,
  /* Retry on CI only */
  retries: process.env.CI ? 2 : 0,
  /* Opt out of parallel tests on CI. */
  workers: process.env.CI ? 1 : undefined,
  /* Reporter to use. See https://playwright.dev/docs/test-reporters */
  reporter: 'html',
  /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
  use: {
    /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
    trace: 'on-first-retry',

    /* Port to use for Playwright component endpoint. */
    ctPort: 3100,

    /* Path to the component test setup file */
    ctTemplateDir: './playwright',

    /* Vite config for component tests */
    ctViteConfig: {
      resolve: {
        alias: {
          '@': fileURLToPath(new URL('./src', import.meta.url)),
        },
      },
    },
  },

  /* Configure projects for major browsers */
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
});

Создаем компонент и тест

Также нам нужен компонент для тестирования и сам тест.

Компонент создадим в src/components/CounterButton.vue:

<template>
  <v-card class="pa-4" max-width="400">
    <v-card-title class="text-h6 mb-4">
      Счетчик кликов
    </v-card-title>
    
    <v-card-text>
      <div class="text-h4 text-center mb-4">
        {{ count }}
      </div>
      
      <v-btn
        color="primary"
        size="large"
        block
        @click="increment"
        data-testid="increment-button"
      >
        Увеличить
      </v-btn>
      
      <v-btn
        color="error"
        size="large"
        block
        class="mt-2"
        @click="reset"
        data-testid="reset-button"
        :disabled="count === 0"
      >
        Сбросить
      </v-btn>
    </v-card-text>
  </v-card>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)

const increment = () => {
  count.value++
}

const reset = () => {
  count.value = 0
}

// Expose for testing
defineExpose({
  count,
  increment,
  reset,
})
</script>

Код теста разместим рядом с компонентом — src/components/CounterButton.test.ts, чтобы проще было его искать.

import { test, expect } from '@playwright/experimental-ct-vue'
import CounterButton from './CounterButton.vue'

test('отображает начальное значение счетчика', async ({ mount }) => {
  const component = await mount(CounterButton)
  
  // Проверяем, что счетчик начинается с 0
  await expect(component.getByText('0')).toBeVisible()
})

test('увеличивает счетчик при нажатии на кнопку', async ({ mount }) => {
  const component = await mount(CounterButton)
  
  // Нажимаем на кнопку увеличения
  await component.getByTestId('increment-button').click()
  
  // Проверяем, что счетчик увеличился до 1
  await expect(component.getByText('1')).toBeVisible()
  
  // Нажимаем еще раз
  await component.getByTestId('increment-button').click()
  
  // Проверяем, что счетчик увеличился до 2
  await expect(component.getByText('2')).toBeVisible()
})

test('сбрасывает счетчик при нажатии на кнопку сброса', async ({ mount }) => {
  const component = await mount(CounterButton)
  
  // Увеличиваем счетчик несколько раз
  await component.getByTestId('increment-button').click()
  await component.getByTestId('increment-button').click()
  await component.getByTestId('increment-button').click()
  
  // Проверяем, что счетчик равен 3
  await expect(component.getByText('3')).toBeVisible()
  
  // Нажимаем на кнопку сброса
  await component.getByTestId('reset-button').click()
  
  // Проверяем, что счетчик сброшен до 0
  await expect(component.getByText('0')).toBeVisible()
})

test('кнопка сброса отключена когда счетчик равен 0', async ({ mount }) => {
  const component = await mount(CounterButton)
  
  // Проверяем, что кнопка сброса отключена при начальном значении
  // В реальном DOM это обычная кнопка: атрибут `disabled` присутствует и обычно равен пустой строке.
  const resetButton = component.getByTestId('reset-button')
  await expect(resetButton).toBeDisabled()
  
  // Увеличиваем счетчик
  await component.getByTestId('increment-button').click()
  
  // Проверяем, что кнопка сброса теперь включена (disabled="false" или отсутствует)
  // Vuetify может установить disabled="false" или удалить атрибут
  const disabledAttr = await resetButton.getAttribute('disabled')
  expect(disabledAttr).not.toBe('true')
  
  // Сбрасываем счетчик
  await resetButton.click()
  
  // Проверяем, что кнопка сброса снова отключена
  await expect(resetButton).toBeDisabled()
})

test('отображает правильный заголовок', async ({ mount }) => {
  const component = await mount(CounterButton)
  
  // Проверяем наличие заголовка
  await expect(component.getByText('Счетчик кликов')).toBeVisible()
})

Playwight знает где искать тесты благодаря двум параметрам в playwright-ct.config.ts:

  testDir: './',
  testMatch: /.*\.test\.(ts|tsx)/,

Запуск тестов

Теперь мы можем запустить тесты:

npm run test-ct

> vuetify-playwright-ct@0.0.0 test-ct
> playwright test -c playwright-ct.config.ts


Running 15 tests using 4 workers

vite v6.4.1 building for production...
✓ 70 modules transformed.
.cache/index.html                                             0.40 kB │ gzip:   0.27 kB
.cache/assets/materialdesignicons-webfont-Dp5v-WZN.woff2    403.22 kB
.cache/assets/materialdesignicons-webfont-PXm3-2wK.woff     587.98 kB
.cache/assets/materialdesignicons-webfont-B7mPwVP_.ttf    1,307.66 kB
.cache/assets/materialdesignicons-webfont-CSr8KVlo.eot    1,307.88 kB
.cache/assets/index-nioaPivh.css                            753.57 kB │ gzip:  96.80 kB
.cache/assets/CounterButton-DCYlvpZh.js                       3.34 kB │ gzip:   1.15 kB │ map:     2.92 kB
.cache/assets/index-DSp2zPdR.js                             728.38 kB │ gzip: 179.25 kB │ map: 1,334.52 kB
  15 passed (23.8s)

To open last HTML report run:

  npx playwright show-report

Все тесты пройдены успешно. Тестов было 15 (а не 5), потому что каждый тест проверяется в 3 браузерах. Мы можем запустить наши тесты только в одном браузере. Например:

npm run test-ct -- --project=chromium

> vuetify-playwright-ct@0.0.0 test-ct
> playwright test -c playwright-ct.config.ts --project=chromium


Running 5 tests using 4 workers
  5 passed (3.9s)

To open last HTML report run:

  npx playwright show-report

При желании можно запустить и режим ui — npm run test-ct -- --project=chromium --ui и пройти по шагам любой тест. На Рисунке 1 приведен снимок экрана для этого режима.

Рисунок 1. Запуск компонентного теста с Veutify в режиме UI

Тесты более сложного компонента

Первый компонент был очень простой. Теперь добавим новый, с моделью, свойствами и событиями. Это будет модальное окно «Ввод кода подтвеждения» с полем ввода. При нажатии кнопки «Отмена» в окне оно просто закрывается и отпаравляется событие cancel, а при нажатии «ОК» происходит событие approve. В событие approve передается введенный код. Также нужно иметь возможность свойством менять заголовок диалога.

Код компонента:

<template>
  <v-dialog
    v-model="model"
    max-width="520"
    data-testid="confirmation-code-dialog"
  >
    <v-card>
      <v-card-title class="text-h6" data-testid="title">
        {{ props.title }}
      </v-card-title>

      <v-card-text>
        <v-text-field
          v-model="code"
          label="Код подтверждения"
          autocomplete="one-time-code"
          inputmode="numeric"
          data-testid="code-input"
        />
      </v-card-text>

      <v-card-actions>
        <v-spacer />
        <v-btn
          variant="text"
          data-testid="cancel"
          @click="onCancel"
        >
          Отмена
        </v-btn>
        <v-btn
          color="primary"
          variant="text"
          data-testid="approve"
          @click="onApprove"
        >
          ОК
        </v-btn>
      </v-card-actions>
    </v-card>
  </v-dialog>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'

interface Props {
  title?: string
}

const props = withDefaults(defineProps<Props>(), {
  title: 'Ввод кода подтвеждения',
})

const model = defineModel<boolean>({ default: false })

const emit = defineEmits<{
  cancel: []
  approve: [code: string]
}>()

const code = ref('')

watch(model, (isOpen) => {
  // При каждом открытии начинаем с пустого значения.
  if (isOpen) code.value = ''
})

function onCancel() {
  model.value = false
  emit('cancel')
}

function onApprove() {
  model.value = false
  emit('approve', code.value)
}
</script>

Файл с тестами компонента:

import { test, expect } from '@playwright/experimental-ct-vue'
import ConfirmationCodeDialog from './ConfirmationCodeDialog.vue'

const selectors = {
  title: 'title',
  input: 'code-input',
  cancel: 'cancel',
  approve: 'approve',
} as const

test('закрыт по умолчанию (modelValue=false)', async ({ mount, page }) => {
  await mount(ConfirmationCodeDialog)

  // В диалогах Vuetify контент может оставаться в DOM, поэтому проверяем "hidden".
  await expect(page.getByTestId(selectors.title)).toBeHidden()
})

test('открывается по модели (modelValue=true) и показывает элементы', async ({ mount, page }) => {
  await mount(ConfirmationCodeDialog, {
    props: { modelValue: true },
  })

  await expect(page.getByTestId(selectors.title)).toBeVisible()
  await expect(page.getByTestId(selectors.input)).toBeVisible()
  await expect(page.getByTestId(selectors.cancel)).toBeVisible()
  await expect(page.getByTestId(selectors.approve)).toBeVisible()
})

test('заголовок: по умолчанию и через prop title', async ({ mount, page }) => {
  const component = await mount(ConfirmationCodeDialog, {
    props: { modelValue: true },
  })
  await expect(page.getByTestId(selectors.title)).toHaveText('Ввод кода подтвеждения')

  await component.update({ props: { title: 'Введите код из СМС' } })
  await expect(page.getByTestId(selectors.title)).toHaveText('Введите код из СМС')
})

test('Отмена: закрывает диалог, эмитит cancel, не эмитит approve', async ({ mount, page }) => {
  let cancelCalls = 0
  let approveCalls: string[] = []
  const modelUpdates: boolean[] = []

  await mount(ConfirmationCodeDialog, {
    props: { modelValue: true },
    on: {
      cancel: () => cancelCalls++,
      approve: (code: string) => approveCalls.push(code),
      'update:modelValue': (v: boolean) => modelUpdates.push(v),
    },
  })

  await page.getByTestId(selectors.cancel).click()

  expect(cancelCalls).toBe(1)
  expect(approveCalls).toEqual([])
  expect(modelUpdates).toContain(false)
  await expect(page.getByTestId(selectors.title)).toBeHidden()
})

test('ОК: эмитит approve с введенным кодом и закрывает диалог', async ({ mount, page }) => {
  const approveCodes: string[] = []
  let cancelCalls = 0
  const modelUpdates: boolean[] = []

  await mount(ConfirmationCodeDialog, {
    props: { modelValue: true },
    on: {
      cancel: () => cancelCalls++,
      approve: (code: string) => approveCodes.push(code),
      'update:modelValue': (v: boolean) => modelUpdates.push(v),
    },
  })

  const input = page.getByTestId(selectors.input).locator('input')
  await input.fill('1234')

  await page.getByTestId(selectors.approve).click()

  expect(cancelCalls).toBe(0)
  expect(approveCodes).toEqual(['1234'])
  expect(modelUpdates).toContain(false)
  await expect(page.getByTestId(selectors.title)).toBeHidden()
})

test('при повторном открытии поле ввода очищается', async ({ mount, page }) => {
  const modelUpdates: boolean[] = []
  const component = await mount(ConfirmationCodeDialog, {
    props: { modelValue: true },
    on: {
      'update:modelValue': (v: boolean) => modelUpdates.push(v),
    },
  })

  const input = page.getByTestId(selectors.input).locator('input')
  await input.fill('9999')
  await expect(input).toHaveValue('9999')

  // Закрываем через отмену.
  await page.getByTestId(selectors.cancel).click()
  await expect(page.getByTestId(selectors.title)).toBeHidden()

  // Синхронизируем "родителя": применяем modelValue=false, который компонент запросил.
  expect(modelUpdates).toContain(false)
  await component.update({ props: { modelValue: false } })

  // Открываем снова через обновление props.
  await component.update({ props: { modelValue: true } })
  await expect(page.getByTestId(selectors.title)).toBeVisible()

  const inputAfterReopen = page.getByTestId(selectors.input).locator('input')
  await expect(inputAfterReopen).toHaveValue('')
})

Запуск тестов:

npm run test-ct -- --project=chromium 

> vuetify-playwright-ct@0.0.0 test-ct
> playwright test -c playwright-ct.config.ts --project=chromium


Running 11 tests using 4 workers

vite v6.4.1 building for production...
✓ 559 modules transformed.
.cache/index.html                                             0.40 kB │ gzip:   0.27 kB
.cache/assets/materialdesignicons-webfont-Dp5v-WZN.woff2    403.22 kB
.cache/assets/materialdesignicons-webfont-PXm3-2wK.woff     587.98 kB
.cache/assets/materialdesignicons-webfont-B7mPwVP_.ttf    1,307.66 kB
.cache/assets/materialdesignicons-webfont-CSr8KVlo.eot    1,307.88 kB
.cache/assets/index-Cg7M3Q1a.css                          1,055.24 kB │ gzip: 135.67 kB
.cache/assets/_plugin-vue_export-helper-pcqpp-6-.js           0.25 kB │ gzip:   0.20 kB │ map:     0.13 kB
.cache/assets/CounterButton-D9Uu-jOr.js                       3.26 kB │ gzip:   1.11 kB │ map:     2.90 kB
.cache/assets/ConfirmationCodeDialog-DQ49Qqrn.js              4.85 kB │ gzip:   1.44 kB │ map:     4.15 kB
.cache/assets/index-BDdZdE3K.js                           1,706.40 kB │ gzip: 378.62 kB │ map: 3,617.81 kB
  11 passed (6.2s)

To open last HTML report run:

  npx playwright show-report

Итоги

В результате мы получили компонентные тесты для проекта, использующего Vuetify. Главное здесь — передать верный объект с настройками для createVuetify, использовать в нем ссылку на компоненты и директивы Vuetify, а также вызвать hook beforeMount.

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

Важно помнить, что в Playwright Component Testing свойства (props) проходят через сериализацию/десериализацию, поэтому объекты, внутри которых есть функции, передавать через props нельзя (как и сами функции в props): они не сериализуются.

Слушать события компонента через опцию on у mount() можно. Вот здесь функции разрешены, потому что Playwright проксирует их как обработчики событий, а не сериализует как данные.

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

0 0 голоса
Рейтинг статьи
guest
0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии