Mpsc channel that discards inputs

I have a bunch of functions I want to test that take one or more channels.
The channel behavior is either irrelevant for unit testing or being tested
elsewhere, or both.

Is there a quick [1] way of creating a /dev/null style std::sync::mpsc
channel that simply discards everything the caller sends? The test code should
be clean and easy to review so I would like to avoid keeping the rx handles
around. There’s quite a few test cases so I’d prefer not to leak the handles either.

[1] Possibly dirty, I don’t mind.

As long as you use channel() (and not sync_channel()), and the messages are not too many (which will cause a significant memory leak), you can just not recv() messages from the channel. When the receiver and the senders will drop, the memory will be freed.

Does the function take ownership of the channels? Or does it get the sending ends as references?

As long as you use channel()
(and not sync_channel()),

I’m afraid it’s sync_channel.

When the receiver and the senders will drop, the memory
will be freed.

I’m currently doing that with sync_channel with a vastly
oversized buffer. It sorta works for testing purposes but having
to keep the rx handle around is what I’d prefer to avoid for
clarity’s sake.

Does the function take ownership of the channels? Or does it
get the sending ends as references?

Owned clones; they’re actually members of some Send struct
that gets cloned everywhere.

I was going to suggest creating a trait, basically so you can have

  1. an implementation for the real type
  2. a mock implementation for testing

(I don't think this is always the best approach, because you can rapidly end up with many more traits than types if it's your go-to approach, but it has its place.)

Then you can also do impl MyTrait for RealThing, pass through the method calls you care about, and just pass the real thing in your non-test code without needing wrappers/conversions/etc.

I was also going to suggest a mock implementation, but instead of generics (which means more complicated code and longer compile times), I'd suggest conditional compilation. In the root of the crate:

#[cfg(not(test))]
pub(crate) use std::sync::mpsc as channel;
#[cfg(test)]
pub(crate) mod channel;

The instead of importing std::sync::mpsc, you import crate::channel.

Code for the mock module
use std::fmt;
use std::marker::PhantomData;

pub use std::sync::mpsc::{RecvError, SendError, TryRecvError, TrySendError};

pub struct Sender<T>(PhantomData<std::sync::mpsc::Sender<T>>);
impl<T> Clone for Sender<T> {}
impl<T> fmt::Debug for Sender<T> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Sender").finish_non_exhaustive()
    }
}
impl<T> Sender<T> {
    pub fn send(&self, t: T) -> Result<(), SendError<T>> { Ok(()) }
}

pub struct SyncSender<T>(PhantomData<std::sync::mpsc::SyncSender<T>>);
impl<T> Clone for SyncSender<T> {}
impl<T> fmt::Debug for SyncSender<T> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("SyncSender").finish_non_exhaustive()
    }
}
impl<T> SyncSender<T> {
    pub fn send(&self, t: T) -> Result<(), SendError<T>> { Ok(()) }
    pub fn try_send(&self, t: T) -> Result<(), TrySendError<T>> { Ok(()) }
}

pub struct Receiver<T>(PhantomData<std::sync::mpsc::Receiver<T>>);

pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
    (Sender(PhantomData), Receiver(PhantomData))
}
pub fn sync_channel<T>(_bound: usize) -> (SyncSender<T>, Receiver<T>) {
    (SyncSender(PhantomData), Receiver(PhantomData))
}
1 Like

I was also going to suggest a mock implementation, but instead
of generics (which means more complicated code and longer compile
times), I'd suggest conditional compilation. In the root of the
crate:

#[cfg(not(test))]
pub(crate) use std::sync::mpsc as channel;
#[cfg(test)]
pub(crate) mod channel;

I like this approach but the issue is that this would apply to
all unit tests whereas I would require the mock version only
for unit tests in a certain file. Elsewhere the channels should
be proper channels also during testing.

Then I would use a feature, named for instance mock_channels, and group the tests that requires the mock into a separate module:

#[cfg(feature = "mock_channels")]
mod requires_mock_channels {
    #[test]
    fn assert_abc() {
        // ...
    }
    // ...
}

And in lib.rs (or main.rs):

#[cfg(not(feature = "mock_channels"))]
pub(crate) use std::sync::mpsc as channel;
#[cfg(feature = "mock_channels")]
pub(crate) mod channel;

Then cargo test twice:

cargo test --features mock_channels requires_mock_channels::
cargo test

You could spawn a thread that just consumes items forever.

fn sink<T>() -> SyncSender<T> {
    let (tx rx) = sync_channel(0);
    std::thread::spawn(move || {
        rx.into_iter().for_each(drop);
    });
    tx
}
10 Likes

Rewrite-it-with-for-loop guy here.

fn sink<T>() -> SyncSender<T> {
    let (tx, rx) = sync_channel();
    std::thread::spawn(|| for _ in rx {});
    tx
}
5 Likes

The problem is that you spawn a new thread for each test, which can be expensive. You can create one thread for the complete test suite, but the need for synchronization may make it even slower.

It's not that slow especially for the tests. You don't need to run thousands of test cases concurrently.

1 Like

But you do often need to run thousands of short tests one-by-one, and the overhead of spawning a thread is quite big.

Thousands of short tests should be ok. This blog post I found on quick googling says it takes about tens of microseconds. The post uses C++ but it shouldn't be different in scale.

The Rust test harness already spawns a new thread for each unit test by default (unless running on a single-core processor or with RUST_TEST_THREADS=1), so you are already paying for this overhead once. If thread spawning is a significant part of your total test time, you might want to combine some of your tests into larger (and fewer) units.

5 Likes

Why doesn't it use a thread pool? Was it tested and no performance gain were found?

As far as I can tell, nobody has tried it yet.

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.