Add Safety to Your Elm JSON Encoders With Fuzz Testing

How do you keep your JSON encoders, decoders, and model in sync? You can skip fields in your encoder, right? But should you? And what about when you add new fields? Decoders are a little easier, but you have to sync them up with your encoders or you’ll lose data. And the worst part is that we can’t rely on the compiler to catch these classes of errors… argh!

This is a perfect situation for property tests (fuzz tests in elm-test lingo.) The test system will keep us honest by giving us random values to test with. You can assert that encoders and decoders mirror each other, and add a bit more safety to your app.

Let’s explore this with a simple record and an encoder/decoder pair:

import Json.Decode as Decode exposing (Decoder) import Json.Encode as Encode exposing (Value) type alias Person = { name : String } encoder : Person -> Value encoder person = Encode.object [ ( "name", Encode.string person.name ) ] decoder : Decoder Person decoder = Decode.map Person (Decode.field "name" Decode.string)

encoder and decoder are mirrors of one another: if I encode a value then decode the result I should get the same data I started with. Let’s write a test to make sure this property holds. We’ll need to:

Create some test data Encode it to a string Decode it from a string Check to make sure the decoded value is the same as the test data

It ends up looking like this:

import Expect import Fuzz exposing (Fuzzer) import Json.Decode as Decode import Json.Encode as Encode import Person exposing (Person, encoder, decoder) import Test exposing (Test, fuzz, describe) -- note: you *could* do this inline but I prefer my fuzzers as top-level -- definitions so I can reuse them between test modules. person : Fuzzer Person person = Fuzz.map Person Fuzz.string serialization : Test serialization = describe "serialization" [ fuzz person "round trip" <| \thisPerson -> thisPerson |> encoder |> Decode.decodeValue decoder |> Expect.equal (Ok thisPerson) ] all : Test all = describe "person" [ serialization ]

We want to get random Person values for testing, so we create a Fuzzer . Not only will this test with a wide variety of values, it will shrink them if a test fails. Shrinking makes sure that we get the simplest possible failure cases for our values.

Once we get a value, we’re encoding it and then decoding it. If the value is the same, we’re done! Otherwise, we’ll see how the value changed in the round trip.

We can test this by breaking something. Let’s add a new field to the record and decoder, but not the encoder:

type alias Person = { name : String , age : Int } decoder : Decoder Person decoder = Decode.map2 Person (Decode.field "name" Decode.string) (Decode.field "age" Decode.int) encoder : Person -> Value encoder person = Encode.object [ ( "name", Encode.string person.name ) ]

Once we fix the test file so it compiles (adding the new field to the fuzzer) we can run the test and see the failure:

↓ person ↓ serialization ✗ round trip Given { name = "", age = 0 } Err "Expecting an object with a field named `age` but instead got: {\"name\":\"\"}" ╷ │ Expect.equal ╵ Ok { name = "", age = 0 }

And then, once we add the proper field:

encoder : Person -> Value encoder person = Encode.object [ ( "name", Encode.string person.name ) , ( "age", Encode.int person.age ) ]

It passes!

TEST RUN PASSED Duration: 34 ms Passed: 1 Failed: 0

So now you know! Next time you find yourself wondering if your encoder is going to break, write some fuzz tests and sleep easy!

Want More? Want to get hot, fresh Elm help and tips in your inbox? Slap your email in the box below and I'll send you new articles! Subscribe We won't send you spam. Unsubscribe at any time.