Функция F# UnitTesting с побочным эффектом

Я разработчик C#, который только начинает изучать F#, и у меня есть несколько вопросов о модульном тестировании. Допустим, я хочу следующий код:

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() в качестве параметра
  • попробуйте проверить, что мы получаем (сопоставление с образцом?)
  • Использование NUnit, как описано здесь< /а>

Честно говоря, я просто изо всех сил пытаюсь найти пример, который показывает мне это, чтобы изучить синтаксис и другие лучшие практики в F #. Так что, если бы вы могли показать мне путь, это было бы очень здорово.

Заранее спасибо.


person Cedric Royer-Bertrand    schedule 30.07.2017    source источник


Ответы (2)


Во-первых, ваша функция на самом деле не является функцией. Это ценность. Различие между функциями и значениями синтаксическое: если у вас есть какие-либо параметры, вы — функция; иначе - ты ценность. Следствие этого различия очень важно при наличии побочных эффектов: значения вычисляются только один раз, во время инициализации, и затем никогда не меняются, в то время как функции выполняются каждый раз, когда вы их вызываете.

Для вашего конкретного примера это означает, что следующая программа:

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.

Поместите эти тесты в отдельный проект, добавьте ссылку на свой основной проект, а затем добавьте средство запуска тестов в свой скрипт сборки.


ОБНОВЛЕНИЕ
Из ваших комментариев у меня сложилось впечатление, что вы стремились не только протестировать функцию как она есть (что следует из вашего первоначального вопроса), но и попросить совета по улучшению функции себя, чтобы сделать его более безопасным и удобным.

Да, определенно лучше проверять условия ошибки внутри функции и возвращать соответствующий результат. Однако, в отличие от 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#, проверка всех условий ошибки не должна заканчиваться пирамида судьбы. Валидации можно красиво комбинировать линейным образом (см. пример ниже).
  2. Стандартная библиотека F# 4.1 уже предоставляет тип, аналогичный приведенному выше ParseResult, с именем Result<'t, 'e>.

Подробнее об этом подходе читайте в этой замечательной публикации (и не забудьте изучить все ссылки оттуда, особенно видео).

И здесь я оставлю вас с примером того, как может выглядеть ваша функция с полной проверкой всего (но имейте в виду, что это все еще не самая чистая версия):

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"
person Fyodor Soikin    schedule 30.07.2017
comment
Спасибо за этот подробный ответ и объяснение функции и значения, это действительно полезно. Читая ваш ответ, кажется, что вы не добавляете никаких проверок в сам код, а просто проверяете его сбой в модульном тесте, почему это? (Я имею в виду, что в С# я бы добавил проверку и выдал бы исключение, а затем проверил его в модульном тесте. Кажется, больше кода, но я привык к подходу TDD) - person Cedric Royer-Bertrand; 30.07.2017
comment
Спасибо за обновление, статья о железнодорожном программировании очень интересна. - person Cedric Royer-Bertrand; 30.07.2017

Это небольшое продолжение отличного ответа @FyodorSoikin, пытающегося изучить предложение

имейте в виду, что это еще не самая чистая версия

Создание универсального 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)
person Community    schedule 30.07.2017