F#UnitTesting函数有副作用

时间:2017-07-30 13:19:16

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

我是刚刚开始学习F#的C#dev,我对单元测试有一些疑问。假设我想要以下代码:

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()func注入参数
  • 尝试测试我们得到的东西(模式匹配?)
  • 按照here
  • 的说明使用NUnit

老实说,我正在努力找到一个向我展示这个例子的例子,以便学习F#中的语法和其他最佳实践。所以,如果你能告诉我一条非常棒的道路。

提前致谢。

2 个答案:

答案 0 :(得分:7)

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

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

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.

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

<强>更新
根据您的评论,我得到的印象是您不仅要测试功能(从原始问题开始),还要求提供改进功能本身的建议,以使其更安全可用。

是的,检查函数中的错误条件肯定更好,并返回适当的结果。然而,与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(',','.') } 
         }

此函数将返回包含在MyType中的Success或包含在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<'t, 'e>
  3. 有关此方法的更多信息,请查看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"
    

答案 1 :(得分:1)

这是@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)