Rust in Everything
基于 tower 0.5.3

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.0tag 里的 0.4.1
pin 投影pin-projectpin-project-lite
ready!futures-utilstd::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 共享。

State:

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 手里

poll_ready:

    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
        }
    }

按顺序检查:

  1. Handle 已经被 drop:返回 Closed 错误("service closed")。
  2. 有待发的错误:取走并返回。take() 意味着只有一次 poll_ready 会拿到它,不管是哪个副本。
  3. 这个实例已经就绪过、还没 call:再问还是 Ready。这是 Service trait 的规则:返回 Ready(Ok) 之后、call 之前,再问必须还是 Ready。
  4. 还有额度:标记 can_send,返回 Ready。额度在这里不扣,要等 call 才扣。
  5. 没有额度:记下 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>());
}
❗IMPORTANT
动脑筋:第 4 组里 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、drop Mock、drop Handle)都会因为 PoisonError 再 panic。所以「没就绪就 call」这种测试用 #[should_panic] 写,panic 之后不要再碰这一对 mock。
📝NOTE
冷知识:mock 以前允许不问就绪就 call

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;drop Handle 和 drop Mock 也 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 投影的依赖,行为相同。

💬 关联讨论

发起讨论
加载中...