Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

What is the recommended way to run TaskBunny in test env? #44

Open
coop opened this issue Oct 5, 2017 · 5 comments
Open

What is the recommended way to run TaskBunny in test env? #44

coop opened this issue Oct 5, 2017 · 5 comments

Comments

@coop
Copy link
Contributor

coop commented Oct 5, 2017

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:

  1. the side-effect of do_something_with_args/1
  2. 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.

@ono
Copy link
Contributor

ono commented Oct 5, 2017

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.

  1. meck doesn't support async tests
  2. 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!

@coop
Copy link
Contributor Author

coop commented Oct 5, 2017

@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.

@erikreedstrom
Copy link

@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.

@erikreedstrom
Copy link

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)

@nsweeting
Copy link

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants