F# 3.1, an Ideal Language For Writing .NET Unit Tests

posted in: Language Features | 0

In this post, we are going to test the escaped concat/split functions that we implemented last time. Along the way, it will become apparent why F# is an ideal language for writing .NET unit tests. These are the signatures of the two F# functions to be tested:

val concatEscape : esc:char -> sep:string -> strings:seq<string> -> string
val splitUnescape : esc:char -> sep:string -> string:string -> seq<string>

Seen from C# (e.g., in Visual Studio's object browser), the signatures would look like this:

public static string concatEscape(char esc, string sep, IEnumerable<string> strings);
public static IEnumerable<string> splitUnescape(char esc, string sep, string @string);

We will use Visual Studio's built-in Test Explorer to visualize the results, which will look like this:

Test Explorer

Step 1: Prepare the Test Project

Create a new F# class library project named e.g. MyCompany.Foundation.Tests, and add the following NuGet packages:

  • NUnit
  • NUnit Test Adapter for VS >= 2012. This package is optional; it allows to run the tests in the GUI of Visual Studio's built-in Test Explorer.
  • FsUnit. This package is optional; it provides functions to access NUnit in a more F#-idiomatic way.

Step 2: Define Custom Operators to Further Simplify Testing

Add a new F# source code file called StringTests.fs to the project, with the following code:

namespace MyCompany.Foundation.Tests

open System
open NUnit.Framework
open FsUnit.TopLevelOperators
open MyCompany.Foundation.Core

[<AutoOpen>]
module Operators =
    let throws<'T when 'T :> exn> : (unit -> unit) -> unit = should throw typeof<'T>

    /// Asserts equality of two instances of the same type.
    let (.=) (actual:'T) (expected:'T) = should equal expected actual 

throws is a supporting function who tests whether a given function throws a certain type of exception. The .= operator enables a very simple syntax two test if two values of the same type are equal, e.g. x .= y.

Step 3: Begin a Module for the Tests

In the StringTests.fs file, add the following code:

module StringTests =

That's all you need to do to create the test fixture class required by NUnit. It is not necessary to define a full-blown class, and it is not necessary to add a [<TestFixture>] attribute.

Step 4: Implement reusable testing functionality

concatEscape and splitUnescape both require an escape character and a separator string as arguments, which must be validated in the same way. To avoid repetitive code, we define a function testSeparatorValidationOf that

  • takes another function f as parameter,
  • calls f with several combinations of escape character and separator string,
  • tests whether f correctly validates the escape character and separator string.
    let private testSeparatorValidationOf f =
        let test sep = f '!' sep

        // Separator must not be null
        (fun _ -> test null |> ignore) |> throws<ArgumentNullException>
        
        // Separator must not be empty
        (fun _ -> test String.Empty |> ignore) |> throws<ArgumentException>

        // Separator must not have same beginning and ending
        for sep in ["xx"; "xax"; "xabx"; "xyxy"; "xyaxy"; "xyabxy"
                    "xyzqwertyabcxyzqwerty"] do
            (fun _ -> test sep |> ignore) |> throws<ArgumentException>

        // Separator must not start with escape character
        (fun _ -> test "!; " |> ignore) |> throws<ArgumentException>

Note that we did not have to write any type annotations. For instance, the F# compiler automatically inferred that the parameter f must be of type char -> string -> 'a, i.e., a function who takes a character parameter and a string parameter and returns a value of generic type. In C#, to achieve something similar, we would have to write:

    static a testSeparatorValidationOf<a>(Func<char, string, a> f){
        Func<string, a> test = sep => f('!', sep);
    
        // Etc. ...
    }

Step 5: Implement Test Functions

The first two tests check whether concatEscape and splitUnescape validate arguments correctly:

    [<Test>]
    let ``concatEscape validates parameters correctly``() = 
        // Test validation of separator
        testSeparatorValidationOf
        <| fun esc sep -> String.concatEscape esc sep ["asdf"; "qwer"; "uiop"]

        // Test validation of input string sequence (must not be null)
        (fun _ -> String.concatEscape '!' "; " null |> ignore) 
        |> throws<ArgumentNullException>

    [<Test>]
    let ``splitUnescape validates parameters correctly``() = 
        // Test validation of separator
        testSeparatorValidationOf 
        <| fun esc sep -> String.splitUnescape esc sep "asdfqwpoiqwe"

        // Test validation of source string (must not be null)
        (fun _ -> String.splitUnescape '!' "; " null |> ignore) 
        |> throws<ArgumentNullException

Note how F# allows using any kind of expression as identifier when you put it inside pairs of backtick marks, as in ``This :) is " a valid function name``.

The next test checks if concatEscape correctly handles cases where the whole sequence of elements is empty, or some elements are empty strings:

    [<Test>]
    let ``concatEscape handles missing or "" elements correctly``() =
        let concat = String.concatEscape '~' ";"
        let e, ab, cd = String.Empty, "ab", "cd"

        concat []          .= e
        concat [e]         .= e
        concat [e; e]      .= ";"
        concat [e; ab]     .= ";ab"
        concat [ab; e]     .= "ab;"
        concat [e; e; e]   .= ";;"
        concat [e; e; ab]  .= ";;ab"
        concat [e; ab; e]  .= ";ab;"
        concat [ab; e; e]  .= "ab;;"
        concat [e; ab; cd] .= ";ab;cd"
        concat [ab; e; cd] .= "ab;;cd"
        concat [ab; cd; e] .= "ab;cd;"

Note how we have defined the local concat function by partially applying the existing String.concatEscape function (calling it with only the first two of three possible arguments. This results in a new function who has the esc and sep arguments already built-in and expects only the remaining strings argument in its signature). Also note the usage of our custom-defined .= should equal operator. These F#-specific features allow us to test twelve different cases with minimum plumbing, but maximum expressiveness.

The next test checks whether concatEscape handles null strings correctly. This is needed because types who are defined by non-F# languages, such as System.String of mscorlib.dll, can be null. (Types who are defined in F#, even if they are reference types, by default cannot be null.)

    [<Test>]
    let ``concatEscape handles null elements like "" elements``() =
        let concat = String.concatEscape '~' ";"
        let e, ab, cd = String.Empty, "ab", "cd"

        concat [null]             .= concat [e]
        concat [null; null]       .= concat [e; e]
        concat [null; ab]         .= concat [e; ab]
        concat [ab; null]         .= concat [ab; e]
        concat [null; null; null] .= concat [e; e; e]
        concat [null; null; ab]   .= concat [e; e; ab]
        concat [null; ab; null]   .= concat [e; ab; e]
        concat [ab; null; null]   .= concat [ab; e; e]
        concat [null; ab; cd]     .= concat [e; ab; cd]
        concat [ab; null; cd]     .= concat [ab; e; cd]
        concat [ab; cd; null]     .= concat [ab; cd; e]

