1. 项目概述与整体拆解1.1 为什么2FA测试总是让人头疼双因素认证2FA已经是绝大多数系统的安全标配尤其是涉及账号体系、支付、后台管理这类场景。可一旦上了自动化测试2FA几乎就是最让人头大的环节。普通登录可以靠Playwright轻松完成但到第二步“输入邮箱验证码”时验证码是实时发到用户邮箱里的脚本根本不知道邮箱里有什么。很多团队图省事直接在测试环境关掉2FA或者把验证码写死成一个固定值。这两种做法我都会劝退关掉2FA等于没测核心安全路径写死验证码则完全失真根本不能证明“真实用户能顺利完成多因素登录”。真正需要的是在自动化流程里用真实邮箱收到验证码再回填到页面上走完整个流程。Playwright本身是个很成熟的浏览器端到端测试框架它能处理点击、填表、等待、iframe这类操作但它没有能力“读邮件”。所以我们必须找一个能配合它的外部组件。Mailosaur就可以充当这个“测试邮箱中间件”它提供了虚拟的SMTP邮箱地址还能通过API把收到的邮件内容、主题、发件人、验证码全部返回来。于是整条链路就通了被测系统向某个Mailosaur地址发送验证码Playwright脚本通过API读取这封邮件提取验证码填进页面完成登录。整个过程是真实发生的不是模拟也不是跳过所以测试价值完全保留。1.2 两个工具各自扮演的角色Playwright的角色很好理解它是一个浏览器自动化引擎可以驱动Chromium、Firefox、WebKit执行真实的用户操作。在2FA测试中它负责的控制范围包括打开登录页、输入账号密码、点击发送验证码、等待验证码输入框出现、填入收到的验证码、点击确认按钮、最后断言登录成功后的页面元素。这套操作如果用Selenium也能做到但Playwright的自动等待、内置断言、Trace Viewer、并发隔离要舒服得多所以现在很多新项目的端到端测试都直接选它。Mailosaur的角色则更像一个“邮件API”。你不需要真的去注册一个临时邮箱、登录网页收信、复制验证码。Mailosaur会给你的测试环境分配一台虚拟邮件服务器每个服务器有一个固定的ID所有发送到“任意前缀你的ServerID.mailosaur.net”的邮件都会被收集起来。之后你可以通过REST API或官方SDK按收件人地址、邮件主题、发件人等条件来查询邮件并直接提取邮件正文里的验证码。它把“收邮件”这个异步事件变成了一个可轮询、可等待、可断言的接口调用这是整套方案能自动化的关键。1.3 这篇内容适合谁如果你已经能写基本的Playwright脚本但对2FA该自动化和如何去拿验证码还不太有把握这篇内容应该能帮你省不少时间。哪怕你之前完全没用过Mailosaur只要会一点Python或Node.js能发HTTP请求跟完下面的步骤就能落地。我下面的代码都用Python写因为Python在测试辅助工具里出现频率高结构也直白。如果你用Node.js思路完全一样只是把HTTP请求换成axios或官方npm包而已。不过要提前说清楚被测系统本身得支持邮件验证码如果你们家产品只支持扫码或TOTP那这套方案不能直接套用后面我会简单讲怎么改。2. 环境准备与工具配置2.1 安装Playwright并准备浏览器Playwright的安装通常分两步先装Python库再装浏览器内核。pip install playwright playwright install chromium如果你跑的是Node.js项目对应命令是npm init playwrightlatest npx playwright install有些人会问为什么Playwright还要单独下载浏览器因为它不是调用你电脑上已经装好的Chrome而是维护了一套专门的浏览器版本。这样做的最大好处是版本一致不会因为本地Chrome升级导致测试脚本失效。我自己习惯只在测试环境装Chromium除非产品明确要求兼容Safari或Firefox否则Chromium足够覆盖绝大多数登录页场景。第一次跑脚本前建议用playwright install --with-deps把系统依赖也装上尤其是CI里的Linux环境缺依赖会报一堆莫名其妙找不到库的错。这里有一个很常见的坑公司内部网络下playwright install可能下不动浏览器。解决办法是设置PLAYWRIGHT_DOWNLOAD_HOST指向内网镜像源或者提前把浏览器打包到CI的基础镜像里。不要在这个环节浪费太多时间网上一搜一大把解决方案。2.2 准备Mailosaur服务并理解邮箱地址规则先去Mailosaur服务后台创建一个Server这一步会得到两个关键信息Server ID和API Key。Server ID是一串短ID比如abc12345。它不仅是API调用时的路径参数也是邮箱域名的一部分。所有发送到任意前缀abc12345.mailosaur.net的邮件都会被这台虚拟邮件服务器接收。你可以把前缀理解成测试用例的“独立收件人”前缀可以随便取但建议用字母、数字和连字符不要用中文。每个测试用例用不同的前缀这样邮件就能互相隔离。API Key是访问邮件数据的凭证这个必须当成密码处理。我见过不少人在测试代码里直接把API Key写死并提交到仓库这是非常危险的因为一旦仓库泄露别人就能读你测试邮箱里的所有邮件。正确做法是从环境变量或密钥管理服务拿。export MAILOSAUR_API_KEY你的APIKey export MAILOSAUR_SERVER_ID你的ServerID然后在Python脚本里统一读取import os api_key os.environ[MAILOSAUR_API_KEY] server_id os.environ[MAILOSAUR_SERVER_ID]在Mailosaur后台还可以配置数据保留策略、发件人白名单等。对2FA测试来说保留策略设成默认就好毕竟验证码邮件很快就会被消费掉。2.3 被测系统需要满足的条件这套方案不是拿到任何系统都能立刻跑的。被测系统至少要有这两个前提第一它必须允许填写任意邮箱作为登录账号且验证码真的发往这个邮箱。有些系统为了防滥用会限制邮箱域名比如只允许公司内部邮箱。这种情况在测试环境通常可以通过配置开关绕过但如果绕不过去Mailosaur方案就无法落地只能换别的方案。第二验证码邮件的内容格式要相对稳定。Mailosaur能提取验证码但它依赖邮件里存在可解析的文本。如果你们产品把验证码做成一整张图片或者用canvas渲染数字Mailosaur是读不出来的。这种情况建议让开发在测试环境额外输出一份纯文本版本或者直接把验证码也写入邮件标题虽然后者不太符合安全最佳实践但很多团队确实会这么干。邮件里如果既有HTML又有纯文本Mailosaur的extract_passcode()会从两个版本里找所以只要有一个版本保留明文数字就能用。3. 实操过程与核心实现3.1 从Mailosaur API读取最新邮件在写Playwright脚本之前先把“拿邮件”这个动作独立成函数。这里我直接用Mailosaur的官方Python SDK因为它带了等待逻辑比我们自己循环请求省很多事。pip install mailosaur基本调用方式import time from mailosaur import MailosaurClient from mailosaur.models import SearchCriteria client MailosaurClient(api_key) criteria SearchCriteria() criteria.sent_to email_address # 这里会等待最长30秒直到匹配邮件到达 message client.messages.get( server_idserver_id, criteriacriteria, options{timeout: 30000} )如果你是走REST API核心请求是这样的import requests url fhttps://mailosaur.com/api/v1/servers/{server_id}/messages params { server: server_id, sentTo: email_address, receivedAfter: start_time.isoformat() } headers { Authorization: fBearer {api_key} } resp requests.get(url, paramsparams, headersheaders, timeout30) data resp.json()推荐加上receivedAfter参数把时间范围限定到触发验证码发送之后。否则如果这个邮箱地址之前还收到过别的邮件第一次查询就可能匹配到旧邮件提取出过期的验证码。所谓的“旧邮件干扰”是把这个方案从单条脚本扩展到几百条并行用例时最常见的翻车点。3.2 从邮件正文提取验证码拿到邮件对象后提取验证码最省力的方式就是用SDK自带的extract_passcode()方法。它内部会扫面邮件文本和HTML识别常见的验证码格式返回找到的数字串。如果你不知道验证码是几位或者邮件模板经常变用这个方法比你自己写正则稳得多。message client.messages.get(...) code message.extract_passcode() if not code: raise RuntimeError(f无法从邮件中提取验证码: {message.subject})不过它也有失败的时候。我遇到过一次邮件模板里把验证码每个数字都包在不同span里HTML看起来就是span1/spanspan2/spanspan3/spanspan4/spanspan5/spanspan6/span这种结构用通用提取方法可能只抠到一个单数字。如果你也遇到这种情况可以先用正则找文本中“verification code”或“验证码”后面的连续数字import re text message.text.body match re.search(r(?:verification code|security code|验证码)[:\s]*(\d{4,8}), text, re.IGNORECASE) if match: code match.group(1)这里正则里的\d{4,8}故意不限制得太死因为不同系统验证码长度可能是4到8位。但如果你直接搜全数字文本很容易匹配到年份、订单号、手机号等干扰信息。所以我的原则是优先用extract_passcode()它失败再手动搜索关键词附近内容。3.3 在Playwright里把整套流程串起来现在把Mailosaur和Playwright组合起来。完整流程如下import os import uuid from playwright.sync_api import sync_playwright, expect from mailosaur import MailosaurClient from mailosaur.models import SearchCriteria API_KEY os.environ[MAILOSAUR_API_KEY] SERVER_ID os.environ[MAILOSAUR_SERVER_ID] # 生成一个专属测试邮箱地址 email_alias fe2e-{uuid.uuid4().hex[:8]}{SERVER_ID}.mailosaur.net # 记录开始时间用于过滤旧邮件 from datetime import datetime, timezone start_time datetime.now(timezone.utc) client MailosaurClient(API_KEY) def fetch_verification_code(timeout_ms60000): criteria SearchCriteria() criteria.sent_to email_alias message client.messages.get( server_idSERVER_ID, criteriacriteria, options{timeout: timeout_ms} ) code message.extract_passcode() if not code: raise RuntimeError(未提取到验证码) return code with sync_playwright() as p: browser p.chromium.launch(headlessFalse) page browser.new_page() # 1. 打开登录页 page.goto(https://example.com/login) # 2. 输入账号密码 page.get_by_label(账号).fill(test_user) page.get_by_label(密码).fill(test_password) # 3. 点击登录触发2FA邮件 page.get_by_role(button, name登录).click() # 4. 等待验证码输入框出现 otp_input page.locator(#otp) otp_input.wait_for(statevisible, timeout15000) # 5. 从Mailosaur获取验证码 code fetch_verification_code() # 6. 填入验证码并提交 otp_input.fill(code) page.get_by_role(button, name验证).click() # 7. 断言登录成功 expect(page.locator(.dashboard)).to_be_visible(timeout15000) print(f登录成功邮箱别名{email_alias}) browser.close()这段代码看起来不长但已经把核心链路都覆盖了。从第4步开始页面已经渲染了验证码输入框但邮件可能还没到所以第5步用Mailosaur的get方法等待。get方法的timeout参数会阻塞到邮件到达或超时这个等待时间和Playwright自身的等待是两个独立的时钟需要注意一下整体测试时长。我这里把验证码获取超时设为60秒是因为个别邮件服务在高峰期可能会有几十秒的投递延迟。3.4 利用Playwright轮询让代码更健壮实际跑多了你会发现Mailosaur的get方法确实能等邮件但它是一锤子买卖如果一次调用超时就会抛异常整个测试直接失败。而真实场景中邮件可能刚好在超时边缘到达或者Mailosaur查询过快先查了一次没查到就结束。所以我更推荐把“获取验证码”改写成带重试的轮询函数而不是只靠一次get。def fetch_verification_code_with_retry(client, server_id, email, start_time, max_wait60): deadline time.time() max_wait while time.time() deadline: try: message client.messages.get( server_idserver_id, criteriaSearchCriteria(sent_toemail), options{receivedAfter: start_time.isoformat()} ) code message.extract_passcode() if code: return code except Exception: pass time.sleep(2) raise TimeoutError(等待2FA邮件超时)这里面的关键点是每次查询都带上receivedAfter并且每次都生成新的查询条件。因为Mailosaur的查询接口会返回最近匹配的邮件如果同一封邮件被重复命中你可能每次都提取到同一个已用过的验证码看起来没问题实则已经过期。通过receivedAfter限制时间窗口再配合轮询间隔能最大程度避免这种情况。4. 常见问题与排查技巧实录4.1 邮件到得没问题但验证码提取总是空值这种情况我排查过很多次原因基本集中在三类。第一类是邮件正文里验证码被加了不可见字符比如在数字中间插入了零宽空格或\u200b普通的\d正则匹配不上。解决方法是先把字符串中的不可见字符过滤掉import re clean_text re.sub(r[\u200b\u200c\u200d\ufeff], , raw_text)第二类是验证码出现在图片或CSS背景里纯文本版本里没有。这种只能找前端或后端同事要一份测试环境专用的纯文本邮件模板。如果必须解析HTML可以尝试定位包含“验证码”文本的相邻元素而不是全局扫数字。第三类是最容易被忽略的你提取到的是邮件里的“不支持回复”地址里面也可能有一串看起来像验证码的数字。比如支持邮箱中带123456example.com正则会把123456匹配出来。解决方法是先把邮件文本按行切分锁定包含关键词的那一行再提取数字。4.2 测试用例并发跑邮箱互相污染端到端测试一多肯定会并行跑。如果所有人都用同一个邮箱地址比如e2eserverid.mailosaur.net那测到一半你都不知道拿到的验证码是哪条用例触发的。这个问题很典型。解决办法很粗暴每个用例、甚至每次运行都生成一个全新的邮箱前缀。我前面代码里用uuid.uuid4().hex[:8]就是为了保证每次前缀都不同。生成之后把邮箱地址通过参数传给注册或登录操作。被测试系统不需要关心邮箱前缀是什么只要能收到验证码就行。另外Mailosaur后台默认会对邮件做保留如果测试环境长时间跑会产生大量历史邮件。虽然按receivedAfter过滤能避免大部分干扰但为了性能建议定期清理或把保留策略设短一点。这个在Mailosaur后台可以直接配置。4.3 等待时间设多少才合理这个问题没有绝对答案取决于你们邮件系统的投递速度。我用过的系统里最快的1秒内到最慢的接近30秒。建议初次落地时把超时时间放到60秒连续运行两周后看统计再决定能不能降。如果Mailosaur的get方法等待60秒还是收不到不要急着调大超时先做两件事。第一检查邮件是否真的发出去。去Mailosaur后台看服务器里有没有对应邮箱的邮件记录。如果根本没有问题出在被测系统不是自动化工具。常见原因是被测系统在测试环境走的是“假发信”逻辑邮件从未进入真实SMTP链路。第二检查邮箱前缀和发送地址是否一致。尤其当被测系统在验证码邮件里使用固定发件人或者把回复地址改掉时查询条件sentTo必须和注册时填写的邮箱完全一样大小写也要留意。4.4 headless模式下的疑难问题很多CI流水线会用headlessTrue跑Playwright省资源、稳定。但2FA测试在headless下更容易出问题原因倒不是验证码而是页面元素等待和渲染时序。最常见的现象是脚本在page.get_by_label(账号)这一行就报找不到元素但用headed模式跑就能通过。这往往是headless浏览器窗口尺寸、字体加载和可视区域渲染和正常浏览器有差异。可以先强制设置viewportpage.set_viewport_size({width: 1280, height: 720})同时尽量少依赖“不可见但存在于DOM”的定位方式用get_by_role或get_by_label这样更鲁棒。还有一个比较隐蔽的问题是某些登录系统会在headless环境触发安全校验比如滑块、设备指纹导致2FA邮件根本不发送。如果遇到这种情况可以先尝试用page.add_init_script去掉webdriver特征或者干脆在CI里也用headed模式加虚拟显示器比如xvfb-run。我不是鼓励绕过风控而是在测试环境里确保被测功能可测这个前提很重要。4.5 使用Trace Viewer定位具体卡点当脚本失败时最快的定位方式不是看一堆日志而是看Playwright的Trace Viewer。在写测试的时候给整个测试包一层page.context.tracing出错时能保存完整的操作录屏和DOM快照。context browser.new_context() context.tracing.start(screenshotsTrue, snapshotsTrue, sourcesTrue) # 执行测试... context.tracing.stop(pathtrace.zip)配合Mailosaur后台的收信记录你就能判断到底是页面没触发邮件、邮件到了但解析失败、还是验证码填了但被系统拒绝。这个“前后端分工”的排查思路建议每个人都养成习惯。5. 多场景扩展与实践建议5.1 提取邮件验证码的代码可以复用一旦把“获取2FA验证码”的代码封装成独立helper你会发现它不仅能用于登录测试还能用于注册、改密码、解绑设备、找回账号这些同样需要邮件验证码的流程。我通常会在测试项目里建一个mail_utils.py里面放两个函数wait_for_message和extract_code。所有用例都从这个文件导入避免每个文件都复制一段。如果你们系统对一个账号的验证码邮件做了防重放或有效期限制务必在测试用例里做好清理逻辑比如测试结束后注销用户、使用新的随机邮箱。否则同一账号多次测试可能会因为验证码过期、或服务端保存了上一次的验证码导致填入当前验证码依然报错。5.2 如果2FA不是邮件验证码而是TOTP邮件验证码只是2FA的一种实现。如果你被测系统用的是TOTP动态口令比如微信小程序扫码、Google Authenticator这类这套方案就不适用了。TOTP的自动化测试思路是另一个方向需要提前拿到测试用户的安全密钥Secret然后用pyotp生成动态码填入页面。import pyotp totp pyotp.TOTP(测试环境提供的Secret) code totp.now()这种方式对Mailosaur就没有依赖了反而需要被测系统在测试环境提供可控的Secret注入入口。考虑到很多企业级系统已经开始从短信验证码转向TOTP这是一个值得单独研究的领域。5.3 安全建议不要在日志里打印验证码最后说一个和测试代码本身无关、但容易被忽略的安全问题。验证码邮件包含敏感信息Mailosaur的API Key权限要尽可能小最好单独创建一个只能访问某个Server的Key而不要使用账号级全量Key。测试代码里不要直接打印验证码或邮件原文尤其在持续集成日志中这些信息会留存很长时间。如果遇到失败要调试优先用本地Trace Viewer把日志级别降到最低。另外测试数据本身也应该像生产数据一样保护。Mailosaur虚拟邮箱里的邮件内容虽然是测试产生的但如果里面用了真实手机号、真实地址或真实姓名依然存在合规风险。建议所有测试账号统一使用虚构身份这样既能放心大规模并行测试也能避免后续被安全审计追问。在我实际项目里这套方案跑了将近半年从最开始每个用例手工去查邮箱到后来完全自动化最大的感触是2FA测试不是靠绕过它来省事而是用正确工具把“不确定的邮件”变成“可等待的接口”。Mailosaur和Playwright的组合正好把异步、不可控的真实邮件流程拉回到脚本可控的轨道上。如果你们也在被2FA测试卡住可以按照这套思路先跑通一个最小用例再逐步铺开。
阅读完成 · 觉得有帮助?