Skip to content

Commit ca7cf04

Browse files
authored
Support expectation on exact number of requests (#128)
* support expectation on exact number of requests * regroup guard * document feature * Use Agent.update instead of get_and_update in examples
1 parent 8e5b58e commit ca7cf04

File tree

3 files changed

+124
-5
lines changed

3 files changed

+124
-5
lines changed

lib/bypass.ex

+45-3
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,13 @@ defmodule Bypass do
115115
{:error, :too_many_requests, {method, path}} ->
116116
raise error_module, "Expected only one HTTP request for Bypass at #{method} #{path}"
117117

118+
{:error, {:unexpected_request_number, expected, actual}, {:any, :any}} ->
119+
raise error_module, "Expected #{expected} HTTP request for Bypass, got #{actual}"
120+
121+
{:error, {:unexpected_request_number, expected, actual}, {method, path}} ->
122+
raise error_module,
123+
"Expected #{expected} HTTP request for Bypass at #{method} #{path}, got #{actual}"
124+
118125
{:error, :unexpected_request, {:any, :any}} ->
119126
raise error_module, "Bypass got an HTTP request but wasn't expecting one"
120127

@@ -172,6 +179,21 @@ defmodule Bypass do
172179
def expect(%Bypass{pid: pid}, fun),
173180
do: Bypass.Instance.call(pid, {:expect, fun})
174181

182+
@doc """
183+
Expects the passed function to be called exactly `n` times for any route.
184+
185+
```elixir
186+
Bypass.expect(bypass, 3, fn conn ->
187+
assert "/1.1/statuses/update.json" == conn.request_path
188+
assert "POST" == conn.method
189+
Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>)
190+
end)
191+
```
192+
"""
193+
@spec expect(Bypass.t(), pos_integer(), (Plug.Conn.t() -> Plug.Conn.t())) :: :ok
194+
def expect(%Bypass{pid: pid}, n, fun),
195+
do: Bypass.Instance.call(pid, {:expect, n, fun})
196+
175197
@doc """
176198
Expects the passed function to be called at least once for the specified route (method and path).
177199
@@ -181,7 +203,7 @@ defmodule Bypass do
181203
182204
```elixir
183205
Bypass.expect(bypass, "POST", "/1.1/statuses/update.json", fn conn ->
184-
Agent.get_and_update(AgentModule, fn step_no -> {step_no, step_no + 1} end)
206+
Agent.update(AgentModule, fn step_no -> step_no + 1 end)
185207
Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>)
186208
end)
187209
```
@@ -190,6 +212,26 @@ defmodule Bypass do
190212
def expect(%Bypass{pid: pid}, method, path, fun),
191213
do: Bypass.Instance.call(pid, {:expect, method, path, fun})
192214

215+
@doc """
216+
Expects the passed function to be called exactly `n` times for the specified route (method and path).
217+
218+
- `method` is one of `["GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "OPTIONS", "CONNECT"]`
219+
220+
- `path` is the endpoint.
221+
222+
- `n` is the number of times the route is expected to be called.
223+
224+
```elixir
225+
Bypass.expect(bypass, "POST", "/1.1/statuses/update.json", 3, fn conn ->
226+
Agent.update(AgentModule, fn step_no -> step_no + 1 end)
227+
Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>)
228+
end)
229+
```
230+
"""
231+
@spec expect(Bypass.t(), String.t(), String.t(), pos_integer(), (Plug.Conn.t() -> Plug.Conn.t())) :: :ok
232+
def expect(%Bypass{pid: pid}, method, path, n, fun),
233+
do: Bypass.Instance.call(pid, {{:exactly, n}, method, path, fun})
234+
193235
@doc """
194236
Expects the passed function to be called exactly once regardless of the route.
195237
@@ -214,7 +256,7 @@ defmodule Bypass do
214256
215257
```elixir
216258
Bypass.expect_once(bypass, "POST", "/1.1/statuses/update.json", fn conn ->
217-
Agent.get_and_update(AgentModule, fn step_no -> {step_no, step_no + 1} end)
259+
Agent.update(AgentModule, fn step_no -> step_no + 1 end)
218260
Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>)
219261
end)
220262
```
@@ -232,7 +274,7 @@ defmodule Bypass do
232274
233275
```elixir
234276
Bypass.stub(bypass, "POST", "/1.1/statuses/update.json", fn conn ->
235-
Agent.get_and_update(AgentModule, fn step_no -> {step_no, step_no + 1} end)
277+
Agent.update(AgentModule, fn step_no -> step_no + 1 end)
236278
Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>)
237279
end)
238280
```

lib/bypass/instance.ex

+23-2
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,17 @@ defmodule Bypass.Instance do
108108
do_handle_call({expect, :any, :any, fun}, from, state)
109109
end
110110

111+
defp do_handle_call({:expect, n, fun}, from, state) do
112+
do_handle_call({{:exactly, n}, :any, :any, fun}, from, state)
113+
end
114+
111115
defp do_handle_call(
112116
{expect, method, path, fun},
113117
_from,
114118
%{expectations: expectations} = state
115119
)
116-
when expect in [:stub, :expect, :expect_once] and
120+
when (expect in [:stub, :expect, :expect_once] or
121+
(is_tuple(expect) and elem(expect, 0) == :exactly)) and
117122
method in [
118123
"GET",
119124
"POST",
@@ -140,6 +145,7 @@ defmodule Bypass.Instance do
140145
:expect -> :once_or_more
141146
:expect_once -> :once
142147
:stub -> :none_or_more
148+
{:exactly, n} -> {:exactly, n}
143149
end
144150
)
145151
)
@@ -177,6 +183,10 @@ defmodule Bypass.Instance do
177183
%{expected: :once, request_count: count} when count > 0 ->
178184
{:reply, {:error, :too_many_requests, route}, increase_route_count(state, route)}
179185

