Playwright前端自动化测试

2024-10-03 09:29:10 浏览数 (4)

Playwright 是一个强大的前端自动化测试工具。

Playwright优点:

一、跨浏览器支持

  1. 支持多种主流浏览器:Playwright 可以在 Chromium、Firefox 和 WebKit 等多种浏览器上进行测试,这使得测试可以覆盖更广泛的用户场景。不同的浏览器在渲染页面、执行 JavaScript 等方面可能存在差异,通过在多个浏览器上进行测试,可以确保应用在各种环境下都能正常运行。
  2. 一致性的 API:尽管支持多种浏览器,但 Playwright 提供了一套一致的 API,这使得开发者可以在不同的浏览器上使用相同的代码进行测试,减少了代码的维护成本。无论在哪个浏览器上进行测试,开发者都可以使用相同的方法来定位元素、执行操作和断言结果,提高了测试的可维护性和可扩展性。

二、强大的自动化能力

  1. 模拟用户交互:Playwright 可以模拟各种用户操作,如点击、输入、滚动、拖拽等。这使得测试可以更加真实地模拟用户行为,发现潜在的问题。例如,可以模拟用户在页面上的点击操作,验证点击后的页面变化是否符合预期;或者模拟用户输入文本,检查输入框的验证逻辑是否正确。
  2. 等待机制:Playwright 提供了自动等待机制,可以等待页面加载、元素出现、动画完成等状态。这减少了测试中的不稳定因素,提高了测试的可靠性。例如,可以等待页面加载完成后再进行下一步操作,避免因为页面未完全加载而导致的测试失败;或者等待元素出现后再进行操作,确保操作的对象存在。
  3. 截图和视频录制:Playwright 可以在测试过程中截取页面截图和录制视频,这对于调试测试失败和分析问题非常有帮助。通过查看截图和视频,可以直观地了解测试过程中页面的状态和操作的执行情况,快速定位问题所在。

三、易于使用和集成

  1. 简洁的 API:Playwright 的 API 设计简洁明了,易于学习和使用。开发者可以快速上手,编写高效的测试代码。例如,使用 Playwright 可以通过几行代码就实现打开浏览器、访问页面、定位元素和执行操作等功能,大大提高了测试的开发效率。
  2. 与测试框架集成:Playwright 可以与各种流行的测试框架(如 Jest、Mocha、Pytest 等)集成,方便开发者在现有的测试框架中使用 Playwright。这使得开发者可以根据自己的项目需求和团队的技术栈选择合适的测试框架,并轻松地引入 Playwright 进行自动化测试。
  3. 跨平台支持:Playwright 可以在多种操作系统上运行,包括 Windows、macOS 和 Linux。这使得测试可以在不同的开发环境和部署环境中进行,确保应用在各种平台上都能正常运行。

使用 Playwright 进行前端自动化测试的步骤:

快速安装一个 Playwright

pnpm dlx create-playwright

文件目录

代码语言:json复制
e2e-auto
 ┣  node_modules
 ┣  tests
 ┃ ┗  example.spec.ts
 ┣  tests-examples
 ┃ ┗  demo-todo-app.spec.ts
 ┣  .gitignore
 ┣  package.json
 ┣  playwright.config.ts
 ┗  pnpm-lock.yaml

安装浏览器

npx playwright install

运行测试

npx playwright test

配置文件playwright.config.ts

代码语言:ts复制
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  // 测试目录
  testDir: './tests',
  // 是否并发运行测试
  fullyParallel: true,
  /* Fail the build on CI if you accidentally left test.only in the source code. */
  forbidOnly: !!process.env.CI,
  // 测试失败用例重试次数
  retries: process.env.CI ? 2 : 0,
  // 测试时使用的进程数,进程数越多可以同时执行的测试任务就越多。不设置则尽可能多地开启进程。
  workers: process.env.CI ? 1 : undefined,
  // 指定测试结果如何输出
  reporter: 'html',

  // 测试 project 的公共配置,会与与下面 projects 字段中的每个对象的 use 对象合并。
  use: {
    // 测试时各种请求的基础路径
    baseURL: 'http://127.0.0.1:3000',

    // 生成测试追踪信息的规则,on-first-retry 意为第一次重试时生成。
    trace: 'on-first-retry',
  },

  // 定义每个 project,示例中将不同的浏览器测试区分成了不同的项目
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
});

