asomers / mockall

A powerful mock object library for Rust
Apache License 2.0
1.5k stars 62 forks source link

Mocking Default Trait Method Implementations #569

Closed naftulikay closed 6 months ago

naftulikay commented 6 months ago

I've been through the docs and I'm having trouble figuring out how to test the functionality of a default trait implementation.

I have the following trait:

command.rs

use anyhow::Result;

#[cfg(test)]
mod tests;

/// A CLI command that can be executed
pub(crate) trait Command {
    /// Execute command itself, define your custom logic here
    async fn execute(&self) -> Result<()>;

    /// Hook called before execution
    async fn pre_execute(&self) -> Result<()>
        where
            Self: Sized,
    {
        Ok(())
    }

    /// Hook called after execution with the outcome of the execution
    async fn post_execute(&self, result: Result<()>) -> Result<()>
        where
            Self: Sized,
    {
        result
    }

    /// Run the command, firing pre- and post-execute calls
    async fn run(&self) -> Result<()>
        where
            Self: Sized,
    {
        // run the pre-execute hook
        if let Err(e) = self.pre_execute().await {
            tracing::error!(
                error = format!("{:#}", e),
                "Error encountered during pre-execution phase"
            );
            return Err(e);
        }

        // trace the error if necessary
        let outcome = self.execute().await.inspect_err(|e| {
            tracing::error!(
                error = format!("{:#}", e),
                "Error encountered during execution phase"
            );
        });

        // run the post-execute hook
        match self.post_execute(outcome).await {
            Ok(r) => Ok(r),
            Err(e) => {
                tracing::error!(
                    error = format!("{:#}", e),
                    "Error encountered during post-execution phase"
                );
                Err(e)
            }
        }
    }
}

Essentially, calling the run method should invoke the pre_execute hook, and if this does not succeed, the execute hook should not be executed. If pre_execute does succeed, it should call the execute hook and then call post_execute with the output of execute.

I'm now writing tests to ensure that the default implementation of Command::run does what it should. Here is my test:

command/tests.rs:

use anyhow::Result;
use mockall::mock;

use super::Command;

mock! {
    PreExecFailure {}
    impl Command for PreExecFailure {
        async fn pre_execute(&self) -> Result<()> {
            Err(anyhow::anyhow!("Error"))
        }
        async fn execute(&self) -> Result<()> {
            Ok(())
        }
        async fn post_execute(&self, _result: Result<()>) -> Result<()> {
            Ok(())
        }
    }
}

#[tokio::test]
async fn test_pre_exec_failure() {
    // a failure in pre-exec MUST NOT result in exec or post-exec being called
    let mut mock = MockPreExecFailure::new();

    if let Ok(_) = mock.run().await {
        panic!("Mock run should not have succeeded if pre-exec failed");
    }

    mock.expect_pre_execute().once();
}

When I drop debug breakpoints into my code, it appears that run is never actually called, and my test case fails:

MockPreExecFailure::pre_execute(): No matching expectation found

Is there a different method I should be using to test the default functionality of the Command::run implementation?

asomers commented 6 months ago

run is definitely getting called. That's why pre_execute is being called, after all. And pre_execute panics because you never set any expectations for it. Unless you're using the nightly flag, you need to add some variation of .returning to every expectation . I'm actually surprised that this compiles. There shouldn't be any method bodies inside of mock!, because they don't do anything there.

naftulikay commented 6 months ago

@asomers thanks for your response! I have updated my test code and I'm still seeing the failure.

tests.rs:

use anyhow::Result;
use mockall::mock;

use super::Command;

mock! {
    PreExecFailure{}
    impl Command for PreExecFailure {
        // mock these three methods only, not run
        async fn pre_execute(&self) -> Result<()>;
        async fn execute(&self) -> Result<()>;
        async fn post_execute(&self, result: Result<()>) -> Result<()>;
    }
}

#[tokio::test]
async fn test_pre_exec_failure() {
    let mut mock = MockPreExecFailure::new();
    mock.expect_pre_execute().returning(|| Err(anyhow::anyhow!("Error")));
    mock.expect_execute().returning(|| Ok(()));
    mock.expect_post_execute().returning(|_r| Ok(()));

    assert!(mock.run().await.is_err(), "Command::run should have returned an error on failed pre_execute");

    // execute should never have been called
    mock.expect_execute().never();

    // pre-execute should have been called once
    mock.expect_pre_execute().times(1);
}

The other source file has not changed.

Here is the failed output:

MockPreExecFailure::pre_execute: Expectation(<anything>) called 0 time(s) which is fewer than expected 1

I can observe that execute was never called, but pre_execute was never called according to the mock. With debug points, I can see that pre_execute was indeed called, but I don't know why it doesn't register as having been called.

asomers commented 6 months ago

You can't set expectations after calling the code under test. Mockall doesn't work that way. You have to set them before.

naftulikay commented 6 months ago

I was able to resolve the issue, full code:

tests.rs:

use anyhow::{anyhow, Result};
use mockall::mock;

use super::Command;

mock! {
    Exec{}
    impl Command for Exec {
        // mock these three methods only, not run
        async fn pre_execute(&self) -> Result<()>;
        async fn execute(&self) -> Result<()>;
        async fn post_execute(&self, result: Result<()>) -> Result<()>;
    }
}

/// A pre-exec failure should mean that exec is never called, nor post-exec
#[tokio::test]
async fn test_pre_exec_failure() {
    let mut mock = MockExec::new();
    // pre execute should be called and fail
    mock.expect_pre_execute()
        .once()
        .returning(|| Err(anyhow::anyhow!("Error")));
    // execute should never be called
    mock.expect_execute().never().returning(|| Ok(()));
    // post execute should never be called
    mock.expect_post_execute().never().returning(|_r| Ok(()));

    // run it
    assert!(
        mock.run().await.is_err(),
        "Command::run should have returned an error on failed pre_execute"
    );
}

/// An exec failure combined with post-exec propagation, should fail
#[tokio::test]
async fn test_exec_failure() {
    let mut mock = MockExec::new();

    // pre execute should be called and succeed
    mock.expect_pre_execute().once().returning(|| Ok(()));
    // execute should be called and fail
    mock.expect_execute()
        .once()
        .returning(|| Err(anyhow!("Fail")));
    // post execute should be called with an error
    mock.expect_post_execute()
        .withf(|r| r.is_err() && r.as_ref().is_err_and(|e| e.to_string().eq("Fail")))
        .returning(|r| r);

    // run it
    assert!(
        mock.run().await.is_err(),
        "Command::run should have returned an error on failed execute/post-execute"
    );
}

/// An exec failure should be able to be recovered by post exec
#[tokio::test]
async fn test_post_exec_recover() {
    let mut mock = MockExec::new();

    // pre execute should be called and succeed
    mock.expect_pre_execute().once().returning(|| Ok(()));
    // execute should be called and fail
    mock.expect_execute()
        .once()
        .returning(|| Err(anyhow!("Fail")));
    // post execute should be called, receive an error, and recover
    mock.expect_post_execute()
        .withf(|r| r.is_err() && r.as_ref().is_err_and(|e| e.to_string().eq("Fail")))
        .returning(|_r| Ok(()));

    // run it
    assert!(
        mock.run().await.is_ok(),
        "Command::run should have recovered from error in post exec"
    );
}

I wasn't aware that expectations were evaluated on drop(), I had assumed that you configured the mock first, ran the code, and then after running, validate the assumptions.