#book Testing Elixir(2021) 독후감
2022-08-05구성이 마음에 들어서 책을 읽었다. 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 …

- category:
- book 100
- tags:
- elixirlang 39
- testing 10
@ohyecloudy
,ohyecloudy@gmail.com