task_bunny
task_bunny copied to clipboard
What is the recommended way to run TaskBunny in test env?
At Square Enix how do you run TaskBunny in test env?
From the documentation I can see that it is possible to disable workers (which I do) but it doesn't seem possible to swap the backend to process inline or even not enqueue a job. As a concrete example, for the following code I want to write some tests:
defmodule MyModule do
def call(args) do
args
|> do_something_with_args()
|> MyJob.enqueue()
end
end
Tests:
- the side-effect of
do_something_with_args/1
- the side-effect of the job running
NOTE: job enqueuing usually happens in the web process and job working happens in a separate app (umbrella).
At the moment I don't have a good solution for MyModule.call/1
that includes both [1] and [2] - I've been writing a test for [1] and a separate unit test specifically for MyJob
to handle [2]. Also, because I disable workers in tests my queues are full of jobs that try and get worked when I boot the app.
It is likely that I am approaching the problem wrong because I am used to rails' active_job which provides an abstraction around job processing libraries which means in test env I can swap out the backend for a "same process, immediate worker" backend. I'm fairly certain that the goal of TaskBunny isn't to provide that functionality (makes total sense) but I'd like to know how you've approached solving these problems.
Good question. At Square Enix, we don't enqueue jobs to RabbitMQ when we run tests for our applications. We wrote a little test helper to mock enqueue in our application.
defmodule YourApp.TaskBunnyHelper do
alias TaskBunny.{Publisher, Queue, Message}
@doc """
Mocks TaskBunny.Queue.Publisher so that we can isolate our tests from RabbitMQ.
"""
def mock_publish do
:meck.expect Publisher, :publish!, fn (_host, _queue, _message) ->
:ok
end
:meck.expect Publisher, :publish!, fn (_host, _queue, _message, _options) ->
:ok
end
:meck.expect Queue, :declare_with_subqueues, fn (_host, _queue) ->
:ok
end
end
@doc """
Mocks TaskBunny.Publisher and performs jobs given immediately.
Once you call `TaskBunnyHelper.sync_publish()`, `TaskBunny.Publisher` will perform the job immediately instead of sending a message to queue.
"""
def sync_publish do
:meck.expect Publisher, :publish!, fn (_host, _queue, message, _option) ->
{:ok, json} = Message.decode(message)
json["job"].perform(json["payload"])
end
end
@doc """
Check if the job is enqueued with given condition
"""
def enqueued?(job, payload \\ nil) do
history = :meck.history(Publisher)
queued = Enum.find history, fn ({_pid, {_module, :publish!, args}, _ret}) ->
case args do
[_h, _q, message | _] ->
{:ok, json} = Message.decode(message)
json["job"] == job && (is_nil(payload) || json["payload"] == payload)
_ -> false
end
end
queued != nil
end
end
On setup you call the function.
TaskBunnyHelper.mock_publish()
on_exit(&:meck.unload/0)
It also helps you to test if the job was enqueued with the expected parameters too.
assert TaskBunnyHelper.enqueued?(MyJob, %{"name" => "Loto"})
Two things you might want to be aware if you want to use this snippet.
- meck doesn't support async tests
- the helper module might stop working in the future update (we are happy to share the fix though)
We don't provide it as an official way since we can imagine people have different preference on test approach but we are always happy to discuss the idea and share what we do here. Hope the information above helps your situation.
Thanks!
@ono thanks for sharing that example - this evening I watched your ElixirConf presentation from earlier this year to see if you mentioned the approach you take, unfortunately you didn't but I enjoyed the talk anyway.
I was thinking of doing something fairly similar to the code you posted except I was going to take the approach that thoughtbot/bamboo did and configure it through an adapter. The main reason I was thinking that would be a better solution than :meck
was simply due to :meck
not supporting async tests. For now I'll probably use your code and revisit it later.
Thanks again.
@ono We've been looking at this as well. It seems the community has been learning towards explicit contracts as a way to establish mocks at the boundary of a given module.
By defining a behaviour for TaskBunny.Publisher
, and loading the module from config, it's then possible to take advantage of the Mox library, allowing for async.
Here is where we ended up. This may be a bit heavy handed, but it has allowed us to mock out our job execution using the Mox lib.
First, we created a stub module that duplicates the functionality of TaskBunny.Job
, but defines the behaviour for enqueuing.
Stub Module
defmodule TaskBunny.Job.Stub do
@moduledoc """
Provides a test harness for mocking enqueue calls to TaskBunny jobs.
"""
alias TaskBunny.Job.Mock
alias TaskBunny.Job.QueueNotFoundError
alias TaskBunny.Connection.ConnectError
alias TaskBunny.Publisher.PublishError
@callback enqueue(atom, any, keyword) :: :ok | {:error, any}
@callback enqueue!(atom, any, keyword) :: :ok
defmacro __using__(_options \\ []) do
quote do
@behaviour TaskBunny.Job
def enqueue(payload, options \\ []) do
Mock.enqueue(__MODULE__, payload, options)
rescue
e in [ConnectError, PublishError, QueueNotFoundError] -> {:error, e}
end
def enqueue!(payload, options \\ []), do: Mock.enqueue!(__MODULE__, payload, options)
def timeout, do: 120_000
def max_retry, do: 10
def retry_interval(_failed_count), do: 300_000
defoverridable [timeout: 0, max_retry: 0, retry_interval: 1]
end
end
end
This stub is swapped in for the test config.
config :task_bunny, :job_module, TaskBunny.Job.Stub
def job do
quote do
use unquote(Application.get_env(:task_bunny, :job_module, TaskBunny.Job))
end
end
We establish the mock with Mox:
Mox.defmock(TaskBunny.Job.Mock, for: TaskBunny.Job.Stub)
And finally in the test...
test "enqueues for known job" do
TaskBunny.Job.Mock
|> expect(:enqueue, fn (FooJob, %{"target" => "a"}, _) -> :ok end)
|> expect(:enqueue, fn (FooJob, %{"target" => "b"}, _) -> :ok end)
integrations = ~w(a b)
message = %{"integrations" => integrations, "job" => to_string(FooJob), "payload" => %{"foo" => "bar"}}
assert FanoutJob.perform(message) == :ok
end
Profit!!!
Note: This way has the added benefit of mocking when necessary, but then delegating to the actual
TaskBunny.Job.enqueue
when one wants to have original behaviour.TaskBunny.Job.Mock |> expect(:enqueue, fn (FooJob, %{"target" => "a"}, _) -> :ok end) |> expect(:enqueue, &TaskBunny.Job.enqueue/3)
Just thought I would post another alternative.
With my projects, I have been using my GenQueue abstraction to work with different forms of background job queues. Typically, we might have a "simple" queue that uses something like GenStage, and a more "durable" queue with something like TaskBunny. With adapters, GenQueue lets me use a common API for both - as well as a unified testing format using assert_recieve.
With TaskBunny, one can just drop in the TaskBunny adapter.