函数式编程艺术 / 16 函数式测试
16 函数式测试
“纯函数天生就是可测试的——相同的输入永远产生相同的输出,无需 mock。”
16.1 函数式测试的优势
16.1.1 纯函数测试 vs 不纯函数测试
| 维度 | 纯函数 | 不纯函数 |
|---|---|---|
| Mock | 不需要 | 必须 mock 外部依赖 |
| 可重复性 | 100% 可重复 | 可能受环境影响 |
| 并行测试 | 安全 | 可能冲突 |
| 速度 | 极快(无 I/O) | 可能慢(I/O) |
| 测试数量 | 典型场景几组数据 | 需要覆盖各种状态 |
16.1.2 测试金字塔
/ E2E \ ← 少量(纯函数内核减少需求)
/ 集成测试 \ ← 适量(测试组合和 I/O 边界)
/ 单元测试 \ ← 大量(纯函数测试)
/ Property-based \ ← 补充(自动生成测试用例)
16.2 单元测试
16.2.1 基本测试示例
JavaScript(使用 Jest):
// 纯函数
const calculateDiscount = (price, customerType) => {
const rates = { regular: 0.05, premium: 0.10, vip: 0.15 };
return price * (1 - (rates[customerType] || 0));
};
// 测试
describe('calculateDiscount', () => {
test('regular customer gets 5% discount', () => {
expect(calculateDiscount(100, 'regular')).toBe(95);
});
test('premium customer gets 10% discount', () => {
expect(calculateDiscount(100, 'premium')).toBe(90);
});
test('vip customer gets 15% discount', () => {
expect(calculateDiscount(100, 'vip')).toBe(85);
});
test('unknown customer type gets no discount', () => {
expect(calculateDiscount(100, 'unknown')).toBe(100);
});
test('zero price returns zero', () => {
expect(calculateDiscount(0, 'vip')).toBe(0);
});
});
Python(使用 pytest):
def calculate_discount(price, customer_type):
rates = {'regular': 0.05, 'premium': 0.10, 'vip': 0.15}
return price * (1 - rates.get(customer_type, 0))
def test_regular_discount():
assert calculate_discount(100, 'regular') == 95.0
def test_premium_discount():
assert calculate_discount(100, 'premium') == 90.0
def test_vip_discount():
assert calculate_discount(100, 'vip') == 85.0
def test_unknown_type():
assert calculate_discount(100, 'unknown') == 100.0
def test_zero_price():
assert calculate_discount(0, 'vip') == 0.0
# 使用参数化测试减少重复
import pytest
@pytest.mark.parametrize("price, customer_type, expected", [
(100, 'regular', 95),
(100, 'premium', 90),
(100, 'vip', 85),
(100, 'unknown', 100),
(0, 'vip', 0),
])
def test_calculate_discount(price, customer_type, expected):
assert calculate_discount(price, customer_type) == expected
Rust:
fn calculate_discount(price: f64, customer_type: &str) -> f64 {
let rate = match customer_type {
"regular" => 0.05,
"premium" => 0.10,
"vip" => 0.15,
_ => 0.0,
};
price * (1.0 - rate)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_regular_discount() {
assert_eq!(calculate_discount(100.0, "regular"), 95.0);
}
#[test]
fn test_premium_discount() {
assert_eq!(calculate_discount(100.0, "premium"), 90.0);
}
#[test]
fn test_unknown_type() {
assert_eq!(calculate_discount(100.0, "unknown"), 100.0);
}
}
16.3 Property-Based Testing(基于属性的测试)
Property-Based Testing(PBT) 不是测试具体的输入输出,而是测试函数的属性(不变量),让工具自动生成测试用例。
16.3.1 核心思想
传统测试:手动选择几个测试用例 PBT:定义属性,工具自动生成成百上千个随机输入
16.3.2 常见属性
| 属性 | 说明 | 示例 |
|---|---|---|
| 恒等 | f(x) ≡ x 对某些操作 | reverse(reverse(xs)) == xs |
| 幂等 | f(f(x)) ≡ f(x) | sort(sort(xs)) == sort(xs) |
| 交换律 | f(a, b) ≡ f(b, a) | a + b == b + a |
| 结合律 | f(f(a, b), c) ≡ f(a, f(b, c)) | (a + b) + c == a + (b + c) |
| 单位元 | f(x, e) ≡ x | x + 0 == x |
| 逆元 | f(x, inv(x)) ≡ e | x + (-x) == 0 |
| 往返 | decode(encode(x)) ≡ x | JSON.parse(JSON.stringify(x)) |
| 不变性 | f 不改变某些属性 | length(filter(p, xs)) <= length(xs) |
16.3.3 JavaScript 示例(fast-check)
const fc = require('fast-check');
// 测试 reverse 的属性
describe('reverse properties', () => {
// 属性 1:反转两次得到原数组
test('reverse(reverse(xs)) == xs', () => {
fc.assert(fc.property(
fc.array(fc.integer()),
(xs) => JSON.stringify([...xs].reverse().reverse()) === JSON.stringify(xs)
));
});
// 属性 2:反转不改变长度
test('length stays the same', () => {
fc.assert(fc.property(
fc.array(fc.integer()),
(xs) => [...xs].reverse().length === xs.length
));
});
// 属性 3:反转的第一个等于原来的最后一个
test('first becomes last', () => {
fc.assert(fc.property(
fc.array(fc.integer()).filter(arr => arr.length > 0),
(xs) => [...xs].reverse()[0] === xs[xs.length - 1]
));
});
});
// 测试排序的属性
describe('sort properties', () => {
// 属性:排序后是有序的
test('sorted array is ordered', () => {
fc.assert(fc.property(
fc.array(fc.integer()),
(xs) => {
const sorted = [...xs].sort((a, b) => a - b);
return sorted.every((val, i) => i === 0 || val >= sorted[i - 1]);
}
));
});
// 属性:排序不改变长度
test('sort preserves length', () => {
fc.assert(fc.property(
fc.array(fc.integer()),
(xs) => [...xs].sort().length === xs.length
));
});
// 属性:排序是幂等的
test('sort is idempotent', () => {
fc.assert(fc.property(
fc.array(fc.integer()),
(xs) => {
const once = [...xs].sort((a, b) => a - b);
const twice = [...once].sort((a, b) => a - b);
return JSON.stringify(once) === JSON.stringify(twice);
}
));
});
});
16.3.4 Python 示例(Hypothesis)
from hypothesis import given, strategies as st
# 测试反转的属性
@given(st.lists(st.integers()))
def test_reverse_twice(xs):
assert list(reversed(list(reversed(xs)))) == xs
@given(st.lists(st.integers()))
def test_reverse_preserves_length(xs):
assert len(list(reversed(xs))) == len(xs)
@given(st.lists(st.integers()))
def test_sort_is_idempotent(xs):
once = sorted(xs)
twice = sorted(once)
assert once == twice
@given(st.lists(st.integers()))
def test_sort_preserves_elements(xs):
assert sorted(xs) == sorted(set(xs)) or len(sorted(xs)) >= len(set(xs))
# 更复杂的属性
@given(st.lists(st.integers()), st.lists(st.integers()))
def test_concat_length(xs, ys):
assert len(xs + ys) == len(xs) + len(ys)
@given(st.text())
def test_encode_decode_roundtrip(s):
encoded = s.encode('utf-8')
decoded = encoded.decode('utf-8')
assert decoded == s
16.3.5 Haskell 示例(QuickCheck)
import Test.QuickCheck
-- 测试反转的属性
prop_reverse_twice :: [Int] -> Bool
prop_reverse_twice xs = reverse (reverse xs) == xs
prop_reverse_length :: [Int] -> Bool
prop_reverse_length xs = length (reverse xs) == length xs
-- 测试排序
prop_sort_ordered :: [Int] -> Bool
prop_sort_ordered xs = isSorted (sort xs)
where
isSorted [] = True
isSorted [_] = True
isSorted (a:b:rest) = a <= b && isSorted (b:rest)
prop_sort_idempotent :: [Int] -> Bool
prop_sort_idempotent xs = sort (sort xs) == sort xs
-- 测试 map
prop_map_identity :: [Int] -> Bool
prop_map_identity xs = map id xs == xs
prop_map_compose :: [Int] -> Bool
prop_map_compose xs = map ((*2) . (+1)) xs == map (*2) (map (+1) xs)
-- 运行测试
main :: IO ()
main = do
quickCheck prop_reverse_twice
quickCheck prop_reverse_length
quickCheck prop_sort_ordered
quickCheck prop_sort_idempotent
16.3.6 Rust 示例(proptest)
use proptest::prelude::*;
proptest! {
#[test]
fn test_reverse_twice(xs in prop::collection::vec(any::<i32>(), 0..100)) {
let reversed: Vec<_> = xs.iter().rev().cloned().collect();
let double_reversed: Vec<_> = reversed.iter().rev().cloned().collect();
prop_assert_eq!(xs, double_reversed);
}
#[test]
fn test_sort_preserves_length(xs in prop::collection::vec(any::<i32>(), 0..100)) {
let mut sorted = xs.clone();
sorted.sort();
prop_assert_eq!(xs.len(), sorted.len());
}
#[test]
fn test_sort_idempotent(xs in prop::collection::vec(any::<i32>(), 0..100)) {
let mut once = xs.clone();
once.sort();
let mut twice = once.clone();
twice.sort();
prop_assert_eq!(once, twice);
}
}
16.4 生成器(Generators)
PBT 工具使用生成器产生随机测试数据。
16.4.1 常用生成器
| 生成器 | 说明 |
|---|---|
integer(min, max) | 整数范围 |
float(min, max) | 浮点数范围 |
string() | 字符串 |
array(gen) | 数组 |
object({key: gen}) | 对象 |
oneOf(gens) | 选择一个生成器 |
frequency([(weight, gen)]) | 加权选择 |
recursive(gen, fn) | 递归生成 |
filter(gen, pred) | 过滤生成器 |
16.4.2 自定义生成器
const fc = require('fast-check');
// 自定义用户生成器
const userGen = fc.record({
id: fc.integer({ min: 1 }),
name: fc.string({ minLength: 1, maxLength: 50 }),
email: fc.emailAddress(),
age: fc.integer({ min: 0, max: 150 }),
active: fc.boolean(),
});
// 自定义树结构生成器
const treeGen = fc.letrec(tie => ({
value: fc.integer(),
children: fc.oneof(
{ depthFactor: 0.5, maxDepth: 5 },
fc.constant([]),
fc.array(tie('node'), { maxLength: 3 })
),
node: fc.record({
value: tie('value'),
children: tie('children'),
})
})).node;
// 使用自定义生成器
fc.assert(fc.property(userGen, (user) => {
return user.name.length > 0 && user.age >= 0;
}));
from hypothesis import strategies as st
from string import ascii_letters
# 自定义策略
@st.composite
def user_strategy(draw):
return {
'id': draw(st.integers(min_value=1)),
'name': draw(st.text(alphabet=ascii_letters, min_size=1, max_size=50)),
'email': draw(st.emails()),
'age': draw(st.integers(min_value=0, max_value=150)),
'active': draw(st.booleans()),
}
# 使用
@given(user_strategy())
def test_user_valid(user):
assert len(user['name']) > 0
assert 0 <= user['age'] <= 150
16.5 Mock vs 依赖注入
16.5.1 函数式风格减少 Mock
// ❌ 需要 Mock
const fetchUserData = async (userId) => {
const response = await fetch(`/api/users/${userId}`);
return response.json();
};
// ✅ 依赖注入
const fetchUserData = (fetchFn) => async (userId) => {
const response = await fetchFn(`/api/users/${userId}`);
return response.json();
};
// 生产环境
const prodFetchUserData = fetchUserData(fetch);
// 测试环境(无需 Mock,直接注入)
const mockFetch = async (url) => ({
json: async () => ({ id: 1, name: 'Test User' })
});
const testFetchUserData = fetchUserData(mockFetch);
16.5.1 Haskell 的纯函数内核
-- 纯函数核心:不需要 Mock
processData :: [RawData] -> ProcessedData
processData = normalize . validate . transform
-- 只测试这个纯函数
testProcessData :: IO ()
testProcessData = do
assertEqual "empty input" (processData []) defaultResult
assertEqual "valid data" (processData validInput) expectedOutput
assertEqual "invalid data" (processData invalidInput) fallbackResult
16.6 测试 Monad
16.6.1 测试效果
-- 测试 IO 操作
-- 方式 1:将 IO 操作注入并 Mock
class Monad m => MonadDB m where
query :: String -> m [Record]
execute :: String -> m Int
-- 测试实现
newtype TestDB a = TestDB { runTestDB :: State [Record] a }
instance MonadDB TestDB where
query sql = TestDB $ get
execute sql = TestDB $ modify (drop 1) >> return 1
-- 使用测试数据库运行
testUserCreation :: Assertion
testUserCreation =
let result = evalState (runTestDB createUser) []
in assertEqual "user created" expectedUser result
// Reader Monad 测试
const createUserService = (deps) => ({
register: async (userData) => {
const user = await deps.db.save(userData);
await deps.mailer.send(user.email, 'Welcome!');
return user;
},
});
// 测试依赖
const testDeps = {
db: {
save: jest.fn().mockResolvedValue({ id: 1, email: '[email protected]' }),
},
mailer: {
send: jest.fn().mockResolvedValue(true),
},
};
// 测试
test('register sends welcome email', async () => {
const service = createUserService(testDeps);
await service.register({ email: '[email protected]', name: 'Test' });
expect(testDeps.db.save).toHaveBeenCalled();
expect(testDeps.mailer.send).toHaveBeenCalledWith('[email protected]', 'Welcome!');
});
16.7 业务场景
16.7.1 价格计算引擎测试
// 价格计算引擎的综合测试
const calculateTotalPrice = (items, discounts, taxRate) => {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const discountAmount = discounts.reduce((sum, d) =>
sum + (d.type === 'percentage' ? subtotal * d.value : d.value), 0);
const taxable = subtotal - discountAmount;
return taxable * (1 + taxRate);
};
// Property-based Tests
describe('calculateTotalPrice properties', () => {
test('total is non-negative for valid inputs', () => {
fc.assert(fc.property(
fc.array(fc.record({
price: fc.float({ min: 0.01, max: 1000 }),
quantity: fc.integer({ min: 1, max: 100 }),
})),
fc.array(fc.record({
type: fc.constantFrom('percentage', 'fixed'),
value: fc.float({ min: 0, max: 100 }),
})),
fc.float({ min: 0, max: 0.5 }),
(items, discounts, taxRate) => {
const total = calculateTotalPrice(items, discounts, taxRate);
return total >= 0;
}
));
});
test('adding items increases total', () => {
fc.assert(fc.property(
fc.array(fc.record({
price: fc.float({ min: 0.01, max: 100 }),
quantity: fc.integer({ min: 1, max: 10 }),
})),
fc.record({
price: fc.float({ min: 0.01, max: 100 }),
quantity: fc.integer({ min: 1, max: 10 }),
}),
(items, newItem) => {
const without = calculateTotalPrice(items, [], 0.1);
const withNew = calculateTotalPrice([...items, newItem], [], 0.1);
return withNew > without;
}
));
});
});
16.8 注意事项
| 注意事项 | 说明 |
|---|---|
| 属性选择 | 选择有意义的属性,不是所有属性都值得测试 |
| 生成器质量 | 生成的数据要覆盖边界情况 |
| 缩小反例 | PBT 工具会自动缩小失败的用例 |
| 测试速度 | PBT 比传统测试慢,合理设置测试数量 |
| 浮点数 | 浮点数比较需要使用近似相等 |
16.9 小结
| 要点 | 说明 |
|---|---|
| 纯函数测试 | 无需 mock,直接测试输入输出 |
| Property-Based Testing | 定义属性,自动生成测试用例 |
| QuickCheck | Haskell 经典 PBT 库 |
| Hypothesis | Python PBT 库 |
| fast-check | JavaScript PBT 库 |
| 依赖注入 | 减少 mock 的函数式技术 |
扩展阅读
- Property-Based Testing in a Screencast Editor — Oskar Wickström
- Hypothesis 文档 — Python PBT
- fast-check 文档 — JavaScript PBT
- QuickCheck — Haskell PBT
下一章:17 实际应用