强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

函数式编程艺术 / 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) ≡ xx + 0 == x
逆元f(x, inv(x)) ≡ ex + (-x) == 0
往返decode(encode(x)) ≡ xJSON.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定义属性,自动生成测试用例
QuickCheckHaskell 经典 PBT 库
HypothesisPython PBT 库
fast-checkJavaScript PBT 库
依赖注入减少 mock 的函数式技术

扩展阅读

  1. Property-Based Testing in a Screencast Editor — Oskar Wickström
  2. Hypothesis 文档 — Python PBT
  3. fast-check 文档 — JavaScript PBT
  4. QuickCheck — Haskell PBT

下一章17 实际应用