Date

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 掉对象的方法更好
  • 划分边界,定义合约,避免模块间的耦合