以下是一个简单的 Playwright 测试示例:

代码语言:javascript复制
import { test, expect } from '@playwright/test';

test('has title', async ({ page }) => {
  await page.goto('https://playwright.dev/');

  // Expect a title "to contain" a substring.
  await expect(page).toHaveTitle(/Playwright/);
});

test('get started link', async ({ page }) => {
  await page.goto('https://playwright.dev/');

  // Click the get started link.
  await page.getByRole('link', { name: 'Get started' }).click();

  // Expects the URL to contain intro.
  await expect(page).toHaveURL(/.*intro/);
});

// test('测试环境', async ({ page }) => {
//   // 与 playwright.config.ts 一样,测试脚本中也可以访问环境变量
//   await page.goto('/');
//   const url = await page.url();

//   if(process.env.TEST_MODE === 'production') {
//     await expect(url).toBe('https://github.com/')
//   } else {
//     await expect(url).toBe('https://gitee.com/')
//   }
// });

输出测试结果

代码语言:ts复制
// playwright.config.ts
export default defineConfig({
  // 指定测试产物(追踪信息、视频、截图)输出路径
  outputDir: 'test-results',
  //...
// reporter: 'html',
  reporter: [
    // 在命令行中同步打印每条用例的执行结果
    ['list'],
    // 输出 html 格式的报告,并将报告归档与指定路径
    ['html', {
      outputFolder: 'playwright-report',
    }],
  ],
  // ...
  use: {
    //...
    // 非 CI 环境下,第一次失败重试时生成追踪信息。非 CI 环境下,总是生成追踪信息
    trace: process.env.CI ? 'on-first-retry' : 'on',
    // 非 CI 环境下,第一次失败重试时生成视频。非 CI 环境下,总是生成视频
    video: process.env.CI ? 'on-first-retry' : 'on',
  },
});

区分环境

借鉴 Vite 的 环境变量 的解决方案,在测试工程目录中建立多个环境变量文件:

代码语言:json复制
e2e-auto
 ┣ ...
 ┣  .env             # 所有情况下都会加载
 ┣  .env.dev         # 本地开发环境下加载
 ┣  .env.test        # 测试环境下加载
 ┣  .env.test.local  # 测试环境下加载,但是只在本地有效不会入仓,可以用于存放一些不该入仓的敏感配置。其他环境也可以有 .local 配置文件。
 ┗  .env.production  # 生产环境下加载
引入 cross-env 来设置环境变量

pnpm i -D cross-env

代码语言:json复制
{// ...
  "scripts": {
    "test:development": "cross-env TEST_MODE=development playwright test",
    "test:test": "cross-env TEST_MODE=test playwright test",
    "test:production": "cross-env TEST_MODE=production playwright test"
  }
}
要使得测试执行时,环境变量能够切实地被读取

pnpm i -D dotenv

代码语言:ts复制
  // playwright.config.ts
  import dotenv from 'dotenv';

  // TEST_MODE 的值决定了加载哪个环境的文件
  const modeExt = process.env.TEST_MODE || 'development';

  // 先加载入仓的配置文件,再加载本地的配置文件
  dotenv.config({ path: '.env' });
  dotenv.config({ path: `.env.${modeExt}`, override: true });
  dotenv.config({ path: '.env.local', override: true });
  dotenv.config({ path: `.env.${modeExt}.local`, override: true });

  export default defineConfig({ 
    // ... 
  });

