Rust中的单元测试、模拟和特质

15

我目前正在构建一个应用程序,它严重依赖于文件IO,因此我的代码中显然有很多部分使用了File::open(file).

做一些集成测试还好,我可以轻松设置文件夹以加载需要的文件和场景。

问题在于当我想要进行单元测试和代码分支测试时。我知道有很多模拟库声称可以进行模拟,但我感觉我最大的问题是代码设计本身。

例如,如果我在任何面向对象的语言(例如Java)中执行相同的代码,则可以编写一些接口,并在测试中简单地覆盖我要模拟的默认行为,设置一个虚假的ClientRepository,重新实现固定返回值,或者使用一些模拟框架,如Mockito。


public interface ClientRepository {
   Client getClient(int id)
}

public class ClientRepositoryDB {
   
  private ClientRepository repository;
  
  //getters and setters

  public Client getClientById(int id) {
    Client client = repository.getClient(id);

    //Some data manipulation and validation
  }
}

但是我在Rust中无法获得相同的结果,因为我们最终会将数据与行为混合在一起。

RefCell文档中,有一个类似于我在Java中提供的示例。有些答案指向特征(traits),闭包(closures)条件编译(conditional compilation)

我们可能会遇到一些测试方案,第一个是某个mod.rs中的公共函数



#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SomeData {
    pub name: Option<String>,
    pub address: Option<String>,
}


pub fn get_some_data(file_path: PathBuf) -> Option<SomeData> {
    let mut contents = String::new();


    match File::open(file_path) {
        Ok(mut file) => {
            match file.read_to_string(&mut contents) {
                Ok(result) => result,
                Err(_err) => panic!(
                    panic!("Problem reading file")
                ),
            };
        }
        Err(err) => panic!("File not find"),
    }
    
    // using serde for operate on data output
    let some_data: SomeData = match serde_json::from_str(&contents) {
        Ok(some_data) => some_data,
        Err(err) => panic!(
            "An error occour when parsing: {:?}",
            err
        ),
    };

    //we might do some checks or whatever here
    Some(some_data) or None
}


mod test {

    use super::*;
    
    #[test]
    fn test_if_scenario_a_happen() -> std::io::Result<()> {
       //tied with File::open
       let some_data = get_some_data(PathBuf::new);

        assert!(result.is_some());

        Ok(())
    }


    #[test]
    fn test_if_scenario_b_happen() -> std::io::Result<()> {
       //We might need to write two files, and we want to test is the logic, not the file loading itself
       let some_data = get_some_data(PathBuf::new);

        assert!(result.is_none());

        Ok(())
    }
}

一旦相同的功能成为一个特征(trait),一些结构体(struct)就会实现它。




#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SomeData {
   pub name: Option<String>,
   pub address: Option<String>,
}

trait GetSomeData {
   fn get_some_data(&self, file_path: PathBuf) -> Option<SomeData>;
}

pub struct SomeDataService {}

impl GetSomeData for SomeDataService {
   fn get_some_data(&self, file_path: PathBuf) -> Option<SomeData> {
       let mut contents = String::new();

       match File::open(file_path) {
           Ok(mut file) => {
               match file.read_to_string(&mut contents) {
                   Ok(result) => result,
                   Err(_err) => panic!("Problem reading file"),
               };
           }
           Err(err) => panic!("File not find"),
       }
       // using serde for operate on data output
       let some_data: SomeData = match serde_json::from_str(&contents) {
           Ok(some_data) => some_data,
           Err(err) => panic!("An error occour when parsing: {:?}", err),
       };

       //we might do some checks or whatever here
       Some(some_data) or None
   }
}

impl SomeDataService {
   pub fn do_something_with_data(&self) -> Option<SomeData> {

       self.get_some_data(PathBuf::new())
   }
}


mod test {

   use super::*;
   
   #[test]
   fn test_if_scenario_a_happen() -> std::io::Result<()> {
      //tied with File::open
      let service = SomeDataService{}

      let some_data = service.do_something_with_data(PathBuf::new);

       assert!(result.is_some());

       Ok(())
   }

}

在这两个例子中,我们很难进行单元测试,因为我们与File::open绑定在一起,而且很明显,这可能会扩展到任何非确定性函数,如时间、数据库连接等。

您将如何设计此类代码,使其更易于单元测试并具有更好的设计?

2个回答

9
你如何设计这段或类似的代码,以便更容易进行单元测试和更好的设计呢?
一种方法是使get_some_data()对输入流进行泛型处理。 std :: io 模块为所有可读取数据的对象定义了Read特征,因此它可能看起来像这样(未经测试):
use std::io::Read;

pub fn get_some_data(mut input: impl Read) -> Option<SomeData> {
    let mut contents = String::new();
    input.read_to_string(&mut contents).unwrap();
    ...
}

