From 6f2c811a8da04b51ff9d8a1f36b81e19558aecb3 Mon Sep 17 00:00:00 2001 From: Rafael Bachmann Date: Thu, 25 Jan 2024 15:56:41 +0100 Subject: [PATCH] Add topic page for testing (#735) --- content/tokio/topics/index.md | 1 + content/tokio/topics/testing.md | 188 ++++++++++++++++++++++++++++++++ doc-test/Cargo.toml | 3 +- pages/[...slug].jsx | 2 +- 4 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 content/tokio/topics/testing.md diff --git a/content/tokio/topics/index.md b/content/tokio/topics/index.md index 18bf4cea..6ea95202 100644 --- a/content/tokio/topics/index.md +++ b/content/tokio/topics/index.md @@ -11,3 +11,4 @@ The currently available topics articles are: * [Graceful shutdown](/tokio/topics/shutdown) * [Getting started with Tracing](/tokio/topics/tracing) * [Next steps with Tracing](/tokio/topics/tracing-next-steps) + * [Testing](/tokio/topics/testing) diff --git a/content/tokio/topics/testing.md b/content/tokio/topics/testing.md new file mode 100644 index 00000000..b04948c6 --- /dev/null +++ b/content/tokio/topics/testing.md @@ -0,0 +1,188 @@ +--- +title: "Unit Testing" +--- + +The purpose of this page is to give advice on how to write useful unit tests in +asynchronous applications. + +## Pausing and resuming time in tests + +Sometimes, asynchronous code explicitly waits by calling [`tokio::time::sleep`] +or waiting on a [`tokio::time::Interval::tick`]. Testing behaviour based on +time (for example, an exponential backoff) can get cumbersome when the unit +test starts running very slowly. However, internally, the time-related +functionality of tokio supports pausing and resuming time. Pausing time has the +effect that any time-related future may become ready early. The condition for +the time-related future resolving early is that there are no more other futures +which may become ready. This essentially fast-forwards time when the only +future being awaited is time-related: + +```rust +#[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()); +} +``` + +This code prints `0ms` on a reasonable machine. + +For unit tests, it is often useful to run with paused time throughout. This can +be achieved simply by setting the macro argument `start_paused` to `true`: + +```rust +#[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()); +} +``` + +See [tokio::test "Configure the runtime to start with time paused"](https://docs.rs/tokio/latest/tokio/attr.test.html#configure-the-runtime-to-start-with-time-paused) for more details. + +Of course, the temporal order of future resolution is maintained, even when +using different time-related futures: + +```rust +#[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; +} +``` + +This code immediately prints `"Tick!"` exactly 4 times. + +[`tokio::time::Interval::tick`]: https://docs.rs/tokio/1/tokio/time/struct.Interval.html#method.tick +[`tokio::time::sleep`]: https://docs.rs/tokio/1/tokio/time/fn.sleep.html + +## Mocking using [`AsyncRead`] and [`AsyncWrite`] + +The generic traits for reading and writing asynchronously ([`AsyncRead`] and +[`AsyncWrite`]) are implemented by, for example, sockets. They can be used for +mocking I/O performed by a socket. + +Consider, for setup, this simple TCP server loop: + +```rust +use tokio::net::TcpListener; + +#[tokio::main] +async fn main() { + # if true { return } + 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"); + }); + } +} +``` + +Here, each TCP client connection is serviced by its dedicated tokio task. This +task owns a reader and a writer, which are [`split`] off of a [`TcpStream`]. + +Consider now the actual client handler task, especially the `where`-clause of the +function signature: + +```rust +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; + +async fn handle_connection( + 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(); + } +} +``` + +Essentially, the given reader and writer, which implement [`AsyncRead`] and +[`AsyncWrite`], are serviced sequentially. For each received line, the handler +replies with `"Thanks for your message."`. + +To unit test the client connection handler, a [`tokio_test::io::Builder`] can +be used as a mock: + +```rust +# use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; +# +# async fn handle_connection( +# 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(); +# } +# } + +#[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; +} +``` + +[`AsyncRead`]: https://docs.rs/tokio/latest/tokio/io/trait.AsyncRead.html +[`AsyncWrite`]: https://docs.rs/tokio/latest/tokio/io/trait.AsyncWrite.html +[`split`]: https://docs.rs/tokio/latest/tokio/net/struct.TcpStream.html#method.split +[`TcpStream`]: https://docs.rs/tokio/latest/tokio/net/struct.TcpStream.html +[`tokio_test::io::Builder`]: https://docs.rs/tokio-test/latest/tokio_test/io/struct.Builder.html diff --git a/doc-test/Cargo.toml b/doc-test/Cargo.toml index 1174ee7c..ab9131de 100644 --- a/doc-test/Cargo.toml +++ b/doc-test/Cargo.toml @@ -10,6 +10,7 @@ publish = false async-stream = "0.2" mini-redis = "0.4" tokio = { version = "1", features = ["full"] } +tokio-test = "0.4" tokio-stream = "0.1" bytes = "1" futures = "0.3" @@ -18,7 +19,7 @@ crossbeam = "0.8" tracing = "0.1.34" tracing-subscriber = "0.3.11" console-subscriber = "0.1.6" -opentelemetry = "0.17.0" +opentelemetry = "0.17.0" tracing-opentelemetry = "0.17.2" opentelemetry-aws = "0.5.0" opentelemetry-otlp = "0.10.0" diff --git a/pages/[...slug].jsx b/pages/[...slug].jsx index 43f5d541..60c87838 100644 --- a/pages/[...slug].jsx +++ b/pages/[...slug].jsx @@ -20,7 +20,7 @@ const menu = { ], }, topics: { - nested: ["bridging", "shutdown", "tracing", "tracing-next-steps"], + nested: ["bridging", "shutdown", "tracing", "tracing-next-steps", "testing"], }, glossary: {}, api: {