Rust mocking a trait consumed by another struct implementation

Viewed 1316

I'm strictly trying to mock the std::path::PathBuf.is_dir method, but I think there is a more generic use case here where really this is about mocking an external feature.

I've created a trait that encapsulates the PathBuf.is_dir method which theoretically, according to the mockall documentation should enable me to mock my is_dir encapsulation.

use mockall::*;
use std::path::PathBuf;

#[derive(Debug, Clone, PartialEq)]
pub enum PackageFileIndexError {
    ArchiveRootNotADirectory,
}

#[automock]
trait PathInterface {
    // Encapsulate the is_dir method to make it mockable.
    fn is_dir(this_path: &PathBuf) -> bool {
        this_path.is_dir()
    }
}

pub struct PackageFileIndexData {
    archive_root_path: PathBuf,
}

impl PackageFileIndexData {
    pub fn new(archive_root: &str) -> Result<PackageFileIndexData, PackageFileIndexError> {
        let archive_root_path = PathBuf::from(archive_root.clone());

        if !Self::is_dir(&archive_root_path) {
            return Err(PackageFileIndexError::ArchiveRootNotADirectory);
        }

        Ok(PackageFileIndexData { archive_root_path })
    }
}

impl PathInterface for PackageFileIndexData {}

#[cfg(test)]
mod tests {
    use super::*;

    mock! {
        PackageFileIndexData {}

        trait PathInterface {
            fn is_dir(this_path: &PathBuf) -> bool;
        }
    }

    #[test]
    fn test_bad_directory() {
        let ctx = MockPackageFileIndexData::is_dir_context();
        ctx.expect().times(1).returning(|_x| false);

        let result = PackageFileIndexData::new("bad_directory").err().unwrap();

        assert_eq!(result, PackageFileIndexError::ArchiveRootNotADirectory);
    }

    #[test]
    fn test_good_directory() {
        let ctx = MockPackageFileIndexData::is_dir_context();
        ctx.expect().times(1).returning(|_x| true);

        let _result = PackageFileIndexData::new("good_directory").unwrap();
    }

    #[test]
    fn test_bad_directory2() {
        let ctx = MockPathInterface::is_dir_context();
        ctx.expect().times(1).returning(|_x| false);

        let result = PackageFileIndexData::new("bad_directory").err().unwrap();

        assert_eq!(result, PackageFileIndexError::ArchiveRootNotADirectory);
    }

    #[test]
    fn test_good_directory2() {
        let ctx = MockPathInterface::is_dir_context();
        ctx.expect().times(1).returning(|_x| true);

        let _result = PackageFileIndexData::new("good_directory").unwrap();
    }
}

All these tests fail, as follows. It looks to me like the mocks that are available (the tests are finding the various mock contexts) are not being consumed by the running test.

---- mock_is_dir::tests::test_good_directory1 stdout ----
thread 'mock_is_dir::tests::test_good_directory1' panicked at 'called `Result::unwrap()` on an `Err` value: ArchiveRootNotADirectory', src/mock_is_dir.rs:63:23

---- mock_is_dir::tests::test_bad_directory2 stdout ----
thread 'mock_is_dir::tests::test_bad_directory2' panicked at 'MockPathInterface::is_dir: Expectation(<anything>) called fewer than 1 times', src/mock_is_dir.rs:10:1

---- mock_is_dir::tests::test_good_directory2 stdout ----
thread 'mock_is_dir::tests::test_good_directory2' panicked at 'called `Result::unwrap()` on an `Err` value: ArchiveRootNotADirectory', src/mock_is_dir.rs:81:23

---- mock_is_dir::tests::test_bad_directory1 stdout ----
thread 'mock_is_dir::tests::test_bad_directory1' panicked at 'MockPackageFileIndexData::is_dir: Expectation(<anything>) called fewer than 1 times', src/mock_is_dir.rs:40:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    mock_is_dir::tests::test_bad_directory1
    mock_is_dir::tests::test_bad_directory2
    mock_is_dir::tests::test_good_directory1
    mock_is_dir::tests::test_good_directory2

test result: FAILED. 0 passed; 4 failed; 0 ignored; 0 measured; 0 filtered out
2 Answers

Unfortunately, what you are doing won't work, at least in the way you want. #[automock] is creating a struct called MockPathInterface. You can then pass that type into functions that expect something that implements a PathInterface. It cannot change the behaviour of existing structs that implement that trait, at least, not directly. You do set up the MockPathInterface correctly in your tests, but there is nothing associating it with the PackageFileInfoData, so it never gets used.

One way you can do this is my modifying your code to pass the behaviour around, and then you can pass your mocked behaviour in. For example:

#[automock]
pub trait PathInterface {
    // Encapsulate the is_dir method to make it mockable.
    fn is_dir(this_path: &PathBuf) -> bool {
        this_path.is_dir()
    }
}

pub struct PackageFileIndexData {
    archive_root_path: PathBuf,
}

impl PackageFileIndexData {
    pub fn new<PI: PathInterface>(archive_root: &str) -> Result<PackageFileIndexData, PackageFileIndexError> {
        let archive_root_path = PathBuf::from(archive_root.clone());

        if !PI::is_dir(&archive_root_path) {
            return Err(PackageFileIndexError::ArchiveRootNotADirectory);
        }

        Ok(PackageFileIndexData { archive_root_path })
    }
}

Notice that we use a generic parameter in the new static method to pass in the type implementing the trait. Also notice that we change the is_dir call to reference that generic type.

Then you can modify your tests to use that type parameter (one below is an example):

#[test]
fn test_bad_directory() {
    let ctx = MockPathInterface::is_dir_context();
    ctx.expect().times(1).returning(|_x| false);

    let result = PackageFileIndexData::new::<MockPathInterface>("bad_directory").err().unwrap();

    assert_eq!(result, PackageFileIndexError::ArchiveRootNotADirectory);
}

The only change here is the use of a turbofish (::<>) operator to pass the type into the new static method.

These changes are aesthetically ugly, and I'm not necessarily recommending that you use this pattern everywhere - if you really have to mock a lot of behaviour like this, it's basically unmaintainable. But this illustrates how you can do it, and the limitations of mocking in Rust.

Using cfg-if is exactly the way that Mockall is intended to be used when mocking concrete structs.

Related