异步Rust实战(下)——全面测试

228 阅读25分钟

在本章中,我们了解了如何在 Rust 中编写高效的异步系统。然而,当我们在 Rust 中构建大型异步系统时,我们需要知道如何测试我们的异步代码。随着系统规模的增加,系统的复杂性也会增加。

测试增加了我们正在实现的代码的反馈,使得代码编写变得更快速、更安全。例如,如果我们有一个庞大的代码库,需要修改或添加功能到某段代码中,如果我们必须启动整个系统并运行它来检查代码是否正常工作,这样做会很慢且危险。相反,修改或添加所需的代码,并运行该部分代码的特定测试,不仅能提供更快的反馈,还能让我们测试更多的边缘案例,从而使我们的代码更安全。在本章中,我们探讨了异步代码测试以及我们代码和外部系统之间接口的多种测试方法。

到本章结束时,你将能够构建隔离的测试,在其中你可以模拟接口并检查对这些接口的调用。这将使你能够构建真正隔离的原子测试。你还将能够测试同步陷阱,如死锁、竞态条件和阻塞异步任务的通道容量问题。最后,你还将学习如何模拟与网络(如服务器)之间的交互,获得对所有 future 的细粒度测试控制,并知道何时轮询它们,以查看在不同轮询条件下系统的进展。

我们可以从覆盖同步测试的基础开始我们的测试之旅。

执行基本的同步测试

在《构建隔离模块》中,我们构建了一个具有同步接口的异步运行时环境。因为该接口仅包含几个同步函数,所以隔离模块是最容易测试的模块之一。我们可以从执行同步测试开始。

在构建测试之前,我们需要在 Cargo.toml 文件中添加以下依赖:

[dev-dependencies]
mockall = "0.11.4"

mockall 依赖项将使我们能够模拟 traits 及其函数,从而检查输入并模拟输出。

对于我们隔离模块的接口,我们回顾一下这两个函数:spawn,它返回一个键;以及 get_result,它返回我们生成的异步任务的结果。我们可以为这些交互定义以下 trait:

pub trait AsyncProcess<X, Y, Z> {
    fn spawn(&self, input: X) -> Result<Y, String>;
    fn get_result(&self, key: Y) -> Result<Z, String>;
}

在这里,我们有泛型参数,以便可以改变输入、输出和使用的键的类型。接下来,我们可以继续编写异步函数,在其中生成任务,打印一些内容,然后从异步函数中获取结果并处理结果:

fn do_something<T>(async_handle: T, input: i32)
    -> Result<i32, String>
    where T: AsyncProcess<i32, String, i32>
{
    let key = async_handle.spawn(input)?;
    println!("something is happening");
    let result = async_handle.get_result(key)?;
    if result > 10 {
        return Err("result is too big".to_string());
    }
    if result == 8 {
        return Ok(result * 2)
    }
    Ok(result * 3)
}

在这里,我们依赖于依赖注入。在依赖注入中,我们将一个结构体、对象或函数作为参数传递给另一个函数。传递到函数中的内容执行计算。

对我们而言,我们传入一个实现了 trait 的结构体,然后调用该 trait。这是非常强大的。例如,我们可以为一个访问数据库的结构体实现一个读取 trait。然而,我们也可以获得一个处理文件读取的结构体,并且同样实现读取 trait。根据我们想要的存储解决方案,我们只需将该句柄传入函数。正如你可能猜到的,我们可以创建一个模拟结构体,并为该模拟结构体实现任何我们想要的功能,然后将模拟结构体传递给我们正在测试的函数。然而,如果我们通过使用 mockall 正确模拟我们的结构体,我们还可以断言某些条件,例如对处理程序传递了什么。我们的测试布局如下所示:

#[cfg(test)]
mod get_team_processes_tests {
    use super::*;
    use mockall::predicate::*;
    use mockall::mock;

    mock! {
        DatabaseHandler {}
        impl AsyncProcess<i32, String, i32> for DatabaseHandler {
            fn spawn(&self, input: i32) -> Result<String, String>;
            fn get_result(&self, key: String) -> Result<i32, String>;
        }
    }

    #[test]
    fn do_something_fail() {
        . . .
    }
}

在我们的 do_something_fail 测试函数中,我们定义了模拟处理程序,并断言传入 spawn 函数的是 4,随后会返回一个 test_key

let mut handle = MockDatabaseHandler::new();
handle.expect_spawn()
    .with(eq(4))
    .returning(|_| { Ok("test_key".to_string()) });

现在我们有了 test_key,我们可以假设它将被传入 get_result 函数,我们声明 get_result 将返回 11:

handle.expect_get_result()
    .with(eq("test_key".to_string()))
    .returning(|_| { Ok(11) });

我们假设我们正在测试的函数将返回错误,因此我们断言:

let outcome = do_something(handle, 4);
assert_eq!(outcome, Err("result is too big".to_string()));

我们遵循行业标准的安排、执行和断言的测试流程:

  • 安排:我们设置测试环境并定义模拟的期望行为。(这在我们创建模拟处理程序并指定期望的输入和输出时完成。)
  • 执行:我们以已安排的条件执行待测函数。(这发生在我们调用 do_something(handle, 4) 时。)
  • 断言:我们验证结果是否符合预期。(这就是我们使用 assert_eq! 来检查结果的地方。)

现在我们的测试已经定义好了,我们可以使用 cargo test 命令运行它,结果将输出:

running 1 test
test get_team_processes_tests::do_something_fail ... ok

就这样:我们的测试通过了。尽管为了简洁起见,我们不会在本书中定义所有可能的结果,但这为你提供了一个练习单元测试的机会,可以尝试所有边缘案例。

注意
模拟功能非常强大,因为它使我们能够隔离我们的逻辑。假设我们的 do_something 函数在一个需要数据库处于特定状态的应用程序中。例如,如果 do_something 处理数据库中的团队成员数量,那么数据库中可能需要有这些团队。但是,如果我们想运行 do_something,我们不希望每次都填充数据库并确保所有内容都准备好再运行代码。这样做有几个原因。如果我们想为另一个边缘案例重新定义参数,我们将不得不重新排列数据库。这会花费很长时间,每次运行代码时,我们都必须再次调整数据库。模拟使我们的测试变得原子化。我们可以一遍又一遍地运行代码,而无需设置环境。采用测试驱动开发的开发人员通常能以更快的速度开发,并且出现的错误更少。

所以我们已经为我们的程序定义了基本的模拟,但我们不会在所有地方使用隔离模块。你的代码可能是完全异步的。如果你从事 Web 开发,尤其如此。你可能希望测试异步模块中的异步函数。为此,我们需要涵盖异步模拟的内容。

模拟异步代码

为了测试我们的异步 trait,我们需要在测试函数中使用异步运行时。你可以选择任何你熟悉的运行时,但在我们的示例中,我们将使用 Tokio,并在 Cargo.toml 文件中添加以下依赖项:

[dependencies]
tokio = { version = "1.34.0", features = ["full"] }

[dev-dependencies]
mockall = "0.11.4"

由于我们的 trait 是异步的,我们不再需要两个函数,因为异步函数将返回一个我们可以等待的句柄。所以我们只保留 get_result 函数,因为句柄在异步代码中管理:

use std::future::Future;

pub trait AsyncProcess<X, Z> {
    fn get_result(&self, key: X) -> impl Future<Output = Result<Z, String>> + Send + 'static;
}

在我们的 AsyncProcess trait 中,get_result 函数返回一个 future,而不是像以前那样直接是一个异步函数。这种去糖化使我们可以更好地控制 future 上实现的 trait。

我们的 do_something 函数也进行了重新定义:

async fn do_something<T>(async_handle: T, input: i32)
    -> Result<i32, String>
where
    T: AsyncProcess<i32, i32> + Send + Sync + 'static
{
    println!("something is happening");
    let result: i32 = async_handle.get_result(input).await?;
    if result > 10 {
        return Err("result is too big".to_string());
    }
    if result == 8 {
        return Ok(result * 2);
    }
    Ok(result * 3)
}

处理结果的逻辑保持不变:我们在打印语句之前启动任务,在打印语句之后获取结果。然而,在我们进行模拟之前,我们必须确保我们的测试模块中包含以下导入:

use super::*;
use mockall::predicate::*;
use mockall::mock;
use std::boxed::Box;

由于我们的 trait 现在只有一个函数,我们的 mock 也需要重新定义,代码如下:

mock! {
    DatabaseHandler {}
    impl AsyncProcess<i32, i32> for DatabaseHandler {
        fn get_result(&self, key: i32) -> impl Future<Output = Result<i32, String>> + Send + 'static;
    }
}

现在,由于我们的函数是异步的,我们需要在测试中定义一个运行时,并在其上阻塞。我们的测试不会都在一个线程上运行,所以我们可以通过在每个单独的测试中定义运行时来确保测试是原子的:

#[test]
fn do_something_fail() {
    let mut handle = MockDatabaseHandler::new();
    handle.expect_get_result()
             .with(eq(4))
             .returning(|_| {
                 Box::pin(async move { Ok(11) })
             });

    let runtime = tokio::runtime::Builder::new_current_thread()
                                          .enable_all()
                                          .build()
                                          .unwrap();
    let outcome = runtime.block_on(do_something(handle, 4));
    assert_eq!(outcome, Err("result is too big".to_string()));
}

现在我们已经为异步代码进行了模拟。我们建议你为与其他资源(如 HTTP 请求或数据库连接)交互的过程编写隔离的异步函数。这样可以更容易地模拟它们,从而使我们的代码更容易测试。然而,我们知道,在异步代码中,除了调用外部资源之外,还需要考虑其他同步问题,如死锁,因此我们需要对这些问题进行测试。

测试死锁

在死锁中,一个异步任务因锁而无法完成。并非本书中的所有示例都会暴露异步系统到死锁的情况,但死锁是可能发生的。一个简单的死锁示例是让两个异步任务试图访问相同的两个锁,但顺序相反(见图 11-1)。

image.png

在图 11-1 中,任务一获取锁一,任务二获取锁二。然而,两个任务都没有释放它们的锁,但每个任务都尝试获取另一个已经被占用的锁,同时保持自己已持有的锁。这就导致了死锁:这两个任务永远无法完成,因为它们永远无法获取到正在尝试获取的第二个锁。

这种死锁不仅会阻塞这两个任务。如果其他任务需要访问这些锁,当它们被启动时,也会被阻塞;不久之后,我们的整个系统将会陷入停滞。因此,测试死锁是非常重要的。通过单元测试,我们可以在将代码集成到系统的其他部分之前,尽早捕捉到这些死锁。对于我们的测试,输出如下所示:

#[cfg(test)]
mod tests {
    use tokio::sync::Mutex;
    use std::sync::Arc;
    use tokio::time::{sleep, Duration, timeout};

    #[tokio::test]
    async fn test_deadlock_detection() {
        . . .
    }
}

我们在测试中使用了 #[tokio::test] 宏,这基本上等同于在测试函数中创建一个异步运行时。在我们的测试函数内部,我们创建了两个互斥锁以及这些互斥锁的引用,两个任务可以访问这两个互斥锁:

let resource1 = Arc::new(Mutex::new(0));
let resource2 = Arc::new(Mutex::new(0));
let resource1_clone = Arc::clone(&resource1);
let resource2_clone = Arc::clone(&resource2);

然后我们生成两个任务:

let handle1 = tokio::spawn(async move {
    let _lock1 = resource1.lock().await;
    sleep(Duration::from_millis(100)).await;
    let _lock2 = resource2.lock().await;
});
let handle2 = tokio::spawn(async move {
    let _lock2 = resource2_clone.lock().await;
    sleep(Duration::from_millis(100)).await;
    let _lock1 = resource1_clone.lock().await;
});

第一个任务获取第一个互斥锁,然后在睡眠后获取第二个互斥锁。第二个任务反过来,尝试获取它们的锁。睡眠函数为两个任务提供了时间,使它们能够在尝试获取第二个锁之前,先获取第一个锁。现在,我们希望等待这两个任务完成,但我们要测试死锁。如果发生死锁且我们没有设置超时,测试将无限期挂起。为避免这种情况,我们可以设置一个超时:

let result = timeout(Duration::from_secs(5), async {
    let _ = handle1.await;
    let _ = handle2.await;
}).await;

超时时间比较大,但如果两个异步任务在 5 秒钟内没有完成,我们可以得出死锁已发生的结论。现在,我们设置了超时,可以使用以下代码来检查:

assert!(result.is_ok(), "A potential deadlock detected!");

运行我们的测试时,会输出以下内容:

thread 'tests::test_deadlock_detection'
panicked at 'A potential deadlock detected!', src/main.rs:43:9
note: run with `RUST_BACKTRACE=1` environment variable to
display a backtrace
test tests::test_deadlock_detection ... FAILED

failures:
    tests::test_deadlock_detection

test result: FAILED. 0 passed; 1 failed; 0 ignored;
0 measured; 0 filtered out; finished in 5.01s

时间略超过 5 秒,我们得到了一个有用的消息,提示检测到潜在的死锁。我们不仅捕获了死锁,还隔离出了导致死锁的具体函数。

注意
另一个锁问题是叫做活锁(livelock),它与死锁有相同的效果:活锁也能让系统停滞不前。在活锁中,两个或更多异步任务被阻塞。但与死锁不同的是,两个或更多的异步任务虽然互相响应,但没有进展。一个简单的例子是两个异步任务在一个常规循环中互相回显相同的消息。死锁和活锁之间的经典但清晰的类比是:死锁就像两个人站在走廊里停下来,互相等待对方移动,但没有人移动。活锁则像两个人不停地试图避开对方,但都朝着错误的方向走,结果陷入了一个持续的阻塞,导致两个人都无法通过对方。

虽然我们应该尽一切可能避免死锁,但死锁的发生通常是显而易见的,因为系统通常会停滞不前。然而,我们的代码可能会在我们不知道的情况下悄悄地引发错误。这就是我们需要测试竞态条件的原因。

测试竞态条件

在竞态条件中,数据的状态发生了变化,但对该状态的引用已过时。图 11-2 中展示了一个简单的数据竞态条件示例。

image.png

在竞态条件中,数据的状态发生了变化,但对该状态的引用已过时。图 11-2 展示了一个简单的数据竞态条件示例。

图 11-2 显示了两个异步任务,它们从数据存储中获取一个数字并将该数字加一。由于第二个任务在第一个任务更新数据存储之前获取了数据,两个任务都从 10 开始增加,导致数据最终是 11,而应该是 12。在本书中,我们通过使用互斥锁或特定的原子操作来防止数据竞态的发生。比较和更新的原子操作是防止这种竞态条件发生的最简单方法。然而,尽管防止竞态条件是最佳做法,但实现这一目标的方式并不总是显而易见,我们需要探索如何测试代码中的数据竞态。

我们的测试大致结构如下:

#[cfg(test)]
mod tests {
    use std::sync::atomic::{AtomicUsize, Ordering};
    use tokio::time::{sleep, Duration};
    use tokio::runtime::Builder;

    static COUNTER: AtomicUsize = AtomicUsize::new(0);

    async fn unsafe_add() {
        let value = COUNTER.load(Ordering::SeqCst);
        COUNTER.store(value + 1, Ordering::SeqCst);
    }

    #[test]
    fn test_data_race() {
        . . .
    }
}

与进行原子加法不同,我们先获取数字,增加它,然后设置新值,这样就有了发生竞态条件的机会,如图 11-2 所示。在我们的测试函数中,我们可以构建一个单线程的运行时,并启动 10,000 个 unsafe_add 任务。处理完这些任务后,我们可以断言 COUNTER 的值是 10,000:

let runtime = Builder::new_current_thread().enable_all()
                                           .build()
                                           .unwrap();
let mut handles = vec![];
let total = 100000;

for _ in 0..total {
    let handle = runtime.spawn(unsafe_add());
    handles.push(handle);
}
for handle in handles {
    runtime.block_on(handle).unwrap();
}
assert_eq!(
    COUNTER.load(Ordering::SeqCst),
    total,
    "race condition occurred!"
);

如果我们运行测试,我们可以看到它通过了。这是因为运行时只有一个线程,且在获取和设置之间没有异步操作。然而,假设我们将运行时改为多个线程:

let runtime = tokio::runtime::Runtime::new().unwrap();

我们会得到以下错误:

