引言
这是一个新的小系列,跟大家聊聊怎么让前端的单元测试变得“有用”,而不是那种写了等于没写的摆设。
使用DDD与TDD结合,打造前端有意义的单元测试
很多时候,我们的测试都停留在“点个按钮,看看页面变没变”,跟业务逻辑没啥关系。但如果把**领域驱动设计(DDD)和测试驱动开发(TDD)**结合起来,前端也能写出靠谱的测试,既能保证功能没问题,还能少写点没用的代码。
这篇文章,我会用一个简单的例子,带大家从业务建模开始,一步步写用例(UseCase),用TDD驱动代码实现,最后用React Hook把前端界面和业务逻辑连起来。整个过程会让前后端解耦,测试也能真正验证业务价值。
用例这个概念,其实不是DDD中的一部分,但为了方便理解,我会用这个概念。
一、为什么我要写TDD?
先说说前端开发里常见的糟心事:
- 测试覆盖难:组件逻辑和UI渲染混在一起,想单独测试业务逻辑都费劲。
- 测试价值低:测来测去都是"按钮点击了没",抓不住业务逻辑的核心。
- 测试维护难:需求改了,测试用例没人删,时间一长全是过时的测试。
而TDD呢,先写测试,再写业务代码,能让我们只写有用的代码——每行代码都有测试撑腰,需求没要的就不写,干净又省心。
谦卑对象模式
这就不得不提一个设计模式,谦卑对象模式。谦卑对象模式(Humble Object Pattern)是一种设计模式,用于将复杂逻辑从难以测试的组件中分离出来,以提高代码的可测试性和可维护性。其核心思想是将与用户界面、外部系统或复杂依赖相关的代码(难以测试的部分)剥离,保留一个“谦卑”的对象,只包含简单逻辑或直接调用,而将主要业务逻辑放入易于测试的独立对象中。
比如:前端的GUI展示部分难以测试,所以应尽量保持简单,只负责渲染数据。数据处理和业务逻辑则单独拆分到易于测试的模块中,这样既降低出错率,也方便单元测试。
而DDD能帮我们把业务抽出来,变成独立的“领域模型”和“用例”,让前端也有自己的逻辑层,跟界面分开。
二、实践路径:从业务建模到TDD
1. 先搞清楚业务:建个领域模型
第一步,咱们得从业务角度想想,核心是什么。比如说,我们要做一个任务管理系统,里面有“任务”,任务有标题、描述、状态(待办、进行中、已完成)。这些东西可以建个模型,叫Task
,只管业务规则,不管界面长啥样。
class Task {
constructor(id, title, description, status) {
this.id = id;
this.title = title;
this.description = description;
this.status = status;
}
// 业务规则:已完成的任务不能再改状态
changeStatus(newStatus) {
if (this.status === 'done' && newStatus !== 'done') {
throw new Error('已完成的任务不能随便改状态哦');
}
this.status = newStatus;
}
}
这个Task
就是个纯业务对象,比如“已完成的任务不能改状态”这种规则就写在里面,跟页面没半毛钱关系。
2. 写用例(UseCase):定义业务操作
接下来,把具体的业务操作封装成UseCase。比如“创建任务”是个常见的操作,咱们可以写个CreateTaskUseCase
,告诉系统怎么创建任务。
class CreateTaskUseCase {
constructor(taskRepository) {
this.taskRepository = taskRepository; // 模拟个仓库,存任务的地方
}
async execute(title, description) {
const task = new Task('task-001', title, description, 'todo'); // 默认待办状态
await this.taskRepository.save(task); // 存起来
return task;
}
}
这个用例就是前端跟后端交互的“合同”,告诉大家创建任务的步骤是什么。
3. 先写测试:TDD的“红灯”阶段
TDD的核心是先写测试,再写代码。咱们以CreateTaskUseCase
为例,写个测试,确保它能正常工作:
test('创建任务时应该有正确的标题和默认状态', async () => {
const taskRepository = { save: jest.fn() }; // 假装有个仓库
const useCase = new CreateTaskUseCase(taskRepository);
const task = await useCase.execute('买牛奶', '记得买全脂的');
expect(task.title).toBe('买牛奶');
expect(task.status).toBe('todo');
});
这时候运行测试,肯定挂,因为taskRepository
和代码细节还没写呢。
4. 写最小代码:让测试“绿灯”
然后,咱们写刚好能通过测试的代码。把taskRepository
模拟一下:
class MockTaskRepository {
async save(task) {
// 假装保存,啥也不干
}
}
再跑测试,就通过了。TDD就是要先“红”再“绿”,每步都踏实。
5. 重构:让代码更好看
测试过了,就可以优化一下。比如把硬编码的'task-001'
改成随机ID生成器,但前提是测试还得过。
三、用React Hook把业务和前端连起来
传统写法里,前端组件直接调API、改数据,乱七八糟。现在业务逻辑都在UseCase里了,组件只管“调用用例”和“显示结果”。
咱们写个useCreateTask
Hook,把用例塞进去:
function useCreateTask() {
const taskRepository = { save: async (task) => console.log('保存任务:', task) }; // 模拟仓库
const createTaskUseCase = new CreateTaskUseCase(taskRepository);
const createTask = async (title, description) => {
const task = await createTaskUseCase.execute(title, description);
console.log('任务创建成功:', task);
};
return { createTask };
}
然后在组件里用:
function TaskForm() {
const { createTask } = useCreateTask();
const [title, setTitle] = useState('');
const handleSubmit = async () => {
await createTask(title, '用户输入的任务');
setTitle(''); // 清空输入框
};
return (
<div>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<button onClick={handleSubmit}>创建任务</button>
</div>
);
}
组件只管界面和调用,具体怎么创建任务交给Hook和UseCase,干净又解耦。
测试的时候,咱们测UseCase或者Hook就行,不用管页面渲染。比如:
test('useCreateTask应该调用useCase', async () => {
const mockUseCase = { execute: jest.fn().mockResolvedValue({ title: '测试任务' }) };
const taskRepository = { save: jest.fn() };
const useCase = new CreateTaskUseCase(taskRepository);
mockUseCase.execute = useCase.execute;
const { createTask } = useCreateTask();
await createTask('测试任务', '描述');
expect(mockUseCase.execute).toHaveBeenCalledWith('测试任务', '描述');
});
这测的是业务逻辑,不是按钮点了啥效果。
四、DDD+TDD的好处
用了这套组合拳,开发体验和代码质量都上去了:
- 逻辑不乱了:业务规则都在模型和用例里,组件只管展示。
- 测试靠谱了:测的是业务行为,不是界面效果,出了问题一眼就能看出来。
- 代码干净了:TDD保证每行代码都有用,需求没了测试一删,代码也没了。
- 回归省心了:以前改个需求要手动测半天,现在跑测试就知道行不行。
五、总结
用DDD把业务抽象成模型和用例,再用TDD推着实现,前端也能像后端一样,写出健壮、可测试、好维护的代码。React Hook把界面和逻辑桥接起来,既模块化又好测。这不只是换个写法,更是换个思路。试试看吧,绝对有收获!
还有一件事,最近接触的工作内容是react 所以这里用这个做举例,但是说真的,vite系列的vitest的单元测试真的顶级好用,同样做单元测试,vue3的单元测试可覆盖率也会很容易更高。