凌晨1点47分,手机震得像马达,安全同事在群里甩了一张截图:某个已退出的用户,拿历史的token居然还能调接口。我第一反应不可能——前端明明在退出时调了localStorage.clear()。可事实就摆在那里,值班运维已经把那个token的日志拉了出来。那天晚上我从被窝爬出来连上VPN,在Chrome DevTools里反复重现,直到天蒙蒙亮才揪出根因。这场事故逼着我们用Playwright搭建了一套localStorage自动化测试体系,把各种清除策略焊死在版本迭代里。
问题拆解:localStorage的“幽灵数据”
我们应用的前端登录机制很常规:登录成功,后端返回JWT,前端存进localStorage,axios拦截器每次请求带上。退出登录时,前端调用localStorage.removeItem('token')。看起来滴水不漏,为什么token还会滞留?
复现路径是这样的:用户在同一浏览器打开多个标签页,在标签页A点击退出,removeItem执行了,A也跳转到登录页。但标签页B仍停留在需要登录的页面上,它从localStorage读到了token(因为A的操作已经清掉,B读取是null),可B之前已经在内存里持有了一份token的副本——在一个Vuex store里。B页面的axios拦截器继续使用内存中的token发请求,后端验签通过,直到我们手动把那个token拉入黑名单。
常规的单元测试根本覆盖不到这个场景。Jest + jsdom模拟的localStorage是单页面的,测不出多标签行为。人肉回归更靠不住,没人会反复开着五个标签页做退出登录测试。这里的根因是:离开了真实浏览器的多页面环境,localStorage的持久化与清除策略就是一笔糊涂账。必须用端到端测试工具在真实浏览器里验证。
这就是Playwright上场的原因。
方案设计:用浏览器自动化把清除策略变成可重复的断言
我们对测试工具有几个硬要求:
- 多页面/多上下文:必须能模拟同一浏览器下的多个标签页,并操作各自的localStorage。
- 支持直接读写Storage:不能只模拟点击,要能在测试中断言localStorage的实际值。
- 跨标签通信检测:能监听
storage事件,验证一个标签页清除后,其他标签页是否响应。 - 稳定、快:团队不想等几分钟跑一轮。
Cypress其实也不错,但它对多标签页的支持直到10.x才勉强成熟,而且每个测试用例都在同一个浏览器上下文里,隔离性偏高,反而难以模拟真实的标签页混用。Selenium就别提了——操作localStorage还得走脚本注入,维护成本爆炸。Playwright的原生context.storageState() / page.evaluate(() => localStorage)让我们可以直接在测试中把localStorage当成一等公民来操作,而且创建多个page在同一context里,就是天然的多标签页环境。
架构思路很简单:把localStorage策略拆成三大断言域——持久化、主动清除、被动清除。持久化验证刷新/重开后token依然在;主动清除验证退出登录、切换账号等场景removeItem确实执行;被动清除验证token过期或跨标签退出时,其他页面内存副本是否也被干掉。每个域写一个test.describe,测试数据用固定token和过期时间,不依赖真实后端。
核心实现:三个必须覆盖的典型场景
1. 验证登录刷新后localStorage持久化
这段代码解决“用户登录后关掉标签页再打开,token是否还在”的问题。我们模拟登录写入token,然后关闭页面,用同一context重新打开,断言token没有丢失。
import { test, expect } from '@playwright/test';
const AUTH_KEY = 'auth_token';
const SAMPLE_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxx';
test.describe('localStorage 持久化', () => {
test('token 在页面刷新/重开后仍然存在', async ({ context, page }) => {
// 写入 token 模拟登录成功
await page.goto('https://your-app.example.com');
await page.evaluate(({ key, value }) => {
localStorage.setItem(key, value);
}, { key: AUTH_KEY, value: SAMPLE_TOKEN });
// 关闭当前页,新建页面(模拟重开标签页)
await page.close();
const newPage = await context.newPage();
await newPage.goto('https://your-app.example.com');
// 读取 localStorage 验证 token 持久化
const token = await newPage.evaluate((key) => {
return localStorage.getItem(key);
}, AUTH_KEY);
expect(token).toBe(SAMPLE_TOKEN);
});
});
这里用context.newPage()在同一浏览器上下文下新建页面,close之前的page,这完全模仿了用户关闭标签页再打开的行为。如果把token写进sessionStorage的话,这个测试会立刻暴露出来——因为sessionStorage跟标签页生命周期绑定。
2. 验证退出登录后localStorage被清空
这个场景直接对应那次宕机事故:点击退出按钮后,token必须消失,而且不能有任何副本残留在已经打开的其它标签页内存里。我们模拟两个标签页,一个执行退出,另一个检查是否还能读到token。
test.describe('localStorage 主动清除', () => {
test('退出登录后所有标签页的 token 都被移除', async ({ context }) => {
const pageOne = await context.newPage();
const pageTwo = await context.newPage();
// 两个标签页都预先写入 token
await pageOne.goto('https://your-app.example.com');
await pageOne.evaluate((key, value) => localStorage.setItem(key, value), AUTH_KEY, SAMPLE_TOKEN);
await pageTwo.goto('https://your-app.example.com');
await pageTwo.evaluate((key, value) => localStorage.setItem(key, value), AUTH_KEY, SAMPLE_TOKEN);
// 在第一个标签页模拟退出登录(清除 token)
await pageOne.evaluate((key) => {
localStorage.removeItem(key);
// 通过 storage 事件通知其他标签页(真实场景下前端需要监听该事件)
window.dispatchEvent(new StorageEvent('storage', {
key,
oldValue: SAMPLE_TOKEN,
newValue: null,
url: window.location.href,
storageArea: localStorage,
}));
}, AUTH_KEY);
// 第二个标签页应该感知到清除,并同步移除内存中的 token
// 这里我们直接用 evaluate 模拟前端监听 storage 后清空内存副本
await pageTwo.evaluate((key) => {
const handler = (e) => {
if (e.key === key && e.newValue === null) {
// 实际业务中这里会 clear Vuex/Redux 中的 token
localStorage.removeItem(key);
}
};
window.addEventListener('storage', handler);
}, AUTH_KEY);
// 等待事件传播
await pageTwo.waitForTimeout(100);
const tokenInPageTwo = await pageTwo.evaluate((key) => localStorage.getItem(key), AUTH_KEY);
expect(tokenInPageTwo).toBeNull();
});
});
这里故意借助dispatchEvent来模拟storage事件,因为在同一上下文里的同源页面,一个页面的localStorage变化确实会触发另一个页面的storage事件,但Playwright的page对象需要显式触发或监听到。真实的前端代码如果没监听storage事件,标签页B的内存副本就无法被感知清除,这就是当初事故的技术债——我们用这个测试把它堵上了。
3. 验证token过期后的自动清除
有些场景下我们会把token过期时间也存进localStorage,前端定时器检查到期后主动清除。这个测试验证“过期时间一到,token被清扫出门”。
test.describe('localStorage 被动清除', () => {
test('expiry 过期后 token 被清除', async ({ page }) => {
await page.goto('https://your-app.example.com');
// 生成一个已过期的时间戳(10分钟前)
const expiredAt = Date.now() - 10 * 60 * 1000;
await page.evaluate(({ key, token, expiry }) => {
localStorage.setItem(key, token);
localStorage.setItem('token_expiry', expiry.toString());
}, { key: AUTH_KEY, token: SAMPLE_TOKEN, expiry: expiredAt });
// 触发前端定时清除逻辑(假设 app 初始化时会检查 expiry)
await page.goto('https://your-app.example.com'); // 重新载入触发检查
const token = await page.evaluate((key) => localStorage.getItem(key), AUTH_KEY);
expect(token).toBeNull();
});
});
这个测试有一个非常微妙的点:时间的“假”我们必须通过直接写token_expiry来控制,而不是真的去等服务端返回一个过期时间,这样测试才能稳定在毫秒级运行,不受网络波动影响。
踩坑记录
坑1:同一个context下localStorage残留导致测试间污染
现象:第一个持久化测试通过后,第二个测试运行时localStorage里居然还有前一个测试的token。排查了半小时,发现Playwright默认会给每个test文件创建新的context,但同一个文件内的test共享同一个context实例。如果上个测试写入了localStorage,下个测试开始时不手动清理,就会互相干扰。
解决:在每个test最前面加上context.clearCookies()但这对localStorage没用,必须加:
test.beforeEach(async ({ page }) => {
await page.goto('about:blank');
await page.evaluate(() => localStorage.clear());
});
官方文档在storage隔离那章提了一句,但很多样例代码没写,新人很容易栽进去。
坑2:page.evaluate获取localStorage时页面未完全加载
现象:执行page.goto后立即读localStorage,偶尔拿到undefined,测试随机红。一开始以为是token写入失败,加了waitForLoadState还是概率性出现。最后发现当页面是SPA时,前端脚本可能还没完成初始化,我们的page.evaluate就被调用了,而在某些路由守卫中恰恰会先清空localStorage再跳转,这导致读到的是清空后的值。
解决:插入一个专门的轮询等待,等待前端插入特定DOM或标志位,确认应用初始化完毕后再检查localStorage。
await page.waitForFunction(() => document.querySelector('#app-ready') !== null);
const token = await page.evaluate(() => localStorage.getItem('auth_token'));
这个坑藏得非常深,外部完全看不到报错,只能靠对应用加载时序的理解硬啃。
效果验证
这三组测试跑起来之前,我们的localStorage相关bug每两个迭代冒出3-4个。引入Playwright自动化回归后,最近6个迭代里,这类问题被拦截了5次,P0事故归零。测试耗时5秒完成全部场景,比原先人肉跨标签操作快了几十倍。下表是直观对比:
| 场景 | 人肉测试耗时 | 自动化耗时 | 发现缺陷数(近3个月) |
|---|---|---|---|
| 持久化刷新 | 2分钟 | 0.8秒 | 2 |
| 多标签退出 | 4分钟(手动开标签页) | 1.5秒 | 3 |
| 过期自动清除 | 3分钟(改系统时间) | 1.2秒 | 1 |
团队里现在提交PR只要涉及Token存储逻辑,CI流水线会强制跑这套Playwright用例,再也没有半夜被叫醒看token泄露的恐惧。
可直接用的代码/工具
把上面的测试抽象成一个函数,改个AUTH_KEY和url就能在你项目里跑起来:
export async function verifyLocalStorageCleanup(context, page, storageKey, baseURL) {
// 注入token、打开多标签、测试退出清除、过期清除...
// 直接复制上面三段,替换参数即可
}
把这个函数放进setup文件,所有localStorage相关的测试都能复用。
#Playwright #前端自动化 #localStorage #Web安全 #测试左移
关于作者
我是宝富,一个在后端和前端缝隙里反复横跳的架构老狗,坚信能用自动化锁死的策略,绝不靠人肉记忆。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba… — 如果这篇文章让你不用半夜爬起来排查token,请我喝杯咖啡。
提供服务:Python后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege