You shouldn’t mock an API (verb), instead you create a mock (noun) that implements a given API.
Mocks and explicit contracts
最简单的例子是访问外部 API
defmodule MyApp.MyController do
def show(conn, %{"username" => username}) do
# ...
MyApp.TwitterClient.get_username(username)
# ...
end
end
在测试中一般通过 mock MyApp.TwitterClient
所使用的 HTTPClient
来解决
mock(HTTPClient, :get, to_return: %{..., "username" => "josevalim", ...})
问题在于 mock 掉 HTTPClient
会将程序和此 HTTPClient
进行耦合。如果某一天想要使用一个新的 HTTP Client,即使应用本身没有变化,测试也可能会失败,因为它们都依赖于 HTTPClient
。
另一种方法是在测试时替换掉 MyApp.TwitterClient
而不是去 mock HTTPClient
。在 Elixir 中所有的应用都附带配置文件和读取它们的机制。可以利用这一点,根据不同的环境变量使用不同的 Twitter 客户端。代码如下:
defmodule MyApp.MyController do
@twitter_api Application.get_env(:my_app, :twitter_api)
def show(conn, %{"username" => username}) do
# ...
@twitter_api.get_username(username)
# ...
end
end
配置文件如下:
# In config/dev.exs
config :my_app, :twitter_api, MyApp.Twitter.Sandbox
# In config/test.exs
config :my_app, :twitter_api, MyApp.Twitter.InMemory
# In config/prod.exs
config :my_app, :twitter_api, MyApp.Twitter.HTTPClient
这样我们可以根据不同的需求选择最佳的策略。如果 Twitter 提供某种 sandbox 环境用于开发,则可以使用 MyApp.Twitter.Sandbox
;如果需要避免 HTTP 请求,则可以使用 MyApp.Twitter.InMemory
defmodule MyApp.Twitter.InMemory do
def get_username("josevalim") do
%MyApp.Twitter.User{
username: "josevalim"
}
end
end
事实上,MyApp.Twitter.InMemory
就是一个 mock object
因为 mock 意味着替换真正的实体(real entity),所以只有在我们能够明确的定义实体的行为时,这种替换才是有效的。如果无法做到这一点,mock object 会随着时间愈益复杂,增加了测试组件间的耦合
我们定义了三种 Twitter API 的实现,因此我们最好将其显式声明。Elixir 中可以使用回调函数来定义 behaviour
defmodule MyApp.Twitter do
@doc "..."
@callback get_username(username :: String.t) :: %MyApp.Twitter.User{}
@doc "..."
@callback followers_for(username :: String.t) :: [%MyApp.Twitter.User{}]
end
并将 @behaviour MyApp.Twitter
添加在每个实现此 behavior 的模块上。关于这一部分可以参考 参考 Typespecs and behaviours
先前由于没有显式的合约(explicit contract),应用的边界会像下面这样
[MyApp] -> [HTTP Client] -> [Twitter API]
这就是改变 HTTPClient
后可能会破坏集成测试的原因。而现在我们的应用依赖于合约,且只有合约的某个具体实现依赖于 HTTPClient
[MyApp] -> [MyApp.Twitter (contract)]
[MyApp.Twitter.HTTP (contract impl)] -> [HTTPClient] -> [Twitter API]
对于应用的测试已经和 HTTPClient
与 Twitter API 解耦。改变 HTTPClient
后不会破坏对于应用的测试,只会对 MyApp.Twitter.HTTP
的测试产生影响
测试大型系统的挑战在于如何找到合适的边界。如果定义了太多的边界也没有编写集成测试来覆盖它们的交互,那么测试将会变得脆弱,错误将会被带到生产环境中。另一方面,定义过少的边界会导致套件缓慢且难以调试测试。通常而言,因团队和其他外部因素而异,没有标准答案
虽然可以使用配置文件来解决外部API问题,但有时将依赖项作为参数传递更容易为容易。比如希望在测试中避免一些开销过高的工作:
defmodule MyModule do
def my_function do
# ...
SomeDependency.heavy_work(arg1, arg2)
# ...
end
end
更改其实现,将内部的依赖作为参数
defmodule MyModule do
def my_function(dependency \\ SomeDependency) do
# ...
dependency.heavy_work(arg1, arg2)
# ...
end
end
测试的时候将依赖注入进去
test "my function performs heavy work" do
# Simulate heavy work by sending self() a message
defmodule TestDependency do
def heavy_work(_arg1, _arg2) do
send self(), :heavy_work
end
end
MyModule.my_function(TestDependency)
assert_received :heavy_work
end
Summing up
- 创建一个实现了所有接口的 mock object 比 mock 掉对象的方法更好
- 划分边界,定义合约,避免模块间的耦合