Testing Elixir (Andrea Leopardi, Jeffrey Matthias, 2021) 독후감

5 minute read

구성이 마음에 들어서 책을 읽었다. otp, ecto schemas, ecto queries, phoenix를 테스트하는 방법이 목차에 담겨있다. 언어만 elixir로 바꾼 일반 테스팅 책이 아니라는 기대가 있었다. 하지만 막 감탄하면서 읽지는 않았다. 주제가 달라진다고 테스트하는 방법이 드라마틱하게 바뀌지는 않는다. 약간의 고려 사항만 생길 뿐인데, 너무 기대가 큰 탓도 있다. elixir 테스트 챕터가 있는 ’Designing Elixir Systems with OTP’ 책을 먼저 읽어서 겹친 내용 때문에 좀 더 평가가 박한 것 같다. 예제 코드는 훌륭했다. 코드에서 많이 배웠다. test 코드를 더 편하게 할 수 있는 라이브러리 사용도 배웠다.

MatchError를 내기보단 assert

assert {:ok, [weather_struct]} = ResponseParser.parse_response(%{"list" => [record]})

이런 코드를 봤다. 앞에 assert 함수 호출을 빼도 검사가 된다. MatchError 가 발생하기 때문이다. 왜 assert 함수를 붙였을까? 간단히 테스트를 해봤다.

1) test greets the world (AssertTestTest)
test/assert_test_test.exs:5
match (=) failed
code:  assert :elixir = AssertTest.hello()
right: :world
stacktrace:
    test/assert_test_test.exs:6: (test)

assert 함수를 호출할 때.

1) test greets the world (AssertTestTest)
test/assert_test_test.exs:5
** (MatchError) no match of right hand side value: :world
code: :elixir = AssertTest.hello()
stacktrace:
    test/assert_test_test.exs:6: (test)

assert 함수를 빼고 MatchError 를 내게 했을 때.

둘 다 원하는 대로 동작한다. 실패하면 멈추고 에러를 내니깐. assert 함수를 붙이면 출력이 더 보기가 좋다. 이래서 붙이는구나.

테스트도 메시지를 받을 수 있는 process라는 걸 활용

test "success: gets forecasts, returns true for imminent rain" do
  now = DateTime.utc_now()
  future_unix = DateTime.to_unix(now) + 1
  expected_city = Enum.random(["Denver", "Los Angeles", "New York"])
  test_pid = self()

  # 실제 HTTP API 호출 대신 사용할 double 함수를 정의
  weather_fn_dobule = fn city ->
    # 잘 호출됐다는 걸 검사할 수 있도록 process에 메시지를 보낸다
    send(test_pid, {:get_forecast_called, city})

    data = [
      %{
        "dt" => future_unix,
        "weather" => [%{"id" => _drizzle_id = 300}]
      }
    ]

    {:ok, %{"list" => data}}
  end

  # 위에서 정의한 double 함수를 인자로 넘겨서 HTTP API 호출 대신 사용
  assert SoggyWaffle.rain?(expected_city, now, weather_fn_dobule)
  # dobule 함수가 잘 호출되었는지도 검사
  assert_received {:get_forecast_called, ^expected_city}, "get_forecast/1 was never called"
end

실제 함수를 대신할 대역(double) 함수를 정의해서 호출한다. 여기서 더 꼼꼼하게 테스트한다. 테스트 케이스도 process이기 때문에 메시지를 받을 수 있다. double 함수에서 테스트 케이스로 메시지를 보내게 해서 double 함수가 의도대로 잘 호출되었는지도 검사한다. double 함수가 의도대로 호출하지 않았을 때를 바로 캐치할 수 있어서 좋은 테스트 방법이다.

describe/2 함수로 API 테스트 구조화

describe "parse_response/1" do
  test "success: accpets a valid payload, returns a list of structs", %{
    weather_data: weather_data
  } do
    # 테스트
  end

  test "success: returns rain?: false for any other id codes" do
    # 테스트
  end

  test "error: returns error if weather data is malformed" do
    # 테스트
  end

  test "error: returns error if timestamp is missing" do
    # 테스트
  end
end

describe/2 함수로 테스트 대상 함수에 대한 성공과 실패 테스트를 구조화한다.

통합 테스트를 위한 외부 서비스 mock 사용

defmodule SoggyWaffle do
  @weather_api_module Application.compile_env(
    :soggy_waffle,
    :weather_api_module,
    SoggyWaffle.WeatherAPI
  )

  def rain?(city, datetime) do
    with {:ok, response} <- @weather_api_module.get_forecast(city) do
      weather_data =
        SoggyWaffle.WeatherAPI.ResponseParser.parse_response(response)

      SoggyWaffle.Weather.imminent_rain?(weather_data, datetime)
    end
  end
end

defmodule SoggyWaffleTest do
  use ExUnit.Case

  import Mox

  setup :set_mox_global
  setup :verify_on_exit!

  describe "rain?/2" do
    test "success: using the real API" do
      Mox.stub_with(SoggyWaffle.WeatherAPIMock, SoggyWaffle.WeatherAPI)

      # rest of the test
    end
  end
end

mox 라이브러리를 사용해 테스트하는 방법을 소개한다. 설정으로 사용하는 모듈을 결정할 수 있게 하고 test 환경에서는 mock 모듈로 교체해서 테스트하는 방식이다. 이렇게 만든 mock이 잘 유지되려면 외부 서비스를 호출하는 모듈에 다른 계산은 넣지 않고 날씬하게 잘 유지해야 한다. 그렇지 않으면 mock과 동작이 달라져서 테스트가 제대로 되지 않는다.

defmodule SoggyWaffle.WeatherAPITestRouter do
  use Plug.Router
  import ExUnit.Assertions

  plug :match
  plug :dispatch
  plug :fetch_query_params

  get "/data/2.5/forecast" do
    params = conn.query_params

    assert is_binary(params["q"])
    assert is_binary(params["APPID"])

    forecast_data = %{
      "list" => [
      %{
        "dt" => DateTime.to_unix(DateTime.utc_now()),
        "weather" => [%{"id" => _thunderstorm = 231}]
      }
    ]
    }

    conn
    |> put_resp_content_type("application/json")
    |> send_resp(200, Jason.encode!(forecast_data))
  end
end

defmodule SoggyWaffle.WeatherAPITest do
  use ExUnit.Case

  setup do
    options = [
      scheme: :http,
      plug: SoggyWaffle.WeatherAPITestRouter,
      options: [port: 4040]
    ]

    start_supervised!({Plug.Cowboy, options})
    :ok
  end

  test "get_forecast/1 hits GET /data/2.5/forecast" do
    query = "losangeles"
    app_id = "MY_APP_ID"
    test_server_url = "http://localhost:4040"

    assert {:ok, body} =
      SoggyWaffle.WeatherAPI.get_forecast(
        "Los Angeles",
        test_server_url
      )

    assert %{"list" => [weather | _]} = body
    assert %{"dt" => _, "weather" => _} = weather
    # potentially more assertions on the weather
  end
end

그래서 난 이 방식을 더 선호한다. 외부 서비스 API를 흉내 내는 웹서비스를 띄워서 테스트하는 방식이다. 테스트에서 빼먹는 응답 정보 파싱도 온전히 테스트가 된다.

defmodule SoggyWaffle.WeatherAPITest do
  use ExUnit.Case

  setup do
    bypass = Bypass.open()
    {:ok, bypass: bypass}
  end

  test "get_forecast/1 hits GET /data/2.5/forecast", %{bypass: bypass} do
    query = "losangeles"
    app_id = "MY_APP_ID"
    test_server_url = "http://localhost:4040"

    forecast_data = %{
      "list" => [
      %{
        "dt" => DateTime.to_unix(DateTime.utc_now()) + _seconds = 60,
        "weather" => [%{"id" => _thunderstorm = 231}]
      }
    ]
    }

    Bypass.expect_once(bypass, "GET", "/data/2.5/forecast", fn conn ->
      conn = Plug.Conn.fetch_query_params(conn)

      assert conn.query_params["q"] == query
      assert conn.query_params["APPID"] == app_id

      conn
      |> Plug.Conn.put_resp_content_type("application/json")
      |> Plug.Conn.resp(200, Jason.encode!(forecast_data))
    end)

    assert {:ok, body} =
      SoggyWaffle.WeatherAPI.get_forecast(
        "Los Angeles",
        test_server_url
      )

    assert body == forecast_data
  end
end

이걸 더 손쉽게 해주는 bypass라는 라이브러리가 있다는 걸 배웠다.

실제 http 요청과 응답을 저장해서 그걸 리플레이하는 방식의 exvcr 라이브러리 사용도 보여준다. 하지만 bypass로도 충분해서 사용할 일이 있을까 싶다.

통합 테스트에 빠질 수 없는 mock 테크닉들이 나와서 도움이 됐다. 특히 들어본 적은 있지만 사용해 본 적은 없는 mock 라이브러리 사용법을 책으로 쉽게 배울 수 있었다.

1초마다 tick 메시지를 보내는 동작은 어떻게 테스트할까?

defmodule SoggyWaffle.WeatherChecker do
  use GenServer

  # same module attribute and start_link/1 as before

  @impl GenServer
  def init(opts) do
    mode = Keyword.get(opts, :mode, :periodic)
    interval = Keyword.fetch!(opts, :interval)

    state = %{
      city: Keyword.fetch!(opts, :city),
      phone_number: Keyword.fetch!(opts, :phone_number),
    }

    case mode do
      :periodic ->
        :timer.send_interval(interval, self(), :tick)

        # 외부에서 직접 메시지를 보내서 동작하는 manual 모드를 추가
        :manual ->
        :ok
    end

    {:ok, state}
  end

  @impl GenServer
  def handle_info(:tick, state) do
    # exactly the same code as before
  end
end

주기적으로 메시지를 보내지 않고 외부에서 메시지를 보내서 동작하는 manual 모드를 추가해서 테스트한다. interval을 인자로 넘길 수 있으니 1초 정도로 세팅해서 동작하는지 확인하지 않고 외부에서 직접 메시지를 보내서 동작하게 하는 모드를 추가한다. 실용적인 이유라는데 이렇게 해야 하는지는 공감하지 못했다.

테스트에 유용한 faker 라이브러리

defp valid_params(fields_with_types) do
  valid_value_by_type = %{
    date: fn -> to_string(Faker.Date.date_of_birth()) end,
    float: fn -> :rand.uniform() * 10 end,
    string: fn -> Faker.Lorem.word() end
  }

  for {field, type} <- fields_with_types, into: %{} do
    {Atom.to_string(field), valid_value_by_type[type].()}
  end
end


defp invalid_params(fields_with_types) do
  invalid_value_by_type = %{
    date: fn -> Faker.Lorem.word() end,
    float: fn -> Faker.Lorem.word() end,
    string: fn -> DateTime.utc_now() end
  }

  for {field, type} <- fields_with_types, into: %{} do
    {Atom.to_string(field), invalid_value_by_type[type].()}
  end
end

테스트 데이터를 대충 만들어서 썼는데, faker 라이브러리 배웠다.

테스트 데이터 팩토리 구현에 유용한 ex_machina 라이브러리

defmodule TestingEcto.Factory do
  use ExMachina.Ecto, repo: TestingEcto.Repo
  alias TestingEcto.Schemas.User

  def user_factory do
    %User{
      date_of_birth: to_string(Faker.Date.date_of_birth()),
      email: Faker.Internet.email(),
      favorite_number: :rand.uniform() * 10,
      first_name: Faker.Name.first_name(),
      last_name: Faker.Name.last_name(),
      phone_number: Faker.Phone.EnUs.phone(),
    }
  end
end

이런 factory를 만들면 뭐를 대신해주는 걸까? 이렇게 만들면 ExMachina가 유용한 helper 함수를 해당 모듈에 injection 해준다.

defmodule TestingEcto.UsersTest do
  describe "update/2" do
    test "success: it updates database and returns the user" do
      existing_user = Factory.insert(:user)

      params =
        Factory.string_params_for(:user)
        |> Map.take(["first_name"])

      assert {:ok, returned_user} = Users.update(existing_user, params)
      # ...
    end
  end
end

insert/2, string_params_for/2 같은 헬퍼 함수를 추가해준다. Ecto와 궁합도 좋고 없이 사용해도 된다.

링크