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?
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:
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.
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.
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?