thread 'tests::test_data_race' panicked at
'assertion failed: `(left == right)`
  left: `99410`,
 right: `100000`: race condition occurred!'

一些任务受到了竞态条件的影响。假设我们在获取和设置之间放入另一个异步函数,例如一个睡眠函数:

let value = COUNTER.load(Ordering::SeqCst);
sleep(Duration::from_secs(1)).await;
COUNTER.store(value + 1, Ordering::SeqCst);

我们会得到以下错误:

thread 'tests::test_data_race' panicked at
'assertion failed: `(left == right)`
  left: `1`,
 right: `100000`: Race Condition occurred!'

所有任务都成了竞态条件的受害者。这是因为所有任务最初都在任何任务写入 COUNTER 之前读取了 COUNTER,因为异步的睡眠会将控制权返回给执行器,其他异步任务有机会读取 COUNTER

如果睡眠是非阻塞的,这个结果也会发生在单线程环境中。这突显了如果我们担心竞态条件,使用多线程测试环境的必要性。我们还可以感受到我们在任务周围更改参数的速度,并测试这些参数变化对任务的影响。

我们知道,互斥锁和原子值并不是唯一允许多个任务访问数据的方法。我们还可以使用通道在异步任务之间传递数据。因此,我们需要测试我们的通道容量。

测试通道容量

在一些示例中,我们使用了无界通道,但有时我们希望限制通道的最大大小,以防止过度的内存消耗。然而,如果通道达到其最大限制,发送方将无法向通道发送更多消息。我们可能会有一个系统,比如一组 actor,我们需要查看如果向系统发送过多消息时,系统是否会堵塞。根据需求,我们可能希望系统在所有消息处理完之前减缓速度,但进行测试是有益的,这样我们就可以知道在我们的使用场景中系统的表现。

我们的测试大致布局如下:

#[cfg(test)]
mod tests {
    use tokio::sync::mpsc;
    use tokio::time::{Duration, timeout};
    use tokio::runtime::Builder;

    #[test]
    fn test_channel_capacity() {
        . . .
    }
}

在我们的测试函数中,我们定义了一个异步运行时和一个容量为五的通道:

let runtime = Builder::new_current_thread().enable_all()
                                           .build()
                                           .unwrap();
let (sender, mut receiver) = mpsc::channel::<i32>(5);

然后,我们启动一个任务,向通道发送超过容量的消息:

let sender = runtime.spawn(async move {
    for i in 0..10 {
        sender.send(i).await.expect("Failed to send message");
    }
});

我们想看看在超时测试中,系统是否会崩溃:

let result = runtime.block_on(async {
    timeout(Duration::from_secs(5), async {
        sender.await.unwrap();
    }).await
});
assert!(result.is_ok(),  "A potential filled channel is not handled correctly");

此时,我们的测试将失败,因为发送者的 future 永远不会完成,因此超时被超出。为了使我们的测试通过,我们需要在超时测试之前添加一个接收者的 future:

let receiver = runtime.spawn(async move {
    let mut i = 0;
    while let Some(msg) = receiver.recv().await {
        assert_eq!(msg, i);
        i += 1;
        println!("Got message: {}", msg);
    }
});

现在,当我们运行测试时,它将通过。尽管我们测试了一个简单的发送者和接收者系统,但我们必须认识到,测试突出显示了由于我们没有正确处理消息,系统将陷入停滞。通道也可能导致死锁,就像我们的互斥锁一样,并且如果我们的测试足够充分,超时测试也将突出死锁。

正如我们所知,通道使我们能够异步地共享系统中的数据。涉及到跨进程和计算机共享数据时,我们可以使用网络协议。毫无疑问,在实际应用中,你会编写与服务器使用协议交互的异步代码。我们的交互也需要进行测试。

测试网络交互

在开发过程中进行网络交互时,可能会有将服务器本地启动并依赖该服务器进行测试的诱惑。然而,这种做法可能不适合测试。例如,如果我们有一个操作,在运行测试后删除服务器上的一行数据,那么我们无法立即重新运行测试,因为该行数据已被删除。我们可以构建一个步骤来插入该行数据,但如果该行数据有依赖关系,这将变得更加复杂。此外,cargo test 命令会跨多个进程运行。如果几个测试正在访问同一个服务器,可能会在服务器上发生数据竞态条件。这时我们可以使用 mockito。这个 crate 允许我们直接在测试中模拟服务器,并断言服务器端点是否被特定参数调用。对于我们的网络测试示例,我们需要以下依赖:

[dependencies]
tokio = { version = "1.34.0", features = ["full"] }
reqwest = { version = "0.11.22", features = ["json"] }

[dev-dependencies]
mockito = "1.2.0"

我们的测试结构大致如下:

#[cfg(test)]
mod tests {
    use tokio::runtime::Builder;
    use mockito::Matcher;
    use reqwest;

    #[test]
    fn test_networking() {
        . . .
    }
}

在我们的测试函数中,我们启动一个测试服务器:

let mut server = mockito::Server::new();
let url = server.url();

在这里,mockito 会寻找当前计算机上没有被占用的端口。如果我们向这个 URL 发送请求,我们的模拟服务器将能够跟踪这些请求。记住,我们的服务器在测试函数的作用域内,因此测试完成后,模拟服务器会被终止。通过使用 mockito,我们的测试保持了真正的原子性。

接下来,我们定义一个服务器端点的模拟:

let mock = server.mock("GET", "/my-endpoint")
.match_query(Matcher::AllOf(vec![
    Matcher::UrlEncoded("param1".into(), "value1".into()),
    Matcher::UrlEncoded("param2".into(), "value2".into()),
]))
.with_status(201)
.with_body("world")
.expect(5)
.create();

我们的模拟有一个端点 /my-endpoint,并且期望 URL 中的某些参数。模拟将返回状态码 201 和响应体 "world"。我们还期望服务器被调用五次。如果需要,我们可以添加更多的端点,但为了本例,我们只使用一个,以避免章节内容过于臃肿。

现在,模拟服务器构建完成,我们定义我们的运行时环境:

let runtime = Builder::new_current_thread()
            .enable_io()
            .enable_time()
            .build()
            .unwrap();
let mut handles = vec![];

一切准备好后,我们向运行时发送五个异步任务:

for _ in 0..5 {
    let url_clone = url.clone();
    handles.push(runtime.spawn(async move {
        let client = reqwest::Client::new();
        client.get(&format!(
            "{}/my-endpoint?param1=value1&param2=value2",
        url_clone)).send().await.unwrap()
    }));
}

最后,我们可以阻塞线程等待异步任务完成,并断言模拟:

for handle in handles {
    runtime.block_on(handle).unwrap();
}
mock.assert();

我们可以断言所有的异步任务都成功地调用了服务器。如果其中一个任务失败,我们的测试将会失败。

注意
当定义请求的 URL 时,最好将其定义为动态的,而不是硬编码的。这使得我们可以根据是否向实际服务器、本地服务器或模拟服务器发出请求,动态地改变 URL 的主机部分。虽然使用环境变量很诱人,但在测试中,这可能会在 cargo test 的多线程环境中造成问题。因此,最好定义一个 trait 来提取配置变量。然后,我们可以传递实现该配置变量 trait 的结构体。当将视图函数绑定到实际服务器时,我们可以传入一个从环境中提取配置变量的结构体。但在测试函数和视图中调用其他服务器时,我们也可以直接将 mockito 的 URL 传入提取配置变量的 trait 实现。

mockito 还有更多的功能。例如,JSON 响应体、决定请求响应的函数和其他功能,都可以在 mockito 的 API 文档中找到。现在,我们已经能够模拟服务器和 traits,并在隔离的环境中测试我们的系统。然而,如何隔离 futures 并以细粒度的方式测试这些 futures,检查它们在轮询之间的状态呢?这时,异步测试框架可以帮助我们。

精细粒度的 Future 测试

在本节中,我们将使用 Tokio 测试工具。但是,这里介绍的概念可以应用于任何异步运行时中对 futures 的测试。对于我们的测试,我们需要以下依赖项:

[dependencies]
tokio = { version = "1.34.0", features = ["full"] }

[dev-dependencies]
tokio-test = "0.4.3"

