Best way to write unit tests

Hello fellow Rustaceans,

I've dabbled with Rust a few years back... so my Rust is rusty :wink: I'm onboarding a new Rust project for which I want to write tests from the start before it gets too big. Let's say I want to write a test for a very simple function:

/**
 * This function sends the response to the packet request through the mpsc channel.
 */
async fn send_response(session: &mut Session, packet: &packet, status: u32) {
    let resp = respond_with_status(packet, status); 
    debug!("sending response: {:?}", resp);
    session.mpsc_write.send(resp).await.unwrap();
}

Seems simple enough. Except that pesky Session object might have a million things like sockets I don't want to deal with here, since it's out of the scope of that unit test.

Is there a way I can override bits of what the code does, in the context of the test? For example here I may want to change what the Session struct looks like (for instance by only having the fields i'm interested in, or by changing its implementation to a mock one).

Also I might even want to replace that respond_with_status function to something else, in order to only test the logic of the function I'm testing.

Since writing test code is so important, is there a way to achieve this that doesn't involve being a level 100 Rust wizard?

Thanks for your input & have a wonderful day!

In general, I find it much cleaner to factor your code in such a way that it's easy to test in isolation, in the first place.

So if you perform some additional logic in your message sends, then put it in a separate function. And/or make the sending of messages decoupled from the details of I/O such as sockets; be generic over reader/writer types and test your code using io::Cursor and a memory-only buffer to ensure correct data is sent and received.

Don't try to unit test your operating system's socket implementation and whether your Internet provider has connectivity to the particular address you are targeting; that's futile.

7 Likes

One particular way to approach that factoring is the “sans-IO” principle — keep your core algorithms and data structures completely free of calling out to IO operations. Don't just make them generic over external communication; make them not even take callbacks, just take data, update state if needed, and return data.

In addition to testability, this helps with:

  • error handling: the algorithms that don't call IO operations don't need to think about IO errors (except maybe to be told things like “there was an error, please reset”)
  • using the algorithms with either blocking or async IO, or in-memory data (not necessarily only for testing!)
  • using different IO operations, like a no_std environment where there might be sockets but they're not provided by std, or intermediation like putting SSL on top of plain sockets
  • fewer dependencies on specific versions of specific libraries, so callers can use a different version if they need to
  • generally, being able to keep your algorithms unchanged while the external interactions are changed to meet new requirements

Of course, sometimes it can be very hard to actually achieve that factoring, so it may not be worthwhile to stick strictly to this principle. But I think it's a useful target to aim for.

5 Likes