通过“Elm in Action”,我了解到要编写测试,某个模块的测试套件中所需的所有函数和类型都必须由该模块公开。这似乎破坏了封装。我不想公开内部函数和类型构造函数,这些函数和类型构造函数应该保持隐藏状态,只是为了使它们可测试。有没有办法公开内部函数和类型仅用于测试,而不用于常规使用?
有几种策略可以解决这个问题,每种策略都有其优点和缺点。作为一个运行示例,让我们创建一个对服务器上的简单商店进行建模的模块,我们希望测试其内部结构:
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)
Run Code Online (Sandbox Code Playgroud)
请注意,按原样,该模块已理想地封装,但完全无法测试。让我们看看一些缓解这个问题的策略:
elm-test 实际上并没有规定您将测试放在哪里,只要该Test类型存在公开的值即可。
因此你可以这样做:
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
]
Run Code Online (Sandbox Code Playgroud)
从测试也与它们正在测试的功能并置的意义上来说,这可能非常好。缺点是模块可能会变得非常大,其中包含所有测试代码。它还要求您将 elm-test 从测试依赖项移至运行时依赖项。理论上这不会产生任何运行时影响,因为 elm 的死代码消除非常出色,但它确实让很多开发人员感到有点紧张。
在 elm 包中大量使用的另一个选项(因为内置 elm.json 中直接支持这种隐藏)是让模块被视为某个模块或库的内部模块,并且其他模块不应该从中读取他们。这可以通过惯例强制执行,或者我相信有 elm-review 规则可以用来强制执行这些边界。
在我们的示例中,它看起来像这样:
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)
Run Code Online (Sandbox Code Playgroud)
那么 FooService 就会变成:
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
}
Run Code Online (Sandbox Code Playgroud)
那么所有测试都可以针对内部模块编写。
正如我所说,这是一种极其常见的模式,您会在大多数已发布的 elm 软件包中看到,但在应用程序中,它会因为工具支持不太好而受到一些影响。例如,即使在不应访问它们的模块中,自动完成功能也会为您提供这些内部函数。
尽管如此,我们在工作中非常成功地使用了这种模式。
也许如果一个模块不可测试,那么它就做得太多了。人们可以研究诸如效果模式之类的东西来改变设计,使其更易于测试。例如,有人可能会争辩说,执行 HTTP 请求超出了处理 Foos 的核心能力,边界应该在解码器/编码器阶段,这将使其非常易于测试;那么一个中央模块将集中处理Http通信。
我们已经在这个方向上研究了一段时间,但还没有找到一种好方法来使其能够很好地处理非常复杂的服务器交互,但在每个单独的情况下,这可能是值得思考的事情:为什么这个模块不可测试?替代设计是否同样好并且可以测试?