Enhancing test writing experience

#1

So, yesterday I was pondering on how to make my project more testable and realized I would really appreciate it if the compiler had a certain feature in it to help with testing code. One of the ways of testing a complex function is by providing a dummy implementation of some feature it uses. In rust you can make a function generic over an interface (trait) and supply the interface when calling the function. That’s almost ideal. The problem is that the dummy impl and the real impl are the only consumers of such an interface and if the function is public it exposes needless complexity to the users. It would be ideal, I think, if you could have a language extension for such functions which would modify what functions the target function calls. Something like

fn do_thing() -> ThingResult {
    do_one_thing();
    do_another_thing();
    ...
}
...
#[test]
fn test() {
    let result = testing! {
        call: do_thing(), 
        replace: do_one_thing with dummy_do_one_thing, 
        replace: do_another_thing with dummy_do_another_thing
    }
    assert!(result.is_ok());
}

For those of you who are familiar with compiler internals and the direction the compiler development is taking my question is: how feasible is making the compiler support something like this? For everyone reading this my question is: what do you think of having this feature and can you think of any other feature you’d like to have to make testing more ergonomic?

1 Like

#2

What about making the generic private (under a different name) and exporting only the function with the desired monomorphisation?

pub
fn foo ()
{
    foo_generic::<RealImpl>()
}

fn foo_generic</*...*/> ()
{
    // ...
}

If you wish to replace functions, you can make traits with just (the) one function.

6 Likes

#3

I would actually love to have something like gmock with real compiler support in Rust (using gmock itself can get a bit gnarly at times, since it’s a pile of C++ macros).

(I interact with gmock frequently at work.)

0 Likes

#4

To note: this would require compiler support and can’t just be a proc-macro as it’d need type information of the mocked symbols in order to make a generic version.

0 Likes

#5

Can it be a compiler plugin? I’m not yet familiar with the API.

0 Likes

#6

This only solves the problem of presenting the user with a simpler interface.

I would say that this code illustrates the problem very well. There’s a lot of complexity added to the code just to make it testable. And it has to be done to every function you want to test. I suppose it might be possible to automate it with a proc macro, but you would still have to annotate each function with the signatures of overridable functions and it’s still a lot of work that could be automated. A better approach, I think, would be to use a build.rs script the way serde used to on stable before procedural macros.

0 Likes

#7

Well, yes, the lack of ergonomics was only my fault: I love type-level constants too much that I tend to (ab)use use them everywhere, but they are indeed a tad cumbersome :sweat_smile:

For your example plain Fn generics can be used, which would actually increase the flexibility of your mocking functions since they would be able to carry state:

fn do_one_thing () {
    println!("do_one_thing()");
}

fn do_other_thing () {
    println!("do_other_thing()");
}

mod lib {
    use super::*;

    fn do_thing_mockable (
        do_one_thing: impl Fn(),
        do_other_thing: impl Fn(),
    )
    {
        do_one_thing();
        do_other_thing();
    }
    
    #[inline]
    pub
    fn do_thing () {
        do_thing_mockable(do_one_thing, do_other_thing);
    }

    #[cfg(test)]
    #[test]
    fn test_do_thing_with_dummy ()
    {
        fn dummy_do_thing () {
            println!("Dummy was indeed called");
        }
        
        do_thing_mockable(dummy_do_thing, dummy_do_thing);
        panic!("Show stdout");
    }
}

fn main () {
    lib::do_thing();
}

The only issue with this is that the functions are given in an unnamed manner which could be error prone when refactoring. Named args is another topic in and on itself, but it would indeed be very nice to have:

    #[cfg(test)]
    #[test]
    fn test_do_thing_with_dummy ()
    {
        fn dummy_do_thing () {
            println!("Dummy was indeed called");
        }
        
        do_thing_mockable(MockOverrides {
            do_one_thing: Some(dummy_do_thing),
            ..Default::default()
        });
        panic!("Show stdout");
    }

which, for cases as simple as these, could be derived with a proc-macro, with a syntax along these lines:

    #[derive(Mock)]
    fn do_thing (
        #[mockable]
        do_one_thing: impl Fn(),
        #[mockable]
        do_other_thing: impl Fn(),
    )
1 Like

#8

If you’re not already familiar, Mocktopus is a library that gives you some semblance of this functionality.

3 Likes