The following test checks whether concatEscape uses the escape character as specified in the requirements: The escape character should only escape itself (by duplication) if strictly necessary. Note, again, how F# allows to write 49 different test cases with minimum plumbing and maximum expressivity:

    [<Test>]
    let ``concatEscape uses escape character correctly``() =
        let concat = String.concatEscape '&' ";"
  
        // Escape single pre-existing separator
        concat [";"]       .= "&;"
        concat [";ab"]     .= "&;ab"
        concat ["ab;"]     .= "ab&;"
        concat ["ab;cd"]   .= "ab&;cd"
        concat [";"; ""]   .= "&;;"
        concat [";"; "ab"] .= "&;;ab"
        concat [""; ";"]   .= ";&;"
        concat ["ab"; ";"] .= "ab;&;"

        // Escape series of pre-existing separators
        concat [";;"]       .= "&;&;"
        concat [";;ab"]     .= "&;&;ab"
        concat ["ab;;"]     .= "ab&;&;"
        concat ["ab;;cd"]   .= "ab&;&;cd"
        concat [";;"; ""]   .= "&;&;;"
        concat [";;"; "ab"] .= "&;&;;ab"
        concat [""; ";;"]   .= ";&;&;"
        concat ["ab"; ";;"] .= "ab;&;&;"

        // Repeat pre-existing escape characters if followed by pre-existing separator
        // and then escape the pre-existing separator.
        concat ["&;"]          .= "&&&;"
        concat ["&;ab"]        .= "&&&;ab"
        concat ["ab&;"]        .= "ab&&&;"
        concat ["ab&;cd"]      .= "ab&&&;cd"
        concat ["&&;"]         .= "&&&&&;"
        concat ["&&;ab"]       .= "&&&&&;ab"
        concat ["ab&&;"]       .= "ab&&&&&;"
        concat ["ab&&;cd"]     .= "ab&&&&&;cd"
        concat ["&;"; ""]      .= "&&&;;"
        concat ["ab&;"; "cd"]  .= "ab&&&;;cd"
        concat ["&&;"; ""]     .= "&&&&&;;"
        concat ["ab&&;"; "cd"] .= "ab&&&&&;;cd"
        concat [""; "&;"]      .= ";&&&;"
        concat ["ab"; "&;cd"]  .= "ab;&&&;cd"
        concat [""; "&&;"]     .= ";&&&&&;"
        concat ["ab"; "&&;cd"] .= "ab;&&&&&;cd"

        // Repeat pre-existing escape characters if followed by "real" separator.
        concat ["&"; ""]      .= "&&;"
        concat ["ab&"; "cd"]  .= "ab&&;cd"
        concat ["&&"; ""]     .= "&&&&;"
        concat ["ab&&"; "cd"] .= "ab&&&&;cd"

        // Do not repeat pre-existing escape characters
        // if not followed by pre-existing or real separator.
        concat ["&"]          .= "&"
        concat ["&ab"]        .= "&ab"
        concat ["ab&"]        .= "ab&"
        concat ["ab&cd"]      .= "ab&cd"
        concat ["&&"]         .= "&&"
        concat ["&&ab"]       .= "&&ab"
        concat ["ab&&"]       .= "ab&&"
        concat ["ab&&cd"]     .= "ab&&cd"
        concat [""; "&"]      .= ";&"
        concat ["ab"; "&cd"]  .= "ab;&cd"
        concat [""; "&&"]     .= ";&&"
        concat ["ab"; "&&cd"] .= "ab;&&cd"

        // Use escape character correctly, even if the escape character is a space,
        // and is contained in the separator (but not in first position).
        String.concatEscape ' ' "; "
            [""; "a"; ""; "b"; "c; "; "d"; "; e"; " f"; "g "; "h"; ";i"; "j;"; ""] .= 
            "; a; ; b; c ; ; d; ; e; f; g ; h; ;i; j;; "

The next test checks whether splitUnescape reproduces the input of concatEscape as expected. Note how F#, as an expression-based language, allows us to initialize inputLists (a list of string lists) in a very straight-forward way, with all permutations needed for testing visible at one glance. Using only 30 lines of code (excluding white space and comments), we are testing the concatenation and splitting of 110 lists of input strings with 9 different separators = 990 cases overall:

    [<Test>]
    let ``splitUnescape reverses concatEscape correctly``() = 
        /// Input lists for concatEscape, with '^' as escape and "|" as separator.
        let inputLists = 
            /// 3 permutations are enough to cover all positions (start, inside, and end).
            let permutate3 x y z =
                [[x; y; z]; [x; z; y]; [y; x; z]; [y; z; x]; [z; x; y]; [z; y; x]]

            /// Permutate 3 strings and use 3 variants of the last string
            /// (alone, prefixed, or suffixed).
            let permutate3x3 x y z preZ postZ = 
                let perm a b c = permutate3 x y <| sprintf "%s%s%s" a b c
                List.collect id [perm z preZ postZ; perm preZ z postZ; perm preZ postZ z]

            [ // Singular items
              [ [""]; [null]; ["^"]; ["|"]; ["^|"]; ["a"] ]
              
              // Singular items at start/middle/end of string and/or list
              permutate3 "a" "bc" ""
              permutate3 "d" "ef" null
              permutate3x3 "g" "hi" "^" "jk" "lm"
              permutate3x3 "o" "pq" "^^" "rs" "tu"
              permutate3x3 "v" "wx" "^^^" "yz" "12"
              permutate3x3 "a" "bc" "|" "de" "ef"
              permutate3x3 "g" "hi" "^|" "jk" "lm"
              
              // Some advanced complex input lists
              [ [ "asbc"; ""; "ef"; ""; ""; "gh"; null; "i"; null; null; "kl" ]
                [ "op^q^^rs^^^tu^^^^vw^^^^^x"; "op|q||rs|||t"; "op^|q^|^|rs^|^|^|t" ] ] ]
            |> List.collect id

        let esc = '^'
        let seps = ["x"; "xy"; "x^"; "xxy"; "xx^"; "xyy"; "xy^"; "x^y"; "x^^"]

        for lst in inputLists do
            for sep in seps do
                // Replace "|" placeholder with current separator.
                let concatSource =
                    lst |> List.map (function null -> null | s -> s.Replace("|", sep)) 
                let concatResult = String.concatEscape esc sep concatSource

                // Split result should be like concat source, except for nulls, which are "".
                let expectedSplitResult =
                    concatSource |> List.map (function null -> String.Empty | x -> x)
                let actualSplitResult =
                    String.splitUnescape esc sep concatResult |> Seq.toList
                actualSplitResult .= expectedSplitResult

The following test checks whether we can correctly reproduce lists of lists of lists of input strings by

  • repeatedly concatenating the strings at each nesting level, resulting in a large single string, and then
  • repeatedly splitting back the strings at each level.
    [<Test>]
    let ``splitUnescape reverses nested concatEscape correctly``() = 
        let esc, sep = '&', ";"

        let nestedConcatSources = 
            [ [ ["asdf"; "&x; asf & asdf"; "qipwueriuy"]
                ["qwer;qwer&;qerpo"; ""; "qwer"]
                [""; "qiuq"]
                [""]
                [" qwu ^qpoij&*qwoerui;"] ] 
              [ ["qwieoruy"; "qwopeiurj&&"] ]
              [ ["tyiqwe"; "a"]
                [""; "&^a;lsdkj&q ; "; "qwer"]
                ["qoidu"; ""; "qwer&qq"] ] ]

        let concatResult = 
            let concat = String.concatEscape esc sep
            let mapConcat = List.map concat
            nestedConcatSources |> List.map mapConcat |> mapConcat |> concat

        let splitResult =
            let split = String.splitUnescape esc sep >> Seq.toList
            let mapSplit = List.map split
            concatResult |> split |> mapSplit |> List.map mapSplit

        splitResult .= nestedConcatSources

In the last line above, note how F#'s built-in structural equality for (nested) lists allowed us to verify that splitResult equals nestedConcatSources with just a single line of code.

As is typical for functional code, concatEscape/splitUnescape rely on recursion. An inportant feature of the F# compiler is the ability to optimize recursive code so that stack overflows cannot occur, as long as the recursive calls are in tail position. Our last test checks whether we can pass lists of large strings, as well as large lists of strings, to the concatEscape/splitUnescape functions, without producing a stack overflow.

    /// This test will only pass in release mode (when --optimize is set).
    [<Test; Timeout 3000>]
    let ``concatEscape and splitUnescape scale without stack overflow``() =
        let roundTripTest strings =
            let esc, split = '&', ";"
            let concatResult = String.concatEscape esc split strings
            // Causes stack overflow in debug mode (when --optimize is not set).
            let splitResult = String.splitUnescape esc split concatResult |> Seq.toArray
            splitResult .= strings

        // Avoid stack overflow with source strings of 10k length each
        let rep2K = String.replicate 2000
        roundTripTest [| rep2K "qwert"; rep2K "abcd&"; rep2K "ab;cd" |]

        // Avoid stack overflow with source list of 10k items
        roundTripTest [| for i in 1 .. 10000 -> sprintf "%ixg;s&;c" i |]

Conclusion

We have implemented a comprehensive battery of test cases to verify the correctness of the concatEscape and splitUnescape functions. Using the light syntax of the expression-based, functional-first language F#, it is possible to write type-safe unit tests much more succinctly and self-documentary than with C# or any other .NET language.

Comments are closed.