This is part 7 of how we generate types from our .Net backend to be used in our TypeScript client.
- Why do we even bother?
- Generating TypeScript constants from .Net constants
- Finding the types used in communication between the .Net backend and the TypeScript client
- Generating TypeScript classes from .Net types
- Generating Angular Services from .Net WebApi Controllers
- TypeScript-friendly JSON serialization of F# types
- Testing JSON serialization and deserialization
- 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).