您需要使用输入参数调用get_some_data(),例如:get_some_data(File::open(file_name).unwrap())get_some_data(&mut io::stdin::lock())等。在进行测试时,您可以将输入准备成字符串,然后调用get_some_data(io::Cursor::new(prepared_data))

至于 trait 示例,我认为您误解了如何将此模式应用到代码中。您应该使用 trait 将获取数据与处理数据解耦,有点类似于在 Java 中使用接口。 get_some_data() 函数将接收一个已知实现该 trait 的对象。

更类似于面向对象语言的代码可能会选择使用trait 对象

trait ProvideData {
    fn get_data(&self) -> String
}

struct FileData(PathBuf);

impl ProvideData for FileData {
    fn get_data(&self) -> String {
        std::fs::read(self.0).unwrap()
    }
}

pub fn get_some_data(data_provider: &dyn ProvideData) -> Option<SomeData> {
    let contents = data_provider.get_data();
    ...
}

// normal invocation:
// let some_data = get_some_data(&FileData("file name".into()));

在测试中,您只需要创建Trait的不同实现 - 例如:

#[cfg(test)]
mod test {
    struct StaticData(&'static str);

    impl ProvideData for StaticData {
        fn get_data(&self) -> String {
            self.0.to_string()
        }
    }

    #[test]
    fn test_something() {
        let some_data = get_some_data(StaticData("foo bar"));
        assert!(...);
    }
}

6

首先,我想感谢@user4815162342对特征的启发。借鉴他的答案,我为这个问题找到了自己的解决方案。

首先,像提到的那样,我构建了特征来更好地设计我的代码:


trait ProvideData {
    fn get_data(&self) -> String
}

但是我遇到了一些问题,因为有大量的糟糕设计代码,还有很多我需要在运行测试之前模拟的代码,类似于下面的代码。


pub fn some_function() -> Result<()> {
   let some_data1 = some_non_deterministic_function(PathBuf::new())?;

   let some_data2 = some_non_deterministic_function_2(some_data1);

   match some_data2 {
      Ok(ok) => Ok(()),
      Err(err) => panic!("something went wrong"),
   }
}

我需要改变几乎所有函数的签名来接受Fn,这不仅会改变我的大部分代码,而且实际上会使它难以阅读,因为大多数修改只是为了测试目的。


pub fn some_function(func1: Box<dyn ProvideData>, func2: Box<dyn SomeOtherFunction>) -> Result<()> {
   let some_data1 = func1(PathBuf::new())?;

   let some_data2 = func2(some_data1);

   match some_data2 {
      Ok(ok) => Ok(()),
      Err(err) => panic!("something went wrong"),
   }
}

我仔细阅读了Rust文档,稍微改变了实现方式。

  1. 将几乎所有的代码都改为使用traits和structs(大量代码是公共函数)

trait ProvideData {
    fn get_data(&self) -> String;
}

struct FileData(PathBuf);

impl ProvideData for FileData {
    fn get_data(&self) -> String {
        String::from(format!("Pretend there is something going on here with file {}", self.0.to_path_buf().display()))
    }
}

  1. 在结构体中添加默认实现的new函数,并使用动态调度函数添加具有默认实现的构造器。

struct SomeData(Box<dyn ProvideData>);

impl SomeData {
    pub fn new() -> SomeData {
        let file_data = FileData(PathBuf::new());

        SomeData {
            0: Box::new(file_data)
        }
    }

    pub fn get_some_data(&self) -> Option<String> {
        let contents = self.0.get_data();
        
        Some(contents)
    }
}

  1. 由于构造函数是私有的,我们可以防止用户注入代码,并且我们可以自由更改内部实现进行测试,而集成测试仍然可以顺利运行。


fn main() {
    //When the user call this function, it would no know that there is multiple implementations for it.

    let some_data = SomeData::new();
    
    assert_eq!(Some(String::from("Pretend there is something going on here with file ")),some_data.get_some_data());
    
    println!("HEY WE CHANGE THE INJECT WITHOUT USER INTERATION");
}


最后,由于我们是在声明的作用域内进行测试,因此即使是私有的注入也可能会被更改。

mod test {
    use super::*;

    struct MockProvider();

    impl ProvideData for MockProvider {
        fn get_data(&self) -> String {
            String::from("Mocked data")
        }
    }

    #[test]
    fn test_internal_data() {
        let some_data = SomeData(Box::from(MockProvider()));

        assert_eq!(Some(String::from("Mocked data")), some_data.get_some_data())
    }

    #[test]
    fn test_ne_internal_data() {
        let some_data = SomeData(Box::from(MockProvider()));

        assert_ne!(Some(String::from("Not the expected data")), some_data.get_some_data())
    }
}

结果代码可以在Rust Playground中查看,希望这能帮助用户设计他们的代码。

https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=62348977502accfed55fa4600d149bcd


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接