Testing Elixir (Andrea Leopardi, Jeffrey Matthias, 2021) 독후감
구성이 마음에 들어서 책을 읽었다. 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와 궁합도 좋고 없이 사용해도 된다.
링크
- PSPDFKit-labs/bypass - github.com
- dashbitco/mox - github.com
- elixir-ecto/ecto - github.com
- elixirs/faker - github.com
- parroty/exvcr - github.com
- thoughtbot/ex_machina - github.com
- ExUnit.Case — ExUnit v1.13.4 - hexdocs.pm
- Testing Elixir: Effective and Robust Testing for Elixir and its Ecosystem by …