我们可以验证一下效果,将 test 环境中的 url 设置为码云,将 production 环境中的 url 设置为 Github:

代码语言:bash复制
# .env.test
WEBSITE_URL = https://gitee.com/
# .env.production
WEBSITE_URL = https://github.com/

分别运行 pnpm run test:test --ui 和 pnpm run test:production --ui

我们使用 Playwright 来打开 Chromium 浏览器,访问一个示例网站,并检查页面标题是否正确。下面我们来说说一些使用Playwright的常见操作。

在 Playwright 中可以使用以下方法来处理页面的滚动

一、滚动到页面底部

可以使用 page.evaluate 方法结合 JavaScript 来滚动到页面底部。

代码语言:javascript复制
await page.evaluate(() => {
  window.scrollTo(0, document.body.scrollHeight);
});

二、滚动到特定元素

  1. 定位到特定元素:
代码语言:javascript复制
   const element = await page.$('#your-element-id');
  1. 滚动到该元素:
代码语言:javascript复制
   await element.scrollIntoViewIfNeeded();

三、模拟滚动行为

可以使用 page.mouse 来模拟鼠标滚轮滚动。

代码语言:javascript复制
await page.mouse.wheel(0, 100); // 垂直方向向下滚动 100 像素

四、在特定的框架内滚动

如果页面中有 iframe,可以先切换到该 iframe,然后进行滚动操作。

代码语言:javascript复制
const frame = await page.frameLocator('iframe[src="your-frame-url"]');
await frame.evaluate(() => {
  window.scrollTo(0, document.body.scrollHeight);
});

在 Playwright 中处理异步流程

一、使用**async/await**

Playwright 的 API 通常返回 Promise 对象,所以可以很自然地使用async/await来处理异步操作。例如:

代码语言:javascript复制
const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com');
  const element = await page.$('some-selector');
  if (element) {
    // 对元素进行操作
  }
  await browser.close();
})();

二、等待特定条件

  1. 使用内置的等待方法:
  2. Playwright 提供了一些等待方法,如page.waitForSelector等待特定元素出现,page.waitForNavigation等待页面导航完成等。
代码语言:javascript复制
   await page.waitForSelector('some-selector');
  1. 自定义等待条件:
  2. 可以使用page.waitForFunction来执行自定义的 JavaScript 函数,直到该函数返回true
代码语言:javascript复制
   await page.waitForFunction(() => {
     return document.querySelector('some-selector') && document.querySelector('some-selector').innerText === 'expected text';
   });

三、处理多个异步操作的顺序

  1. 依次执行异步操作:
  2. 如果需要按照特定顺序执行多个异步操作,可以在一个async函数中依次使用await
代码语言:javascript复制
   await operation1();
   await operation2();
   await operation3();
  1. 并行执行异步操作:
  2. 如果多个操作之间没有依赖关系,可以使用Promise.all并行执行它们,以提高效率。
代码语言:ts复制
   const [result1, result2] = await Promise.all([operation1(), operation2()]);

四、错误处理

  1. 捕获异步操作的错误:
  2. 使用try/catch块来捕获异步操作中的错误。
代码语言:javascript复制
   try {
     await someAsyncOperation();
   } catch (error) {
     console.error('Error occurred:', error);
   }
  1. 确保资源正确释放:
  2. 在异步操作中,如果出现错误,仍要确保正确释放资源,如关闭浏览器等。可以在finally块中执行资源释放操作。
代码语言:javascript复制
   let browser;
   try {
     browser = await chromium.launch();
     // 其他操作
   } catch (error) {
     console.error('Error occurred:', error);
   } finally {
     if (browser) {
       await browser.close();
     }
   }

其他CI 通过上传代码github自动跑测试脚本

添加.github/workflows

代码语言:yml复制
playwright.yml
name: Playwright Tests
on:
  push:
    branches: [ main, master ]
  pull_request:
    branches: [ main, master ]
