A Tale of Testability and Sending Non-Send Types in Rust

Banner Image
Posted 2025-05-24

This is a story of testability, multithreading, and the age old question of how do we send a !Send type in Rust. I’ll explore how (not) to do this, while rambling on about how writing obsessively testable code leads to better design. Hot takes incoming.

Testability and Multithreading

Let’s take a few steps back and set the scene: say we have a function that spawns a thread. We’ll keep it simple –trivial even– to focus on the essentials. Let’s say that all this thread does, is play some audio1. For a first try, we could structure our code like so:

struct SystemAudio {/* ...*/}

impl SystemAudio {
    fn new(/* config */) -> Self {
        /*...*/
    }
    fn play_music(&self) {
        /* ...*/
    }
}

// ❓ how do we test this??
fn spawn_thread(audio: SystemAudio) {
    std::thread::spawn(move || {
        audio.play_music();
    });
}

To my mind, there’s a problem with this code and it stems from the fact that I am a huge nut for testability. I need to make sure that I can test the behavior of that function. The least I want to do, is make sure that the play_music function of the audio instance really gets called.

One great way to make all kinds of code testable is dependency inversion, which boils down to coding against interfaces rather than concrete types2. So rather than passing in our SystemAudio instance directly, we’ll define a trait Audio to abstract over the behavior of the audio backend. This allows us to mock the audio backend for testing. During testing, we can pass in our mock to make sure that the correct behavior was indeed invoked. So let’s refactor our code:

trait Audio {
    fn play_music(&self);
}

struct SystemAudio {/* ...*/}

impl SystemAudio {
    fn new(/* config */) -> Self {
        /*...*/
    }
}

impl Audio for SystemAudio {
    fn play_music(&self) {
        /* ...*/
    }
}

// ⚡ this doesn't compile
fn spawn_thread<A>(audio: A)
    where A: Audio {
    std::thread::spawn(move || {
        audio.play_music();
    });
}

You probably already knew this wouldn’t compile. The compiler will correctly complain that A is neither Send nor 'static, which is what we’d need to move it into the thread. If we can further restrict A to be Send and 'static, that’s a fine solution and that would be the end of this article. But what if we can’t? What if our production audio backend really does not implement Send?

How Do We impl Send for a !Send Type?

Visualization of the FT and DTFT
The answer to the section heading in a nutshell.

If we’ve written a type T, where the compiler doesn’t automatically implement Send but we know it would be sound to do so, we can of course implement Send manually and call it a day3. However, what if the compiler doesn’t implement Send on our type for a good reason? Let’s say we’re using a field of foreign type that is itself not Send. We’d better assume that the crate authors deliberately did not implement Send on that type. Thus, just overriding their decision by manually implementing Send on our type might be unsound. So that’s out of the question, unless we have a very good reason to believe it’s sound.

Using Mutex<T> and Arc<Mutex<T>>

Why don’t we just wrap it in a Mutex<T>? Or an Arc<Mutex<T>> for good measure? That usually helps with all kinds of multithreading-induced compile errors, right? The unfortunate truth is that Mutex<T> is Send if and only if T is Send, so sticking a non-Send type into a mutex won’t make it Send. The same is true for Arc<T> and thus for Arc<Mutex<T>>, so that won’t help either4.

Interlude: Isn’t There a Crate for That?

The short answer is: No, I don’t think so, let’s have a look on crates.io:

  • mutex-extra: aims to create a Send type from a non-Send type. Diplays a “my code is erroneous, don’t use” warning. I guess we won’t be using that then.
  • send_cells: a pretty recent crate claiming to be an alternative to fragile (see below).
  • sendable: I believe this crate is concerned with sharing resources, rather than moving values across threads. We might be able to use it for our purpose, but it would only give us runtime guarantees that we didn’t do anything wrong.
  • send-cell: deprecated in favor of fragile.
  • fragile: “wrap a value and provide a Send bound. Neither of the types permit access to the enclosed value unless the thread that wrapped the value is attempting to access it”. That’s the opposite of what we want.

Please tell me if I’m wrong about this: for fundamental reasons, I can’t imagine a crate existing that gives us Send wrappers for non-Send types without using unsafe code that would be equivalent to implementing Send ourselves. We’ve already ruled that out for good reason.

Why Even Test spawn_thread?

Yes, why even do that if the Rust compiler makes it so hard? Even if we generally agree on the value of testing, we might be tempted to refactor our code like so:

fn spawn_thread() {
    std::thread::spawn(|| {
        let audio = SystemAudio::new(/*config*/);
        execute_thread(audio);
    });
}

fn execute_thread<A: Audio>(audio: A) {
    audio.play_music();
    /* and all other logic...*/
}

Now, we can write a nice test for execute_thread, since that still depends on an interface, rather than a concrete type. Isn’t this basically just as good as testing spawn_thread? After all, spawn_tread only calls execute_thread with a SystemAudio instance, which we want to use in production anyways. Call me crazy, but I’ll argue this is not good enough.

To my mind, we should treat items under test as black boxes as much as is feasible. Testing execute_thread as a substitute for spawn_thread relies on the knowledge that spawn_thread only calls execute_thread in a new thread. But from a testing point of view, that’s an implementation detail we shouldn’t care about. We should instead be interested in making sure that spawn_thread does the right thing. This might seem overly pedantic, but imagine someone else5 editing our spawn_thread and inadvertendly introducing a bug. I personally would want to have a test for spawn_thread that flags if something unexpected happens, to catch errors higher up the chain.

For me, that’s the most important thing. It’s not about getting a couple lines more test coverage, but it’s about testing the behavior of the things that are actually used… at all levels of integration6. I’ll one up myself and also claim that putting in the effort to make spawn_thread testable leads to a better design.

Advanced Dependency Inversion

Don’t get me wrong, if it’s possible to restrict our type to be Send + 'static, we should definitely do that and move it into the thread, like we initially intended. We just have to come to terms with the fact that we simply can’t do that in this scenario. To overcome this, we can take inspiration from the previous section. What we did there, was to create the audio instance in the thread itself, rather than move it into the thread from the outside.

Let’s expand on this idea: rather than hardcode the creation of the audio instance in the thread itself, we create an API to inject some code to create the instance for us. This sounds more complicated than it is, and there are many ways to do that7. I like this one:

fn spawn_thread<F,A>(audio_constructor: F)
    where F: FnOnce()-> A + Send + 'static,
          A: Audio {
    std::thread::spawn(move || {
        let audio = audio_constructor();
        audio.play_music();
    });
}

Instead of passing in the audio instance itself, we now pass in a callable that constructs the instance. The callable F itself is restricted on Send + 'static, but A is not restricted on either of these bounds. I’ve created a slighty more involved example on the playground that illustrates the use. Here’s what we can do now:

// passing a default system backend
// using a closure.
spawn_thread(||SystemAudio::default());
// a mocking backend 
spawn_thread(||MockAudio);
// passing in a configuration
let config = AudioConfiguration {device: 123, volume: 0.5}; 
spawn_thread(move ||SystemAudio::with_config(config));

Using the new API like this is almost as simple as passing in the instance itself, and it makes spawn_thread completely testable. We just have to add || or move ||. One problem we have to deal with is constructor failure, meaning the constructor function might return a Result<A,E> rather than an A directly. We have to make the thread communicate the error to the outside. However, that problem isn’t unique to this approach, so I’ll leave it at that.

Why It’s Better

In this last section, let me defend my claim that this design is better, not only because it makes spawn_thread testable. This design also decouples the implementation of spawn_thread from the actual audio backend again, by using dependency inversion. This means that we can use different audio backends either at runtime (by refactoring to dyn Audio), or at compile time e.g. for different operating systems or hardware.

I believe the benefits of this approach completely justify the additional complexity… even if this starts looking a bit like a factory pattern. It’s no AbstractFactoryBean<T>, though.

Endnotes

  1. Since this example is so trivial, there are other ways to go about testing it. But I want to focus on the bare essentials of the problem and I ask you to bear with me, dear reader. 

  2. Say what you will about SOLID, but dependency inversion is the hill I am willing to die on. 

  3. This can happen if our type contains a raw pointer field. 

  4. Matters would be different if we were interested in implementing Send and Sync on a type T that only implements Send. In this case, reachig for Mutex<T> is a solution. 

  5. Yes, yes… those pesky other developers. Of course we would never introduce a bug ourselves, right? ;) 

  6. Don’t get me wrong: you shouldn personally would only test the highest, most integrated, levels of our code. It’s good to test execute_thread in our example. But, to my mind, that should not absolve us from having to test spawn_thread as well. 

  7. We could also extend the Audio trait to provide a constructor. I don’t like this quite as much because different implementors of the Audio trait might need different parameters for callable F

Banner Image

Comments

    You can comment on this post using your GitHub account.

    Join the discussion for this article on this ticket. Comments appear on this page instantly.