Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Testing Oban Jobs From the Inside Out

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
Tweet

More Decks by Parker Selbert

Other Decks in Technology

Transcript

  1. Oban is a persistent background job system with layered components.

    (queues, workers, jobs, plugins, telemetry)
  2. Automated testing is an essential component of any reliable application.

    (at least those that are long-lived and actively developed 😉)
  3. Database Application Queue Queue Queue Worker Job Job Job Job

    Job Job Job Job Worker Worker Context Controller
  4. 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
  5. @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
  6. 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
  7. 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
  8. 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})
  9. 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
  10. 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})
  11. 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
  12. 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})
  13. 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
  14. 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
  15. 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)
  16. 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
  17. 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
  18. @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
  19. 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
  20. 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
  21. 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,
  22. 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
  23. 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
  24. 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,
  25. 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
  26. 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
  27. 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
  28. @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
  29. 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
  30. 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
  31. 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
  32. 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}
  33. 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
  34. 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
  35. 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}
  36. 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
  37. 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}
  38. 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
  39. 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)
  40. 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
  41. 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
  42. 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
  43. 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
  44. 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}
  45. 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
  46. 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
  47. @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
  48. @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
  49. @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
  50. @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
  51. Use start_supervised_oban!/1 to: • Run Jobs Normally in the Background

    • …Without Scheduling • …Without Isolation • …Without Control
  52. Write tests for your Oban jobs! Whether it’s from the

    inside-out or the outside-in, that’s up to you.