775 lines
31 KiB
Markdown
775 lines
31 KiB
Markdown
# 第三方集成
|
||
|
||
> **何时使用**:测试应用程序与外部服务(OAuth 提供商、支付网关、分析工具、聊天组件、地图、社交登录和 CAPTCHA)的交互。核心原则:模拟第三方边界,而非你自己的应用程序代码。
|
||
> **前置要求**:[core/network-mocking.md](network-mocking.md)、[core/when-to-mock.md](when-to-mock.md)、[core/authentication.md](authentication.md)
|
||
|
||
## 快速参考
|
||
|
||
```typescript
|
||
// 模拟 OAuth 回调以绕过真实的提供商
|
||
await page.route("**/auth/callback*", (route) => {
|
||
route.fulfill({
|
||
status: 302,
|
||
headers: { Location: "/dashboard?token=mock-jwt-token" },
|
||
})
|
||
})
|
||
|
||
// 阻止分析脚本加载
|
||
await page.route("**/*.google-analytics.com/**", (route) => route.abort())
|
||
await page.route("**/segment.io/**", (route) => route.abort())
|
||
|
||
// 模拟第三方组件端点
|
||
await page.route("**/api.stripe.com/**", (route) => {
|
||
route.fulfill({ status: 200, contentType: "application/json", body: '{"id":"pi_mock"}' })
|
||
})
|
||
```
|
||
|
||
**核心原则**:在 E2E 测试中,模拟外部服务,而非你自己的应用程序。你的代码应原样运行;只有第三方响应是被伪造的。
|
||
|
||
## 模式
|
||
|
||
### 模拟 OAuth 提供商(Google、GitHub 等)
|
||
|
||
**何时使用**:测试完整的登录流程,而不依赖真实 OAuth 提供商的可用性、速率限制或测试账号。
|
||
**避免使用**:当你需要验证实际的 OAuth 集成端到端工作时(请使用专门的集成测试)。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
test("Google OAuth 登录 —— 通过路由模拟", async ({ page }) => {
|
||
// 拦截重定向到 Google 并模拟回调
|
||
await page.route("**/accounts.google.com/**", async (route) => {
|
||
// 从请求 URL 中提取 redirect_uri
|
||
const url = new URL(route.request().url())
|
||
const redirectUri = url.searchParams.get("redirect_uri") || "/auth/callback"
|
||
|
||
// 模拟 Google 重定向回来并附带授权码
|
||
await route.fulfill({
|
||
status: 302,
|
||
headers: {
|
||
Location: `${redirectUri}?code=mock-auth-code&state=${url.searchParams.get("state") || ""}`,
|
||
},
|
||
})
|
||
})
|
||
|
||
// 模拟你自己后端的令牌交换端点
|
||
await page.route("**/api/auth/google/callback*", async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
token: "mock-jwt-token",
|
||
user: {
|
||
id: "1",
|
||
name: "Test User",
|
||
email: "testuser@gmail.com",
|
||
avatar: "https://placehold.co/100x100",
|
||
},
|
||
}),
|
||
})
|
||
})
|
||
|
||
await page.goto("/login")
|
||
await page.getByRole("button", { name: "Sign in with Google" }).click()
|
||
|
||
// 应进入仪表盘页面并显示用户信息
|
||
await page.waitForURL("/dashboard")
|
||
await expect(page.getByText("Test User")).toBeVisible()
|
||
})
|
||
|
||
test("GitHub OAuth 登录 —— 模拟提供商", async ({ page }) => {
|
||
await page.route("**/github.com/login/oauth/**", async (route) => {
|
||
const url = new URL(route.request().url())
|
||
const redirectUri = url.searchParams.get("redirect_uri") || "/auth/callback"
|
||
await route.fulfill({
|
||
status: 302,
|
||
headers: {
|
||
Location: `${redirectUri}?code=mock-github-code`,
|
||
},
|
||
})
|
||
})
|
||
|
||
await page.route("**/api/auth/github/callback*", async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
token: "mock-jwt-token",
|
||
user: { id: "2", name: "GitHub User", email: "user@github.com" },
|
||
}),
|
||
})
|
||
})
|
||
|
||
await page.goto("/login")
|
||
await page.getByRole("button", { name: "Sign in with GitHub" }).click()
|
||
await page.waitForURL("/dashboard")
|
||
await expect(page.getByText("GitHub User")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test("Google OAuth 登录 —— 通过路由模拟", async ({ page }) => {
|
||
await page.route("**/accounts.google.com/**", async (route) => {
|
||
const url = new URL(route.request().url())
|
||
const redirectUri = url.searchParams.get("redirect_uri") || "/auth/callback"
|
||
await route.fulfill({
|
||
status: 302,
|
||
headers: {
|
||
Location: `${redirectUri}?code=mock-auth-code&state=${url.searchParams.get("state") || ""}`,
|
||
},
|
||
})
|
||
})
|
||
|
||
await page.route("**/api/auth/google/callback*", async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
token: "mock-jwt-token",
|
||
user: { id: "1", name: "Test User", email: "testuser@gmail.com" },
|
||
}),
|
||
})
|
||
})
|
||
|
||
await page.goto("/login")
|
||
await page.getByRole("button", { name: "Sign in with Google" }).click()
|
||
await page.waitForURL("/dashboard")
|
||
await expect(page.getByText("Test User")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
### 支付网关测试(Stripe Elements)
|
||
|
||
**何时使用**:测试使用 Stripe Elements、PayPal 按钮或类似嵌入式支付 UI 的结账流程。
|
||
**避免使用**:当 Stripe 提供带有测试卡号的测试模式,且你想要完整的集成覆盖时。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
test("使用测试卡完成 Stripe 结账", async ({ page }) => {
|
||
await page.goto("/checkout")
|
||
|
||
// Stripe Elements 在 iframe 中加载
|
||
const stripeFrame = page.frameLocator('iframe[name*="__privateStripeFrame"]').first()
|
||
|
||
// 在 Stripe iframe 中填写卡号详情
|
||
await stripeFrame.getByPlaceholder("Card number").fill("4242424242424242")
|
||
await stripeFrame.getByPlaceholder("MM / YY").fill("12/28")
|
||
await stripeFrame.getByPlaceholder("CVC").fill("123")
|
||
await stripeFrame.getByPlaceholder("ZIP").fill("10001")
|
||
|
||
await page.getByRole("button", { name: "Pay" }).click()
|
||
await expect(page.getByText("Payment successful")).toBeVisible({ timeout: 15000 })
|
||
})
|
||
|
||
test("Stripe 结账 —— 模拟 API 以提高速度", async ({ page }) => {
|
||
// 模拟 Stripe API 调用以获得更快、更可靠的测试
|
||
await page.route("**/api.stripe.com/v1/payment_intents*", async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
id: "pi_mock_123",
|
||
status: "succeeded",
|
||
client_secret: "pi_mock_123_secret_456",
|
||
}),
|
||
})
|
||
})
|
||
|
||
await page.route("**/api.stripe.com/v1/payment_methods*", async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
id: "pm_mock_789",
|
||
type: "card",
|
||
card: { brand: "visa", last4: "4242" },
|
||
}),
|
||
})
|
||
})
|
||
|
||
await page.goto("/checkout")
|
||
// 使用模拟的 Stripe,你的应用级支付表单可能无需 iframe 即可工作
|
||
await page.getByRole("button", { name: "Pay" }).click()
|
||
await expect(page.getByText("Payment successful")).toBeVisible()
|
||
})
|
||
|
||
test("优雅处理卡片被拒的情况", async ({ page }) => {
|
||
await page.goto("/checkout")
|
||
|
||
const stripeFrame = page.frameLocator('iframe[name*="__privateStripeFrame"]').first()
|
||
// Stripe 测试卡 —— 始终被拒
|
||
await stripeFrame.getByPlaceholder("Card number").fill("4000000000000002")
|
||
await stripeFrame.getByPlaceholder("MM / YY").fill("12/28")
|
||
await stripeFrame.getByPlaceholder("CVC").fill("123")
|
||
await stripeFrame.getByPlaceholder("ZIP").fill("10001")
|
||
|
||
await page.getByRole("button", { name: "Pay" }).click()
|
||
await expect(page.getByText(/declined|failed/i)).toBeVisible({ timeout: 15000 })
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test("使用测试卡完成 Stripe 结账", async ({ page }) => {
|
||
await page.goto("/checkout")
|
||
|
||
const stripeFrame = page.frameLocator('iframe[name*="__privateStripeFrame"]').first()
|
||
await stripeFrame.getByPlaceholder("Card number").fill("4242424242424242")
|
||
await stripeFrame.getByPlaceholder("MM / YY").fill("12/28")
|
||
await stripeFrame.getByPlaceholder("CVC").fill("123")
|
||
await stripeFrame.getByPlaceholder("ZIP").fill("10001")
|
||
|
||
await page.getByRole("button", { name: "Pay" }).click()
|
||
await expect(page.getByText("Payment successful")).toBeVisible({ timeout: 15000 })
|
||
})
|
||
```
|
||
|
||
### 分析工具拦截
|
||
|
||
**何时使用**:阻止分析脚本在测试期间执行,以提高速度、避免污染真实分析数据,并消除第三方脚本故障带来的不稳定性。
|
||
**避免使用**:当你专门需要测试分析事件是否正确触发时。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
// 在 fixture 中为所有测试拦截分析工具
|
||
import { test as base } from "@playwright/test"
|
||
|
||
export const test = base.extend({
|
||
page: async ({ page }, use) => {
|
||
// 拦截常见的分析和追踪脚本
|
||
await page.route(
|
||
/(google-analytics|googletagmanager|segment\.io|hotjar|mixpanel|amplitude)/,
|
||
(route) => route.abort()
|
||
)
|
||
await page.route("**/collect?**", (route) => route.abort()) // GA 信标
|
||
await page.route("**/analytics/**", (route) => route.abort())
|
||
await use(page)
|
||
},
|
||
})
|
||
|
||
export { expect }
|
||
```
|
||
|
||
```typescript
|
||
// 用于验证分析事件确实被发送的测试
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
test("结账完成时触发购买事件", async ({ page }) => {
|
||
const analyticsRequests: { url: string; body: string }[] = []
|
||
|
||
// 拦截分析调用而非阻止
|
||
await page.route("**/collect**", async (route) => {
|
||
analyticsRequests.push({
|
||
url: route.request().url(),
|
||
body: route.request().postData() || "",
|
||
})
|
||
await route.fulfill({ status: 200 }) // 响应但不发送到真实分析平台
|
||
})
|
||
|
||
await page.goto("/checkout")
|
||
await page.getByRole("button", { name: "Complete order" }).click()
|
||
await page.waitForURL("/order/confirmation")
|
||
|
||
// 验证购买事件已被发送
|
||
const purchaseEvent = analyticsRequests.find(
|
||
(r) => r.body.includes("purchase") || r.url.includes("purchase")
|
||
)
|
||
expect(purchaseEvent).toBeDefined()
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
const { test: base, expect } = require("@playwright/test")
|
||
|
||
const test = base.extend({
|
||
page: async ({ page }, use) => {
|
||
await page.route(/(google-analytics|googletagmanager|segment\.io|hotjar|mixpanel)/, (route) =>
|
||
route.abort()
|
||
)
|
||
await use(page)
|
||
},
|
||
})
|
||
|
||
module.exports = { test, expect }
|
||
```
|
||
|
||
### 聊天组件测试(Intercom、Drift 等)
|
||
|
||
**何时使用**:你的应用嵌入了第三方聊天组件,需要测试交互或验证它不会破坏你的 UI。
|
||
**避免使用**:当聊天组件仅是装饰性的,不属于关键用户流程时。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
test("Intercom 聊天组件打开并接受消息", async ({ page }) => {
|
||
await page.goto("/")
|
||
|
||
// 等待聊天组件加载(通常有延迟)
|
||
const chatLauncher = page
|
||
.frameLocator('iframe[name*="intercom"]')
|
||
.getByRole("button", { name: /chat|message/i })
|
||
await expect(chatLauncher).toBeVisible({ timeout: 15000 })
|
||
|
||
// 打开聊天
|
||
await chatLauncher.click()
|
||
|
||
// 聊天对话的 iframe 加载
|
||
const chatFrame = page.frameLocator('iframe[name*="intercom-messenger"]')
|
||
await expect(chatFrame.getByText(/How can we help/i)).toBeVisible()
|
||
|
||
// 输入消息
|
||
await chatFrame.getByRole("textbox").fill("I need help with my order")
|
||
await chatFrame.getByRole("button", { name: /send/i }).click()
|
||
|
||
await expect(chatFrame.getByText("I need help with my order")).toBeVisible()
|
||
})
|
||
|
||
test("聊天组件不遮挡关键 UI", async ({ page }) => {
|
||
await page.goto("/checkout")
|
||
|
||
// 获取聊天组件位置
|
||
const chatWidget = page.locator('iframe[name*="intercom"]').first()
|
||
if (await chatWidget.isVisible()) {
|
||
const chatBox = await chatWidget.boundingBox()
|
||
const payButton = await page.getByRole("button", { name: "Pay" }).boundingBox()
|
||
|
||
// 确保支付按钮没有被聊天组件遮挡
|
||
if (chatBox && payButton) {
|
||
const overlaps =
|
||
payButton.x < chatBox.x + chatBox.width &&
|
||
payButton.x + payButton.width > chatBox.x &&
|
||
payButton.y < chatBox.y + chatBox.height &&
|
||
payButton.y + payButton.height > chatBox.y
|
||
expect(overlaps).toBe(false)
|
||
}
|
||
}
|
||
})
|
||
|
||
// 在大多数测试中拦截聊天组件以提高速度
|
||
test("在非聊天测试中拦截聊天组件", async ({ page }) => {
|
||
await page.route("**/widget.intercom.io/**", (route) => route.abort())
|
||
await page.route("**/js.intercomcdn.com/**", (route) => route.abort())
|
||
|
||
await page.goto("/dashboard")
|
||
// 聊天组件不会加载 —— 测试运行更快
|
||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test("拦截聊天组件以提高速度", async ({ page }) => {
|
||
await page.route("**/widget.intercom.io/**", (route) => route.abort())
|
||
await page.route("**/js.intercomcdn.com/**", (route) => route.abort())
|
||
|
||
await page.goto("/dashboard")
|
||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
|
||
})
|
||
```
|
||
|
||
### 地图集成测试(Google Maps)
|
||
|
||
**何时使用**:你的应用嵌入了 Google Maps 或类似地图提供商,需要验证与地图相关的交互。
|
||
**避免使用**:当地图是装饰性的,不属于用户工作流程的一部分时。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
test("门店定位器在地图上显示标记", async ({ page }) => {
|
||
await page.goto("/store-locator")
|
||
|
||
// Google Maps 在 iframe 中或直接加载在页面上
|
||
await page.getByLabel("Search location").fill("New York")
|
||
await page.getByRole("button", { name: "Search" }).click()
|
||
|
||
// 等待地图标记出现
|
||
await expect(page.locator('[data-testid="map-marker"]')).toHaveCount(5, {
|
||
timeout: 10000,
|
||
})
|
||
|
||
// 点击标记查看门店详情
|
||
await page.locator('[data-testid="map-marker"]').first().click()
|
||
await expect(page.getByTestId("store-info")).toBeVisible()
|
||
await expect(page.getByTestId("store-info")).toContainText("New York")
|
||
})
|
||
|
||
test("模拟 Google Maps API 进行离线测试", async ({ page }) => {
|
||
// 拦截真实的 Google Maps 并提供模拟响应
|
||
await page.route("**/maps.googleapis.com/**", async (route) => {
|
||
const url = route.request().url()
|
||
|
||
if (url.includes("/geocode/")) {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
results: [
|
||
{
|
||
geometry: { location: { lat: 40.7128, lng: -74.006 } },
|
||
formatted_address: "New York, NY, USA",
|
||
},
|
||
],
|
||
status: "OK",
|
||
}),
|
||
})
|
||
} else if (url.includes("/places/")) {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
results: [
|
||
{ name: "Store A", geometry: { location: { lat: 40.71, lng: -74.0 } } },
|
||
{ name: "Store B", geometry: { location: { lat: 40.72, lng: -74.01 } } },
|
||
],
|
||
status: "OK",
|
||
}),
|
||
})
|
||
} else {
|
||
await route.continue()
|
||
}
|
||
})
|
||
|
||
await page.goto("/store-locator")
|
||
await page.getByLabel("Search location").fill("New York")
|
||
await page.getByRole("button", { name: "Search" }).click()
|
||
|
||
await expect(page.getByText("Store A")).toBeVisible()
|
||
await expect(page.getByText("Store B")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test("模拟 Google Maps 地理编码 API", async ({ page }) => {
|
||
await page.route("**/maps.googleapis.com/maps/api/geocode/**", async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
results: [
|
||
{
|
||
geometry: { location: { lat: 40.7128, lng: -74.006 } },
|
||
formatted_address: "New York, NY, USA",
|
||
},
|
||
],
|
||
status: "OK",
|
||
}),
|
||
})
|
||
})
|
||
|
||
await page.goto("/store-locator")
|
||
await page.getByLabel("Search location").fill("New York")
|
||
await page.getByRole("button", { name: "Search" }).click()
|
||
await expect(page.getByText("New York")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
### 测试中绕过 reCAPTCHA
|
||
|
||
**何时使用**:你的应用使用 reCAPTCHA 或 hCaptcha,需要测试在不解决验证挑战的情况下继续进行。
|
||
**避免使用**:当你可以通过服务端标志在测试环境中禁用 CAPTCHA 时(这是推荐做法)。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
test.describe("reCAPTCHA 处理", () => {
|
||
// 最佳做法:使用 Google 提供的测试密钥
|
||
// Site key: 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI(始终通过)
|
||
// Secret key: 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe
|
||
|
||
test("通过测试密钥绕过 reCAPTCHA 提交表单", async ({ page }) => {
|
||
// 你的测试环境应配置为使用 Google 的测试密钥
|
||
await page.goto("/contact")
|
||
|
||
await page.getByLabel("Name").fill("Test User")
|
||
await page.getByLabel("Email").fill("test@example.com")
|
||
await page.getByLabel("Message").fill("Test message")
|
||
|
||
// 使用测试密钥,reCAPTCHA 自动通过 —— 只需点击提交
|
||
await page.getByRole("button", { name: "Send" }).click()
|
||
await expect(page.getByText("Message sent")).toBeVisible()
|
||
})
|
||
|
||
test("模拟 reCAPTCHA 验证端点", async ({ page }) => {
|
||
// 模拟 Google 的 reCAPTCHA 验证 API
|
||
await page.route("**/recaptcha/api/siteverify**", async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ success: true, score: 0.9 }),
|
||
})
|
||
})
|
||
|
||
// 模拟 reCAPTCHA 脚本以提供虚假令牌
|
||
await page.addInitScript(() => {
|
||
;(window as any).grecaptcha = {
|
||
ready: (cb: () => void) => cb(),
|
||
execute: () => Promise.resolve("mock-recaptcha-token"),
|
||
render: () => "mock-widget-id",
|
||
getResponse: () => "mock-recaptcha-token",
|
||
reset: () => {},
|
||
}
|
||
})
|
||
|
||
await page.goto("/contact")
|
||
await page.getByLabel("Name").fill("Test User")
|
||
await page.getByLabel("Email").fill("test@example.com")
|
||
await page.getByLabel("Message").fill("Test message")
|
||
await page.getByRole("button", { name: "Send" }).click()
|
||
|
||
await expect(page.getByText("Message sent")).toBeVisible()
|
||
})
|
||
|
||
test("不可见 reCAPTCHA v3 绕过", async ({ page }) => {
|
||
// 对于 reCAPTCHA v3,模拟企业 API
|
||
await page.route("**/recaptcha/**", async (route) => {
|
||
if (route.request().url().includes("api.js")) {
|
||
// 提供一个模拟脚本来提供 grecaptcha 对象
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "application/javascript",
|
||
body: `
|
||
window.grecaptcha = {
|
||
ready: function(cb) { cb(); },
|
||
execute: function() { return Promise.resolve('mock-token'); },
|
||
enterprise: {
|
||
ready: function(cb) { cb(); },
|
||
execute: function() { return Promise.resolve('mock-token'); },
|
||
}
|
||
};
|
||
`,
|
||
})
|
||
} else {
|
||
await route.abort()
|
||
}
|
||
})
|
||
|
||
await page.goto("/login")
|
||
await page.getByLabel("Email").fill("user@example.com")
|
||
await page.getByLabel("Password").fill("password123")
|
||
await page.getByRole("button", { name: "Sign in" }).click()
|
||
await page.waitForURL("/dashboard")
|
||
})
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test("模拟 reCAPTCHA 以便提交表单", async ({ page }) => {
|
||
await page.route("**/recaptcha/api/siteverify**", async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ success: true, score: 0.9 }),
|
||
})
|
||
})
|
||
|
||
await page.addInitScript(() => {
|
||
window.grecaptcha = {
|
||
ready: (cb) => cb(),
|
||
execute: () => Promise.resolve("mock-recaptcha-token"),
|
||
render: () => "mock-widget-id",
|
||
getResponse: () => "mock-recaptcha-token",
|
||
reset: () => {},
|
||
}
|
||
})
|
||
|
||
await page.goto("/contact")
|
||
await page.getByLabel("Name").fill("Test User")
|
||
await page.getByLabel("Email").fill("test@example.com")
|
||
await page.getByLabel("Message").fill("Test message")
|
||
await page.getByRole("button", { name: "Send" }).click()
|
||
await expect(page.getByText("Message sent")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
### 社交登录模拟
|
||
|
||
**何时使用**:你的应用提供多种社交登录选项,需要测试登录流程而无需使用真实的提供商账号。
|
||
**避免使用**:当你拥有后端绕过方式可以直接设置认证状态时。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
import { test as base, expect } from "@playwright/test"
|
||
|
||
// 可复用的 fixture,用于模拟任何 OAuth 提供商
|
||
type OAuthFixtures = {
|
||
mockOAuth: (provider: string, userData: Record<string, string>) => Promise<void>
|
||
}
|
||
|
||
export const test = base.extend<OAuthFixtures>({
|
||
mockOAuth: async ({ page }, use) => {
|
||
const mock = async (provider: string, userData: Record<string, string>) => {
|
||
const providerPatterns: Record<string, string> = {
|
||
google: "**/accounts.google.com/**",
|
||
github: "**/github.com/login/oauth/**",
|
||
facebook: "**/facebook.com/v*/dialog/oauth**",
|
||
apple: "**/appleid.apple.com/**",
|
||
microsoft: "**/login.microsoftonline.com/**",
|
||
}
|
||
|
||
const pattern = providerPatterns[provider]
|
||
if (!pattern) throw new Error(`Unknown provider: ${provider}`)
|
||
|
||
// 拦截 OAuth 重定向
|
||
await page.route(pattern, async (route) => {
|
||
const url = new URL(route.request().url())
|
||
const redirectUri = url.searchParams.get("redirect_uri") || "/auth/callback"
|
||
await route.fulfill({
|
||
status: 302,
|
||
headers: { Location: `${redirectUri}?code=mock-${provider}-code` },
|
||
})
|
||
})
|
||
|
||
// 模拟令牌交换
|
||
await page.route(`**/api/auth/${provider}/callback*`, async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ token: `mock-${provider}-jwt`, user: userData }),
|
||
})
|
||
})
|
||
}
|
||
|
||
await use(mock)
|
||
},
|
||
})
|
||
|
||
// 在测试中使用
|
||
test("使用多个社交提供商登录", async ({ page, mockOAuth }) => {
|
||
// 测试 Google 登录
|
||
await mockOAuth("google", { name: "Google User", email: "user@gmail.com" })
|
||
await page.goto("/login")
|
||
await page.getByRole("button", { name: "Sign in with Google" }).click()
|
||
await page.waitForURL("/dashboard")
|
||
await expect(page.getByText("Google User")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
const { test: base, expect } = require("@playwright/test")
|
||
|
||
const test = base.extend({
|
||
mockOAuth: async ({ page }, use) => {
|
||
const mock = async (provider, userData) => {
|
||
const patterns = {
|
||
google: "**/accounts.google.com/**",
|
||
github: "**/github.com/login/oauth/**",
|
||
facebook: "**/facebook.com/v*/dialog/oauth**",
|
||
}
|
||
|
||
await page.route(patterns[provider], async (route) => {
|
||
const url = new URL(route.request().url())
|
||
const redirectUri = url.searchParams.get("redirect_uri") || "/auth/callback"
|
||
await route.fulfill({
|
||
status: 302,
|
||
headers: { Location: `${redirectUri}?code=mock-${provider}-code` },
|
||
})
|
||
})
|
||
|
||
await page.route(`**/api/auth/${provider}/callback*`, async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ token: `mock-${provider}-jwt`, user: userData }),
|
||
})
|
||
})
|
||
}
|
||
await use(mock)
|
||
},
|
||
})
|
||
|
||
test("使用 Google 登录", async ({ page, mockOAuth }) => {
|
||
await mockOAuth("google", { name: "Google User", email: "user@gmail.com" })
|
||
await page.goto("/login")
|
||
await page.getByRole("button", { name: "Sign in with Google" }).click()
|
||
await page.waitForURL("/dashboard")
|
||
await expect(page.getByText("Google User")).toBeVisible()
|
||
})
|
||
|
||
module.exports = { test, expect }
|
||
```
|
||
|
||
## 决策指南
|
||
|
||
| 集成方式 | 模拟策略 | 何时使用真实服务 | 何时模拟 |
|
||
| ------------------------------ | ------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------- |
|
||
| OAuth(Google、GitHub 等) | 对提供商 URL 进行路由拦截 + 模拟回调 | 使用测试账号的专用集成测试 | 所有需要登录的 E2E 功能测试 |
|
||
| Stripe/支付网关 | 使用 Stripe 测试模式 + 测试卡,或模拟 API | 结账流程集成测试 | 所有非支付的 E2E 测试 |
|
||
| Google Analytics / Segment | 使用 `route.abort()` 完全阻止 | 专用的分析验证测试 | 其他所有测试(阻止以提高速度) |
|
||
| 聊天组件(Intercom、Drift) | 使用 `route.abort()` 阻止,或通过 iframe 交互 | 专门针对聊天功能的测试 | 其他所有测试(阻止以提高速度/稳定性) |
|
||
| Google Maps | 模拟地理编码/地点 API 响应 | 验证真实地图渲染的测试 | 仅需位置数据的测试 |
|
||
| reCAPTCHA | Google 测试密钥(推荐)或模拟 `grecaptcha` | 自动化测试中从不使用 | 始终模拟 —— CAPTCHA 无法通过自动化解决 |
|
||
| 社交登录 | 按提供商创建可复用的 OAuth 模拟 fixture | 定期集成检查 | 所有需要认证的 E2E 测试 |
|
||
| 邮件服务(SendGrid、SES) | 模拟 API 端点,验证调用是否发出 | 端到端邮件投递测试(独立测试套件) | 所有触发邮件的测试 |
|
||
|
||
## 反模式
|
||
|
||
| 不要这样做 | 问题 | 应该这样做 |
|
||
| ------------------------------------------------------------ | ------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||
| 在测试中使用真实的 OAuth 提供商账号 | 速率限制、2FA 要求、账号锁定、测试不稳定 | 模拟 OAuth 流程或使用提供商的测试模式 |
|
||
| 让分析脚本在所有测试中运行 | 测试变慢、污染真实分析数据、增加网络不稳定性 | 在共享 fixture 中使用 `route.abort()` 阻止分析工具 |
|
||
| 使用图像识别解决 reCAPTCHA | 脆弱、缓慢、违反 CAPTCHA 服务条款 | 使用 Google 的测试密钥或模拟 `grecaptcha` 对象 |
|
||
| 模拟你自己的应用程序代码而非第三方代码 | 没有测试你的集成逻辑 | 仅模拟外部边界(API 端点、脚本) |
|
||
| 在每个测试中硬编码模拟响应 | 模拟设置重复、难以维护 | 构建可复用的模拟 fixture(参见社交登录模式) |
|
||
| 不测试第三方服务的错误路径 | 遗漏应用如何处理 OAuth 失败、支付被拒等情况 | 模拟错误响应:`{ error: 'access_denied' }`、被拒卡片等 |
|
||
| 在每个测试中加载真实的 Stripe.js | 初始加载慢、偶发 CDN 故障 | 为非支付测试模拟 Stripe;仅在结账测试中使用真实 Stripe |
|
||
| 对第三方脚本依赖 `networkidle` | 第三方脚本可能无限轮询 | 等待特定的应用级指示器,而非 `networkidle` |
|
||
|
||
## 故障排查
|
||
|
||
| 症状 | 可能原因 | 修复方法 |
|
||
| ---------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||
| OAuth 模拟路由从未触发 | 应用使用重定向链绕过了你的路由模式 | 使用更宽泛的模式如 `**accounts.google.com**`,或记录所有请求以查看实际 URL |
|
||
| Stripe iframe 未找到 | Stripe.js 异步加载;iframe 名称因版本而异 | 使用灵活的定位器:`iframe[name*="__privateStripeFrame"]` 并设置较长的超时时间 |
|
||
| reCAPTCHA 模拟不起作用 | 脚本在 `addInitScript` 运行之前已加载 | 使用 `route` 拦截 reCAPTCHA 脚本并提供模拟版本 |
|
||
| 分析工具拦截破坏了应用 | 应用代码依赖分析库存在(例如 `window.gtag`) | 不要阻止,而是用一个桩响应来满足:`route.fulfill({ body: 'window.gtag=function(){}' })` |
|
||
| 聊天组件 iframe 无法访问 | 跨域 iframe 限制 | 使用 `frameLocator` 配合正确的 iframe 名称/定位器 |
|
||
| 模拟 OAuth 返回了错误的重定向 URI | `redirect_uri` 参数是 URL 编码的,或在测试中不同 | 使用 `page.on('request')` 记录实际请求 URL 并相应匹配 |
|
||
| 支付测试本地通过但 CI 失败 | Stripe 测试模式有速率限制;CI IP 可能被阻止 | 在 CI 中模拟 Stripe API,仅在单独的集成流水线中使用真实 Stripe |
|
||
| 社交登录模拟未触发重定向 | 你的应用在新弹窗中打开 OAuth,而非重定向 | 处理弹窗:`page.waitForEvent('popup')` 并在弹窗页面上模拟路由 |
|
||
|
||
## 相关文档
|
||
|
||
- [core/network-mocking.md](network-mocking.md) —— 基础路由拦截模式
|
||
- [core/authentication.md](authentication.md) —— 认证状态管理和登录绕过
|
||
- [core/when-to-mock.md](when-to-mock.md) —— 模拟与真实服务的决策框架
|
||
- [core/multi-context-and-popups.md](multi-context-and-popups.md) —— 处理 OAuth 弹窗
|
||
- [core/security-testing.md](security-testing.md) —— 测试认证安全方面
|
||
- [core/iframes-and-shadow-dom.md](iframes-and-shadow-dom.md) —— 与支付组件 iframe 交互
|