如何在不暴露所有内容的情况下测试 Elm 模块?
How to test an Elm module without exposing everything?
通过“Elm 实战”,我了解到要编写测试,某个模块的测试套件中所需的所有函数和类型都必须由该模块公开。这似乎打破了封装。我不想公开应该隐藏的内部函数和类型构造函数,只是为了使它们可测试。有没有办法公开内部函数和类型,仅供测试,但不供常规使用?
有几种策略可以解决这个问题,每种策略都有其优点和缺点。作为一个 运行 示例,让我们制作一个模块来模拟服务器上的一个简单商店,我们想测试以下内容的内部结构:
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)
请注意,按原样,该模块是理想封装的,但完全无法测试。让我们看看一些缓解这个问题的策略:
1。模块内的测试
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
]
从某种意义上说,这非常好,因为测试也与它们正在测试的功能并置在一起。缺点是模块会变得非常大,其中包含所有测试代码。它还要求您将 elm-test 从您的测试依赖项移动到运行时依赖项中。这在理论上应该不会有任何运行时影响,因为 elm 的死代码消除非常好,但它确实让很多开发人员有点紧张。
2。内部模块
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)
然后 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
}
然后所有测试都可以针对内部模块编写。
正如我所说,这是一种非常常见的模式,您会在大多数已发布的 elm 包中看到它,但在应用程序中,由于工具支持不太好,它会受到一些影响。例如自动完成将为您提供这些内部功能,即使在不应该访问它们的模块中也是如此。
尽管如此,我们在工作中非常成功地使用了这种模式。
3。更改设计
也许如果一个模块不可测试,那么它做的太多了。人们可以研究 effect pattern 之类的东西,以将设计更改为更易于测试。例如,有人可能会争辩说,执行 HTTP 请求不在处理 Foos 的核心能力范围内,边界应该在 decoder/encoder 阶段,这将使它非常可测试;然后一个中央模块将集中处理Http通信。
我们一直在朝这个方向寻找一些时间,但还没有找到一个很好的方法来使它与真正复杂的服务器交互很好,但在每个单独的情况下这可能是值得考虑的事情:为什么这个模块不可测试?替代设计是否同样好并且可以测试?
通过“Elm 实战”,我了解到要编写测试,某个模块的测试套件中所需的所有函数和类型都必须由该模块公开。这似乎打破了封装。我不想公开应该隐藏的内部函数和类型构造函数,只是为了使它们可测试。有没有办法公开内部函数和类型,仅供测试,但不供常规使用?
有几种策略可以解决这个问题,每种策略都有其优点和缺点。作为一个 运行 示例,让我们制作一个模块来模拟服务器上的一个简单商店,我们想测试以下内容的内部结构:
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)
请注意,按原样,该模块是理想封装的,但完全无法测试。让我们看看一些缓解这个问题的策略:
1。模块内的测试
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
]
从某种意义上说,这非常好,因为测试也与它们正在测试的功能并置在一起。缺点是模块会变得非常大,其中包含所有测试代码。它还要求您将 elm-test 从您的测试依赖项移动到运行时依赖项中。这在理论上应该不会有任何运行时影响,因为 elm 的死代码消除非常好,但它确实让很多开发人员有点紧张。
2。内部模块
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)
然后 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
}
然后所有测试都可以针对内部模块编写。
正如我所说,这是一种非常常见的模式,您会在大多数已发布的 elm 包中看到它,但在应用程序中,由于工具支持不太好,它会受到一些影响。例如自动完成将为您提供这些内部功能,即使在不应该访问它们的模块中也是如此。
尽管如此,我们在工作中非常成功地使用了这种模式。
3。更改设计
也许如果一个模块不可测试,那么它做的太多了。人们可以研究 effect pattern 之类的东西,以将设计更改为更易于测试。例如,有人可能会争辩说,执行 HTTP 请求不在处理 Foos 的核心能力范围内,边界应该在 decoder/encoder 阶段,这将使它非常可测试;然后一个中央模块将集中处理Http通信。
我们一直在朝这个方向寻找一些时间,但还没有找到一个很好的方法来使它与真正复杂的服务器交互很好,但在每个单独的情况下这可能是值得考虑的事情:为什么这个模块不可测试?替代设计是否同样好并且可以测试?