tower-test
主线位置:两条主线的最里面一站。
Mock是被测中间件的内层服务:它的poll_ready什么时候Ready由Handle::allow决定(就绪主线的终点),它的响应 future 什么时候完成由SendResponse决定(请求主线的起点)。
sequenceDiagram participant T as 测试代码 participant S as 被测中间件 participant M as Mock participant H as Handle T->>H: allow(1) T->>S: poll_ready S->>M: poll_ready M-->>S: Ready T->>S: call(req) S->>M: call(req) M->>H: 请求和 SendResponse T->>H: next_request T->>H: send_response H-->>T: 响应 future 完成
测中间件,难点在时机:内层第几次 poll_ready 返回 Pending,响应在 poll 之前还是之后回来。tower-test 的做法是把内层服务拆成两半,Mock 交给被测的中间件,Handle 留在测试代码手里,所有时机都由测试代码一步一步推进。上一页 Policy 与重试状态机 的实验就是这样驱动重试状态机的。
版本:tag 里是 0.4.1,能用的是 0.4.0
tower-test 和 tower 在同一个仓库,但不跟着 tower 发布。tower-0.5.3 这个 tag 里,tower-test/Cargo.toml 写的是 0.4.1(2024 年 #781 准备发布时改的);写作时 crates.io 上最新的是 0.4.0(2021 年 1 月),tower-test = "0.4" 解析到的就是它。两者的差别:
| crates.io 0.4.0 | tag 里的 0.4.1 | |
|---|---|---|
| pin 投影 | pin-project | pin-project-lite |
ready! | futures-util | std::task::ready |
| 其他 | #![forbid(unsafe_code)];assert_request_eq! 里加了一个 clippy allow |
Mock、Handle、Spawn 的代码逻辑完全相同。行号有出入:0.4.0 的 mock/mod.rs 在 import 里多一行 u64,,tag 里第 21 行及之后的内容在 0.4.0 里行号都大 1;mock/future.rs 和 macros.rs 也对不上;mock/spawn.rs、mock/error.rs 两边一样。本页链接按 tag。
同一个提交还在 tower-test/CHANGELOG.md 顶上加了一节「0.5.0 (August 1, 2023)」,内容是「Update dependency on tower to 0.5.0-alpha.1」,和 Cargo.toml 的 0.4.1 对不上,tower-test 也并不依赖 tower。读 tag 里的 CHANGELOG 时不要当真。
tower-test 不依赖 tower,只依赖 tower-service、tower-layer、tokio(sync feature)和 tokio-test,所以测试代码通常还要自己加上 tokio-test 来用它的 task::spawn 和 assert_ready! 一类的宏。
结构:共享状态加一条通道
pair 造出一对:
pub fn pair<T, U>() -> (Mock<T, U>, Handle<T, U>) {
let (tx, rx) = mpsc::unbounded_channel();
let tx = Mutex::new(tx);
let state = Arc::new(Mutex::new(State::new()));
let mock = Mock {
id: 0,
tx,
state: state.clone(),
can_send: false,
};
let handle = Handle { rx, state };
(mock, handle)
}
- 请求走一条无界 mpsc,每条消息是
(请求, SendResponse);SendResponse里是一个 oneshot 的发送端,Mock的响应 future 拿着接收端。 - 其余的控制都在
Arc<Mutex<State>>里,Mock的所有副本和Handle共享。
struct State {
/// Tracks the number of requests that can be sent through
rem: u64,
/// Tasks that are blocked
tasks: HashMap<u64, Waker>,
/// Tracks if the `Handle` dropped
is_closed: bool,
/// Tracks the ID for the next mock clone
next_clone_id: u64,
/// Tracks the next error to yield (if any)
err_with: Option<Error>,
}
State::new 把 rem 设成 u64::MAX:不调用 allow,mock 就一直就绪。tasks 按 mock 的 id 存等待者的 Waker,Clone 给每个副本分一个新 id,can_send 设回 false,和真实中间件一样,副本不继承就绪状态。
poll_ready:放行额度在 Handle 手里
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
let mut state = self.state.lock().unwrap();
if state.is_closed {
return Poll::Ready(Err(error::Closed::new().into()));
}
if let Some(e) = state.err_with.take() {
return Poll::Ready(Err(e));
}
if self.can_send {
return Poll::Ready(Ok(()));
}
if state.rem > 0 {
assert!(!state.tasks.contains_key(&self.id));
// Returning `Ready` means the next call to `call` must succeed.
self.can_send = true;
Poll::Ready(Ok(()))
} else {
// Bit weird... but whatevz
*state
.tasks
.entry(self.id)
.or_insert_with(|| cx.waker().clone()) = cx.waker().clone();
Poll::Pending
}
}
按顺序检查:
Handle已经被 drop:返回Closed错误("service closed")。- 有待发的错误:取走并返回。
take()意味着只有一次poll_ready会拿到它,不管是哪个副本。 - 这个实例已经就绪过、还没
call:再问还是Ready。这是 Service trait 的规则:返回Ready(Ok)之后、call之前,再问必须还是 Ready。 - 还有额度:标记
can_send,返回Ready。额度在这里不扣,要等call才扣。 - 没有额度:记下
Waker,返回Pending。
Handle::allow 和 Handle::send_error:
pub fn allow(&mut self, num: u64) {
let mut state = self.state.lock().unwrap();
state.rem = num;
if num > 0 {
for (_, task) in state.tasks.drain() {
task.wake();
}
}
}
/// Make the next poll_ method error with the given error.
pub fn send_error<E: Into<Error>>(&mut self, e: E) {
let mut state = self.state.lock().unwrap();
state.err_with = Some(e.into());
for (_, task) in state.tasks.drain() {
task.wake();
}
}
allow(n)是设定剩余额度,不是追加:先allow(2)再allow(1),剩下的是 1。n > 0时唤醒所有在等的副本,它们重新poll_ready去抢额度;allow(0)不唤醒任何人。send_error注入的是poll_ready的错误,不是某个请求的响应错误。用来测「内层报废」的路径,比如上一页Retry在重试前遇到poll_ready报错的情形。
额度在 call 时才扣,加上每个副本各自记 can_send,就有一个推论:allow(1) 之后,几个副本先后 poll_ready,都会看到 rem > 0。下面的程序把这一点和其他几种情形都跑了一遍。mock::spawn 返回的是包在 Spawn 里的 Mock,后面讲。
tower-test = "0.4"
tokio-test = "0.4"
tokio = { version = "=1.54.1", features = ["macros", "rt"] }
use std::panic::{catch_unwind, AssertUnwindSafe};
use tokio_test::task;
use tower_test::mock;
fn show<T: std::fmt::Debug, E: ToString>(p: std::task::Poll<Result<T, E>>) -> String {
format!("{:?}", p.map(|r| r.map_err(|e| e.to_string())))
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let (mut svc, mut handle) = mock::spawn::<&'static str, &'static str>();
let (mut other, other_handle) = mock::spawn::<&'static str, &'static str>();
println!("1. no allow() yet");
println!(" poll_ready -> {}", show(svc.poll_ready()));
let mut fut = task::spawn(svc.call("a"));
let (req, send) = handle.next_request().await.unwrap();
send.send_response("A");
println!(" got {req:?}, response {}", show(fut.poll()));
println!("2. allow(0), then allow(1)");
handle.allow(0);
println!(" poll_ready -> {}", show(svc.poll_ready()));
handle.allow(1);
println!(" woken: {}, poll_ready -> {}", svc.is_woken(), show(svc.poll_ready()));
println!(" poll_ready again before call -> {}", show(svc.poll_ready()));
let _f = svc.call("b");
println!(" after call, poll_ready -> {}", show(svc.poll_ready()));
println!("3. allow sets, it does not add");
handle.allow(2);
handle.allow(1);
println!(" poll_ready -> {}", show(svc.poll_ready()));
let _f = svc.call("c");
println!(" poll_ready -> {}", show(svc.poll_ready()));
println!("4. allow(1) and two clones");
let mut c1 = mock::Spawn::new(svc.get_ref().clone());
let mut c2 = mock::Spawn::new(svc.get_ref().clone());
handle.allow(1);
println!(" c1 -> {}, c2 -> {}", show(c1.poll_ready()), show(c2.poll_ready()));
let _f1 = c1.call("d1");
let _f2 = c2.call("d2");
println!(" both called; c1 -> {}", show(c1.poll_ready()));
println!("5. Handle::send_error hits the next poll_ready once");
handle.allow(1);
let mut c3 = mock::Spawn::new(svc.get_ref().clone());
handle.send_error("overloaded");
println!(" c3 -> {}", show(c3.poll_ready()));
println!(" c3 -> {}", show(c3.poll_ready()));
println!("6. dropping a SendResponse");
let mut fut = task::spawn(c3.call("e"));
let send = loop {
let (req, send) = handle.next_request().await.unwrap();
if req == "e" {
break send;
}
};
drop(send);
println!(" response {}", show(fut.poll()));
println!("7. after the Handle is dropped");
drop(handle);
let mut c5 = mock::Spawn::new(svc.get_ref().clone());
println!(" poll_ready -> {}", show(c5.poll_ready()));
let mut fut = task::spawn(c5.call("g"));
println!(" call without ready -> {}", show(fut.poll()));
println!("8. call without poll_ready (a separate pair)");
std::panic::set_hook(Box::new(|_| {})); // keep the default hook quiet
let r = catch_unwind(AssertUnwindSafe(|| {
let _ = other.call("f");
}));
let p = r.unwrap_err();
println!(" panic: {:?}", p.downcast_ref::<&str>());
let r = catch_unwind(AssertUnwindSafe(|| other.poll_ready().is_ready()));
let p = r.unwrap_err();
println!(" then poll_ready panics: {:?}", p.downcast_ref::<String>());
let r = catch_unwind(AssertUnwindSafe(|| drop(other_handle)));
println!(" dropping its Handle panics: {:?}", r.unwrap_err().downcast_ref::<String>());
let r = catch_unwind(AssertUnwindSafe(|| drop(other)));
println!(" dropping the Mock panics: {:?}", r.unwrap_err().downcast_ref::<String>());
}
c2 的 poll_ready 返回什么?allow(1) 只给了一个额度,c1 先 poll_ready 拿到了 Ready,还没 call。紧接着 c2 也 poll_ready,结果是 Pending 还是 Ready?两个都 call 之后呢?完整输出在文末。
call:没就绪就 panic
call:
fn call(&mut self, request: T) -> Self::Future {
// Make sure that the service has capacity
let mut state = self.state.lock().unwrap();
if state.is_closed {
return ResponseFuture::closed();
}
if !self.can_send {
panic!("service not ready; poll_ready must be called first");
}
self.can_send = false;
// Decrement the number of remaining requests that can be sent
if state.rem > 0 {
state.rem -= 1;
}
// ...
- 没有
poll_ready过(或者已经call过一次)就call,panic,信息是service not ready; poll_ready must be called first。这正好把 Service trait 里「没 ready 就 call 允许 panic」那条规则变成了必然:被测中间件哪里少问了一次poll_ready,测试会当场失败。 - 例外:
Handle已经 drop 时,call不检查就绪,直接返回一个立即以Closed结束的 future。 panic!发生时state的锁还拿在手里,Mutex因此被毒化。之后对这一对Mock/Handle的任何操作(poll_ready、dropMock、dropHandle)都会因为PoisonError再 panic。所以「没就绪就 call」这种测试用#[should_panic]写,panic 之后不要再碰这一对 mock。
2019 年的 tower-mock(tower-test 的前身)里,call 的检查是:can_send 为假并且 rem == 0 才 panic。默认额度是无限的,所以测试里不调 poll_ready 直接 call 也能通过。#223 把条件改成只看 can_send,就是今天的 if !self.can_send { panic!(...) }。改完之后,tower-filter、tower-rate-limit、tower-retry 和 tower-mock 自己的测试都要补上 poll_ready 调用;同一个提交还修了 RateLimit:它的 poll_ready 原来在本周期还有余量时直接返回 Ready,不问内层,改成了转发给内层的 poll_ready。这个提交的说明只有一行:「mock: Panic if poll_ready is not called」。首次发布在 tower-test 0.1.0:五天后的 #237(「Create tower-test and include tower-mock.」)新建了 tower-test,把 tower-mock 并了进去。
回答请求
Handle 这一侧(next_request、poll_request):
/// Asynchronously gets the next request
pub fn poll_request(&mut self) -> Poll<Option<Request<T, U>>> {
tokio_test::task::spawn(()).enter(|cx, _| Box::pin(self.rx.recv()).as_mut().poll(cx))
}
/// Gets the next request.
pub async fn next_request(&mut self) -> Option<Request<T, U>> {
self.rx.recv().await
}
next_request是async,等到下一条请求,得到(请求, SendResponse)。所有副本发出的请求都进同一条通道,按call的先后排队,取的时候也是这个顺序。poll_request不等,用一个一次性的 tokio-test 任务 poll 一下recv(),适合断言「此刻内层还没收到请求」。
SendResponse 的 send_response 和 send_error 都消耗 self,把结果经 oneshot 送给响应 future。不回答就把它丢掉,ResponseFuture 会以 Closed 结束。注意两个 send_error 的区别:SendResponse::send_error 是这一个请求的响应错误,Handle::send_error 是下一次 poll_ready 的错误。
错误类型是 Box<dyn Error + Send + Sync>,所以 send_error 可以直接传字符串。
assert_request_eq! 把「取下一条请求、断言相等、拿到 SendResponse」合成一步:
($mock_handle:expr, $expect:expr, $($arg:tt)*) => {{
let (actual, send_response) = match $mock_handle.next_request().await {
Some(r) => r,
None => panic!("expected a request but none was received."),
};
// ...
{
assert_eq!(actual, $expect, $($arg)*);
}
send_response
}};
宏里有 .await,只能在 async 上下文里用;请求类型要实现 PartialEq 和 Debug。
Spawn 与 spawn_layer:单测一个 Layer
Spawn 把一个服务和一个 tokio-test 的模拟任务绑在一起:
pub struct Spawn<T> {
inner: T,
task: task::Spawn<()>,
}
poll_ready不需要传Context,在模拟任务里调用内层的poll_ready。is_woken回答「上次Pending之后有没有人唤醒过我」,用来检查被测中间件有没有正确地注册和触发Waker。漏了唤醒,真实运行时里就是请求永远挂着。call直接转发。
三个构造函数只差在怎么把 Mock 包起来:spawn 不包,spawn_with 用一个闭包包,spawn_layer 用一个 Layer 包。tower 自己的重试测试就是 mock::spawn_layer(RetryLayer::new(policy))。
下面用 spawn_layer 测 ConcurrencyLimit 的背压,断言用 tokio-test 的宏:
tower = { version = "=0.5.3", features = ["limit"] }
tower-test = "0.4"
tokio-test = "0.4"
tokio = { version = "=1.54.1", features = ["macros", "rt"] }
use tokio_test::{assert_pending, assert_ready, assert_ready_ok, task};
use tower::limit::ConcurrencyLimitLayer;
use tower_test::{assert_request_eq, mock};
#[tokio::main(flavor = "current_thread")]
async fn main() {
let (mut svc, mut handle) = mock::spawn_layer::<&str, &str, _>(ConcurrencyLimitLayer::new(1));
assert_ready_ok!(svc.poll_ready());
let mut first = task::spawn(svc.call("one"));
println!("first called");
// the only permit is inside `first`, so the layer must push back
assert_pending!(svc.poll_ready());
println!("second poll_ready: Pending, woken = {}", svc.is_woken());
assert_request_eq!(handle, "one").send_response("1");
assert_eq!(assert_ready!(first.poll()).unwrap(), "1");
println!("first done, woken = {}", svc.is_woken());
assert_pending!(svc.poll_ready()); // permit is still in `first`
drop(first);
println!("first dropped, woken = {}", svc.is_woken());
assert_ready_ok!(svc.poll_ready());
println!("second poll_ready: Ready");
}
M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同,所有断言通过:
first called
second poll_ready: Pending, woken = false
first done, woken = false
first dropped, woken = true
second poll_ready: Ready
响应已经回来(first done)时 svc 还没被唤醒,poll_ready 仍是 Pending;直到 drop(first) 才被唤醒。许可跟着响应 future 走,future 不丢,许可不还,这是 ConcurrencyLimit 页讲过的行为,这里用 mock 把它钉成了断言。真正的测试会写成 #[tokio::test],把 println! 去掉,只留断言。
动脑筋答案
M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同:
1. no allow() yet
poll_ready -> Ready(Ok(()))
got "a", response Ready(Ok("A"))
2. allow(0), then allow(1)
poll_ready -> Pending
woken: true, poll_ready -> Ready(Ok(()))
poll_ready again before call -> Ready(Ok(()))
after call, poll_ready -> Pending
3. allow sets, it does not add
poll_ready -> Ready(Ok(()))
poll_ready -> Pending
4. allow(1) and two clones
c1 -> Ready(Ok(())), c2 -> Ready(Ok(()))
both called; c1 -> Pending
5. Handle::send_error hits the next poll_ready once
c3 -> Ready(Err("overloaded"))
c3 -> Ready(Ok(()))
6. dropping a SendResponse
response Ready(Err("service closed"))
7. after the Handle is dropped
poll_ready -> Ready(Err("service closed"))
call without ready -> Ready(Err("service closed"))
8. call without poll_ready (a separate pair)
panic: Some("service not ready; poll_ready must be called first")
then poll_ready panics: Some("called `Result::unwrap()` on an `Err` value: PoisonError { .. }")
dropping its Handle panics: Some("PoisonError { .. }")
dropping the Mock panics: Some("PoisonError { .. }")
第 4 组:c2 也是 Ready。poll_ready 只看 rem > 0,不扣额度;c1 拿到 Ready 时 rem 还是 1,c2 看到的也是 1。两个都 call 时各扣一次,rem 从 1 减到 0,第二次因为 if state.rem > 0 的保护不再减。所以 allow(n) 限制的是「额度用完之后还能不能新就绪」,不保证同时就绪的副本不超过 n 个。要测「最多 n 个并发」,不能靠多个 mock 副本加 allow(n),应该让被测中间件只持有一个 mock。
其余各组:
- 1:没调过
allow,额度是u64::MAX,直接就绪。 - 2:
allow(0)后Pending,Waker被记下;allow(1)唤醒了它。就绪后再问还是Ready(can_send分支);call用掉额度后又是Pending。 - 3:
allow(1)覆盖了allow(2),只放行一次。 - 5:
Handle::send_error的错误只给了下一次poll_ready,第二次就恢复正常(这时还有allow(1)的额度)。 - 6:通道里还排着前面几组没取的请求(
b、c、d1、d2),所以循环取到e为止,丢掉它的SendResponse,响应 future 以service closed结束。 - 7:
Handle没了,poll_ready报service closed;不就绪直接call也不 panic,返回的 future 以同样的错误结束。 - 8:另一对 mock,没
poll_ready就call,panic。之后它的poll_ready在lock().unwrap()上 panic;dropHandle和 dropMock也 panic,信息是Drop里panic!("{:?}", e)打出的PoisonError { .. }。
小结
mock::pair给出Mock和Handle:请求走无界 mpsc,响应走每个请求一个的 oneshot,控制状态在共享的Mutex<State>里。poll_ready依次看:已关闭、待发的错误、已就绪、有额度、没额度就记下Waker返回Pending。额度默认无限,allow(n)是设定不是追加,call时才扣。- 没就绪就
call必定 panic,并毒化锁,这一对 mock 之后不能再用;Handle已 drop 时例外,call返回Closed。 next_request/assert_request_eq!取请求,SendResponse回答,丢掉它等于返回Closed。Handle::send_error是下一次poll_ready的错误,只生效一次。Spawn让poll_ready不用传Context,is_woken检查唤醒;spawn_layer用来单测一个 Layer。- 仓库 tag 里的 tower-test 是 0.4.1,crates.io 上是 0.4.0,差别只在 pin 投影的依赖,行为相同。