testing-patterns
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseTesting Patterns & Effect Abstraction
测试模式与副作用抽象
Short version: model your “effects” as traits, inject them, keep core logic pure, and provide real + fake implementations. That’s the idiomatic Rust way; free monads aren’t a thing here.
简短总结:将你的“副作用”建模为trait,进行依赖注入,保持核心逻辑纯函数化,并提供真实实现与Fake实现。这是Rust的惯用方式;这里并不使用自由单子(free monads)。
Pattern
模式
- Define algebras as traits (ports).
- Implement adapters for prod (HTTP, DB, clock, FS) and for tests (fakes/mocks).
- Inject via generics (zero-cost, monomorphized) or trait objects () when you need late binding.
dyn Trait - Keep domain functions pure; pass in effect results or tiny capability traits.
- 将代数定义为trait(端口)。
- 实现适配器:生产环境适配(HTTP、数据库、时钟、文件系统)与测试环境适配(Fake/Mock)。
- 通过泛型注入(零开销,单态化),或在需要延迟绑定时使用trait对象()。
dyn Trait - 领域函数保持纯函数化;传入副作用结果或小型能力trait。
Minimal sync example
最小同步示例
rust
use std::time::{SystemTime, UNIX_EPOCH};
pub trait Clock {
fn now(&self) -> SystemTime;
}
pub trait Payments {
type Err;
fn charge(&self, cents: u32, card: &str) -> Result<String, Self::Err>; // returns ChargeId
}
pub struct Service<P, C> {
pay: P,
clock: C,
}
impl<P, C> Service<P, C>
where
P: Payments,
C: Clock,
{
pub fn bill(&self, card: &str, cents: u32) -> Result<String, P::Err> {
let _ts = self
.clock
.now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
// domain logic… (e.g., time-based rules)
self.pay.charge(cents, card)
}
}
// --- prod adapters ---
pub struct RealClock;
impl Clock for RealClock {
fn now(&self) -> SystemTime {
SystemTime::now()
}
}
pub struct StripeClient;
impl Payments for StripeClient {
type Err = String;
fn charge(&self, cents: u32, _card: &str) -> Result<String, Self::Err> {
// call real API
Ok(format!("ch_{cents}"))
}
}
// --- test fakes ---
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::time::{Duration, SystemTime};
struct FixedClock(SystemTime);
impl Clock for FixedClock {
fn now(&self) -> SystemTime {
self.0
}
}
struct FakePayments {
pub calls: RefCell<Vec<(u32, String)>>,
pub next: RefCell<Result<String, String>>,
}
impl Payments for FakePayments {
type Err = String;
fn charge(&self, cents: u32, card: &str) -> Result<String, Self::Err> {
self.calls.borrow_mut().push((cents, card.to_string()));
self.next.borrow_mut().clone()
}
}
#[test]
fn happy_path() {
let svc = Service {
pay: FakePayments {
calls: RefCell::new(vec![]),
next: RefCell::new(Ok("ch_42".into())),
},
clock: FixedClock(SystemTime::UNIX_EPOCH + Duration::from_secs(123)),
};
let id = svc.bill("4111...", 4200).unwrap();
assert_eq!(id, "ch_42");
}
}Prod wiring stays simple:
rust
let svc = Service { pay: StripeClient, clock: RealClock };rust
use std::time::{SystemTime, UNIX_EPOCH};
pub trait Clock {
fn now(&self) -> SystemTime;
}
pub trait Payments {
type Err;
fn charge(&self, cents: u32, card: &str) -> Result<String, Self::Err>; // returns ChargeId
}
pub struct Service<P, C> {
pay: P,
clock: C,
}
impl<P, C> Service<P, C>
where
P: Payments,
C: Clock,
{
pub fn bill(&self, card: &str, cents: u32) -> Result<String, P::Err> {
let _ts = self
.clock
.now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
// domain logic… (e.g., time-based rules)
self.pay.charge(cents, card)
}
}
// --- prod adapters ---
pub struct RealClock;
impl Clock for RealClock {
fn now(&self) -> SystemTime {
SystemTime::now()
}
}
pub struct StripeClient;
impl Payments for StripeClient {
type Err = String;
fn charge(&self, cents: u32, _card: &str) -> Result<String, Self::Err> {
// call real API
Ok(format!("ch_{cents}"))
}
}
// --- test fakes ---
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::time::{Duration, SystemTime};
struct FixedClock(SystemTime);
impl Clock for FixedClock {
fn now(&self) -> SystemTime {
self.0
}
}
struct FakePayments {
pub calls: RefCell<Vec<(u32, String)>>,
pub next: RefCell<Result<String, String>>,
}
impl Payments for FakePayments {
type Err = String;
fn charge(&self, cents: u32, card: &str) -> Result<String, Self::Err> {
self.calls.borrow_mut().push((cents, card.to_string()));
self.next.borrow_mut().clone()
}
}
#[test]
fn happy_path() {
let svc = Service {
pay: FakePayments {
calls: RefCell::new(vec![]),
next: RefCell::new(Ok("ch_42".into())),
},
clock: FixedClock(SystemTime::UNIX_EPOCH + Duration::from_secs(123)),
};
let id = svc.bill("4111...", 4200).unwrap();
assert_eq!(id, "ch_42");
}
}生产环境的组装非常简单:
rust
let svc = Service { pay: StripeClient, clock: RealClock };Trait objects (dynamic dispatch when needed)
Trait对象(需要时使用动态分发)
rust
pub struct Svc<'a> {
pay: &'a dyn Payments<Err = String>,
clock: &'a dyn Clock,
}Ensure traits are object-safe (no generic methods, no returns).
impl Traitrust
pub struct Svc<'a> {
pay: &'a dyn Payments<Err = String>,
clock: &'a dyn Clock,
}确保trait是对象安全的(无泛型方法,无返回值)。
impl TraitAsync Effects
异步副作用
- macro – ergonomic, small overhead:
async-trait
rust
use async_trait::async_trait;
#[async_trait]
pub trait Http {
async fn get(&self, url: &str) -> Result<String, anyhow::Error>;
}- RPITIT (return-position in traits) for macro-free, low-overhead code:
impl Trait
rust
use core::future::Future;
pub trait Http {
fn get(&self, url: &str) -> impl Future<Output = Result<String, anyhow::Error>> + Send;
}Pick #1 for simplicity, #2 if you want zero-macro builds and control over allocations.
- 宏 – 符合人体工程学,开销小:
async-trait
rust
use async_trait::async_trait;
#[async_trait]
pub trait Http {
async fn get(&self, url: &str) -> Result<String, anyhow::Error>;
}- RPITIT(trait中返回位置使用) – 无需宏,开销低:
impl Trait
rust
use core::future::Future;
pub trait Http {
fn get(&self, url: &str) -> impl Future<Output = Result<String, anyhow::Error>> + Send;
}如果追求简单选择方案1,如果希望零宏构建并控制内存分配则选择方案2。
Mocks vs. Fakes
Mock与Fake
- Prefer hand-rolled fakes/stubs or in-memory adapters.
- If you need expectation-based mocks:
- for general traits.
mockall - /
wiremockfor HTTP.httpmock
- For FS/DB, lean on temp dirs (,
tempfile) or in-memory backends.assert_fs
- 优先选择手动实现的Fake/Stub或内存适配器。
- 如果需要基于预期的Mock:
- 通用trait使用。
mockall - HTTP相关使用/
wiremock。httpmock
- 通用trait使用
- 文件系统/数据库相关,依赖临时目录(、
tempfile)或内存后端。assert_fs
Tips
小贴士
- Don’t over-abstract; put traits only at IO boundaries (time, network, FS, DB).
- In async code, wrap shared deps in when needed.
Arc<dyn Trait + Send + Sync> - Return owned data () from trait methods to avoid lifetime tangles.
Vec<T> - Keep domain logic as pure functions over data; invoke effects at the edges.
- For CLI flows, lean on (
tests/support/mod.rs,CliFixture, and helpers that pre-wireRemoteRepo/SK_CACHE_DIR) so every integration test spins up the same deterministic temp repos.SK_CONFIG_DIR
- 不要过度抽象;仅在IO边界(时间、网络、文件系统、数据库)处使用trait。
- 在异步代码中,必要时将共享依赖包装在中。
Arc<dyn Trait + Send + Sync> - 从trait方法返回自有数据()以避免生命周期纠缠。
Vec<T> - 领域逻辑保持为基于数据的纯函数;在边缘层调用副作用。
- 对于CLI流程,依赖中的工具(
tests/support/mod.rs、CliFixture,以及预配置RemoteRepo/SK_CACHE_DIR的辅助函数),确保每个集成测试都启动相同的确定性临时仓库。SK_CONFIG_DIR
Testing Standards
测试标准
- Coverage gate: 45% (cargo llvm-cov). CI currently enforces . Treat that as the floor, not the ceiling—once
cargo llvm-cov --fail-under-lines 45sits comfortably above a higher percentage, ratchet the workflow file and avoid ever lowering the bar without a written justification.main - Business logic ⇒ property tests. Use for any non-trivial domain rule (scheduling, diffing, parsing, state machines, etc.). Unit tests that check a couple of examples aren’t enough; capture invariants as properties.
proptest - Structure: keep property tests in modules alongside unit tests, e.g.:
tests
rust
#[cfg(test)]
mod prop_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn fee_is_never_negative(amount in 0u64..) {
let fee = compute_fee(amount);
prop_assert!(fee >= 0);
}
}
}- Make generators realistic. Compose ,
any::<T>(), or custom strategies so you’re exercising edge cases (empty, max values, random ordering).prop::collection - Integration tests still matter. Use harnesses under or
tests/to cover end-to-end flows (e.g., env lifecycle, DB migrations) but keep them deterministic—no real network calls.crates/*/tests/
When in doubt, assume reviewers will ask “where’s the property test?” and “what’s the coverage delta?” Bake both answers into the PR.
- 覆盖率门槛:45%(通过cargo llvm-cov)。 CI当前强制执行。将其视为最低要求而非上限——一旦
cargo llvm-cov --fail-under-lines 45分支的覆盖率稳定高于此比例,就更新工作流文件,且除非有书面理由,否则不得降低门槛。main - 业务逻辑 ⇒ 属性测试。 任何非平凡的领域规则(调度、差异对比、解析、状态机等)都使用。仅检查几个示例的单元测试是不够的;需将不变量提炼为属性。
proptest - 结构: 将属性测试与单元测试放在同一模块中,例如:
tests
rust
#[cfg(test)]
mod prop_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn fee_is_never_negative(amount in 0u64..) {
let fee = compute_fee(amount);
prop_assert!(fee >= 0);
}
}
}- 生成器要贴近真实场景。 组合、
any::<T>()或自定义策略,以覆盖边缘情况(空值、最大值、随机排序)。prop::collection - 集成测试仍然重要。 使用或
tests/下的测试工具来覆盖端到端流程(例如环境生命周期、数据库迁移),但要保持确定性——不进行真实的网络调用。crates/*/tests/
如有疑问,默认假设评审者会问“属性测试在哪里?”以及“覆盖率变化是多少?”请在PR中准备好这两个问题的答案。
Property-Based Testing Workflow (proptest)
基于属性的测试工作流(proptest)
When you add or refresh property tests, approach the work like a mini bd task—not a plan-tool exercise. Claim/track the effort via ( → → ), and keep the following loop tight:
bdreadyupdate ... --status in_progressclose- Identify high-value properties. Start with the public API or core modules. Look for invariants (round trips, idempotence, ordering guarantees, etc.) that actually buy us something. Skip trivial wrappers.
- Study how the code is used. Before writing a property, grep the repo to see how that function/struct is consumed so your strategy stays within real-world preconditions.
- Write precise cases. Small number of high-signal tests beats shotgun suites. Favor clear strategies (e.g.,
proptest,prop::collection,any::<T>()) and only add bounds when the code truly requires them.from_regex - Lean on real generators. Model inputs with strategies (vecs, maps, enums) instead of manual loops like . Let the generator produce arbitrarily long lists/arrays (only constrain them when the production code has a hard limit) so burn-in runs and shrink output stay meaningful.
for skip in 0..5 - Run and reflect. (or the specific crate) with the new property tests. If a proptest failure exposes a gap, either fix the bug or constrain the strategy with a documented reason.
cargo test
Keep the tests maintainable: name the property after the behavior it documents, describe why the invariant matters in a short comment when it isn’t obvious, and prefer deterministic shrink-friendly strategies. The expectation is that every non-trivial business rule eventually has a companion block living next to its unit tests.
When you record notes or TODOs for these efforts, put them in the bd issue itself so the history stays alongside the task—no side trackers, no plan tool usage, no Hypothesis snippets.
proptest!Analogy: Traits + adapters ≈ Haskell typeclasses + interpreters. Stick to this pattern instead of free monads.
当你添加或更新属性测试时,将这项工作视为一个小型bd任务——而非计划工具中的任务。通过跟踪进度( → → ),并遵循以下紧凑循环:
bdreadyupdate ... --status in_progressclose- 识别高价值属性。 从公共API或核心模块开始。寻找真正有价值的不变量(往返一致性、幂等性、顺序保证等)。跳过 trivial的包装器。
- 研究代码的使用方式。 在编写属性测试前,搜索仓库以了解函数/结构体的使用场景,确保你的策略符合真实世界的前置条件。
- 编写精确的用例。 少量高信号测试胜过大量零散测试。优先选择清晰的策略(例如
proptest、prop::collection、any::<T>()),仅当代码确实需要时才添加约束。from_regex - 依赖真实生成器。 使用策略(向量、映射、枚举)建模输入,而非像这样的手动循环。让生成器生成任意长度的列表/数组(仅当生产代码有硬限制时才进行约束),这样预运行和收缩输出才有意义。
for skip in 0..5 - 运行并反思。 运行(或特定 crate)执行新的属性测试。如果proptest失败暴露了漏洞,要么修复bug,要么添加带有文档说明的策略约束。
cargo test
保持测试的可维护性:根据测试验证的行为命名属性,当不变量的重要性不明显时,用简短注释说明原因,优先选择确定性、易于收缩的策略。我们期望每个非平凡的业务规则最终都有一个对应的块,与单元测试放在一起。
当你记录这些工作的笔记或TODO时,将它们放在bd任务本身中,以便历史记录与任务保持一致——不使用其他跟踪工具,不使用计划工具,不添加Hypothesis代码片段。
proptest!类比: Trait + 适配器 ≈ Haskell类型类 + 解释器。请坚持使用此模式,而非自由单子。