Share
Embed
Testing Oban Jobs From the Inside Out
Background jobs with Oban are integral to the business side of many applications. Knowing that your jobs function correctly in isolation, and when composed with other jobs, and when integrated with the rest of the system is essential for peace of mind. Fortunately, Oban provides all the tools necessary to test jobs at any level of isolation.
Together we’ll explore how to make optimal use of Oban’s robust testing functionality for unit, integration, and acceptance tests. Come learn how to test Oban from the inside out—from asserting that workers return correct values to assuring every job in a queue is executed as it would be in production.
Parker Selbert
October 14, 2021
Transcript
84 slides
-
Oban is a persistent background job system with layered components.
(queues, workers, jobs, plugins, telemetry) -
Automated testing is an essential component of any reliable application.
(at least those that are long-lived and actively developed 😉) -
We need to verify that all moving parts are working
together as we expect. -
Database Application Queue Queue Queue Worker Job Job Job Job
Job Job Job Job Worker Worker Context Controller -
config :my_app, Oban, repo: MyApp.Repo, plugins: [Oban.Plugins.Pruner, Oban.Plugins.Stager], queues: [
default: 10, events: 20, emails: 5 ] config.exs -
defmodule MyApp.Case do use ExUnit.CaseTemplate using do quote do use
Oban.Testing, repo: MyApp.Repo end end setup do :ok = Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual) :ok = Ecto.Adapters.SQL.Sandbox.checkout(MyApp.Repo) end end MyApp.Repo.start_link() ExUnit.start() test_helper.exs -
@doc """ Construct a job and execute it with a
worker module. """ @spec perform_job( worker :: Worker.t(), args :: term(), opts :: [Job.option() | {:repo, module()}] ) :: Worker.result() oban/testing.ex -
defmodule MyApp.ActivationWorkerTest do use MyApp.Case, async: true test "activating a
new account" do account = MyApp.Account.create(name: “ElixirConf”, email: “[email protected]”) {:ok, account} = perform_job(MyApp.ActivationWorker, %{id: account.id}) assert account.active? end end activation_worker_test.exs -
defmodule MyApp.ActivationWorkerTest do use MyApp.Case, async: true test "activating a
new account" do account = MyApp.Account.create(name: “ElixirConf”, email: “[email protected]") {:ok, account} = perform_job(MyApp.ActivationWorker, %{id: account.id}) assert account.active? end end activation_worker_test.exs -
1) test activating a new account (MyApp.AccountWorkerTest) Expected worker to
be a module that implements the Oban.Worker behaviour, got: MyApp.AccountWorker code: {:ok, account} = perform_job(MyApp.AccountWorker, %{id: account.id}) -
defmodule MyApp.ActivationWorker do use Oban.Worker @impl Worker def perform(%Job{args: %{id:
account_id}}) do MyApp.Account.activate(account_id) end end activation_worker.ex -
1) test activating a new account (MyApp.AccountWorkerTest) ** (FunctionClauseError) no
function clause matching in MyApp.AccountWorker.perform/1 The following arguments were given to MyApp.AccountWorker.perform/1: # 1 %Oban.Job{args: %{"id" => 1}, attempt: 1, worker: "MyApp.AccountWorker"} code: {:ok, account} = perform_job(MyApp.AccountWorker, %{id: account.id}) -
defmodule MyApp.ActivationWorker do use Oban.Worker @impl Worker def perform(%Job{args: %{"id"
=> account_id}}) do MyApp.Account.activate(account_id) end end activation_worker.ex -
1) test activating a new account (MyApp.AccountWorkerTest) Expected result to
be one of - `:ok` - `:discard` - `{:ok, value}` - `{:error, reason}` - `{:discard, reason}` - `{:snooze, duration} Instead received: {:activated, %MyApp.Account{active?: true, id: 1, name: nil}} code: {:ok, account} = perform_job(MyApp.AccountWorker, %{id: account.id}) -
defmodule MyApp.ActivationWorker do use Oban.Worker @impl Worker def perform(%Job{args: %{id:
account_id}}) do with {:activated, account} <- MyApp.Account.activate(account_id) do {:ok, account} end end end activation_worker.ex -
. Finished in 0.3 seconds (0.2s on load, 0.08s async,
0.00s sync) 1 test, 0 failures -
defmodule MyApp.ActivationWorkerTest do use MyApp.Case, async: true test "activating a
new account" do account = MyApp.Account.create(name: “ElixirConf”, email: “[email protected]”) {:ok, account} = perform_job(MyApp.ActivationWorker, %{id: account.id}, priority: 9) assert account.active? end end activation_worker_test.exs -
1) test activating a new account (MyApp.ActivationWorkerTest) Expected args and
opts to build a valid job, got validation errors: priority: must be less than 4 code: {:ok, account} = perform_job(MyApp.ActivationWorker, %{id: id}, priority: 9) -
defmodule MyApp.ActivationWorkerTest do use MyApp.Case, async: true test "activating a
new account" do account = MyApp.Account.create(name: “ElixirConf”, email: “[email protected]”) {:ok, account} = perform_job(MyApp.ActivationWorker, %{id: account.id}, priority: 3) assert account.active? end end activation_worker_test.exs -
. Finished in 0.3 seconds (0.2s on load, 0.08s async,
0.00s sync) 1 test, 0 failures -
Use perform_job/1 to: • Ensure Worker Behavior • Check String
Keys in perform/1 • Verify Return Values • Encode/Decode Args to JSON • Validate Job Opts • Exercise a Custom new/1 Callback -
@doc """ Assert that a job with particular options has
been enqueued. """ @spec assert_enqueued(opts :: Keyword.t()) :: true @doc """ Refute that a job with particular options has been enqueued. """ @spec refute_enqueued(opts :: Keyword.t()) :: false oban/testing.ex -
defmodule MyApp.AccountTest do use MyApp.Case, async: true test "scheduling activation
upon sign up" do {:ok, account} = MyApp.Account.sign_up(name: “ElixirConf”, email: “[email protected]") assert_enqueued worker: MyApp.ActivationWorker, args: %{id: account.id}, queue: :default end end account_test.exs -
defmodule MyApp.AccountTest do use MyApp.Case, async: true test "scheduling activation
upon sign up" do {:ok, account} = MyApp.Account.sign_up(name: “ElixirConf”, email: “[email protected]") assert_enqueued worker: MyApp.ActivationWorker, args: %{id: account.id}, queue: :default end end account_test.exs -
1) test scheduling activation upon sign up (MyApp.AccountTest) Expected a
job matching: %{args: %{id: 4}, queue: :default, worker: MyApp.ActivationWorker} to be enqueued in the "public" schema. Instead found: [] code: assert_enqueued worker: MyApp.ActivationWorker, -
defmodule MyApp.Account do def sign_up(args) do changeset = changeset(args) with
{:ok, account} <- MyApp.Repo.insert(changeset) do %{id: account.id} |> MyApp.ActivationWorker.new() |> Oban.insert() {:ok, account} end end account.ex -
. Finished in 0.1 seconds (0.1s async, 0.00s sync) 1
test, 0 failures -
def sign_up(args) do changeset = changeset(args) with {:ok, account} <-
MyApp.Repo.insert(changeset) do Task.start(fn -> Process.sleep(100) %{id: account.id} |> MyApp.ActivationWorker.new() |> Oban.insert() end) {:ok, account} end end account.ex -
1) test scheduling activation upon sign up (MyApp.AccountTest) Expected a
job matching: %{args: %{id: 4}, queue: :default, worker: MyApp.ActivationWorker} to be enqueued in the "public" schema. Instead found: [] code: assert_enqueued worker: MyApp.ActivationWorker, -
test "scheduling activation upon sign up" do {:ok, account} =
MyApp.Account.sign_up(name: "ElixirConf", email: "[email protected]") expected_opts = [ worker: MyApp.ActivationWorker, args: %{id: account.id}, queue: :default ] assert_enqueued expected_opts, 150 end account_test.exs -
. Finished in 0.1 seconds (0.1s async, 0.00s sync) 1
test, 0 failures -
test "skipping activation on failed sign up" do {:error, _changeset}
= MyApp.Account.sign_up(name: "", email: "") refute_enqueued [worker: MyApp.ActivationWorker], 150 end account_test.exs -
. Finished in 0.1 seconds (0.1s async, 0.00s sync) 2
tests, 0 failures -
Use assert_enqueued/* and refute_enqueued/* to: • Assert Jobs in the
Database • Refute Jobs in the Database • Query by Worker/Args/Meta/Queue/ScheduledAt/Etc. • Compensate for Async Activity with Timeouts -
@type drain_option :: {:queue, queue_name()} | {:with_limit, pos_integer()} | {:with_recursion,
boolean()} | {:with_safety, boolean()} | {:with_scheduled, boolean()} @type drain_result :: %{ failure: non_neg_integer(), snoozed: non_neg_integer(), success: non_neg_integer() } @doc """ Synchronously execute all available jobs in a queue. """ @spec drain_queue([drain_option()]) :: drain_result() oban.ex -
test "delivering a follow-up welcome email after sign up" do
{:ok, account} = MyApp.Account.sign_up(name: "ElixirConf", email: "[email protected]") assert %{success: 1} = Oban.drain_queue(queue: :emails) assert_email_sent MyApp.AccountEmail.welcome(account) end account_test.exs -
test "delivering a follow-up welcome email after sign up" do
{:ok, account} = MyApp.Account.sign_up(name: "ElixirConf", email: "[email protected]") assert %{success: 1} = Oban.drain_queue(queue: :emails) assert_email_sent MyApp.AccountEmail.welcome(account) end account_test.exs -
test "delivering a follow-up welcome email after sign up" do
{:ok, account} = MyApp.Account.sign_up(name: "ElixirConf", email: "[email protected]") assert %{success: 1} = Oban.drain_queue(queue: :emails) assert_email_sent MyApp.AccountEmail.welcome(account) end account_test.exs -
1) test delivering a follow-up welcome email after sign up
(MyApp.AccountTest) match (=) failed code: assert %{success: 1} = Oban.drain_queue(queue: :emails) left: %{success: 1} right: %{success: 0, failure: 0, snoozed: 0} -
def sign_up(args) do changeset = changeset(args) with {:ok, account} <-
MyApp.Repo.insert(changeset) do Oban.insert_all([ MyApp.ActivationWorker.new(%{id: account.id}), MyApp.WelcomeWorker.new(%{id: account.id}, schedule_in: {1, :hour}) ]) {:ok, account} end end account.ex -
defmodule MyApp.WelcomeWorker do use Oban.Worker, queue: :emails @impl Worker def
perform(%Job{args: %{"id" => account_id}}) do {:ok, account} = MyApp.Repo.get(MyApp.Account, account_id) account |> MyApp.AccountEmail.welcome() |> MyApp.Mailer.deliver() end end welcome_worker.ex -
1) test delivering a follow-up welcome email after sign up
(MyApp.AccountTest) match (=) failed code: assert %{success: 1} = Oban.drain_queue(queue: :emails) left: %{success: 1} right: %{success: 0, failure: 0, snoozed: 0} -
test "delivering a follow-up welcome email after sign up" do
{:ok, account} = MyApp.Account.sign_up(name: "ElixirConf", email: "[email protected]") assert %{success: 1} = Oban.drain_queue(queue: :emails, with_scheduled: true) assert_email_sent MyApp.AccountEmail.welcome(account) end account_test.exs -
1) test delivering a follow-up welcome email after sign up
(MyApp.AccountTest) match (=) failed code: assert %{success: 1} = Oban.drain_queue(queue: :emails, with_scheduled: true) left: %{success: 1} right: %{success: 0, failure: 1, snoozed: 0} -
test "delivering a follow-up welcome email after sign up" do
{:ok, account} = MyApp.Account.sign_up(name: "ElixirConf", email: "[email protected]") assert %{success: 1} = Oban.drain_queue(queue: :emails, with_safety: false, with_scheduled: true) assert_email_sent MyApp.AccountEmail.welcome(account) end account_test.exs -
1) test delivering a follow-up welcome email after sign up
(MyApp.AccountTest) ** (MatchError) no match of right hand side value: %MyApp.Account{active: false, email: "[email protected]", id: 16, name: "ElixirConf"} code: Oban.drain_queue(queue: :emails, with_scheduled: true, with_safety: false) -
defmodule MyApp.WelcomeWorker do use Oban.Worker, queue: :emails @impl Worker def
perform(%Job{args: %{"id" => account_id}}) do {:ok, account} = MyApp.Repo.get(MyApp.Account, account_id) account |> MyApp.AccountEmail.welcome() |> MyApp.Mailer.deliver() end end welcome_worker.ex -
defmodule MyApp.WelcomeWorker do use Oban.Worker, queue: :emails @impl Worker def
perform(%Job{args: %{"id" => account_id}}) do account = MyApp.Repo.get(MyApp.Account, account_id) account |> MyApp.AccountEmail.welcome() |> MyApp.Mailer.deliver() end end welcome_worker.ex -
. Finished in 0.2 seconds (0.2s async, 0.00s sync) 3
tests, 0 failures, 0 excluded -
test "starting our welcome drip after sign up" do {:ok,
_account} = MyApp.Account.sign_up(name: "ElixirConf", email: "[email protected]") assert %{success: 4, failure: 0, snoozed: 0} = Oban.drain_queue(queue: :emails, with_scheduled: true, with_safety: false) end account_test.exs -
defmodule MyApp.ActivationWorker do use Oban.Worker @impl Worker def perform(%Job{args: %{"id"
=> account_id} = args}) do with {:activated, account} <- MyApp.Account.activate(account_id) do Oban.insert_all([ MyApp.HelloWorker.new(args, schedule_in: {1, :hour}), MyApp.IntroWorker.new(args, schedule_in: {1, :day}), MyApp.NudgeWorker.new(args, schedule_in: {1, :week}) ]) {:ok, account} end end end activation_worker.ex -
1) test starting our welcome drip after sign up (MyApp.AccountTest)
match (=) failed code: assert %{success: 4, failure: 0, snoozed: 0} = Oban.drain_queue(queue: :emails, with_scheduled: true, with_safety: false) left: %{failure: 0, snoozed: 0, success: 4} right: %{failure: 0, snoozed: 0, success: 1} -
test "starting our welcome drip after sign up" do {:ok,
_account} = MyApp.Account.sign_up(name: "ElixirConf", email: "[email protected]") assert %{success: 4, failure: 0, snoozed: 0} = Oban.drain_queue( queue: :emails, with_scheduled: true, with_safety: false, with_recursion: true ) end account_test.exs -
. Finished in 0.2 seconds (0.2s async, 0.00s sync) 4
tests, 0 failures, 0 excluded -
Use drain_queue/* to: • Execute All Jobs in a Particular
Queue • Make Scheduled Jobs Immediately Available • Surface Job Errors and Exceptions • Recursively Execute All Jobs -
If you absolutely must run jobs normally during your tests.
(for browser testing or live view) -
@doc """ Set up a supvervised version of Oban with
the provided options. """ def start_supervised_oban!(opts) do opts = opts |> Keyword.put_new(:name, Oban) |> Keyword.put_new(:repo, Repo) |> Keyword.put_new(:plugins, [{Repeater, interval: 25}]) |> Keyword.put_new(:poll_interval, :timer.minutes(10)) |> Keyword.put_new(:shutdown_grace_period, 25) start_supervised!({Oban, opts}, id: opts[:name]) end test_helper.exs -
@doc """ Set up a supvervised version of Oban with
the provided options. """ def start_supervised_oban!(opts) do opts = opts |> Keyword.put_new(:name, Oban) |> Keyword.put_new(:repo, Repo) |> Keyword.put_new(:plugins, [{Repeater, interval: 25}]) |> Keyword.put_new(:poll_interval, :timer.minutes(10)) |> Keyword.put_new(:shutdown_grace_period, 25) start_supervised!({Oban, opts}, id: opts[:name]) end test_helper.exs -
@doc """ Set up a supvervised version of Oban with
the provided options. """ def start_supervised_oban!(opts) do opts = opts |> Keyword.put_new(:name, Oban) |> Keyword.put_new(:repo, Repo) |> Keyword.put_new(:plugins, [{Repeater, interval: 25}]) |> Keyword.put_new(:poll_interval, :timer.minutes(10)) |> Keyword.put_new(:shutdown_grace_period, 25) start_supervised!({Oban, opts}, id: opts[:name]) end test_helper.exs -
@doc """ Set up a supvervised version of Oban with
the provided options. """ def start_supervised_oban!(opts) do opts = opts |> Keyword.put_new(:name, Oban) |> Keyword.put_new(:repo, Repo) |> Keyword.put_new(:plugins, [{Repeater, interval: 25}]) |> Keyword.put_new(:poll_interval, :timer.minutes(10)) |> Keyword.put_new(:shutdown_grace_period, 25) start_supervised!({Oban, opts}, id: opts[:name]) end test_helper.exs -
Use start_supervised_oban!/1 to: • Run Jobs Normally in the Background
• …Without Scheduling • …Without Isolation • …Without Control -
Write tests for your Oban jobs! Whether it’s from the
inside-out or the outside-in, that’s up to you.