Search code examples
unit-testingrust

How to stub an external crate (or ways around it) in rust


I'm trying to test a struct I have that looks something like this

struct CANProxy {
  socket: CANSocket
  // other stuff .......
}
impl CANProxy {
  pub fn new(can_device: &str) -> Self {
    let socket = CANSocket::open(can_device).unwrap();
    // other stuff .......
    
    Self { socket }
  }
}

What I want to test is that the proper messages are being sent across the socket, but I don't want to actually initialize a new can device while running my tests. I wanted to make a dummy CANSocket (which is from the cansocket crate) that uses the same functions and whatnot.

I tried creating a trait and extending the socketcan::CANSocket but it is super tedious and very redundant. I've looked at the mockall crate but I'm not sure if this would help in this situation. Is there an elegant way to accomplish what I want?

trait CANInterface {
  fn open(name: &str) -> Result<Self, SomeError>;
  // ... all the functions that are a part of the socketcan::CANSocket
  // which is a lot of repetition
}

///////////// Proxy code
struct<T: CANInterface> CANProxy<T> {
  socket: T
  // other stuff .......
}
impl<T: CANInterface> CANProxy<T> {
  pub fn open(can_device: &str) -> Result<Self, SomeError> {
    let socket = T::open(can_device).unwrap();
    // other stuff .......
    
    Ok(Self { socket })
  }
}

////////////// Stubbed CANInterfaces
struct FakeCANSocket;
impl CANInterface for FakeCANSocket {
  // ..... implementing the trait here
}
// extension trait over here
impl CANInterface for socketcan::CANSocket {
  // this is a lot of repetition and is kind of silly
  // because I'm just calling the same things
  fn open(can_device: &str) -> Self {
    CANSocket::open(can_device)
  }
  /// ..............
  /// ..............
  /// ..............
}





Solution

  • So, first of all, there are indeed mock-targeted helper tools and crates such as ::mockall to help with these patterns, but only when you already have a trait-based API. If you don't, that part can be quite tedious.

    For what is worth, know that there are also other helper crates to help write that boiler-plate-y and redundantly-delegating trait impls such as your open -> open situation. One such example could be the ::delegate crate.


    Mocking it with a test-target Cargo feature

    With all that being said, my personal take for your very specific situation —the objective is to override a genuine impl with a mock one, but just for testing purposes—, would be to forgo the structured but heavyweight approach of generics & traits, and to instead embrace "duck-typed" APIs, much like it is often done when having implementations on different platforms. In other words, the following suggestion, conceptually, could be interpreted as your test environment being one such special "platform".

    You'd then #[cfg(…)]-feature-gate the usage of the real impl, that is, the CANSocket type, in one case, and #[cfg(not(…))]-feature gate a mock definition of your own CANSocket type, provided you managed to copy / mock all of the genuine's type API that you may, yourself, be using.

    1. Add a mock-socket Cargo feature to your project:

      [features]
      mock-socket = []
      
      • Remark: some of you may be thinking of using cfg(test) rather than cfg(feature = "…"), but that approach only works for unit (src/… files with #[cfg(test)] mod tests, cargo test --lib invocation) tests, it doesn't for integration tests (tests/….rs files, cargo test --tests invocation) or doctests (cargo test --doc invocation), since the library itself is then compiled without cfg(test).
    2. Then you can feature-gate Rust code using it

      #[cfg(not(feature = "mock-socket"))]
      use …path::to::genuine::CANSocket;
      
      #[cfg(feature("mock-socket"))]
      use my_own_mock_socket::CANSocket;
      
    3. So that you can then define that my_own_mock_socket module (e.g., in a my_own_mock_socket.rs file using mod my_own_mock_socket; declaration), provided you don't forget to feature-gate it itself, so that the compiler doesn't waste time and effort compiling it when not using the mocked CANSocket (which would yield dead_code warnings and so on):

      #[cfg(feature = "mock-socket")]
      mod my_own_mock_socket {
          //! It is important that you mimic the names and APIs of the genuine type!
          pub struct CANSocket…
      
          impl CANSocket { // <- no traits!
              pub fn open(can_device: &str) -> Result<Self, SomeError> {
                  /* your mock logic */
              }
      
              …
          }
      }
      
    4. That way, you can use:

      • either cargo test
      • or cargo test --features mock-socket

      to pick the implementation of your choice when running your tests

    5. (Optional) if you know you will never want to run the tests for the real implementation, and only the mock one, then you may want to have that feature be enabled by default when running tests. While there is no direct way to achieve this, there is a creative way to work around it, by explicitly telling so to the self-as-a-lib dev-dependency that test code has (this dependency is always present implicitly, for what is worth). By making it explicit, we can then use the classic features .toml attribute to enable features for that dev-dependency:

      [dev-dependencies]
      your_crate_name.path = "."  # <- this is always implicitly present
      your_crate_name.features = ["mock-socket"]  # <- we add this
      

    Bonus: not having to define an extra module for the mock code.

    When the mock impls in question are short enough, it could be more tempting to just inline its definition and impl blocks. The issue then is that for every item so defined, it has to carry that #[cfg…] attribute which is annoying. That's when helper macros such as that of https://docs.rs/cfg-if can be useful, albeit adding a dependency for such a simple macro may seem a bit overkill (and, very personally, I find cfg_if!'s syntax too sigil heavy).

    You can, instead, reimplement it yourself in less than a dozen lines of code:

    macro_rules! cfg_match {
        ( _ => { $($tt:tt)* } $(,)? ) => ( $($tt)* );
        ( $cfg:meta => $expansion:tt $(, $($($rest:tt)+)?)? ) => (
            #[cfg($cfg)]
            cfg_match! { _ => $expansion }
            $($(
                #[cfg(not($cfg))]
                cfg_match! { $($rest)+ }
            )?)?
        );
    } use cfg_match;
    

    With it, you can rewrite steps 2. and 3. above as:

    cfg_match! {
        feature = "mock-socket" => {
            /// Mock implementation
            struct CANSocket …
    
            impl CANSocket { // <- no traits!
                pub fn open(can_device: &str) -> Result<Self, SomeError> {
                    /* your mock logic */
                }
                …
            }   
        },
        _ => {
            use …path::to::genuine::CANSocket;
        },
    }