Type-safety across .Net and TypeScript – Testing JSON serialization and deserialization

This is part 7 of how we generate types from our .Net backend to be used in our TypeScript client.

  1. Why do we even bother?
  2. Generating TypeScript constants from .Net constants
  3. Finding the types used in communication between the .Net backend and the TypeScript client
  4. Generating TypeScript classes from .Net types
  5. Generating Angular Services from .Net WebApi Controllers
  6. TypeScript-friendly JSON serialization of F# types
  7. Testing JSON serialization and deserialization
  8. Putting all the parts together

In the previous parts of this blog post series, I’ve shown you how we can generate TypeScript classes for the data that we send between the client and server. To make sure that the JSON data is actually meaningful for all cases used, we introduced the following tests.

Test Approach

Our tests have two goals. First, the test should check that all types used in communication between the backend and frontend can be serialized and when needed deserialized. Second, the test should warn us when the “schema” of the data changes, to prevent incompatibility issues between backend and client.

Create Test Data

Before we can test anything, we need some test data. There exist some test data generators in the .Net ecosystem, but none of them met our needs. We need the data to be generated exactly the same for every run and of course, the generator should be able to generate F# records and discriminated unions. So we built our own:

module Creator

open System.Reflection
open System
open Calitime.Identifiers
open Microsoft.FSharp.Reflection
open NodaTime

type CustomCreator = (Type -> bool) * (Type -> (Type -> obj) -> obj)

type ArrayCreator =
    static member Create<'a>(value : obj option) =
        match value with
        | Some v ->
            let casted : 'a = downcast v
            let a : 'a [] = [| casted |]
            a
        | None -> [||]

type ListCreator =
    static member Create<'a>(value : obj option) =
        match value with
        | Some v ->
            let casted : 'a = downcast v
            let a : 'a list = [ casted ]
            a
        | None -> []

let rec private createRecordInstance
    (customCreators : CustomCreator list)
    (assemblies : Assembly [])
    (loopBreaker : Type list)
    (t : Type)
    =
    let constructors : ConstructorInfo [] =
        t.GetConstructors(BindingFlags.Public ||| BindingFlags.Instance)

    if (constructors.Length = 0) then
        failwith $"no public constructors on ${t.Name}"

    let constructor =
        constructors
        |> Array.minBy (fun c -> c.GetParameters().Length)

    let parameters = constructor.GetParameters()

    let arguments =
        parameters
        |> Array.map (fun p -> createInstance customCreators assemblies loopBreaker p.ParameterType)

    let instance = constructor.Invoke arguments

    instance

and private createInstance
    (customCreators : CustomCreator list)
    (assemblies : Assembly [])
    (loopBreaker : Type list)
    (t : Type)
    =
    let loopBreaker = t :: loopBreaker

    let customCreator =
        customCreators
        |> List.tryFind (fun (matcher, _) -> matcher t)
        |> Option.map (fun (_, c) -> c)

    match customCreator with
    | Some c -> c t (createInstance customCreators assemblies loopBreaker)
    | None ->
        try
            if t.IsGenericType
               && t.GetGenericTypeDefinition() = typedefof<FSharp.Collections.list<_>> then
                let elementType =
                    t.GetGenericArguments() |> Array.exactlyOne

                let innerValue =
                    if loopBreaker |> List.contains elementType then
                        None
                    else
                        createInstance customCreators assemblies loopBreaker elementType
                        |> Some

                let generationMethod =
                    typeof<ListCreator>
                        .GetMethod("Create")
                        .MakeGenericMethod(elementType)

                let value =
                    generationMethod.Invoke(null, [| innerValue |])

                value
            elif t.IsGenericType
                 && t.GetGenericTypeDefinition() = typedefof<System.Collections.Generic.IEnumerable<_>>
                 || t.IsGenericType
                    && t.GetGenericTypeDefinition() = typedefof<System.Collections.Generic.IReadOnlyCollection<_>>
                 || t.IsGenericType
                    && t.GetGenericTypeDefinition() = typedefof<System.Collections.Generic.IReadOnlyList<_>> then
                let elementType =
                    t.GetGenericArguments() |> Array.exactlyOne

                let innerValue =
                    if loopBreaker |> List.contains elementType then
                        None
                    else
                        createInstance customCreators assemblies loopBreaker elementType
                        |> Some

                let generationMethod =
                    typeof<ArrayCreator>
                        .GetMethod("Create")
                        .MakeGenericMethod(elementType)

                let value =
                    generationMethod.Invoke(null, [| innerValue |])

                value
            elif t.IsArray then
                let elementType = t.GetElementType()

                let innerValue =
                    if loopBreaker |> List.contains elementType then
                        None
                    else
                        createInstance customCreators assemblies loopBreaker elementType
                        |> Some

                let generationMethod =
                    typeof<ArrayCreator>
                        .GetMethod("Create")
                        .MakeGenericMethod(elementType)

                let value =
                    generationMethod.Invoke(null, [| innerValue |])

                value
            elif FSharpType.IsUnion t then
                t
                |> FSharpType.GetUnionCases
                |> Array.head
                |> createUnionInstance customCreators assemblies loopBreaker
            elif FSharpType.IsRecord t then
                if t.IsGenericType then
                    failwithf
                        "could not create an instance of type %s because it is generic"
                        t.FullName
                else
                    createRecordInstance customCreators assemblies loopBreaker t
            elif t = typeof<string> then
                "value" :> obj
            elif t = typeof<int> then
                42 :> obj
            elif t = typeof<int64> then
                1234567890123456789L :> obj
            elif t = typeof<bool> then
                true :> obj
            elif t = typeof<Guid> then
                Guid("12345678-1234-1234-1234-123456781234") :> obj
            elif t = typeof<LocalDate> then
                LocalDate(2020, 11, 27) :> obj
            elif t = typeof<LocalTime> then
                LocalTime(10, 9, 0) :> obj
            elif t = typeof<LanguageKey> then
                LanguageKey("xy") :> obj
            elif t.IsInterface || t.IsAbstract then
                let implementation =
                    assemblies
                    |> Seq.collect (fun a -> a.GetTypes())
                    |> Seq.tryFind
                        (fun x ->
                            t.IsAssignableFrom x
                            && not (x = t)
                            && not x.IsAbstract
                            && not x.IsInterface)

                match implementation with
                | Some i -> createInstance customCreators assemblies loopBreaker i
                | None ->
                    failwith
                        $"could not find an implementation of abstract type/interface %s{t.FullName}"
            elif t.IsEnum then
                Enum.GetValues(t).GetValue(0)
            else
                createRecordInstance customCreators assemblies loopBreaker t
        with e ->
            failwithf
                $"could not create an instance of type %s{t.FullName}: exception message: %s{e.Message}"

and private createUnionInstance
    (customCreators : CustomCreator list)
    (assemblies : Assembly [])
    (loopBreaker : Type list)
    (c : UnionCaseInfo)
    =
    let fieldTypes =
        c.GetFields()
        |> Array.map (fun p -> p.PropertyType)

    let arguments =
        fieldTypes
        |> Array.map (createInstance customCreators assemblies loopBreaker)

    FSharpValue.MakeUnion(c, arguments)


let create (customCreators : CustomCreator list) (assemblies : Assembly []) (t : Type) =
    if FSharpType.IsUnion t then
        let cases = FSharpType.GetUnionCases t

        cases
        |> Array.map
            (fun c ->
                (t.Assembly.GetName().Name + t.Name + c.Name,
                 createUnionInstance customCreators assemblies [] c))
        |> Array.toList
    else
        [
            (t.Assembly.GetName().Name + t.Name, createInstance customCreators assemblies [] t)
        ]

Line 9: A custom creator is used to configure the Creator to create types that it cannot create on its own, e.g. classes without a public constructor.

Line 11: The ArrayCreator is used to create an array that is either empty, when no value is passed, or contains a single element. We want that collections contain an element unless it would lead to a cycle – list of A, A contains a list of B, B contains a list of A. That would lead to a stack overflow exception.

Line 20: The same for a list.

Line 29: Here we create an instance of a record or a class by calling the constructor with the smallest number of parameters. For every constructor parameter, we create an argument by calling createInstance.

Line 55: createInstance creates an instance of type t. The assemblies are used to find implementations for interfaces. The loopBreaker is used to find loops between types so that we don’t end up in a stack overflow exception.

Line 61: Let’s add the current type to the list of already processed types to be able to break dependency cycles.

Line 63: If there is a custom creator for the type t then invoke it.

Line 72: If we need to create a list, we get the element type, create a value – None if it would result in a cycle – and call the ListCreator to get our list.

Line 93: For IEnumerable, IReadOnlyCollection and IReadOnlyList we do the same but call the ArrayCreator.

Line 118: And the same for arrays.

Line 137: If we need to create a discriminated union, we create an instance for the first case. You may wonder why we only create a value for the first union case and not for all. The reason is that we will use the TypeReflector that we have seen earlier in this blog post series. The TypeReflector returns all nested types, including all union cases. So we will test all variations. Here we just need a single instance for type t.

Line 142: For records we call createRecrodInstance that we have seen above. If the record is generic, we fail. For such cases, a custom creator has to be used.

Line 149-164: Handle some specific types.

Line 165: If we have an interface or an abstract type, we look for a type that implements the interface or derives from the abstract type that itself is not an interface or abstract. If we find one, we create an instance of this type instead.