对于精细粒度的测试,我们将有两个 futures 获取相同的互斥锁,增加计数值,然后结束。由于两个 futures 通过获取相同的互斥锁发生了一些间接交互,我们可以单独轮询这些 futures,并确定它们在轮询时的状态。

最初,我们的测试结构如下:

#[cfg(test)]
mod tests {

    use tokio::sync::Mutex;
    use tokio::time::{sleep, Duration};
    use tokio_test::{task::spawn, assert_pending};
    use std::sync::Arc;
    use std::task::Poll;

    async fn async_mutex_locker(mutex: Arc<Mutex<i32>>) -> () {
        let mut lock = mutex.lock().await;
        *lock += 1;
        sleep(Duration::from_millis(1)).await;
    }
    #[tokio::test]
    async fn test_monitor_file_metadata() {
        . . .
    }
}

在我们的测试中,我们定义了一个互斥锁和 futures 的引用:

let mutex = Arc::new(Mutex::new(0));
let mutex_clone1 = mutex.clone();
let mutex_clone2 = mutex.clone();

我们现在可以通过使用 tokio_test::spawn 函数来启动带有互斥锁引用的 futures:

let mut future1 = spawn(async_mutex_locker(mutex_clone1));
let mut future2 = spawn(async_mutex_locker(mutex_clone2));

然后我们轮询我们的 futures,断言它们都应该是挂起的:

assert_pending!(future1.poll());
assert_pending!(future2.poll());

尽管这两个 futures 都是挂起的,但我们知道第一个 future 会首先获取互斥锁,因为它首先被轮询。如果我们交换轮询的顺序,效果将相反。这就是我们可以看到这种测试方法强大之处的地方。它使我们能够检查当我们改变轮询顺序时,futures 会发生什么。这样,我们就可以更深入地测试边界情况,因为除非我们故意设计系统,否则我们无法确保在实际系统中轮询的顺序。

正如我们在死锁示例中所看到的,只要第一个 future 获取了互斥锁,我们就知道无论我们多少次轮询第二个 future,第二个 future 都会一直处于挂起状态。我们可以通过以下方式确保我们的假设是正确的:

for _ in 0..10 {
    assert_pending!(future2.poll());
    sleep(Duration::from_millis(1)).await;
}

在这里,我们可以看到使用 assert_pending 特性,如果我们的假设不正确,测试将失败。适当的时间已经过去,因此我们可以假设如果我们现在轮询第一个 future,它将变为就绪。我们定义如下断言:

assert_eq!(future1.poll(), Poll::Ready(()));

然而,我们并没有丢弃第一个 future,而且我们没有在整个 future 的生命周期中释放锁。因此,我们可以得出结论,即使我们等待的时间足够让第二个 future 完成,第二个 future 仍然会挂起,因为第一个 future 仍然持有互斥锁。我们可以通过以下代码断言这一假设:

sleep(Duration::from_millis(3)).await;
assert_pending!(future2.poll());

通过丢弃第一个 future,等待,然后断言第二个 future 已经完成,我们可以验证我们的互斥锁值是否如预期:

drop(future1);
sleep(Duration::from_millis(1)).await;
assert_eq!(future2.poll(), Poll::Ready(()));

最后,我们可以断言互斥锁的值:

let lock = mutex.lock().await;
assert_eq!(*lock, 2);

当我们运行测试时,可以看到它会通过。

在这里,我们已经成功冻结了异步系统,检查了状态,然后逐步轮询,推进 futures。我们甚至可以在测试中随时交换轮询的顺序。这使得我们在轮询顺序改变时对结果的测试变得更加有力。

总结

我们介绍了一系列测试方法,帮助解决异步问题,采用了测试驱动的方法。我们强烈建议,如果你正在启动一个新的异步 Rust 项目,最好在编写异步代码的同时构建测试。这样,你将能够保持快速的开发进度。

现在,我们的旅程已经接近尾声。我们希望你对使用异步 Rust 感到兴奋。它是一个强大且不断发展的领域。凭借你新学到的技能和异步知识,你现在拥有了另一个可以解决问题的工具。你可以将问题拆解成异步概念,并实现强大且快速的解决方案。我们希望你在体验异步的美妙以及 Rust 如何实现异步系统时感到愉悦。我们真诚地期待你用异步 Rust 构建的作品。