【问题标题】:F# UnitTesting function with side effect具有副作用的 F# UnitTesting 函数
【发布时间】:2018-01-06 02:27:27
【问题描述】:

我是刚开始学习 F# 的 C# 开发人员,我有几个关于单元测试的问题。假设我想要以下代码:

let input () = Console.In.ReadLine()

type MyType= {Name:string; Coordinate:Coordinate}

let readMyType = 
   input().Split(';') 
   |> fun x -> {Name=x.[1]; Coordinate = {
   Longitude = float(x.[4].Replace(",",".")) 
   Latitude =float(x.[5].Replace(",","."))
   }}

如您所见,有几点需要考虑:

  • readMyType 调用 input() 有副作用。
  • readMyType 假设读取的字符串上有很多内容(包含 ';' 至少 6 列,有些列是浮动的 ',')

我认为这样做的方法是:

  • 将 input() 函数作为参数注入
  • 尝试测试我们得到了什么(模式匹配?)
  • 按照here 的说明使用 NUnit

老实说,我只是在努力寻找一个向我展示这一点的示例,以便学习 F# 中的语法和其他最佳实践。所以,如果你能告诉我那条路,那就太好了。

提前致谢。

【问题讨论】:

    标签: unit-testing f# side-effects purely-functional


    【解决方案1】:

    首先,您的函数并不是真正的函数。这是一个价值。函数和值的区别在于语法:如果你有任何参数,你就是一个函数;否则 - 你是一个价值。这种区别的结果在存在副作用的情况下非常重要:值在初始化期间只计算一次,然后永远不会改变,而每次调用它们时都会执行函数。

    对于您的具体示例,这意味着以下程序:

    let main _ =
       readMyType
       readMyType
       readMyType
       0
    

    将只要求用户输入 一个,而不是三个。因为readMyType 是一个值,所以它会在程序启动时被初始化一次,随后对它的任何引用都会获得预先计算的值,但不会再次执行代码。

    第二, - 是的,你是对的:为了测试这个函数,你需要注入 input 函数作为参数:

    let readMyType (input: unit -> string) = 
       input().Split(';') 
       |> fun x -> {Name=x.[1]; Coordinate = {
       Longitude = float(x.[4].Replace(",",".")) 
       Latitude =float(x.[5].Replace(",","."))
       }}
    

    然后让测试提供不同的输入并检查不同的结果:

    let [<Test>] ``Successfully parses correctly formatted string``() = 
       let input() = "foo;the_name;bar;baz;1,23;4,56"
       let result = readMyType input
       result |> should equal { Name = "the_name"; Coordinate = { Longitude = 1.23; Latitude = 4.56 } }
    
    let [<Test>] ``Fails when the string does not have enough parts``() = 
       let input() = "foo"
       (fun () -> readMyType input) |> shouldFail
    
    // etc.
    

    将这些测试放在一个单独的项目中,添加对您的主项目的引用,然后将测试运行器添加到您的构建脚本中。


    更新
    从您的 cmets 中,我得到的印象是您不仅在寻求按原样测试功能(这是从您的原始问题得出的),而且还在寻求有关改进功能本身的建议,以使其更加安全和可用.

    是的,最好检查函数内的错误情况,并返回适当的结果。然而,与 C# 不同的是,通常最好避免异常作为控制流机制。 例外 情况除外。对于您从未预料到的这种情况。这就是为什么它们是例外。但由于你的函数的重点是解析输入,因此无效输入是它的正常条件之一。

    在 F# 中,您通常不会抛出异常,而是返回指示操作是否成功的结果。对于您的函数,以下类型似乎是合适的:

    type ErrorMessage = string
    type ParseResult = Success of MyType | Error of ErrorMessage
    

    然后相应地修改函数:

    let parseMyType (input: string) =
        let parts = input.Split [|';'|]
        if parts.Length < 6 
        then 
           Error "Not enough parts"
        else
           Success 
             { Name = parts.[0] 
               Coordinate = { Longitude = float(parts.[4].Replace(',','.')
                              Latitude = float(parts.[5].Replace(',','.') } 
             }
    

    这个函数将返回我们用 Success 包裹的 MyType 或用 Error 包裹的错误消息,我们可以在测试中检查这一点:

    let [<Test>] ``Successfully parses correctly formatted string``() = 
       let input() = "foo;the_name;bar;baz;1,23;4,56"
       let result = readMyType input
       result |> should equal (Success { Name = "the_name"; Coordinate = { Longitude = 1.23; Latitude = 4.56 } })
    
    let [<Test>] ``Fails when the string does not have enough parts``() = 
       let input() = "foo"
       let result = readMyType input
       result |> should equal (Error "Not enough parts)
    

    请注意,即使代码现在检查了字符串中的足够部分,仍然存在其他可能的错误情况:例如,parts.[4] 可能不是有效数字。

    我不打算进一步扩展,因为这会使答案变得太长。我只提两点:

    1. 与 C# 不同,验证所有错误条件并非必须以pyramid of doom 结尾。验证可以以线性方式很好地组合(参见下面的示例)。
    2. F# 4.1 标准库已经提供了一个类似于上面ParseResult 的类型,命名为Result&lt;'t, 'e&gt;

    有关此方法的更多信息,请查看this wonderful post(不要忘记浏览其中的所有链接,尤其是视频)。

    在这里,我将为您提供一个示例,说明您的函数在完全验证所有内容后会是什么样子(请记住,尽管这仍然不是最干净的版本):

    let parseFloat (s: string) = 
        match System.Double.TryParse (s.Replace(',','.')) with
        | true, x -> Ok x
        | false, _ -> Error ("Not a number: " + s)
    
    let split n (s:string)  =
        let parts = s.Split [|';'|]
        if parts.Length < n then Error "Not enough parts"
        else Ok parts
    
    let parseMyType input =
        input |> split 6 |> Result.bind (fun parts ->
        parseFloat parts.[4] |> Result.bind (fun lgt ->
        parseFloat parts.[5] |> Result.bind (fun lat ->
        Ok { Name = parts.[1]; Coordinate = { Longitude = lgt; Latitude = lat } } )))
    

    用法:

    > parseMyType "foo;name;bar;baz;1,23;4,56"
    val it : Result<MyType,string> = Ok {Name = "name";
                                         Coordinate = {Longitude = 1.23;
                                                       Latitude = 4.56;};}
    
    > parseMyType "foo"
    val it : Result<MyType,string> = Error "Not enough parts"
    
    > parseMyType "foo;name;bar;baz;badnumber;4,56"
    val it : Result<MyType,string> = Error "Not a number: badnumber"
    

    【讨论】:

    • 感谢您提供详细的答案以及对功能和价值的解释,这真的很有帮助。通过阅读您的答案,您似乎没有在代码本身中添加任何检查,而只是检查它在单元测试中是否失败,为什么会这样? (我的意思是在 C# 中我会添加检查并引发异常,然后在单元测试中检查它。似乎更多的代码,但我习惯于 TDD 方法)
    • 感谢更新,面向铁路编程的文章很有意思。
    【解决方案2】:

    这是@FyodorSoikin 的excellent answer 试图探索该建议的一点后续行动

    请记住,这仍然不是最干净的版本

    使ParseResult 通用

    type ParseResult<'a> = Success of 'a | Error of ErrorMessage
    type ResultType = ParseResult<Defibrillator> // see the Test Cases
    

    我们可以定义一个构建器

    type Builder() =
        member x.Bind(r :ParseResult<'a>, func : ('a -> ParseResult<'b>)) = 
            match r with
            | Success m -> func m
            | Error w -> Error w 
        member x.Return(value) = Success value
    let builder = Builder()
    

    所以我们得到了一个简洁的符号

    let parse input =
        builder {
           let! parts = input |> split 6
           let! lgt = parts.[4] |> parseFloat 
           let! lat = parts.[5] |> parseFloat 
           return { Name = parts.[1]; Coordinate = { Longitude = lgt; Latitude = lat } }
        }
    

    测试用例

    测试始终是基础

    let [<Test>] ``3. Successfully parses correctly formatted string``() = 
       let input = "foo;the_name;bar;baz;1,23;4,56"
       let result = parse input
       result |> should equal (ResultType.Success { Name = "the_name"; Coordinate = { Longitude = 1.23; Latitude = 4.56 } })
    
    let [<Test>] ``3. Fails when the string does not have enough parts``() = 
       let input = "foo"
       let result = parse input
       result |> should equal (ResultType.Error "Not enough parts")
    
    let [<Test>] ``3. Fails when the string does not contain a number``() = 
       let input = "foo;name;bar;baz;badnumber;4,56"
       let result = parse input
       result |> should equal  (ResultType.Error "Not a number: badnumber")
    

    注意通用 ParseResult 的用法。

    小提示

    Double.TryParse下面就够了

    let parseFloat (s: string) = 
        match Double.TryParse s with
        | true, x -> Success x
        | false, _ -> Error ("Not a number: " + s)
    

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2012-08-01
      • 2018-02-02
      • 2017-06-29
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多