jobs:
  playwright:
    name: 'Playwright Tests'
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright:v1.36.0-jammy
    timeout-minutes: 60
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-node@v3
      with:
        node-version: 18
    - name: Install dependencies
      run: npm i
    - name: Run Playwright tests
      run: npm run e2e:ci
    # - name: Install Playwright Browsers
    #   run: npx playwright install --with-deps
    # - name: Run Playwright tests
    #   run: npx playwright test
    - uses: actions/upload-artifact@v3
      if: always()
      with:
        name: playwright-report
        path: playwright-report/
        retention-days: 30

配置json e2e:ci:result

代码语言:json复制
// ...
"scripts": {
    "test:development": "cross-env TEST_MODE=development playwright test",
    "test:test": "cross-env TEST_MODE=test playwright test",
    "test:production": "cross-env TEST_MODE=production playwright test",
    "e2e": "playwright test",
    "e2e:ci": "cross-env CI=1 CI_WORKERS=1 yarn e2e:ci:run",
    "e2e:ci:run": "playwright test",
    "e2e:ci:result": "ts-node ./ci/ci-result.ts",
    "e2e:report": "playwright show-report"
  },
// ...

ci-result.ts

代码语言:ts复制
/* eslint-disable no-console */
import fs from 'fs';
import os from 'os';
import path from 'path';
import {minimatch} from 'minimatch';
import { fileURLToPath } from 'url'

const __filenameNew = fileURLToPath(import.meta.url)

const __dirnameNew = path.dirname(__filenameNew)
// import axios from 'axios';
import type { JSONReport, JSONReportSuite } from '@playwright/test/reporter';
// @ts-ignore
import { getMiniReportTitle, getModuleMatches, getModuleSource } from './ci-module-map.ts';

const reportDir = path.resolve(__dirnameNew, '../playwright-report');
const resultJsonFile = path.resolve(reportDir, 'results.json');
const miniTestExportJsonFile = path.resolve(reportDir, 'mini_test_report.json');
const resultExportFile = path.resolve(reportDir, 'results-export.sh');
// const postHost = '';

// const ignorePostPassRate = !!process.env.E2E_IGNORE_POST_PASS_RATE;

const readResultJson = (): JSONReport | undefined => {
  if (!fs.existsSync(resultJsonFile)) {
    return;
  }

  return JSON.parse(fs.readFileSync(resultJsonFile, 'utf8'));
};

const summaryPassRateShell = (resultJson: JSONReport, extraShellText: string) => {
  if (os.platform() === 'win32') {
    return;
  }

  let passed = 0;
  let failed = 0;

  const walkSuites = (suites?: JSONReportSuite[]) => {
    suites?.forEach(suite => {
      suite.specs.forEach(spec => {
        // 用例执行结果
        const { ok } = spec;

        if (ok) {
          passed  = 1;
        } else {
          failed  = 1;
        }
      });

      walkSuites(suite.suites);
    });
  };

  walkSuites(resultJson.suites);

  const total = passed   failed;
  const passedPercent = total ? `${((passed * 100) / total).toFixed(1)}%` : 'NaN%';
  const passedRate = total ? (passed / total).toFixed(3) : 0;

  console.log(`passed ${passed}, failed ${failed}, passed percent ${passedPercent}`);

  fs.writeFileSync(
    resultExportFile,
    `
#!/bin/sh
export E2E_TEST_RESULT_PASSED=${passed}
export E2E_TEST_RESULT_FAILED=${failed}
export E2E_TEST_RESULT_PASSED_PERCENT=${passedPercent}
export E2E_TEST_RESULT_PASSED_RATE=${passedRate}
${extraShellText}
`,
  );
};

// 兼容 mini-test-report html
type MiniTestResult = {
  caseId: string;
  caseName: string;
  casePath: string;
  caseLocation?: string;
  fullName: string;
  replayResult: 'failed' | 'passed';
  replayResults: [];
  duration?: number;
};

