Pure Effect 实战:用 6 个原语让业务逻辑脱离 I/O,实现零 Mock 测试
你写过这样的测试吗?
jest.mock('../db', () => ({ findUser: jest.fn(), saveUser: jest.fn() }));
然后为了覆盖”邮箱已被注册”这个分支,你 mock 了 findUser 让它返回一个假用户,再 mock saveUser 确认它没有被调用。测试通过了,你松了口气——但你真的测到了什么吗?
问题在于:mock 测试的是 mock 的行为,不是业务逻辑的正确性。 当真实数据库驱动换了一个版本、查询行为变了,你的 mock 还是返回同样的值,测试照绿,线上照崩。
Pure Effect 用另一种思路解决了这个问题:让业务逻辑返回一个描述 I/O 的纯数据对象,而不是真的执行 I/O。这样你可以在不碰数据库、不写 mock 的情况下,直接断言代码”打算做什么”。
安装与环境
Pure Effect 是纯 JavaScript/TypeScript 库,零外部依赖,安装仅需一行:
npm install pure-effect
导入你需要的原语:
import { Success, Failure, Command, Ask, Retry, Parallel, effectPipe, runEffect } from 'pure-effect';
就这么简单。整个库小于 1KB(min+gzip),没有构建工具要求,没有运行时依赖。
实战场景 1:用户注册流程的「逻辑与 I/O 分离」
先看一个典型的用户注册流程:验证输入 → 查重 → 保存。
用传统 async/await 写,I/O 和逻辑混在一起:
async function registerUser(input) {
const found = await db.findUser(input.email); // I/O 立即执行
if (found) throw new Error('Email in use');
return db.saveUser(input); // 又一个 I/O
}
用 Pure Effect 改写后,逻辑层只返回纯数据:
const validateRegistration = (input) => {
if (!input.email.includes('@')) return Failure('Invalid email.');
if (input.password.length < 8) return Failure('Password too short.');
return Success(input);
};
const findUser = (email) => {
const cmdFindUser = () => db.findUser(email);
return Command(cmdFindUser, (user) => Success(user));
};
const saveUser = (input) => {
const cmdSaveUser = () => db.saveUser(input);
return Command(cmdSaveUser, (saved) => Success(saved));
};
const ensureEmailAvailable = (user) =>
user ? Failure('Email already in use.') : Success(true);
const registerUserFlow = (input) =>
effectPipe(
validateRegistration,
() => findUser(input.email),
ensureEmailAvailable,
() => saveUser(input)
)(input);
注意到没有?findUser 和 saveUser 返回的是 Command 对象——它描述”我要调用 db.findUser“,但从未实际调用。effectPipe 把各个步骤串成一个流水线,每一步的输出喂给下一步。
真正执行的时刻发生在系统边界:
async function registerUser(input) {
const result = await runEffect(registerUserFlow(input));
if (result.type === 'Success') {
console.log('User created:', result.value);
} else {
console.error('Error:', result.error);
}
}
这一层叫命令式外壳(Imperative Shell)——整个应用中只有这里会执行 I/O。业务逻辑层是纯函数,永远不碰数据库。
实战场景 2:零 Mock 测试
传统测试需要 mock 整个 db 对象:
jest.mock('../db');
db.findUser.mockResolvedValue(null);
db.saveUser.mockResolvedValue({ id: 1 });
const result = await registerUser({ email: 'test@test.com', password: '12345678' });
expect(result).toEqual({ id: 1 });
// 你在测 mock 的行为,不是真实逻辑
Pure Effect 的测试完全不同。你直接断言返回的 Effect 树:
// 1. 验证输入校验失败
const badInput = { email: 'bad', password: '123' };
const result = registerUserFlow(badInput);
assert.deepEqual(result, Failure('Invalid email.', badInput));
// 2. 走查流水线,验证每一步的意图
const goodInput = { email: 'test@test.com', password: 'password123' };
const step1 = registerUserFlow(goodInput);
// 第一步:查用户
assert.equal(step1.type, 'Command');
assert.equal(step1.cmd.name, 'cmdFindUser');
// 模拟返回 null(用户不存在),走到下一步
const step2 = step1.next(null);
assert.equal(step2.type, 'Command');
assert.equal(step2.cmd.name, 'cmdSaveUser');
这里的关键区别:没有 mock,没有数据库连接,没有任何 I/O。 你只是读了一个描述行为的 JSON 对象,并断言它的结构。这个测试在 1 毫秒内执行完毕,且 100% 反映真实逻辑。
实战场景 3:完整的 API 路由方案
把前面所有概念组合起来,实现一个带 DI、重试、并发的生产级注册接口。
import { Success, Failure, Command, Ask, Retry, Parallel, effectPipe, runEffect } from 'pure-effect';
// ─── 领域逻辑(纯函数) ───
const validateInput = (input) => {
const errors = [];
if (!input.email?.includes('@')) errors.push('invalid_email');
if (!input.password || input.password.length < 8) errors.push('weak_password');
return errors.length > 0 ? Failure(errors) : Success(input);
};
const findUser = (email) => Ask((ctx) => {
const cmd = () => db[ctx.tenant].users.findByEmail(email);
return Command(cmd, (user) => user ? Failure('email_taken') : Success(true));
});
const createUser = (input) => Ask((ctx) => {
const cmd = () => db[ctx.tenant].users.create(input);
return Retry(
Command(cmd, (user) => Success(user)),
{ attempts: 2, delay: 100, backoff: 2 }
);
});
const sendWelcome = (user) => Ask((ctx) => {
const cmd = () => emailService.sendWelcome(user.email, ctx.locale);
return Command(cmd, () => Success(user));
});
const registerFlow = (input) => effectPipe(
validateInput,
() => findUser(input.email),
() => createUser(input),
(user) => sendWelcome(user)
)(input);
// ─── 路由层(命令式外壳) ───
app.post('/api/register', async (req, res) => {
const result = await runEffect(registerFlow(req.body), {
tenant: req.headers['x-tenant'] || 'default',
locale: req.headers['accept-language']?.split(',')[0] || 'en',
});
if (result.type === 'Failure') {
if (result.retryExhausted) return res.status(503).json({ error: 'Service unavailable' });
return res.status(400).json({ error: result.error });
}
res.status(201).json(result.value);
});
这个示例展示了 Pure Effect 的几个高级特性:
Ask上下文注入:tenant和locale从 HTTP 请求传到领域层,不需要通过函数参数逐层传递Retry重试:createUser在数据库写入失败时自动重试 2 次(100ms → 200ms 退避),且重试配置是可以断言的纯对象- 类型安全:TypeScript 下每个
Failure的 error 类型自动作为联合类型收集,runEffect的返回类型精确反映出所有可能的错误分支
最佳实践
1. 命令式外壳要薄:整个应用中只有最外层的路由/事件处理函数调用 runEffect。领域层永远不要直接执行 I/O——这让测试和审计变得极为容易。
2. Command 用命名函数:const cmdFindUser = () => ... 这种命名方式让断言时 step1.cmd.name === 'cmdFindUser' 自然可读。不要用匿名箭头函数直接传给 Command。
3. Ask 替代依赖注入框架:不需要 NestJS 的 DI 容器或 tsyringe。Ask 从运行时上下文中读取配置、租户、请求 ID,比构造函数注入更灵活——你可以在测试时传入测试 context,在生产时传入真实值。
4. Retry 配置也是数据:你可以在测试中创建一个带重试的 Effect,断言它的 options.attempts、options.delay、options.backoff,而无需真的等待重试发生。
5. 并发用 Parallel 而非 Promise.all:Promise.all 的错误处理是隐式的——一个分支 reject 后其他分支继续执行。Parallel 在首个 Failure 时短路,且错误信息更结构化。同时,Ask 的 context 自动流入所有分支,无需手动传递。
对比其他方案
| 维度 | Pure Effect | Effect-TS | plain async/await |
|---|---|---|---|
| 学习成本 | 6 个原语,一个下午 | 完整函数式生态,数周 | 基础语法,零学习 |
| 包体积 | <1 KB | 数百 KB | 无 |
| Mock 需求 | 零 | 零 | 必须 mock |
| 可重放性 | 原生支持 | 需额外设置 | 不支持 |
| OpenTelemetry | 内置 hooks | 内置 | 需手动 |
| 类型精度 | 全自动错误联合 | 全自动错误联合 | 无 |
如果你只需要解决”测试 async 代码时要写一堆 mock”这一个痛点,Pure Effect 比 Effect-TS 轻量得多。如果你的项目需要用 fibers、流处理、结构化并发,Effect-TS 是更合适的选择。
总结
Pure Effect 用 6 个原语(Success、Failure、Command、Ask、Retry、Parallel)和不到 1KB 的体积,解决了 async 代码测试中一个根深蒂固的问题:I/O 和逻辑的耦合。
它把业务逻辑变成纯数据——可以断言、可以重放、可以组合——而真正执行 I/O 的时刻被推迟到系统边界的一层薄外壳中。不写 mock、不碰数据库、测试在毫秒级完成且完全反映真实逻辑——这不是魔法,只是把”函数调用”换成了”描述函数的对象”。
- GitHub: aycangulez/pure-effect
- npm: pure-effect
- 网站: pure-effect.org