单元测试

本页的目的是提供关于如何在异步应用中编写有用的单元测试的建议。

在测试中暂停和恢复时间

有时,异步代码会通过调用 tokio::time::sleep 或等待 tokio::time::Interval::tick 来显式等待。当单元测试开始运行非常缓慢时,基于时间的行为测试(例如,指数退避)可能会变得很麻烦。然而,在内部,tokio 的时间相关功能支持暂停和恢复时间。暂停时间的效果是任何时间相关的 future 都可能提前就绪。时间相关的 future 提前解析的条件是没有其他可能就绪的 future。这本质上是在唯一等待的 future 与时间相关时快进时间

#[tokio::test]
async fn paused_time() {
    tokio::time::pause();
    let start = std::time::Instant::now();
    tokio::time::sleep(Duration::from_millis(500)).await;
    println!("{:?}ms", start.elapsed().as_millis());
}

这段代码在一台配置合理的机器上打印 0ms

对于单元测试,通常在整个过程中以暂停时间运行很有用。这可以通过简单地将宏参数 start_paused 设置为 true 来实现

#[tokio::test(start_paused = true)]
async fn paused_time() {
    let start = std::time::Instant::now();
    tokio::time::sleep(Duration::from_millis(500)).await;
    println!("{:?}ms", start.elapsed().as_millis());
}

请记住,start_paused 属性需要 tokio 的 test-util 功能。有关更多详细信息,请参阅 tokio::test “配置运行时以暂停时间启动”

当然,即使在使用不同的时间相关 future 时,future 解析的时间顺序也得以维护

#[tokio::test(start_paused = true)]
async fn interval_with_paused_time() {
    let mut interval = interval(Duration::from_millis(300));
    let _ = timeout(Duration::from_secs(1), async move {
        loop {
            interval.tick().await;
            println!("Tick!");
        }
    })
    .await;
}

这段代码立即打印 “Tick!” 4 次。

使用 AsyncReadAsyncWrite 进行模拟

用于异步读取和写入的通用 trait(AsyncReadAsyncWrite)由例如套接字实现。它们可以用于模拟套接字执行的 I/O。

考虑一下,对于设置,这个简单的 TCP 服务器循环

use tokio::net::TcpListener;

#[tokio::main]
async fn main() {
    let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap();
    loop {
        let Ok((mut socket, _)) = listener.accept().await else {
            eprintln!("Failed to accept client");
            continue;
        };

        tokio::spawn(async move {
            let (reader, writer) = socket.split();
            // Run some client connection handler, for example:
            // handle_connection(reader, writer)
                // .await
                // .expect("Failed to handle connection");
        });
    }
}

在这里,每个 TCP 客户端连接都由其专用的 tokio 任务服务。此任务拥有一个 reader 和一个 writer,它们是从 TcpStreamsplit 出来的。

现在考虑实际的客户端处理程序任务,特别是函数签名的 where 子句

use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};

async fn handle_connection<Reader, Writer>(
    reader: Reader,
    mut writer: Writer,
) -> std::io::Result<()>
where
    Reader: AsyncRead + Unpin,
    Writer: AsyncWrite + Unpin,
{
    let mut line = String::new();
    let mut reader = BufReader::new(reader);

    loop {
        if let Ok(bytes_read) = reader.read_line(&mut line).await {
            if bytes_read == 0 {
                break Ok(());
            }
            writer
                .write_all(format!("Thanks for your message.\r\n").as_bytes())
                .await
                .unwrap();
        }
        line.clear();
    }
}

本质上,给定的 reader 和 writer(它们实现了 AsyncReadAsyncWrite)是顺序服务的。对于接收到的每一行,处理程序回复 “Thanks for your message.”。

为了单元测试客户端连接处理程序,可以使用 tokio_test::io::Builder 作为 mock

#[tokio::test]
async fn client_handler_replies_politely() {
    let reader = tokio_test::io::Builder::new()
        .read(b"Hi there\r\n")
        .read(b"How are you doing?\r\n")
        .build();
    let writer = tokio_test::io::Builder::new()
        .write(b"Thanks for your message.\r\n")
        .write(b"Thanks for your message.\r\n")
        .build();
    let _ = handle_connection(reader, writer).await;
}