In Part 0 we looked at the basic anatomy of a test: given, when, and then. In Part 1 we learned about start_supervisor
. Now we will put these into action.
In part 1, our GenServer was straight-forward to test because it was so simple. What if instead of a counter, our GenServer was rate-limiting API calls?
defmodule RateLimiter do
alias API
@min_time_between_requests :timer.seconds(2)
### API
def start_link(_) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def get() do
GenServer.call(__MODULE__, :get)
end
### Implementation
def init(_) do
min_time_ago =
NaiveDateTime.utc_now()
|> NaiveDateTime.add(-1 * @min_time_between_requests, :millisecond)
{:ok, %{last_request: min_time_ago}}
end
def handle_call(:get, _from, %{last_request: last_request}) do
now = NaiveDateTime.utc_now()
api_resp =
case NaiveDateTime.diff(now, last_request, :millisecond) do
millis when millis > @min_time_between_requests ->
API.get()
millis ->
Process.sleep(millis)
API.get()
end
{:reply, api_resp, %{last_request: now}}
end
end
The meat of the module is in the handle_call(:get, ...)
function. If the time since the last request is greater than the minimum time we should wait. We need to pause until that amount of time has passed.
This is obviously not production quality code, as there are way too many edge-cases not handled, (and performance is terrible) but it is complex enough to really make us wonder how we are going to test this.
Here’s the dirty secret: We can’t test this. At least not safely. Any test of the get()
function will call an external API. Bad news considering this is the same API we are trying to rate-limit!
What we can do is change the code to make it testable. If you TDD you never have to do this since the code will always be testable. So let’s start from scratch and TDD this.
First things first, let’s not worry about rate-limiting at all. Let’s just make sure we can call an API.get()
and test it.
test "can call API.get()" do
# Given a rate limiter
_rate_limiter = start_supervised!({RateLimiter, []}
# When I call get
actual = RateLimiter.get()
# Then I get a response
assert %{some_response: true} == actual
end
But wait! We don’t want to call the actual API, instead we are going to call a FakeAPI
.
defmodule FakeAPI do
def get() do
%{some_response: true}
end
end
An our given changes ever so slightly
# Given a rate limiter
_rate_limiter = start_supervised!({RateLimiter, %{api: FakeAPI}})
Our implementation code:
defmodule RateLimiter
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def get() do
GenServer.call(__MODULE__, :get)
end
def init(opts) do
{:ok, opts}
end
def handle_call(:get, _from, {api: api} = state) do
resp = api.get()
{:ok, resp, state}
end
end
And our test should pass! By injecting our API dependency into the GenServer, we made our module much more testable.
We can test the rate-limiting quite easily by also injecting the time. We can also leverage processes to capture exactly when a request was received. Here’s what the test looks like.
test "rate limits API calls" do
timer = :timer.seconds(0.1)
_rate_limiter = start_supervised!({RateLimiter, %{api: FakeAPI, timer: timer}})
send_request = fn pid ->
Task.async(fn ->
RateLimiter.get()
send(pid, %{recieved_at: NaiveDateTime.utc_now()})
end)
end
send_request.(self())
send_request.(self())
assert_received %{recieved_at: time_a}
assert_received %{recieved_at: time_b}
assert NaiveDateTime.diff(time_a, time_b, :milliseconds) > timer
end
Final code:
defmodule RateLimiter do
alias API
use GenServer
### API
def start_link(args) do
GenServer.start_link(__MODULE__, args, name: __MODULE__)
end
def get() do
GenServer.call(__MODULE__, :get)
end
### Implementation
def init(%{api: api, timer: timer}) do
min_time_ago =
NaiveDateTime.utc_now()
|> NaiveDateTime.add(-1 * timer, :millisecond)
{:ok, %{last_request: min_time_ago, api: api, timer: timer}}
end
def handle_call(:get, _from, %{last_request: last_request, api: api, timer: timer} = state) do
now = NaiveDateTime.utc_now()
api_resp =
case NaiveDateTime.diff(now, last_request, :millisecond) do
millis when millis < timer ->
IO.inspect(millis, label: "DIFF")
api.get()
millis ->
IO.inspect(millis, label: "DIFF")
Process.sleep(millis)
api.get()
end
{:reply, api_resp, %{state | last_request: now}}
end
end
defmodule FakeAPI do
def get() do
%{some_response: true}
end
end
ExUnit.start()
defmodule RateLimiterTest do
use ExUnit.Case
setup do
start_supervised({RateLimiter, %{api: FakeAPI, timer: timer}})
%{timer: timer}
end
test "can call API.get()" do
# Given a rate limiter
# RateLimiter.start_link(%{api: FakeAPI, timer: timer})
# When I call get
actual = RateLimiter.get()
# Then I get a response
assert %{some_response: true} == actual
end
test "rate limits API calls", %{timer: timer} do
# rate_limiter = RateLimiter.start_link(%{api: FakeAPI, timer: timer})
send_request = fn pid ->
Task.async(fn ->
RateLimiter.get()
send(pid, %{recieved_at: NaiveDateTime.utc_now()})
end)
end
send_request.(self())
send_request.(self())
assert_receive %{recieved_at: time_first}, timer * 2
assert_receive %{recieved_at: time_second}, timer * 2
assert NaiveDateTime.diff(time_second, time_first, :millisecond) > timer
end
end
Thanks for reading