186+
%{expected: {:exactly, n}, request_count: count} when count >= n ->
187+
{:reply, {:error, {:unexpected_request_number, n, count + 1}, route},
188+
increase_route_count(state, route)}
189+
180190
nil ->
181191
{:reply, {:error, :unexpected_request, route}, state}
182192

@@ -253,9 +263,12 @@ defmodule Bypass.Instance do
253263
problem_route =
254264
expectations
255265
|> Enum.reject(fn {_route, expectations} -> expectations[:expected] == :none_or_more end)
256-
|> Enum.find(fn {_route, expectations} -> Enum.empty?(expectations.results) end)
266+
|> Enum.find(fn {_route, expectations} -> problem_route?(expectations) end)
257267

258268
case problem_route do
269+
{route, %{expected: {:exactly, expected}, request_count: actual}} ->
270+
{:error, {:unexpected_request_number, expected, actual}, route}
271+
259272
{route, _} ->
260273
{:error, :not_called, route}
261274

@@ -275,6 +288,14 @@ defmodule Bypass.Instance do
275288
end
276289
end
277290

291+
defp problem_route?(%{expected: {:exactly, n}} = expectations) do
292+
length(expectations.results) < n
293+
end
294+
295+
defp problem_route?(expectations) do
296+
Enum.empty?(expectations.results)
297+
end
298+
278299
defp route_info(method, path, %{expectations: expectations} = _state) do
279300
segments = build_path_match(path) |> elem(1)
280301

test/bypass_test.exs

+56
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,62 @@ defmodule BypassTest do
293293
end)
294294
end
295295

296+
for {expected, actual, alt} <- [{3, 5, "too many"}, {5, 3, "not enough"}] do
297+
@tag expected: expected, actual: actual
298+
test "Bypass.expect/3 fails when #{alt} requests arrived", %{
299+
expected: expected,
300+
actual: actual
301+
} do
302+
bypass = Bypass.open()
303+
parent = self()
304+
305+
Bypass.expect(bypass, expected, fn conn ->
306+
send(parent, :request_received)
307+
Plug.Conn.send_resp(conn, 200, "")
308+
end)
309+
310+
Enum.map(1..actual, fn _ -> Task.async(fn -> request(bypass.port) end) end)
311+
|> Task.await_many()
312+
313+
Enum.each(1..min(actual, expected), fn _ -> assert_receive :request_received end)
314+
refute_receive :request_received
315+
316+
# Override Bypass' on_exit handler.
317+
ExUnit.Callbacks.on_exit({Bypass, bypass.pid}, fn ->
318+
exit_result = Bypass.Instance.call(bypass.pid, :on_exit)
319+
assert {:error, {:unexpected_request_number, expected, actual}, _} = exit_result
320+
assert expected != actual
321+
end)
322+
end
323+
324+
@tag expected: expected, actual: actual
325+
test "Bypass.expect/5 fails when #{alt} requests arrived", %{
326+
expected: expected,
327+
actual: actual
328+
} do
329+
bypass = Bypass.open()
330+
parent = self()
331+
332+
Bypass.expect(bypass, "GET", "/foo", expected, fn conn ->
333+
send(parent, :request_received)
334+
Plug.Conn.send_resp(conn, 200, "")
335+
end)
336+
337+
Enum.map(1..actual, fn _ -> Task.async(fn -> request(bypass.port, "/foo", "GET") end) end)
338+
|> Task.await_many()
339+
340+
Enum.each(1..min(actual, expected), fn _ -> assert_receive :request_received end)
341+
refute_receive :request_received
342+
343+
# Override Bypass' on_exit handler.
344+
ExUnit.Callbacks.on_exit({Bypass, bypass.pid}, fn ->
345+
exit_result = Bypass.Instance.call(bypass.pid, :on_exit)
346+
assert {:error, {:unexpected_request_number, expected, actual}, _} = exit_result
347+
assert expected != actual
348+
end)
349+
end
350+
end
351+
296352
test "Bypass.stub/4 does not raise if request is made" do
297353
:stub |> specific_route
298354
end

0 commit comments

Comments
 (0)