How to Mock Stdin with Cursor in Tokio
A recipe on how to inject test input using std::io::Cursor with tokio's BufReader.
I was trying to figure out how to test code that reads from stdin in tokio. I couldn’t exactly pass real stdin to my tests, and I didn’t want to use some heavy mocking library either. There had to be a simpler way.
Let me show you what I was trying to test. This is a simplified JSON-RPC server that reads from stdin and writes responses to stdout:
// Simplified from src/cmd/acp/mod.rs
use tokio::io::{BufReader, BufWriter, AsyncBufReadExt, AsyncWriteExt, stdin, stdout};
async fn run_server() -> Result<()> {
let reader = BufReader::new(stdin());
let mut writer = BufWriter::new(stdout());
let mut lines = reader.lines();
while let Some(line) = lines.next_line().await? {
if line.trim().is_empty() {
continue;
}
// Parse JSON-RPC request
let Ok(request) = serde_json::from_str::<JsonRpcRequest>(line.as_str()) else {
let response = jsonrpc::Response::parse_error();
let json = serde_json::to_string(&response)?;
writer.write_all(format!("{}\n", json).as_bytes()).await?;
continue;
};
if request.jsonrpc != "2.0" {
let response = jsonrpc::Response::invalid_request(request.id);
let json = serde_json::to_string(&response)?;
writer.write_all(format!("{}\n", json).as_bytes()).await?;
continue;
}
// Handle request...
}
Ok(())
}The problem is clear. I want to write a test like this:
#[tokio::test]
async fn test_parse_error() {
// How do I pass test input here?
run_server().await;
}But I can’t pass real stdin. And I can’t easily capture the output either.
It turns out you use std::io::Cursor with tokio::io::BufReader. The Cursor
type implements tokio’s AsyncRead trait, so you can use it anywhere you’d use
a real reader like stdin.
Here’s the basic solution:
use std::io::Cursor;
use tokio::io::{BufReader, AsyncBufReadExt};
#[tokio::test]
async fn test_parse_error() {
// Create mock stdin
let input = b"not valid json\n";
let stdin_mock = Cursor::new(input);
let reader = BufReader::new(stdin_mock);
// Now you can use it just like you would stdin!
let mut lines = reader.lines();
while let Some(line) = lines.next_line().await.unwrap() {
// Process the line...
}
}Let me break down what’s happening here.
First, the imports. You need std::io::Cursor from the standard library:
use std::io::Cursor;
use tokio::io::{BufReader, AsyncBufReadExt};Then you create your test input:
let input = b"not valid json\n"; // &'static [u8]
let stdin_mock = Cursor::new(input);
let reader = BufReader::new(stdin_mock);The key insight is that Cursor<T> implements tokio::io::AsyncRead when
T: AsRef<[u8]>. Since &[u8], String, and Vec<u8> all implement
AsRef<[u8]>, they all work out of the box.
One thing to notice: the input has explicit \n characters. The lines()
method splits on newline, so you need to include them yourself:
let input = b"first line\nsecond line\n"; // Two lines
let input = b"no newline at end"; // One line, no trailing newlineOne more tip from the tokio docs: make sure your test input ends with a newline
if you’re using lines(). The last line doesn’t technically need it for the
method to work, but it’s good consistency.