从逻辑到界面:Chrome 扩展程序的单元测试实战
测试驱动的重构
让代码更清晰:逻辑与视图分离
开发 Chrome 扩展时,我们很容易将所有代码都写在一起。比如一个划词翻译功能,可能一个函数就包揽了所有工作:监听鼠标选择、获取文本、请求翻译 API、计算弹窗位置,最后再创建一个 DOM 元素显示结果。
这种“一锅端”的写法在功能简单时很诱人,但随着功能变复杂,代码会变得像一团乱麻。修复一个 bug 可能会引发新的问题,添加新功能也举步维艰。最头疼的是,这样的代码几乎无法进行自动化测试,因为它的核心逻辑和页面显示紧紧地绑在了一起。
要解决这个问题,关键在于将“做什么”(计算、决策)与“怎么做”(显示、更新页面)分开。
纯函数与副作用
要理解如何分离代码,我们首先要认识两个概念:纯函数和副作用。
纯函数 (Pure Function) 就像一个可靠的数学公式。只要你给它相同的输入,它永远返回相同的输出,并且在这个过程中不会对外界产生任何影响。它不修改全局变量,不改变传入的参数,更不会去操作 DOM。
副作用 (Side Effect) 则是指函数除了返回值之外所做的一切额外操作。比如修改网页内容(DOM 操作)、向服务器发送请求(API 调用)、在控制台打印日志等。这些操作都让函数的行为变得不那么“纯粹”,结果也更难预测。
可测试性的关键,就在于尽可能多地使用纯函数来处理核心逻辑,并将有副作用的代码隔离开来。
| 特性 | 纯函数 | 有副作用的函数 |
|---|---|---|
| 输出 | 仅取决于输入参数 | 可能受外部状态影响 |
| 可预测性 | 高,结果稳定 | 低,结果可能变化 |
| 可测试性 | 非常容易,无需特殊环境 | 困难,通常需要模拟(Mock)浏览器环境 |
| 典型操作 | 数据转换、业务逻辑计算 | DOM 操作、API 请求、console.log |
MVC模式的启示
如何组织这些分离后的代码呢?我们可以借鉴经典的 MVC(模型-视图-控制器) 模式。
-
模型 (Model): 负责管理数据和业务逻辑。在我们的划词翻译插件中,Model 就是处理翻译逻辑的纯函数模块。它接收选中的文本,返回翻译结果,或者接收鼠标位置,计算出弹窗的坐标。它完全不关心这些数据最终会如何显示。
-
视图 (View): 负责用户界面。它就是我们创建的那个显示翻译结果的 DOM 弹窗。视图只负责“展示”,它从控制器那里接收数据,然后把它渲染到页面上。
-
控制器 (Controller): 充当模型和视图之间的桥梁。它监听用户的操作(比如鼠标划词),然后调用模型进行计算,拿到结果后,再把结果交给视图去显示。
通过这种方式,我们成功地将复杂的任务拆分成了三个专注各自职责的部分。
实战重构
理论说完了,我们来看代码。假设重构前,我们的代码是这样的:
// content_script.js (重构前)
document.addEventListener('mouseup', event => {
const selectedText = window.getSelection().toString().trim();
if (selectedText) {
// 1. 业务逻辑:调用 API
fetch(`https://api.example.com/translate?text=${selectedText}`)
.then(response => response.json())
.then(data => {
// 2. 渲染逻辑:创建和显示弹窗
const popup = document.createElement('div');
popup.id = 'translation-popup';
popup.textContent = data.translation;
popup.style.position = 'absolute';
// 3. 计算与渲染混合的逻辑
popup.style.top = `${event.pageY + 10}px`;
popup.style.left = `${event.pageX}px`;
document.body.appendChild(popup);
});
}
});
这段代码把所有事情都混在了一起,难以测试。现在,我们用刚才学到的原则来重构它。
首先,分离出纯粹的计算逻辑。计算弹窗位置的逻辑只依赖于鼠标事件的坐标,非常适合做成一个纯函数。
// logic.js (模型 Model)
export function calculatePopupPosition(event) {
// 这是一个纯函数:给定相同的 event,总会返回相同的坐标对象
return {
top: event.pageY + 10,
left: event.pageX,
};
}
接着,我们创建一个专门负责 UI 渲染的模块。
// view.js (视图 View)
export function showTranslationPopup(text, position) {
// 这个函数有副作用:它会操作 DOM
let popup = document.getElementById('translation-popup');
if (!popup) {
popup = document.createElement('div');
popup.id = 'translation-popup';
document.body.appendChild(popup);
}
popup.textContent = text;
popup.style.position = 'absolute';
popup.style.top = `${position.top}px`;
popup.style.left = `${position.left}px`;
popup.style.display = 'block';
}
export function hidePopup() {
const popup = document.getElementById('translation-popup');
if (popup) popup.style.display = 'none';
}
最后,让我们的 content_script.js 扮演控制器的角色,协调模型和视图。
// content_script.js (控制器 Controller)
import { calculatePopupPosition } from './logic.js';
import { showTranslationPopup, hidePopup } from './view.js';
document.addEventListener('mouseup', async (event) => {
const selectedText = window.getSelection().toString().trim();
if (selectedText) {
// 1. 调用模型进行纯计算
const position = calculatePopupPosition(event);
// 2. 处理副作用:API 请求
const response = await fetch(`https://api.example.com/translate?text=${selectedText}`);
const data = await response.json();
// 3. 调用视图更新 UI
showTranslationPopup(data.translation, position);
} else {
hidePopup(); // 如果没有选中文本,则隐藏弹窗
}
});
看,现在每个模块的职责都非常清晰。
logic.js 里的函数是纯粹的,我们可以轻松地为它编写单元测试,验证其计算是否正确,完全不需要启动浏览器。
view.js 专门负责和 DOM 打交道,所有“脏活累活”都在这里。虽然测试它仍然需要浏览器环境,但它的逻辑非常简单,就是接收数据并渲染。
content_script.js 作为控制器,逻辑清晰,负责整个流程的调度。这种分层结构让代码变得可维护和可测试。
一个好的自动化测试框架是和团队成员的能力相匹配,不是很难也不是太容易;是充分和开发建立协议和互信的,确保变化对测试的影响最小化;是充分融入现有工作流程,而不是独立出来自成体系;是高度封装,减少冗余无效工作,易于学习和 理解,可维护的框架体系;是能够交付使用测试体系。
在开发 Chrome 扩展时,以下哪个操作属于“副作用”(Side Effect)?
将核心逻辑封装在纯函数中的主要好处是什么?
通过将逻辑与渲染分离,我们为自动化测试铺平了道路,也让代码库的未来扩展变得更加容易。