Line 181: For enums, we get the first value.

Line 183: For everything else (classes), we use the createRecordInstance function.

Line 189: To create a value for a discriminated union, we get all the fields of the union case and create instances for them. Finally we create the union case.

Line 206: The create function is the function that we will use to get our test data. If the type is a discriminated union then we get a list of instances for every union case*. Otherwise we get a list containing a single instance of the requested type.
* Here we need all cases because the TypeReflector enumerates all the types of the cases (the inner values), but not the union as a whole.

The Tests

Now that we have a way of creating test data, we can write the tests:

module W2w.Facts.SerializationApprovalTests

open System
open ApprovalTests
open ApprovalTests.Namers
open ApprovalTests.Reporters
open Calitime // I left out some opens for our code
open FsUnit
open Newtonsoft.Json
open Program
open W2w
open Xunit

type Encoder() =
    static member Encode<'T>(v : 'T) =
               JsonConversion.Web.encode v

let Match<'a> t =
    t = typeof<'a>

let MatchGeneric<'a> (t : Type) =
    t.IsGenericType && (t.GetGenericTypeDefinition() = typedefof<'a>)

type MaybeCreator =
    static member Create<'a>(creator : Type -> obj) =
        let value : 'a = downcast creator typeof<'a>
        Maybe<'a>.Some(value)

let createMaybe (t : Type) (creator : Type -> obj) =
    let nestedType = t.GenericTypeArguments.[0]
    let method = typeof<MaybeCreator>.GetMethod("Create").MakeGenericMethod(nestedType)
    method.Invoke(null, [| creator |])

// list of explicit creators for types that cannot be created automatically
let customCreators : Creator.CustomCreator list =
    [
        Match<StartAndEndRange<Workday>>, fun _ _ -> { StartAndEndRange.Start = "01.01.2021" |> Workday.parse ; End = "31.01.2021" |> Workday.parse } :> obj
        Match<Calitime.Zeit.Ranges.IRange<Calitime.Zeit.Workday>>, fun _ _ -> Calitime.Zeit.Ranges.NoEndRange(Calitime.Zeit.Workday(2021, 1, 7)) :> obj
        Match<Workday>, fun _ _ -> "01.03.2021" |> Workday.parse :> obj
        Match<Calitime.Zeit.Workday>, fun _ _ -> Calitime.Zeit.Workday(2021, 10, 13) :> obj
        Match<IExpression<EmployeeGuid>>, fun _ _ -> DataExpression(SingleEmployeePipe("E" |> Guid.generate)) :> obj
        Match<IExpression<KontoGuid>>, fun _ _ -> DataExpression(SingleKontoPipe("C" |> Guid.generate)) :> obj
        Match<IExpression<ZeitartGuid>>, fun _ _ -> DataExpression(SingleZeitartenPipe("B" |> Guid.generate)) :> obj
        MatchGeneric<Maybe<_>>, createMaybe
        Match<Calitime.Zeit.Date>, fun _ _ -> Calitime.Zeit.Date(2021, 10, 13) :> obj
        Match<Calitime.Zeit.UtcDateTime<Calitime.Zeit.SimpleDateTime>>, fun _ _ -> Calitime.Zeit.UtcDateTime(Calitime.Zeit.SimpleDateTime(2021, 10, 13, 8, 0)) :> obj
        Match<Calitime.Zeit.ZoneIdName>, fun _ _ -> Calitime.Zeit.ZoneIdName("Europe/Zurich") :> obj
        Match<YearlyRecurrence>, fun _ _ -> YearlyRecurrence(1, 1) :> obj
        Match<ITimeline>, fun _ _ -> Timeline([ ValueInRange("value", Calitime.Zeit.Ranges.EternityRange<Calitime.Zeit.Workday>(), ProjectionState.Final) ]) :> obj
        Match<OperationResult>, fun _ _ -> OperationResult.CreateSuccessful() :> obj
        Match<Type>, fun _ _ -> typeof<int> :> obj
    ]

/// tests that we don't lose any types that were once generated (and that the client could miss)
[<Fact>]
[<UseReporter(typedefof<RiderReporter>)>]
let noMissingTypes () =
    let types =
        TypeReflector.getTypesRecursivelyUsedByControllers
            W2wData.controllersAssembly
            (W2wData.getRelevantAssemblies ())
            W2wData.additionalTypes
            W2wData.ignoredTypes

    Approvals.VerifyAll(types, fun t -> t.Name)


/// tests that all types used by controllers (inputs and outputs) can be serialized and haven't accidentally changed
/// no types are just stored, only mismatches are reported
[<Fact>]
[<UseReporter(typedefof<RiderReporter>)>]
let contract () =
    let relevantAssemblies = W2wData.getRelevantAssemblies ()
    let types =
        TypeReflector.getTypesRecursivelyUsedByControllers
            W2wData.controllersAssembly
            relevantAssemblies
            W2wData.additionalTypes
            W2wData.ignoredTypes

    let typesToSerialize =
        types
        |> List.filter (fun t -> not (t.IsGenericType))
        |> List.filter (fun t -> not (t.IsGenericTypeParameter))
        |> List.sortBy (fun t -> t.Assembly.GetName().Name + t.Name)

    for t in typesToSerialize do
        let instances =
            Creator.create customCreators relevantAssemblies t
            |> List.filter (fun (_, instance) -> W2wData.ignoredTypes |> List.contains (instance.GetType()) |> not)

        for key, instance in instances do

            let json = JsonConvert.SerializeObject(instance, DefaultSettings.JsonSerializerSettings)

            NamerFactory.AdditionalInformation <- sprintf "Approvals/Type-%s" key

            Approvals.VerifyJson(json)


/// tests whether all inputs into controller-methods can be deserialized
[<Fact>]
[<UseReporter(typedefof<RiderReporter>)>]
let serializationDeserialization () =
    let relevantAssemblies = W2wData.getRelevantAssemblies()
    let ignoredTypes = W2wData.ignoredTypes
                        |> List.append [ typeof<Calitime.Modules.UserInterface.Preferences.PreferenceCollectionItem> ]

    let types =
        TypeReflector.getTypesRecursivelyUsedByControllersInputsOnly
            W2wData.controllersAssembly
            relevantAssemblies
            W2wData.additionalTypes
            ignoredTypes

    let typesToSerialize =
        types
        |> List.filter (fun t -> not (t.IsInterface))
        |> List.filter (fun t -> not (t.IsGenericType))
        |> List.filter (fun t -> not (t.IsGenericTypeParameter))

    for t in typesToSerialize do

        let instances =
            Creator.create customCreators relevantAssemblies t

        for _, instance in instances do
            try
                let json = JsonConvert.SerializeObject(instance, DefaultSettings.JsonSerializerSettings)
                try
                    let value = JsonConvert.DeserializeObject(json, t, DefaultSettings.JsonSerializerSettings)
                    if value.GetType().GetProperties().Length > 0 then
                        value |> shouldBeEquivalentTo instance
                    else
                        value.GetType() |> should equal t
                with
                | e -> raise (Exception($"could not deserialize type {t.FullName} from {json}", e))
            with
            | e -> raise (Exception($"could not deserialize type {t.FullName}", e));

Line 14: The Encoder is the same trick that we have seen earlier in this blog post series. It is used to get Thoth to serialize the correct type (it uses the static type).

Line 18-22: Match and matchGeneric help us to write custom creators. See below.

Line 21-27: In our C# code we use Maybe as an equivalent to F#’s Option. The MaybeCreator and createMaybe help us here to write a custom creator. It shows how to use the creator in a custom creator.

Line 35: The list of our custom creators. Types that the Creator cannot create on its own.

Line 55: This fact uses an approval test (also known as a snapshot or verification test) to check that we still have the same types that are passed between backend and frontend. An approval test creates a file called received that contains the actual data and compares it with a file called verified. If they match, the test succeeds; if not, the test fails. If the test fails, you either fix your code – in the case of a defect – or update the verified file.
The test uses the TypeReflector to get all types used in communication (see an earlier post in this series) and checks whether they are still the same as in the last verified run.

Line 70: This test checks that all the types that used in the communication between backend and frontend can be serialized. It first gets all types by calling the TypeReflector, filters out all generic types, creates instances for these types, serializes them and approves them. We create an approval file per type.

Line 102: This test checks that all types used as inputs into web controller methods can be deserialized. We use the TypeReflector to get all input types, create an instance, serialize it, deserialize it and compare if they match.
Comparing the original with the deserialized value is a bit complicated because of how FluentAssertions treats object without any values.
The shouldBeEquivalentTo function is a wrapper function around FluentAssertions’ Should.BeEquivalentTo(...).
We use FluentAssertion when we have a mixed C# and F# object/value graph and for cases when we need equivalency on collections (order does not matter). You can find all our FluentAssertion wrappers here.

Conclusions

With the above tests, we make sure that our frontend and backend can communicate with each other. This is a great help and has prevented many defects for example due to name changes on the backend.

So whenever one of these tests fails, we either introduced a defect or we need to update the frontend to match the backend again. I’ll show you how the whole process works in the next and last blog post of this series.

This blog post is made possible with the support of Time Rocket, the product this blog post is about. Take a look (German only).

About the author

Urs Enzler

Add comment

By Urs Enzler

Recent Posts