Playwright测试中基于Proxy模式的跨测试资源管理与变量共享方案
1. 项目概述为什么我们需要在Playwright沙箱中“搞事情”如果你用过Playwright做自动化测试尤其是写过一些稍微复杂点的脚本那你肯定遇到过这个让人头疼的场景你精心设计了一个测试用例它需要在多个页面之间跳转或者在一个页面上执行一系列有依赖关系的操作。比如你先登录获取到一个关键的token或者用户ID然后带着这个信息去另一个页面下单。在传统的无头浏览器或者简单的脚本里你可能会用一个全局变量来存这个token一路传下去。但当你把脚本搬到Playwright的测试运行器里比如用它的test函数来组织用例时你会发现事情没那么简单。Playwright为了测试的独立性和可靠性默认会为每个测试用例test创建一个干净的“沙箱”环境。这就像给每个测试发了一个全新的、一尘不染的实验室它们之间互不干扰。这当然是好事避免了测试间的状态污染让测试结果更稳定。但硬币的另一面是这种隔离也切断了一些我们期望的“共享”。最典型的就是变量共享和资源比如浏览器上下文、页面的复用。你没法在一个test里设置一个变量然后在另一个test里直接读取它。同样如果你在一个test里创建了一个浏览器上下文browser.newContext()想在另一个test里复用默认也是不行的每个test通常都会自己创建和清理。这就引出了我们标题里的核心问题如何在保持Playwright沙箱隔离优势的前提下巧妙地实现跨测试的变量共享与资源管理硬闯是不行的Playwright的架构设计就是为了防止你这么做。那么我们能不能“智取”呢这就是“Proxy”代理模式登场的时候了。它不是指网络代理而是一种经典的设计模式——用一个代理对象来控制对另一个对象的访问。我们可以创建一个“资源管理器”的代理所有测试用例都通过这个代理来申请和使用资源如浏览器上下文、页面、共享变量而代理则在背后负责资源的创建、缓存、分配和清理。这样从每个测试用例的视角看它们还是在独立操作但实际上底层资源是被集中管理并可复用的。这个项目就是要“解密”这套机制手把手带你实现一个基于Proxy模式的轻量级资源管理中间件解决变量共享和资源清理的痛点。2. 核心思路与架构设计用Proxy模式搭建资源桥梁直接让测试用例互相传数据或者访问彼此的上下文会破坏沙箱的纯洁性是下策。我们的上策是引入一个中间层——一个全局的、单例的“资源协调员”。所有测试用例不直接创建资源而是向这个协调员“申请”。协调员掌握着资源的生杀大权可以实现共享、复用和统一清理。2.1 为什么是Proxy模式在软件设计里Proxy模式通常用于1) 控制访问2) 添加额外功能如缓存、日志3) 延迟初始化。这三点完美契合我们的需求控制访问我们不希望测试用例随意创建或销毁浏览器上下文这可能导致资源泄露或端口冲突。通过代理我们可以集中控制。添加功能代理可以在提供资源前后加入缓存逻辑比如缓存已登录的页面上下文、状态记录记录哪些资源正在被使用以及最重要的——变量共享存储区。延迟初始化/复用代理可以检查是否有可复用的资源例如一个配置相同的浏览器上下文有则直接返回无则创建实现了资源的池化。2.2 系统架构蓝图我们的系统主要由三部分组成资源池 (Resource Pool)一个核心的存储中心用于管理两类资源浏览器资源如Browser实例、BrowserContext实例、Page实例。我们可以为它们建立缓存池。共享数据一个简单的键值对存储例如一个JavaScriptMap或普通对象用于存放跨测试需要传递的变量如authToken、orderId、userId等。代理管理器 (Proxy Manager)这是Proxy模式的核心实现。它对外暴露一套与Playwright原生API非常相似的接口如launchBrowser,newContext,newPage,setSharedData,getSharedData但内部逻辑是拦截资源创建请求优先从资源池中查找可用实例。维护资源的引用计数或使用状态确保资源安全。提供共享数据的存取接口。测试用例集成层我们需要一种方式让Playwright的test函数能方便地使用这个代理管理器而不是原生的playwright对象。这通常通过Playwright提供的Fixture夹具机制来实现。Fixture是Playwright Test运行器的一个强大功能它允许你在测试之间设置和销毁上下文非常适合用来集成我们的代理管理器。整个数据流是这样的测试用例启动 - 通过Fixture获取代理管理器实例 - 测试用例调用代理管理器的方法申请页面或上下文 - 代理管理器检查资源池并返回资源可能是新建的也可能是复用的- 测试用例执行操作可能通过代理管理器存储共享数据 - 测试用例结束 - Fixture的清理逻辑或代理管理器自身的机制决定是否立即销毁资源或放回池中等待复用。注意这里有一个关键的设计取舍资源复用池化的粒度。是复用整个Browser实例还是BrowserContext还是Page复用粒度越粗启动速度越快但测试之间的隔离性越差。通常复用Browser实例是安全的因为每个BrowserContext本身就是隔离的Cookie、本地存储独立。复用BrowserContext需要非常小心除非你能确保测试之间完全不需要会话隔离。本项目示例会采用相对保守且常见的策略复用Browser实例但为每个测试创建全新的BrowserContext。同时我们会实现一个可配置的Page级缓存池用于特定场景如登录态保持。3. 核心实现一步步构建Proxy资源管理器理论说完了我们开始动手写代码。我们将用Node.js和TypeScript来演示这样类型提示更清晰。首先确保你的项目已经安装了Playwright Testnpm init playwrightlatest。3.1 第一步定义资源池与共享存储我们先创建一个核心的管理类比如叫PlaywrightResourceManager。// playwright-resource-manager.ts import { Browser, BrowserContext, BrowserType, Page } from playwright/test; // 定义共享数据的存储结构 interface SharedDataStore { [key: string]: any; } // 定义资源池的配置选项 interface ResourceManagerOptions { // 是否启用浏览器实例复用默认开启 reuseBrowser?: boolean; // 是否启用页面缓存池默认关闭因为风险较高 enablePagePool?: boolean; // 页面缓存池的最大大小 pagePoolMaxSize?: number; } export class PlaywrightResourceManager { // 单例实例 private static instance: PlaywrightResourceManager; // 共享数据存储 private sharedData: SharedDataStore {}; // 缓存的浏览器实例根据浏览器类型和启动参数作为键 private browserCache: Mapstring, Browser new Map(); // 页面缓存池可选 private pagePool: Page[] []; private pagePoolOptions: { enable: boolean; maxSize: number }; // 私有构造函数强制单例 private constructor(options: ResourceManagerOptions {}) { this.pagePoolOptions { enable: options.enablePagePool || false, maxSize: options.pagePoolMaxSize || 10, }; } // 获取单例 public static getInstance(options?: ResourceManagerOptions): PlaywrightResourceManager { if (!PlaywrightResourceManager.instance) { PlaywrightResourceManager.instance new PlaywrightResourceManager(options); } return PlaywrightResourceManager.instance; } // --- 共享数据操作 --- public setSharedData(key: string, value: any): void { this.sharedData[key] value; } public getSharedDataT any(key: string): T | undefined { return this.sharedData[key] as T; } public clearSharedData(key?: string): void { if (key) { delete this.sharedData[key]; } else { this.sharedData {}; } } // --- 浏览器资源操作 --- // 生成浏览器缓存的键基于浏览器类型和启动选项 private getBrowserCacheKey(browserType: BrowserType, launchOptions: any): string { // 简单实现将对象序列化为字符串。生产环境可能需要更稳定的哈希算法。 return ${browserType.name()}-${JSON.stringify(launchOptions)}; } public async launchBrowser( browserType: BrowserType, launchOptions: any {} ): PromiseBrowser { const cacheKey this.getBrowserCacheKey(browserType, launchOptions); // 如果启用复用且缓存中存在直接返回 if (this.browserCache.has(cacheKey)) { const cachedBrowser this.browserCache.get(cacheKey)!; // 检查浏览器是否仍然连接未被关闭 if (cachedBrowser.isConnected()) { console.log([ResourceManager] Reusing cached browser instance for key: ${cacheKey}); return cachedBrowser; } else { // 浏览器已断开从缓存中移除 this.browserCache.delete(cacheKey); } } // 否则启动新浏览器并缓存 console.log([ResourceManager] Launching new browser instance for key: ${cacheKey}); const browser await browserType.launch(launchOptions); this.browserCache.set(cacheKey, browser); // 监听浏览器断开事件自动清理缓存 browser.on(disconnected, () { console.log([ResourceManager] Browser disconnected, removing from cache: ${cacheKey}); this.browserCache.delete(cacheKey); }); return browser; } // 创建新的上下文通常每个测试单独创建不缓存上下文本身 public async newContext(browser: Browser, contextOptions: any {}): PromiseBrowserContext { // 这里我们可以注入一些默认配置比如记录网络请求的Har文件路径 const options { ...contextOptions, // 示例自动记录所有页面的网络活动到Har文件便于调试 recordHar: { path: ./test-results/har-${Date.now()}.har }, }; return await browser.newContext(options); } // --- 页面资源操作带可选缓存池--- public async newPage(context: BrowserContext): PromisePage { // 如果启用页面池且池中有页面则复用 if (this.pagePoolOptions.enable this.pagePool.length 0) { const page this.pagePool.pop()!; console.log([ResourceManager] Reusing page from pool. Pool size: ${this.pagePool.length}); // 重要复用页面前需要清理可能的上一个测试的状态。 // 这里我们选择先关闭它然后从上下文重新打开一个“新”页面。 // 更复杂的策略可能涉及清除Cookie、LocalStorage等但关闭重启是最干净的。 await page.close(); } // 创建新页面 const page await context.newPage(); // 可以在这里为page添加一些通用监听器或设置 page.on(console, msg console.log([Page Console] ${msg.type()}: ${msg.text()})); return page; } // 将页面释放回缓存池由测试用例在适当的时候调用 public releasePage(page: Page): void { if (!this.pagePoolOptions.enable) { return; } if (this.pagePool.length this.pagePoolOptions.maxSize) { // 池已满直接关闭页面 page.close().catch(e console.error(Error closing page:, e)); } else { // 放入池中等待复用 this.pagePool.push(page); console.log([ResourceManager] Page released to pool. Pool size: ${this.pagePool.length}); } } // --- 全局清理 --- public async cleanup(): Promisevoid { console.log([ResourceManager] Starting cleanup...); // 1. 清理所有缓存的浏览器 for (const [key, browser] of this.browserCache.entries()) { if (browser.isConnected()) { await browser.close(); } } this.browserCache.clear(); // 2. 清理页面池 for (const page of this.pagePool) { await page.close().catch(e void 0); // 忽略关闭错误 } this.pagePool.length 0; // 3. 清理共享数据 this.sharedData {}; console.log([ResourceManager] Cleanup completed.); } }这个管理器已经具备了核心功能单例保证全局唯一、共享数据存储、浏览器实例缓存、可选的页面缓存池以及统一的清理入口。3.2 第二步创建Playwright Fixture集成代理接下来我们需要创建Playwright Test的Fixture让测试用例能方便地使用这个管理器。我们通常会创建一个fixtures.ts文件。// fixtures.ts import { test as baseTest, Browser, BrowserContext, Page } from playwright/test; import { PlaywrightResourceManager } from ./playwright-resource-manager; // 扩展Playwright的Fixtures类型定义 export type MyFixtures { sharedData: { set: (key: string, value: any) void; get: T any(key: string) T | undefined; clear: (key?: string) void; }; browser: Browser; context: BrowserContext; page: Page; }; // 资源管理器实例单例 const resourceManager PlaywrightResourceManager.getInstance({ reuseBrowser: true, // 启用浏览器复用 enablePagePool: false, // 默认关闭页面池按需开启 }); // 基于基础test创建我们增强版的test export const test baseTest.extendMyFixtures({ // 1. 共享数据Fixture sharedData: async ({}, use) { const sharedDataProxy { set: (key: string, value: any) resourceManager.setSharedData(key, value), get: T any(key: string) resourceManager.getSharedDataT(key), clear: (key?: string) resourceManager.clearSharedData(key), }; await use(sharedDataProxy); // 注意这里我们不在每个测试后清理共享数据因为可能需要跨测试传递。 // 全局清理会在所有测试完成后通过globalTeardown进行。 }, // 2. 浏览器Fixture - 通过管理器获取 browser: async ({}, use) { // 获取Playwright的浏览器类型对象如chromium const { chromium } require(playwright/test); // 通过管理器启动或复用浏览器 const browser await resourceManager.launchBrowser(chromium, { headless: false, // 可根据环境变量动态设置 args: [--start-maximized], }); await use(browser); // 注意这里我们不关闭browser关闭由管理器的cleanup统一处理。 // 这样同一个browser实例可以在多个测试间复用。 }, // 3. 上下文Fixture - 每个测试一个全新的上下文 context: async ({ browser }, use) { // 每个测试获得一个全新的、隔离的上下文 const context await resourceManager.newContext(browser, { viewport: { width: 1920, height: 1080 }, // 可以在这里设置全局的Cookie或权限 // permissions: [geolocation], }); await use(context); // 测试结束后关闭该上下文释放资源。 // 这是安全的因为每个测试都有自己的上下文。 await context.close(); }, // 4. 页面Fixture - 从上下文中创建可选通过管理器缓存 page: async ({ context }, use) { const page await resourceManager.newPage(context); await use(page); // 测试结束后决定页面的命运。 // 如果启用了页面池则释放回池中否则直接关闭。 resourceManager.releasePage(page); }, }); export default test;现在我们的测试用例就可以像下面这样写了它们天然拥有了共享数据的能力并且浏览器实例在背后被智能复用。// example.spec.ts import { test, expect } from ./fixtures; // 导入我们自定义的test test.describe(跨测试变量共享示例, () { test(测试1登录并获取token, async ({ page, sharedData }) { await page.goto(https://example.com/login); // ... 执行登录操作 const authToken 模拟获取的Token_12345; // 将token存入共享存储 sharedData.set(authToken, authToken); expect(authToken).toBeTruthy(); }); test(测试2使用共享的token访问受保护页面, async ({ page, sharedData }) { // 从共享存储中获取上一个测试存入的token const token sharedData.getstring(authToken); expect(token).toBe(模拟获取的Token_12345); // 使用该token进行API请求或设置请求头 await page.goto(https://example.com/dashboard); // 可以通过page.evaluate或路由拦截器将token注入请求 await page.addInitScript((t) { window.localStorage.setItem(token, t); }, token); // 验证页面成功加载 await expect(page.locator(textWelcome to Dashboard)).toBeVisible(); }); });3.3 第三步实现全局的Setup与Teardown为了确保在所有测试开始前和结束后正确初始化和清理资源管理器我们需要利用Playwright的globalSetup和globalTeardown配置。首先创建一个全局设置文件// global-setup.ts import { PlaywrightResourceManager } from ./playwright-resource-manager; async function globalSetup() { console.log(Global setup: Initializing resource manager if needed.); // 这里主要是触发单例初始化可以执行一些预加载操作。 const manager PlaywrightResourceManager.getInstance(); // 例如可以在这里预先启动一个浏览器加速第一个测试。 // const { chromium } require(playwright/test); // await manager.launchBrowser(chromium, { headless: true }); } export default globalSetup;然后创建全局清理文件// global-teardown.ts import { PlaywrightResourceManager } from ./playwright-resource-manager; async function globalTeardown() { console.log(Global teardown: Cleaning up all resources.); const manager PlaywrightResourceManager.getInstance(); await manager.cleanup(); // 执行统一的资源清理 } export default globalTeardown;最后在playwright.config.ts中配置它们// playwright.config.ts import { defineConfig } from playwright/test; export default defineConfig({ // ... 其他配置 globalSetup: require.resolve(./global-setup), globalTeardown: require.resolve(./global-teardown), // ... 其他配置 });4. 高级技巧与避坑指南实现基础功能只是第一步在实际项目中应用你会遇到更多细节问题。下面分享一些关键的实操心得和避坑技巧。4.1 共享数据的安全性与序列化问题共享数据存储在一个普通的JavaScript对象中。如果存储的是复杂对象如DOM元素、函数、Promise可能会遇到无法序列化或内存泄漏的问题。解决方案只存储可序列化的数据强制约定只存储字符串、数字、布尔值、普通对象和数组。在setSharedData方法中添加类型检查或序列化操作。public setSharedData(key: string, value: any): void { // 简单检查尝试JSON序列化如果不能则抛出错误或警告。 try { JSON.stringify(value); this.sharedData[key] value; } catch (error) { console.warn([ResourceManager] Value for key ${key} is not serializable and may cause issues., error); // 根据策略决定是抛出错误还是仍然存储风险自担 // throw new Error(Shared data must be JSON-serializable. Key: ${key}); this.sharedData[key] value; // 宽松策略 } }使用命名空间避免键名冲突。可以为不同模块或测试套件添加前缀如auth:token、order:latestId。4.2 资源复用的隔离性陷阱问题复用BrowserContext或Page会带来严重的测试污染。即使清理了Cookie和LocalStorage一些更底层的状态如Service Workers、缓存索引也可能残留。经验坚持“Browser复用Context隔离”原则这是最安全、最有效的平衡点。每个测试拥有独立的Context保证了Cookie、本地存储、权限设置的隔离。Browser层面的复用则带来了显著的启动速度提升。谨慎使用Page池除非你的测试场景非常固定例如所有测试都从一个相同的、干净的首页开始并且你能确保每次归还页面时都进行深度清理不仅仅是page.close()可能还需要context.clearCookies()context.clearPermissions()否则不建议开启。在我们的实现中页面池默认关闭且复用策略是“关闭旧页面创建新页面”这是一种相对保守的“伪复用”旨在回收页面对象本身的内存而非其状态。4.3 并行测试下的资源竞争问题Playwright Test默认会并行运行测试。我们的资源管理器是单例多个测试同时请求浏览器或页面时可能会发生竞争条件。解决方案使用锁或队列对资源获取操作如launchBrowser,newPagefrom pool进行同步控制。Node.js环境下可以用async-mutex这样的库。import { Mutex } from async-mutex; export class PlaywrightResourceManager { private browserLaunchMutex new Mutex(); public async launchBrowser(browserType: BrowserType, launchOptions: any): PromiseBrowser { const cacheKey this.getBrowserCacheKey(browserType, launchOptions); // 使用锁确保同一key的浏览器只被创建一次 const release await this.browserLaunchMutex.acquire(); try { if (this.browserCache.has(cacheKey) this.browserCache.get(cacheKey)!.isConnected()) { return this.browserCache.get(cacheKey)!; } // ... 创建新浏览器 } finally { release(); } } }为每个Worker提供独立实例Playwright的并行测试是通过多个Worker进程实现的。一个更彻底的方案是利用Playwright的workerInfo为每个Worker创建独立的资源管理器实例而不是全局单例。这可以通过在Fixture中根据workerIndex来初始化管理器实现完全避免了跨进程的竞争。4.4 调试与监控问题当测试失败时如何知道是资源管理器的问题还是测试逻辑本身的问题技巧增加详细日志像我们在代码中加的console.log清晰地记录资源的创建、复用、释放和清理过程。暴露健康检查接口为资源管理器添加一个getStatus()方法返回当前缓存池大小、共享数据键列表等信息可以在测试失败后或定时任务中调用并输出。集成到Playwright TracePlaywright Trace是强大的调试工具。你可以在创建Context或Page时通过contextOptions或page.on事件将资源管理器的关键操作也记录到Trace中方便回溯。5. 实战扩展更复杂的共享场景与清理策略我们的基础框架已经能解决大部分问题。但对于更复杂的场景还可以进一步扩展。5.1 场景共享一个已登录的用户会话有时我们不想在每个测试里都走一遍登录流程希望复用同一个已登录的BrowserContext。这比共享变量更复杂因为涉及浏览器状态。实现思路创建“模板上下文”在一个专门的Setup阶段比如globalSetup或一个beforeAll钩子启动浏览器完成登录然后将这个登录后的BrowserContext的存储状态Storage State保存下来。Storage State包含了Cookies、LocalStorage等。// auth-setup.ts async function createAuthenticatedContext(browser: Browser): Promise{ context: BrowserContext; state: any } { const context await browser.newContext(); const page await context.newPage(); await page.goto(https://example.com/login); // ... 执行登录操作 await page.fill(#username, testuser); await page.fill(#password, password123); await page.click(button[typesubmit]); await page.waitForURL(**/dashboard); // 保存登录状态 const state await context.storageState(); await context.close(); // 关闭这个临时上下文 return { context: null, state }; // 我们只需要状态不需要保持上下文打开 }在Fixture中注入状态在每个测试的contextFixture中读取保存的Storage State并以此状态创建新的上下文。这样每个测试都获得一个“克隆”的已登录状态且彼此隔离。// 在fixtures.ts的context Fixture中 context: async ({ browser }, use) { let storageState null; try { storageState JSON.parse(fs.readFileSync(./playwright/.auth/user.json, utf-8)); } catch { // 文件不存在表示首次运行或未登录 } const contextOptions: any { viewport: { width: 1920, height: 1080 } }; if (storageState) { contextOptions.storageState storageState; } const context await resourceManager.newContext(browser, contextOptions); await use(context); await context.close(); },这样第一个测试可能需要执行登录并保存状态后续测试就能直接复用这个状态了。这比共享一个活的上下文安全得多。5.2 智能清理策略引用计数与超时释放目前的清理是在globalTeardown中一刀切。对于长期运行的测试服务我们可能希望更精细地管理资源生命周期。引用计数为每个缓存的资源如Browser添加引用计数。当Fixture开始使用它时计数1结束时-1。当计数为0且超过一定空闲时间后再自动关闭它而不是等到全局结束。private browserRefCount: MapBrowser, number new Map(); private browserLastUsed: MapBrowser, number new Map(); public async acquireBrowser(browserType: BrowserType, launchOptions: any): PromiseBrowser { const browser await this.launchBrowser(browserType, launchOptions); const count this.browserRefCount.get(browser) || 0; this.browserRefCount.set(browser, count 1); this.browserLastUsed.set(browser, Date.now()); return browser; } public async releaseBrowser(browser: Browser): Promisevoid { const count (this.browserRefCount.get(browser) || 1) - 1; this.browserRefCount.set(browser, count); if (count 0) { // 标记为可清理启动一个延时清理任务 this.scheduleCleanup(browser); } } private scheduleCleanup(browser: Browser): void { const idleTimeout 30000; // 30秒空闲后清理 setTimeout(async () { if (this.browserRefCount.get(browser) 0) { if (browser.isConnected()) { await browser.close(); this.browserCache.delete(/*对应的key*/); } } }, idleTimeout); }这套Proxy模式的资源管理机制将Playwright沙箱的严格隔离与测试间必要的协作需求巧妙地统一了起来。它没有打破沙箱规则而是架起了一座可控的桥梁。从简单的共享变量到复杂的会话状态复用从基础的资源池化到高级的智能清理你可以根据项目的实际测试规模和复杂度灵活地裁剪和扩展这套方案。