type ModuleMatch = ReturnType<typeof getModuleMatches>[0];

type ModuleResult = ModuleMatch & {
  passed: number;
  failed: number;
  testResults: MiniTestResult[];
};

const findMatchModule = (suite: JSONReportSuite, moduleResults: ModuleResult[]) => {
  for (const module of moduleResults) {
    const { patterns } = module;

    // 最后一个
    if (!patterns) {
      return module;
    }

    // 正常匹配
    const matched = patterns.some(pattern => minimatch(suite.file, pattern));

    if (matched) {
      return module;
    }
  }

  return null;
};

const reportModulePassRate = async (resultJson: JSONReport, moduleResults: ModuleResult[]) => {
  // 统计 passed failed

  const walkSuites = (suites?: JSONReportSuite[]) => {
    suites?.forEach(suite => {
      const module = findMatchModule(suite, moduleResults);

      if (!module) {
        return;
      }

      suite.specs.forEach(spec => {
        // 用例执行结果
        const { id, title, ok, file, line, column, tests } = spec;

        if (ok) {
          module.passed  = 1;
        } else {
          module.failed  = 1;
        }

        const results = tests[tests.length - 1]?.results || [];

        const testResult: MiniTestResult = {
          caseId: id,
          caseName: title,
          casePath: file,
          caseLocation: `${file}:${line}:${column}`,
          fullName: title,
          replayResult: ok ? 'passed' : 'failed',
          replayResults: [],
          duration: results[results.length - 1]?.duration,
        };

        module.testResults.push(testResult);
      });

      walkSuites(suite.suites);
    });
  };

  walkSuites(resultJson.suites);

  // 生成结果reporter json
  fs.writeFileSync(
    miniTestExportJsonFile,
    JSON.stringify({ type: 'playwright', title: getMiniReportTitle(), moduleResults }, undefined, 2),
  );

  // 上报 modulePassMap
  console.log('moduleResults', moduleResults);
  const postData = moduleResults
    .map(module => {
      const { failed, passed, teamTag, expectTotal } = module;
      const total = passed   failed;
      const passedRate = total ? passed / total : 0;

      return {
        tag: teamTag,
        module: module.name,
        caseCount: total,
        autoTestCaseCount: total,
        autoTestCasePassCount: passed,
        passRate: passedRate,
        source: getModuleSource(),
        expectTotal: Math.max(expectTotal || 0, total),
      };
    })
    .filter(res => res.caseCount > 0);

  console.log('Post data', postData);

  // if (ignorePostPassRate) {
  //   return;
  // }

  // return axios
  //   .post(postHost, {
  //     business: '',
  //     type: '',
  //     data: postData,
  //   })
  //   .then(
  //     _res => console.log('Post grafana dashboard passed rate: OK'),
  //     e => {
  //       console.log('Post grafana dashboard passed rate: FAILED', e);
  //       throw e;
  //     },
  //   );
};

const run = async () => {
  const resultJson = readResultJson();

  if (!resultJson) {
    return;
  }

  const moduleResults: ModuleResult[] = getModuleMatches().map(m => ({
    ...m,
    passed: 0,
    failed: 0,
    testResults: [],
  }));

  let extraShellText = 'export E2E_TEST_RESULT_POST_PASS_RATE=OKn';

  try {
    await reportModulePassRate(resultJson, moduleResults);
  } catch (e) {
    extraShellText = 'export E2E_TEST_RESULT_POST_PASS_RATE=FAILn';
  }

  summaryPassRateShell(resultJson, extraShellText);
};

run();

测试CI Demo代码地址

最后Playwright还有很多强大的功能,以上是简单介绍,感兴趣的小伙伴可以学习使用,当作横向知识储备。测试CI Demo代码地址自动化测试结果,还可以保存录屏回放。同时VSCode也有相应Playwright的插件Playwright Test for VSCode

0 人点赞