Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Is there a cleaner way to test functions that use functions that require user input in Rust?

I am writing a CLI question asking library for my first Rust project since I will probably be using it anyway, and I cannot find a clean way to test the terminal method of the builder pattern, which using the configuration gets user input and returns an answer.

pub fn confirm(&mut self) -> Answer {
    self.yes_no();
    self.build_prompt();
    let prompt = self.prompt.clone();
    let valid_responses = self.valid_responses.clone().unwrap();
    loop {
        let stdio = io::stdin();
        let input = stdio.lock();
        let output = io::stdout();
        if let Ok(response) = prompt_user(input, output, &prompt) {
            for key in valid_responses.keys() {
                if *response.trim().to_lowercase() == *key {
                    return valid_responses.get(key).unwrap().clone();
                }
            }
            self.build_clarification();
        }
    }
}

Looking for a solution I discovered dependency injection which allowed me to write tests for the function that prompts the user for input using Cursor. It does not let me change the user input to the confirm() function for each test of Question::new("Continue?").confirm() though so I tried using conditional compilation, and came up with the following.

#[cfg(not(test))]
fn prompt_user<R, W>(mut reader: R, mut writer: W, question: &str) -> Result<String, std::io::Error>
where
    R: BufRead,
    W: Write,
{
    write!(&mut writer, "{}", question)?;
    let mut s = String::new();
    reader.read_line(&mut s)?;
    Ok(s)
}

#[cfg(test)]
fn prompt_user<R, W>(mut reader: R, mut writer: W, question: &str) -> Result<String, std::io::Error>
where
    R: BufRead,
    W: Write,
{
    use tests;
    Ok(unsafe { tests::test_response.to_string() })
}

And in the tests module I use a global variable:

pub static mut test_response: &str = "";

#[test]
fn simple_confirm() {
    unsafe { test_response = "y" };
    let answer = Question::new("Continue?").confirm();
    assert_eq!(Answer::YES, answer);
}

This works as long as I only run tests with a single thread, but also no longer allows me to test the real user input function. Not really a problem for such a small crate but it is very messy. I did not see any solutions to do this from any available testing libraries.

like image 223
datenstrom Avatar asked Mar 08 '23 17:03

datenstrom


1 Answers

As mentioned in the Stack Overflow question you linked, you should generally avoid hard-wiring external dependencies (a.k.a. I/O) if you want testability:

  • disk access,
  • terminal access,
  • network access,
  • database access,
  • time access.

In all such cases, I recommend using Dependency Injection:

  • create a clean interface (trait) to describe the allowed actions (don't overdo it, YAGNI!),
  • implement the interface for "production" use, with the real external dependency behind it,
  • implement a "mock" of the interface for "test" use.

Then, when writing:

  • a function which requires access to this resource, pass it as argument,
  • a method which requires access to this resource, pass it as either argument or in the constructor of the object.

Finally, instantiate the production dependencies in main, and forward them from there.


Tricks, not treats:

  • It may be useful to create an Environment structure which contains all such interfaces, rather than passing heaps of arguments to each function; however functions which only require one/two resource(s) should take those explicitly to make it clear what they use,
  • I have found it useful to pass the timestamp rather than the clock from which it is obtained in the past... just because multiple calls to now() may return different results as the time passes.
like image 69
Matthieu M. Avatar answered Apr 13 '23 00:04

Matthieu M.