elmelm-test

How to test an Elm module without exposing everything?


Working my way through "Elm in Action", I understand that to write tests, all functions and types needed in a test suite for some module must be exposed by that module. This seems to break encapsulation. I don't want to expose internal functions and type constructors that should remain hidden just to make them testable. Is there a way to expose internal functions an types for testing only, but not for regular use?


Solution

  • There are a few strategies to tackle this problem, each with its pluses and minuses. As a running example, let's make a module that models a simple store on the server, that we would like to test the internals of:

    module FooService exposing (Foo, all, update)
    
    import Http
    import Json.Decode as Decode exposing (Decoder)
    import Json.Encode as Encode exposing (Value)
    
    
    type Id
        = Id String
    
    
    type alias Foo =
        { id : Id
        , title : String
        }
    
    
    apiBase : String
    apiBase =
        "https://example.com/api/v2"
    
    
    all : (Result Http.Error (List Foo) -> msg) -> Cmd msg
    all tagger =
        Http.get
            { url = apiBase ++ "/foos"
            , expect = Http.expectJson tagger decodeMany
            }
    
    
    update : Foo -> (Result Http.Error Foo -> msg) -> Cmd msg
    update foo tagger =
        Http.post
            { url = apiBase ++ "/foos/" ++ idToString foo.id
            , body = foo |> encode |> Http.jsonBody
            , expect = Http.expectJson tagger decode
            }
    
    
    idToString : Id -> String
    idToString (Id id_) =
        id_
    
    
    encode : Foo -> Value
    encode foo =
        Encode.object
            [ ( "id", Encode.string (idToString foo.id) )
            , ( "title", Encode.string foo.title )
            ]
    
    
    decode : Decoder Foo
    decode =
        Decode.map2 Foo
            (Decode.field "id" (Decode.map Id Decode.string))
            (Decode.field "title" Decode.string)
    
    
    decodeMany : Decoder (List Foo)
    decodeMany =
        Decode.field "values" (Decode.list decode)
    

    Note that as is, the module is ideally encapsulated, but utterly untestable. Let's look at some strategies to alleviate this problem:

    1. Tests within modules

    elm-test is not actually that prescriptive about where you put your tests, as long as there is an exposed value with the Test type.

    As such you can do something like this:

    module FooService exposing (Foo, all, update, testSuite)
    
    -- FooService remains exactly the same, but the following is added
    
    import Test
    import Fuzz
    
    testSuite : Test
    testSuite =
       Test.describe "FooService internals" 
           [ Test.fuzz (Fuzz.map2 Fuzz (Fuzz.map Id Fuzz.string) Fuzz.string) "Encoding roundtrips"
                 \foo ->
                      encode foo
                         |> Decode.decodeValue decoder
                         |> Expect.equal (Ok foo)
           -- more tests here
    
          ]
    

    This can be quite nice in the sense that tests are also collocated with the functions they are testing. The downside is that modules can get quite large with all the test code in them. It also requires you to move elm-test from your test dependencies into the runtime dependencies. This should theoretically not have any runtime impact, since elm's dead code elimination is excellent, but it does leave a lot of developers a little nervous.

    2. Internal Modules

    Another option, which is heavily used within elm packages (since there is direct support for that kind of hiding in the built in elm.json) is to have modules that are considered internal to a certain module or library and no other modules should read from them. This can be enforced by convention or I believe there are elm-review rules on can use to enforce these boundaries.

    In our example it would look something like this:

    module FooService.Internal exposing (Foo, Id(..), encode, decode, decodeMany, idToString)
    
    import Json.Decode as Decode exposing (Decoder)
    import Json.Encode as Encode exposing (Value)
    
    
    type Id
        = Id String
    
    
    type alias Foo =
        { id : Id
        , title : String
        }
    
    
    idToString : Id -> String
    idToString (Id id_) =
        id_
    
    
    encode : Foo -> Value
    encode foo =
        Encode.object
            [ ( "id", Encode.string (idToString foo.id) )
            , ( "title", Encode.string foo.title )
            ]
    
    
    decode : Decoder Foo
    decode =
        Decode.map2 Foo
            (Decode.field "id" (Decode.map Id Decode.string))
            (Decode.field "title" Decode.string)
    
    
    decodeMany : Decoder (List Foo)
    decodeMany =
        Decode.field "values" (Decode.list decode)
    

    then FooService would simply become:

    module FooService exposing (Foo, all, update)
    
    import Http
    import FooService.Internal as Internal 
    
    
    type alias Foo =
        Internal.Foo
    
    apiBase : String
    apiBase =
        "https://example.com/api/v2"
    
    
    all : (Result Http.Error (List Foo) -> msg) -> Cmd msg
    all tagger =
        Http.get
            { url = apiBase ++ "/foos"
            , expect = Http.expectJson tagger Internal.decodeMany
            }
    
    
    update : Foo -> (Result Http.Error Foo -> msg) -> Cmd msg
    update foo tagger =
        Http.post
            { url = apiBase ++ "/foos/" ++ Internal.idToString foo.id
            , body = foo |> Internal.encode |> Http.jsonBody
            , expect = Http.expectJson tagger Internal.decode
            }
    
    

    then all tests can be written against the internal module.

    As I said, this is an extremely common pattern you will see in the majority of published elm packages, but in applications it suffers a bit from the fact that the tooling support isn't quite as good. For instance autocomplete will offer you these internal functions even in modules that shouldn't have access to them.

    Nonetheless, we use this pattern quite successfully at work.

    3. Change the design

    Perhaps if a module isn't testable, then it is doing too much. One can look into things like the effect pattern to change the design to be more testable. For instance, one could argue that performing HTTP requests is outside the core competency of dealing with Foos, and the boundary should be at the decoder/encoder stage, which would make it quite testable; then a central module would deal with Http communication centrally.

    We've been looking in this direction for a bit, but haven't found a good way to make it nice with really complex server interactions, but it might be something worth thinking about in each individual case: why is this module not testable? Would an alternative design be just as good and also be testable?