简体   繁体   English

F#UnitTesting函数有副作用

[英]F# UnitTesting function with side effect

I am C# dev that has just starting to learn F# and I have a few questions about unit testing. 我是刚刚开始学习F#的C#dev,我对单元测试有一些疑问。 Let's say I want to the following code: 假设我想要以下代码:

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(",","."))
   }}

As you can notice, there are a few points to take in consideration: 您可以注意到,有几点需要考虑:

  • readMyType is calling input() with has a side effect. readMyType调用input()并带有副作用。
  • readMyType assume many thing on the string read (contains ';' at least 6 columns, some columns are float with ',') readMyType假设字符串读取很多东西(包含';'至少6列,有些列浮动',')

I think the way of doing this would be to: 我认为这样做的方法是:

  • inject the input() func as parameter 注入input()func作为参数
  • try to test what we are getting (pattern matching?) 试着测试我们得到的东西(模式匹配?)
  • Using NUnit as explained here 使用NUnit作为解释这里

To be honest I'm just struggling to find an example that is showing me this, in order to learn the syntax and other best practices in F#. 说实话,我只是在努力找到一个向我展示这个例子的例子,以便学习F#中的语法和其他最佳实践。 So if you could show me the path that would be very great. 所以,如果你能告诉我一条非常棒的道路。

Thanks in advance. 提前致谢。

First, your function is not really a function. 首先,你的功能并不是真正的功能。 It's a value. 这是一个价值。 The distinction between functions and values is syntactic: if you have any parameters, you're a function; 函数和值之间的区别是语法:如果你有任何参数,你就是一个函数; otherwise - you're a value. 否则 - 你是一个价值观。 The consequence of this distinction is very important in presence of side effects: values are computed only once, during initialization, and then never change, while functions are executed every time you call them. 这种区别的结果在存在副作用时非常重要:在初始化期间,值只计算一次,然后永不改变,而每次调用时都会执行函数。

For your specific example, this means that the following program: 对于您的具体示例,这意味着以下程序:

let main _ =
   readMyType
   readMyType
   readMyType
   0

will ask the user for only one input, not three. 会询问用户只有一个输入,而不是三个。 Because readMyType is a value, it gets initialized once, at program start, and any subsequent reference to it just gets the pre-computed value, but doesn't execute the code over again. 因为readMyType是一个值,所以它在程序启动时被初始化一次,并且对它的任何后续引用只获得预先计算的值,但不会再次执行代码。

Second, - yes, you're right: in order to test this function, you'd need to inject the input function as a parameter: 第二, - 是的,你是对的:为了测试这个函数,你需要将input函数作为参数注入:

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

and then have the tests supply different inputs and check different outcomes: 然后让测试提供不同的输入并检查不同的结果:

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.

Put these tests in a separate project, add reference to your main project, then add test runner to your build script. 将这些测试放在一个单独的项目中,添加对主项目的引用,然后将test runner添加到构建脚本中。


UPDATE UPDATE
From your comments, I got the impression that you were seeking not only to test the function as it is (which follows from your original question), but also asking for advice on improving the function itself, so as to make it more safe and usable. 根据您的评论,我得到的印象是您不仅要测试功能(从原始问题开始),还要求提供改进功能本身的建议,以使其更安全可用。

Yes, it is definitely better to check error conditions within the function, and return appropriate result. 是的,检查函数中的错误条件肯定更好,并返回适当的结果。 Unlike C#, however, it is usually better to avoid exceptions as control flow mechanism. 然而,与C#不同,通常最好避免异常作为控制流机制。 Exceptions are for exceptional situations. 例外情况适用于特殊情况。 For such situations that you would have never expected. 对于你从未预料到的这种情况。 That is why they are exceptions. 这就是为什么他们是例外。 But since the whole point of your function is parsing input, it stands to reason that invalid input is one of the normal conditions for it. 但是,由于函数的整个点是解析输入,因此无效输入是其正常条件之一。

In F#, instead of throwing exceptions, you would usually return a result that indicates whether the operation was successful. 在F#中,您通常会返回指示操作是否成功的结果,而不是抛出异常。 For your function, the following type seems appropriate: 对于您的功能,以下类型似乎是合适的:

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

And then modify the function accordingly: 然后相应地修改函数:

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(',','.') } 
         }

This function will return us either MyType wrapped in Success or an error message wrapped in Error , and we can check this in tests: 此函数将返回包含在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)

Note that, even though the code now checks for enough parts in the string, there are still other possible error conditions: for example, parts.[4] 请注意,即使代码现在检查字符串中的足够部分,仍然存在其他可能的错误条件:例如, parts.[4] may be not a valid number. 可能不是有效的号码。

I am not going to expand on this further, as that will make the answer way too long. 我不打算进一步扩展这一点,因为这将使答案太长。 I will only stop to mention two points: 我只想提到两点:

  1. Unlike C#, verifying all error conditions does not have to end up as a pyramid of doom . 不像C#,验证所有错误情况并不一定最终成为一个厄运的金字塔 Validations can be nicely combined in a linear-looking way (see example below). 可以以线性外观的方式很好地组合验证(参见下面的示例)。
  2. The F# 4.1 standard library already provides a type similar to ParseResult above, named Result<'t, 'e> . F#4.1标准库已经提供了类似于上面的ParseResult的类型,名为Result<'t, 'e>

For more on this approach, check out this wonderful post (and don't forget to explore all links from it, especially the video). 有关此方法的更多信息,请查看此精彩帖子 (并且不要忘记浏览其中的所有链接,尤其是视频)。

And here, I will leave you with an example of what your function could look like with full validation of everything (keep in mind though that this is not the cleanest version still): 在这里,我将为您提供一个示例,说明您的功能可以完全验证所有内容(请记住,尽管这不是最干净的版本):

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 } } )))

Usage: 用法:

> 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"

This is a little follow-up to the excellent answer of @FyodorSoikin trying to explore the suggestion 这是对@FyodorSoikin试图探索这个建议的优秀答案的一点跟进

keep in mind though that this is not the cleanest version still 请记住,尽管这不是最干净的版本

Making the ParseResult generic 使ParseResult通用

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

we can define a builder 我们可以定义一个构建器

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()

so we get a concise notation : 所以我们得到一个简洁的符号

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 } }
    }

Test Cases 测试用例

Tests are always fundamental 测试始终是基础

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")

Notice the usage of a specific ParseResult from the generic one. 注意通用的ParseResult的使用。

minor note 小调

Double.TryParse is just enough in the following Double.TryParse在下面就足够了

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

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM