Testing background jobs in Rails

In Chapter 14 you delivered an email immediately with deliver_now. That sends the email during the request, which means each delivery occupies a web server thread, so a slow provider or a burst of emails can slow down other requests.

To avoid that you will first update the share integration tests to expect a queued mailer, then switch those deliveries to deliver_later so the email is queued through Active Job. That way the controller can respond immediately instead of waiting for the email to be delivered and remove the waiting time for the user.

deliver_later uses Active Job, but you still have not written a custom job of your own. To learn that you will export every recipe in the Cookbook app to a file in the background. Assembling every recipe, ingredient, and step is the kind of work that gets slower as the recipe grows in the database and not something you can do on the request thread in long term.

Deccan Queen on Rails

This chapter is sponsored by Deccan Queen on Rails

Deccan Queen on Rails is a community-first Rails conference happening in Pune (India) from 8 to 11 October 2026. Single track talks, hallway conversations, and chai with Rubyists from India and abroad.

What you will do in this chapter #

  1. Update the recipe share feature to use deliver_later instead of deliver_now (using TDD).
  2. Export recipes to a file on the request thread (using TDD).
  3. Seed thousands of recipes in development and click Export recipes so you feel the hang in the request thread to see the problem Background jobs solve.
  4. Install and setup Active Storage so export files can be attached to a user.
  5. Add a background job to generate the export file and attach it to a user who clicked the “Export” button in the UI. Discard the job if the user is deleted. Everything, again with TDD.
  6. Wire up controller and views to “export” recipes and download the file. Power file download with Turbo Streams so the user can be notified with the download link via a broadcast from Background Jobs.

Scenarios to automate #

Scenario Expected
Share recipe email in the background Signed-in alice POSTs share; RecipeMailer#share is enqueued instead of delivered on the spot; redirect lands on the recipe show page
Export recipes on the request thread Signed-in alice sees Export recipes on the list, POSTs export, and gets back a text file of every recipe in the app
Guest cannot export on the request thread Guest doesn’t see Export recipes button and cannot POST export
Export recipes in the background Background job for Alice attaches one export file whose text includes recipes, ingredients, and steps of all users in the app
Export when the user is deleted A user deleted after the job was queued produces no file and does not blow up the queue
Export recipes over HTTP Signed-in Alice sees the export button on the recipe list; POSTs export; the job is enqueued; the pending notice is rendered via Turbo Stream
Download the export file After the job has run, Alice GETs download and receives the export file
Export recipes in the browser Signed-in Alice clicks Export recipes on the list; Download appears in the same tab; Export file is saved to tmp folder
Guest cannot export or download The recipe list has no export button, and no Download link. Guests can neither enqueue or download

Queue the email instead of sending it immediately #

In production applications, every millisecond counts for the response time. Every email delivered on the request thread is a millisecond lost and the time the user has to wait for the next page to load.

deliver_now delivers the email immediately on the request thread, which means the user has to wait for the email to be delivered before the next page can load. That’s why you use deliver_later and there are two reasons why you want the recipe share email to switch to async mode (background via active job):

  1. The first and main reason is to avoid the delay in the request thread. Background jobs are better at handling slow or retryable operations like email delivery, file attachments, and image processing.
  2. The other reason is that it gives you your first taste of async behavior in Rails. It’s the easiest way to see how background jobs work before you start writing custom ones by hand.

What counts as working? #

Flow You might say
Share a recipe by email Signed-in alice POSTs share; RecipeMailer#share is enqueued instead of being delivered immediately; redirect lands on show

Update the share scenarios #

Open test/integration/recipes_integration_test.rb and update the two share scenarios so the expected outcome is enqueue instead of a delivered email.

# test/integration/recipes_integration_test.rb
  # Actor: signed-in alice
  # Starting point: recipes(:pancakes) on show
  # Action: GET show, modal with share form present, POST friend email while signed in
  # Expected outcome: RecipeMailer#share enqueued with alice as sender; redirect to show
  # test "shares a recipe" do
  # end

  # Actor: guest (not signed in)
  # Starting point: recipes(:pancakes) on show
  # Action: POST friend email
  # Expected outcome: RecipeMailer#share enqueued with nil sender; redirect to show
  # test "guest shares a recipe" do
  # end

Red: update the share integration tests #

Chapter 14 wrapped each POST in assert_emails because the controller called deliver_now. Now you need to use deliver_later for enqueuing the email so you will need to use assertions related to the background job instead.

Replace the test bodies of the two share scenarios with the following:

# test/integration/recipes_integration_test.rb

  test "shares a recipe" do
    sender = users(:alice)
    recipe = recipes(:pancakes)

    sign_in_as sender

    get recipe_url(recipe)
    assert_response :success
    assert_select "dialog"
    assert_select "input[name='recipe[recipient_email]']"

    assert_enqueued_email_with RecipeMailer,
                               :share,
                               args: [
                                 recipe,
                                 "friend@example.com",
                                 sender.email_address
                               ] do
      post share_recipe_url(recipe),
           params: {
             recipe: {
               recipient_email: "friend@example.com"
             }
           }
    end

    assert_redirected_to recipe_url(recipe)
    follow_redirect!
    assert_response :success
    assert_match recipe.title, response.body
  end

  test "guest shares a recipe" do
    recipe = recipes(:pancakes)

    assert_enqueued_email_with RecipeMailer,
                               :share,
                               args: [recipe, "friend@example.com", nil] do
      post share_recipe_url(recipe),
           params: {
             recipe: {
               recipient_email: "friend@example.com"
             }
           }
    end

    assert_redirected_to recipe_url(recipe)
    follow_redirect!
    assert_response :success
    assert_match recipe.title, response.body
  end

This is what’s happening in the code above:

  • For test "shares a recipe", you also declare new sender variable that is reused for signing in the user and passing the email address to the mailer.
  • All the other assertions and test body is the same as in Chapter 14. Only the delivery assertion changes (assert_emails → assert_enqueued_email_with).
  • assert_enqueued_email_with asks whether RecipeMailer#share was scheduled inside the block with the correct arguments.

Run the tests:

bin/rails test test/integration/recipes_integration_test.rb -i "/shares_a_recipe/"

You should see failures like this:

F
Failure:
RecipesIntegrationTest#test_shares_a_recipe [test/integration/recipes_integration_test.rb:286]:
No enqueued job found with {job: ActionMailer::MailDeliveryJob, args: #<Proc:0x000000010a00c000>, queue: "default"}

No jobs were enqueued

F
Failure:
RecipesIntegrationTest#test_guest_shares_a_recipe [test/integration/recipes_integration_test.rb:306]:
No enqueued job found with {job: ActionMailer::MailDeliveryJob, args: #<Proc:0x000000010a00c000>, queue: "default"}

No jobs were enqueued

That is red for both tests. The failure for both tests is the same: the mail went out during the request because of deliver_now in the controller while it expects the controller to use deliver_later.

Green: queue email with deliver_later #

Update the share action inside app/controllers/recipes_controller.rb to use deliver_later so the email is enqueued instead of being delivered immediately. Replace the existing code with the following:

# app/controllers/recipes_controller.rb

  def share
    recipient = params[:recipe][:recipient_email]
    sender = current_user&.email_address

    RecipeMailer.share(@recipe, recipient, sender).deliver_later

    redirect_to @recipe, notice: "Recipe shared with #{recipient}."
  end

Only the line RecipeMailer.share(@recipe, recipient, sender) is different from the old code. deliver_now is replaced with deliver_later which returns the response immediately by handing off the email delivery to Active Job.

Run the tests again, you want 0 failures and 0 errors:

bin/rails test test/integration/recipes_integration_test.rb -i "/shares_a_recipe/"

Export records to a file #

You just tested async behavior in Rails without writing a job yourself; that’s because Rails ships the mailer queue for you. It’s now a perfect time to introduce custom background jobs in the app.

But before you work with background jobs, you will need to see why you need to use them in the first place. For that, you will first export every recipe in the app to a text file during the normal request (synchronously instead of using background jobs). Then slow down the export operation even more by seeding thousands of recipes in development, click Export recipes, and wait while the server completes that export.

Once you have seen the slowness, you will extract that operation into a background job so the controller hands off the work to the job and returns a response immediately. For the file download (after implementing the background job), you will notify the user with a download link via a Turbo Stream broadcast from inside the background job.

Export during the request #

You will use TDD and implement the recipes export to a file on the request thread first. Once the feature is green, you will seed thousands of rows in development and export recipes to see what a slow export looks like and the need of a background job in production applications.

What counts as working? #

Flow You might say
Signed-in user exports recipes to a file Signed in as alice, the recipe list has Export recipes. A POST returns a text file of every recipe in the app, including Fluffy pancakes, Lentil soup, and Flour.
Guest cannot export recipes No Export recipes button. A POST redirects to sign in.

Add scenarios to the test file #

Open test/integration/recipes_integration_test.rb and append the scenario for the recipe export to the end of the file:

# test/integration/recipes_integration_test.rb
class RecipesIntegrationTest < ActionDispatch::IntegrationTest
  # ... existing tests ...

  # Actor: signed-in alice
  # Starting point: recipe list
  # Action: GET index, then POST export
  # Expected outcome: Export recipes on the list; response is text file with recipes, ingredients, and steps of all users in the app
  # test "exports recipes" do
  # end
end

Open test/integration/recipe_access_integration_test.rb and append the guest restriction for the export feature:

# test/integration/recipe_access_integration_test.rb
class RecipeAccessIntegrationTest < ActionDispatch::IntegrationTest
  # ... existing tests ...

  # Actor: guest
  # Starting point: recipe list
  # Action: GET index, then POST export
  # Expected outcome: no Export button; redirect to sign in
  # test "guest cannot export recipes" do
  # end
end

Red: export during the request #

Replace the test body of the test "exports recipes" scenario in recipes_integration_test.rb with the following:

# test/integration/recipes_integration_test.rb
class RecipesIntegrationTest < ActionDispatch::IntegrationTest
  # ... existing tests ...

  test "exports recipes" do
    sign_in_as users(:alice)

    get recipes_url
    assert_response :success
    assert_select "button", text: "Export recipes"

    post export_recipes_url
    assert_response :success
    assert_equal "text/plain", response.media_type
    assert_includes response.headers["Content-Disposition"], "attachment"
    assert_match(
      /recipes-export-\d{14}\.txt/,
      response.headers["Content-Disposition"]
    )
    assert_includes response.body, recipes(:pancakes).title
    assert_includes response.body, recipes(:lentil_soup).title
    assert_includes response.body, "Flour"
  end
end

Also replace the test body of the test "guest cannot export recipes" scenario in recipe_access_integration_test.rb with the following:

# test/integration/recipe_access_integration_test.rb
class RecipeAccessIntegrationTest < ActionDispatch::IntegrationTest
  # ... existing tests ...

  test "guest cannot export recipes" do
    get recipes_url
    assert_response :success
    assert_select "button", text: "Export recipes", count: 0

    post export_recipes_url
    assert_redirected_to new_session_url
  end
end

This is what’s happening in the code above:

  • In test "exports recipes"
    • It starts by signing in as Alice and then GETs the list of recipes and asserts that export button is present.
    • It then POSTs to export the recipes and asserts against the response to ensure it is a text file with the expected content.
  • In test "guest cannot export recipes"
    • It GETs the list of recipes and asserts that export button is not present for guests.
    • It then POSTs to export the recipes and ensures the restriction is in place by asserting a redirect to the sign in page.

Run the signed-in example first:

bin/rails test test/integration/recipes_integration_test.rb -i test_exports_recipes

You want a failure:

F

Failure:
RecipesIntegrationTest#test_exports_recipes [test/integration/recipes_integration_test.rb:333]:
Expected: "Export recipes"
  Actual: "Sign out".
Expected 0 to be >= 1.

That is red for test "exports recipes". The failure is because the export button is not present on the page for signed-in users.

Then run the guest restriction test:

bin/rails test test/integration/recipe_access_integration_test.rb -i test_guest_cannot_export_recipes

You should see an error:

E

Error:
RecipeAccessIntegrationTest#test_guest_cannot_export_recipes:
NameError: undefined local variable or method 'export_recipes_url' for an instance of RecipeAccessIntegrationTest

That is red for test "guest cannot export recipes". The error is due to missing route for exporting recipes.

Green: routes, button, and controller action #

Next, you will implement the export feature and also hide the export button for guests so the test goes green.

Open config/routes.rb and add a collection route for exporting recipes inside the resources :recipes block just below the resources :steps:

# config/routes.rb
  resources :recipes do
    # ... existing routes ...
    resources :steps, only: :destroy

    collection do
      post :export
    end

    # ... existing routes ...
  end

Update the recipe list view to include the export button so the user can export all recipes in the app. Append the following code just after the <h1>Recipes</h1> tag:

<%# app/views/recipes/index.html.erb %>
<%# ... existing code ... %>

<h1>Recipes</h1>

<% if authenticated? %>
  <%= button_to "Export recipes", export_recipes_path, method: :post, data: { turbo: false } %>
<% end %>

<%# ... existing code ... %>

Add the export action for exporting all recipes in the app to a text file to app/controllers/recipes_controller.rb:

# app/controllers/recipes_controller.rb
class RecipesController < ApplicationController
  # ... existing code ...

  def share
    # ... existing code ...
  end

  def export
    recipes = Recipe.includes(:ingredients, :steps).order(:id)
    recipes_text = recipes.map { |recipe| render_recipe(recipe) }.join("\n\n")

    send_data recipes_text,
              filename:
                "recipes-export-#{Time.current.utc.strftime("%Y%m%d%H%M%S")}.txt",
              type: "text/plain",
              disposition: :attachment
  end
end

render_recipe method used inside the export action hasn’t been added yet. It’s responsible for formatting each recipe as a string to put inside the exported file. Add the new method inside the private section of the controller:

# app/controllers/recipes_controller.rb
class RecipesController < ApplicationController
  # ... existing code ...

  private
  # ... existing code ...

  def recipe_params
    # ... existing code ...
  end

  def render_recipe(recipe)
    lines = [recipe.title, "", "Ingredients"]

    recipe.ingredients.each do |ingredient|
      amount = [ingredient.quantity, ingredient.unit].compact.join(" ")

      lines << "- #{ingredient.name} (#{amount})"
    end

    lines << ""
    lines << "Steps"

    recipe
      .steps
      .sort_by(&:position)
      .each { |step| lines << "#{step.position}. #{step.instruction}" }
    lines.join("\n")
  end
end

This is what’s happening in the code above:

  • export action fetches every recipe in the app, orders them by ID, and maps each recipe to a string with its title and ingredients and steps. The export action is only available for signed-in users as allow_unauthenticated_access is not set for this action.
  • send_data sends the text file to the browser for download. The filename is recipes-export- plus UTC YYYYMMDDHHMMSS plus .txt, so a second export does not overwrite the first one. For e.g. recipes-export-20260915114523.txt.
  • render_recipe formats each recipe as a string with its title, ingredients and steps so the text file has all the information for each recipe.

Finally, run the tests for the export feature, you want 0 failures and 0 errors:

bin/rails test test/integration/recipes_integration_test.rb -i test_exports_recipes
bin/rails test test/integration/recipe_access_integration_test.rb -i test_guest_cannot_export_recipes

You just wired up the export feature that runs synchronously in the request thread, time to see it in action:

  1. Run bin/dev if it is not already running, then open /recipes while signed in as alice.
  2. Confirm Export recipes is on the page.
  3. Click Export recipes. The server should process the request and return a text file with the exported recipes, it should be kinda instant since there are only 2 recipes in the database right now.
  4. When the file is ready, your browser should prompt you to save it under a name like recipes-export-20260915114523.txt. The 14 digits are UTC date and time down to the second to make the filename unique.

Seed thousands of records #

Even though the export is running in the request thread, it’s still fast enough that you can’t really see the hang. That’s because there are only 2 recipes in the database right now. Our goal is to see the hang so we can move the work to a background job. So, to replicate the hang, you will seed thousands of extra recipes in development and see the same request hang for a bit longer before the file is ready for the download.

Create a rake task lib/tasks/recipes.rake with the following code:

# lib/tasks/recipes.rake
namespace :recipes do
  desc "Insert COUNT extra recipes for testing export feature (development only)"
  task seed: :environment do
    abort "Run this in development only." unless Rails.env.development?

    count = Integer(ENV.fetch("COUNT", "200_000"))
    user = User.find_by!(email_address: "alice@example.com")
    now = Time.current

    rows =
      count.times.map do |i|
        {
          user_id: user.id,
          title: "Seeded recipe #{i + 1}",
          created_at: now,
          updated_at: now
        }
      end

    Recipe.insert_all(rows)
    puts "Inserted #{count} recipes for #{user.email_address}. Recipe catalog size: #{Recipe.count}"
  end
end

What’s happening in the code?

  • insert_all writes rows in bulk. You are not using Recipe.create! in a loop because it would take much longer. Not something required for this rake task that only adds sample records.
  • Titles use a Seeded recipe prefix so you can search and delete them later.
  • The task aborts outside development so you do not seed this into production by accident.

Run the rake task to seed the extra recipes:

bin/rails recipes:seed

If you want to see even more records, you can pass a COUNT variable to the task:

COUNT=500_000 bin/rails recipes:seed

Now, click through the export feature again to see the lag:

  1. Run bin/dev if it is not already running, then open /recipes while signed in as alice.
  2. Confirm Export recipes is on the page.
  3. Click Export recipes. The browser will show “loading” until the file has been downloaded (~4-6 seconds) and you cannot usefully browse the rest of the app while that request is open.
  4. When it finishes, the browser should prompt you to save a file named like recipes-export-20260915114523.txt.

In a production app, where there can be millions of records, the hang will be much more noticeable. That’s why production apps move this type of operations to a background job which you will implement in the next section.

Lastly, reload the fixture records to remove the extra recipes so you don’t have to sit ducks if you mistakenly reload the page:

RAILS_ENV=development bin/rails db:fixtures:load

Background job #

You just saw the lag in the export feature when serving it directly inside the request thread. It’s time to move the work to a background job so the response is instant while the file is built and served later from the background.

Install Active Storage #

Rails can store file uploads out of the box via Active Storage, but the Cookbook app has never needed the setup for it until now. Later, the export job will attach one file on User, so you will need to set up Active Storage before you write tests for the job. Otherwise, you will get errors regarding the missing file attachment instead of a real error for the broken test.

Install and migrate the Active Storage tables with the following commands:

bin/rails active_storage:install
bin/rails db:migrate

This is what’s happening in the commands above:

  • active_storage:install adds a migration for the blob, attachment, and variant tables Rails uses to remember files.
  • db:migrate creates those tables in the development database while the test database picks them up the next time you run tests.
  • config/environments/test.rb already points Active Storage at the :test service which stores files in a tmp folder. You do not need configurations for S3 or other cloud storage services for this chapter as we won’t be using this app in production.

Add the has_one_attached just below the has_many :recipes in app/models/user.rb so the recipe export file can be attached to a particular user record:

# app/models/user.rb
class User < ApplicationRecord
  # ... existing code ...
  has_many :recipes, dependent: :destroy

  has_one_attached :recipes_export
  # ... existing code ...
end

What’s happening in the code?

  • has_one_attached :recipes_export ensures each user only has one export file attached to them. If a user exports recipes again, the new file replaces the old one.

What counts as working? #

Flow You might say
Exports all recipes in the app When perform_now runs with Alice’s id, one export file is attached to their user with all recipes in the app.
Export is discarded if the user is deleted A user deleted after the job is queued; no export file is created; the job does not raise an error.

Add scenarios to the job test file #

Create test/jobs/export_recipes_job_test.rb and add the following scenarios:

# test/jobs/export_recipes_job_test.rb
require "test_helper"

class ExportRecipesJobTest < ActiveJob::TestCase
  # Actor: job runner
  # Starting point: alice has no export file; the app has alice's pancakes and lentil soup, plus bob's pizza
  # Action: perform_now with alice's id
  # Expected outcome: one export file attached on alice; downloaded text includes all three titles, Flour, and Cheese
  # test "exports all recipes in the app" do
  # end

  # Actor: job runner
  # Starting point: bob exists
  # Action: destroy bob, then perform_now with the old id
  # Expected outcome: no export file; the job does not blow up the queue
  # test "discards the export when the user is deleted" do
  # end
end

Before you add tests for the background job, you need to also add a recipe that belongs to Bob. Every example so far has run against Alice’s two recipes which has worked out for us so far. But for the recipe export, background job has to export all recipes in the app, so the test data has to contain at least one recipe Alice does not own to ensure export works as expected.

Update fixtures for recipes (test/fixtures/recipes.yml), ingredients (test/fixtures/ingredients.yml), and steps (test/fixtures/steps.yml) to include related records for Bob’s recipe:

# test/fixtures/recipes.yml
# ... existing fixtures ...

pizza:
  title: Pizza
  description: A delicious pizza with cheese and tomato sauce
  prep_time: 60
  servings: 2
  user: bob
# test/fixtures/ingredients.yml
# ... existing fixtures ...

cheese:
  recipe: pizza
  name: Cheese
  quantity: 1
  unit: cup

tomato:
  recipe: pizza
  name: Tomato
  quantity: 10
  unit: slices
# test/fixtures/steps.yml
# ... existing fixtures ...

knead_dough:
  recipe: pizza
  position: 1
  instruction: Knead the dough for 10 minutes.

bake_pizza:
  recipe: pizza
  position: 2
  instruction: Bake the pizza in the oven for 15 minutes at 400 degrees Fahrenheit.

Bob’s recipe breaks an older test #

Run the full test suite now and you will get 1 failure:

bin/rails test
F

Failure:
RecipeAccessIntegrationTest#test_non-owner_does_not_see_change_buttons [test/integration/recipe_access_integration_test.rb:189]:
Expected exactly 0 elements matching "button", found 1.
Expected: 0
  Actual: 1

This is the test that’s failing:

# test/integration/recipe_access_integration_test.rb
  test "non-owner does not see change buttons" do
    sign_in_as users(:bob)

    get recipes_url
    assert_response :success
    assert_select "button", text: "Destroy this recipe", count: 0

    get recipe_url(recipes(:pancakes))
    assert_response :success
    assert_match recipes(:pancakes).title, response.body
    assert_select "a", text: "Edit this recipe", count: 0
    assert_select "button", text: "Destroy this recipe", count: 0
    assert_select "button", text: "Remove", count: 0
  end

The test fails at assert_select "button", text: "Destroy this recipe", count: 0. This is because, previously, Bob had no recipes so he saw no Destroy this recipe button anywhere on the recipe list. Now Bob sees one on his own pizza, and the assertion fails.

That test was right about the behavior but it was too broad about where it looked. Point it at Alice’s recipe instead of looking at recipes in the whole page.

Open test/integration/recipe_access_integration_test.rb and update the list assertion inside test "non-owner does not see change buttons":

# test/integration/recipe_access_integration_test.rb
  test "non-owner does not see change buttons" do
    # ... existing code ...

    assert_select "#recipe_#{recipes(:pancakes).id} button", text: "Destroy this recipe", count: 0

    # ... existing code ...
  end

recipe_<id> is the wrapper id that _recipe.html.erb renders with dom_id, so the selector now checks only one recipe of Alice instead of the whole list. That asks the question the test always meant to ask, which is whether Bob can destroy Alice’s recipe.

This is the first time you are seeing a test you previously wrote failing. This is why tests are important, it didn’t necessarily catch a regression in the app but it still failed because you were not specific enough about what you were testing.

Red: exports all recipes in the app #

Replace the test block for test "exports all recipes in the app" in test/jobs/export_recipes_job_test.rb with the following code:

# test/jobs/export_recipes_job_test.rb

  test "exports all recipes in the app" do
    user = users(:alice)

    assert_not user.recipes_export.attached?

    ExportRecipesJob.perform_now(user.id)

    user.reload
    assert user.recipes_export.attached?
    assert_match(
      /\Arecipes-export-\d{14}\.txt\z/,
      user.recipes_export.filename.to_s
    )
    contents = user.recipes_export.download
    assert_includes contents, "Fluffy pancakes"
    assert_includes contents, "Lentil soup"
    assert_includes contents, "Pizza"
    assert_includes contents, "Flour"
    assert_includes contents, "Cheese"
  end

This is what’s happening in the code above:

  • perform_now runs the background job immediately so you can assert on the result.
  • assert_not user.recipes_export.attached? ensures Alice has no export file before the job runs.
  • assert user.recipes_export.attached? ensures Alice has an export file after the job runs. You run user.reload to pick up the change in the user record as the user variable was declared before the job ran.
  • assert_match ensures the export file has a valid filename.
  • contents = user.recipes_export.download reads the file’s bytes into a string.
  • assert_includes ensures the file includes all recipes in the app along with their ingredients and steps.

Run the test for background job:

bin/rails test test/jobs/export_recipes_job_test.rb -i test_exports_all_recipes_in_the_app

You should see an error like this:

E

Error:
ExportRecipesJobTest#test_exports_all_recipes_in_the_app:
NameError: uninitialized constant ExportRecipesJobTest::ExportRecipesJob
    test/jobs/export_recipes_job_test.rb:10:in 'block in <class:ExportRecipesJobTest>'

That is red for the export job. The error is due to the missing background job class.

Green: generate the export job #

To fix the error, generate the export job and then add the required code inside the job file. Make sure to type “n” when the generator asks to overwrite the test file you added in the last section:

bin/rails generate job ExportRecipes

This creates app/jobs/export_recipes_job.rb with an empty perform method.

Now replace the generated file with the following code:

# app/jobs/export_recipes_job.rb
class ExportRecipesJob < ApplicationJob
  queue_as :default

  def perform(user_id)
    user = User.find(user_id)
    recipes = Recipe.includes(:ingredients, :steps).order(:id)
    recipes_text = recipes.map { |recipe| render_recipe(recipe) }.join("\n\n")

    user.recipes_export.attach(
      io: StringIO.new(recipes_text),
      filename:
        "recipes-export-#{Time.current.utc.strftime("%Y%m%d%H%M%S")}.txt",
      content_type: "text/plain"
    )
  end

  private

  def render_recipe(recipe)
    lines = [recipe.title, "", "Ingredients"]
    recipe.ingredients.each do |ingredient|
      amount = [ingredient.quantity, ingredient.unit].compact.join(" ")
      lines << "- #{ingredient.name} (#{amount})"
    end
    lines << ""
    lines << "Steps"
    recipe
      .steps
      .sort_by(&:position)
      .each { |step| lines << "#{step.position}. #{step.instruction}" }
    lines.join("\n")
  end
end

This is what’s happening in the code above:

  • render_recipe is the same private method you wrote in app/controllers/recipes_controller.rb, and the recipes_text is the same as well from the controller. The job builds the text content exactly the way the controller did because the operation has not changed, only where it runs has changed.
  • Recipe.includes(:ingredients, :steps).order(:id) avoids N+1 queries. It runs one query per association, three in total here each for recipe, ingredients, and steps, and hands render_recipe records that are already in memory so recipe.ingredients and recipe.steps do not go back to the database again.
  • sort_by(&:position) is used instead of order(:position) because order builds a fresh relation and fires its own query (reaches for the database); exactly what we wanted to avoid with .includes.
  • user.recipes_export.attach attaches the file to the user. attach wants something file-like rather than a plain string, so StringIO hands it the text as if it were an open file.

Run the test for the export job again, you want 0 failures and 0 errors:

bin/rails test test/jobs/export_recipes_job_test.rb -i test_exports_all_recipes_in_the_app

Red: discard the job when the user is deleted #

What happens if a user is deleted after the job is enqueued? This is not normal for our edge case where job runs almost immediately even with perform_later but can be very useful in production when there are background jobs that can be scheduled to run tomorrow or in a week.

Right now, because you are looking up user via their ID, the job will explode as the find method will raise a ActiveRecord::RecordNotFound error.

Update the test for "discards the export when the user is deleted" at test/jobs/export_recipes_job_test.rb with the following code:

# test/jobs/export_recipes_job_test.rb

  test "discards the export when the user is deleted" do
    user_id = users(:bob).id
    users(:bob).destroy!

    assert_no_difference "ActiveStorage::Blob.count" do
      ExportRecipesJob.perform_now(user_id)
    end
  end

This is what’s happening in the code above:

  • First you destroy the user record for Bob and then perform the job with the old ID. This should throw an error and discard the job.
  • assert_no_difference "ActiveStorage::Blob.count" ensures no export file was created since the job was discarded and didn’t execute perform method.

Run the test for "discards the export when the user is deleted":

bin/rails test test/jobs/export_recipes_job_test.rb -i test_discards_the_export_when_the_user_is_deleted

You should see an error:

E

Error:
ExportRecipesJobTest#test_discards_the_export_when_the_user_is_deleted:
ActiveRecord::RecordNotFound: Couldn't find User with 'id'=902541635
    app/jobs/export_recipes_job.rb:6:in 'ExportRecipesJob#perform'
    test/jobs/export_recipes_job_test.rb:31:in 'block (2 levels) in <class:ExportRecipesJobTest>'
    test/jobs/export_recipes_job_test.rb:30:in 'block in <class:ExportRecipesJobTest>'

That’s a red for the discarded export job. The error is due to the missing discard_on line in the job file.

Green: add discard_on to the job #

Add discard_on ... just below the queue_as line:

# app/jobs/export_recipes_job.rb
class ExportRecipesJob < ApplicationJob
  queue_as :default
  discard_on ActiveRecord::RecordNotFound

  # ... perform and the private methods ...
end

This is what’s happening in the code above:

  • Without discard_on, the active_job adapter retries or holds off the job. A user who no longer exists can never succeed no matter how many times it runs, so retrying is just noise.
  • discard_on tells Active Job to drop this job if the perform raises an ActiveRecord::RecordNotFound error.

Run the full job test file, you want 0 failures and 0 errors:

bin/rails test test/jobs/export_recipes_job_test.rb

Integration tests #

You just proved your background job for the export recipes works as expected. Now you need to prove that the export recipes feature works as a whole from request to the background job to the final response. Here is how the export feature should work end to end:

  • Signed-in user should see a button to export recipes in the recipes list page.
  • When the user clicks the button, the export recipes job should be enqueued.
  • The user sees a notice that the export is being prepared.
  • When the export is ready, a link to download the export file should appear in that open tab.
  • When the user clicks the link, the export file should be downloaded.
  • The exported file should be in text format and should contain all recipes with associated records (ingredients and steps) in the database.

Most of the feature is already wired up when you exported recipes during the request. You will now need to update the feature to also use the background job and turbo streams.

What counts as working? #

Flow You might say
Export all recipes to a file Signed in as alice, the recipe list has Export recipes button. No attached export file yet. Alice clicks the button to export all recipes to a file. The export recipes job is enqueued and a notice that the export is being prepared is rendered.
Download the export file Alice clicks the link to download the export file. The export file is downloaded and the user sees the text that includes every title and Flour.
Guest cannot export recipes Guest cannot see the Export recipes button and cannot export recipes.

Delete the existing test for export feature #

Before adding any tests, you need to remove the existing test for exports recipes in test/integration/recipes_integration_test.rb because this test will change significantly to support the background job.

Previously the export action processed the file and downloaded it in the same request. With the background job, export only enqueues the job and renders a notice that the export is being prepared so Alice still stays on the list. The file download will be handled by a new controller action download once the job processes the file and attaches it to the user.

Remove the test block for test "exports recipes" in test/integration/recipes_integration_test.rb:

# test/integration/recipes_integration_test.rb
  # ... existing tests ...

  test "exports recipes" do
    # ... existing code ...
  end

Add scenarios to the test file #

Open test/integration/recipes_integration_test.rb and append following scenarios to the end of the file:

# test/integration/recipes_integration_test.rb
  # Actor: signed-in alice
  # Starting point: recipe list, no export file
  # Action: GET index, then POST export
  # Expected outcome: export button exists; ExportRecipesJob enqueued; pending notice is rendered
  # test "exports recipes" do
  # end

  # Actor: signed-in alice
  # Starting point: alice after the job exported recipes to a file
  # Action: GET download
  # Expected outcome: response includes a file with all recipes with associated records in the database
  # test "downloads the export file" do
  # end

For the guest restriction (guests cannot export recipes), open test/integration/recipe_access_integration_test.rb and update the scenario with the following, you can leave the test as it is for now, you will update it shortly:

# test/integration/recipe_access_integration_test.rb
  # Actor: guest
  # Starting point: recipe list
  # Action: GET index, POST export, GET download
  # Expected outcome: no export button; no export job enqueued; download denied
  # test "guest cannot export recipes" do
  # end

Red: update the export integration tests #

Replace the test blocks for test "exports recipes" and test "downloads the export file" in test/integration/recipes_integration_test.rb with the following:

# test/integration/recipes_integration_test.rb
class RecipesIntegrationTest < ActionDispatch::IntegrationTest
  # ... existing tests ...

  test "exports recipes" do
    sign_in_as users(:alice)

    get recipes_url
    assert_response :success
    assert_select "button", text: "Export recipes"
    assert_select "a", text: "Download", count: 0

    assert_enqueued_with(job: ExportRecipesJob, args: [users(:alice).id]) do
      post export_recipes_url
    end

    assert_response :success
    assert_equal "Your recipes are being exported.", flash.notice
  end

  test "downloads the export file" do
    sign_in_as users(:alice)
    user = users(:alice)

    ExportRecipesJob.perform_now(user.id)

    get download_recipes_url
    assert_response :success
    assert_equal "text/plain", response.media_type
    assert_includes response.headers["Content-Disposition"], "attachment"
    assert_match(
      /recipes-export-\d{14}\.txt/,
      response.headers["Content-Disposition"]
    )
    assert_includes response.body, recipes(:pancakes).title
    assert_includes response.body, recipes(:lentil_soup).title
    assert_includes response.body, "Flour"
  end
end

This is what’s happening in the code above:

  1. test "exports recipes":

    • It first signs in the user to the app then asserts the recipe list has button to export all recipes.
    • assert_enqueued_with wraps the POST action for the export so the recipe export job is enqueued by the recipes controller.
    • assert_select "a", text: "Download", count: 0 ensures the download button is not visible before actually exporting the recipes.
    • At the end, it asserts a successful Turbo Stream response and that the pending notice landed in flash.notice.
  2. test "downloads the export file":

    • After signing in the user, the test immediately executes the export recipes job using perform_now to ensure the export file is attached to the user.
    • Then it GETs the download export URL to download the export file and asserts the response body includes content of recipes and it’s associated records (ingredients and steps).

Replace the test block for test "guest cannot export recipes" with the following:

# test/integration/recipe_access_integration_test.rb
class RecipeAccessIntegrationTest < ActionDispatch::IntegrationTest
  # ... existing tests ...

  test "guest cannot export recipes" do
    get recipes_url
    assert_response :success
    assert_select "button", text: "Export recipes", count: 0
    assert_select "a", text: "Download", count: 0

    assert_no_enqueued_jobs only: ExportRecipesJob do
      post export_recipes_url
    end
    assert_redirected_to new_session_url

    get download_recipes_url
    assert_redirected_to new_session_url
  end
end

This is what’s happening in the code above:

  • The test GETs the recipe list and asserts the recipe list does not have button to export all recipes and does not have link to download the export file.
  • It asserts that no export recipes job is enqueued by the recipes controller.
  • Finally, it asserts the user is redirected to the new session page because guests are not allowed to export recipes.

Run the tests for "exports recipes" and "downloads the export file":

bin/rails test test/integration/recipes_integration_test.rb -i "/test_exports_recipes|test_downloads_the_export_file/"

You should see a failure and an error:

F

Failure:
RecipesIntegrationTest#test_exports_recipes [test/integration/recipes_integration_test.rb:336]:
No enqueued job found with {job: ExportRecipesJob, args: [663665735]}

No jobs were enqueued

E

Error:
RecipesIntegrationTest#test_downloads_the_export_file:
NameError: undefined local variable or method 'download_recipes_url' for an instance of RecipesIntegrationTest
    test/integration/recipes_integration_test.rb:351:in 'block in <class:RecipesIntegrationTest>'

That’s the red for the export recipes test. The failure is because you have not updated the controller to use the background job. And the error is due to the missing route for downloading the export file.

Then run the guest restriction test:

bin/rails test test/integration/recipe_access_integration_test.rb -i test_guest_cannot_export_recipes

You want one error here, on the route:

E

Error:
RecipeAccessIntegrationTest#test_guest_cannot_export_recipes:
NameError: undefined local variable or method 'download_recipes_url' for an instance of RecipeAccessIntegrationTest

The guest restriction itself is already in place, because only actions listed in allow_unauthenticated_access are open to guests and export is not one of them. The missing download route is what’s throwing an error.

Green: routes and controller #

Time to wire up the code so tests start passing.

For the export feature, you already have added post :export on the collection when implementing it for the request thread. You now need to add get :download in that same collection block so users can download the export file.

Open config/routes.rb and add the new route just below post :export:

# config/routes.rb
  resources :recipes do
    # ... existing routes ...

    collection do
      post :export
      get :download
    end

    # ... existing routes ...
  end

If you look at the app/controllers/recipes_controller.rb file, you will see the export action is already defined, it was done when you implemented the export feature for the request thread. Previously it was sending the export file to the user but you need to use background job now, replace the export action with the following:

# app/controllers/recipes_controller.rb

  def export
    ExportRecipesJob.perform_later(current_user.id)

    flash.now[:notice] = "Your recipes are being exported."

    render turbo_stream: turbo_stream.update("flash", partial: "shared/flash")
  end

turbo_stream.update("flash", partial: "shared/flash") is used to update the flash partial in the #flash container with the new pending notice. You will create this new partial in a bit.

You might be wondering, what’s the point of staying on the page instead of redirecting? In a moment you will subscribe the list page with turbo_stream_from so the job can broadcast download link. A redirect_to would navigate away, tear that subscription down, and race the job’s broadcast_replace_to against the re-subscription on the new page. Often the download link broadcasts before the subscription is back, so Alice never sees it. Updating #flash in place keeps the Cable subscription alive for the whole wait.

Create the partial for flash messages at shared/flash and add the following content:

<%# app/views/shared/_flash.html.erb %>
<%= tag.div(flash[:alert], style: "color: red") if flash[:alert] %>
<%= tag.div(flash[:notice], style: "color: green") if flash[:notice] %>

There is nothing new in the code above, it’s extracted as it is from the layouts/application.html.erb file which you need to update next to render the flash partial:

<%# app/views/layouts/application.html.erb %>
  <%# ... existing code ... %>

  <body>
    <%# ... existing code ... %>

    <div id="flash">
      <%= render "shared/flash" %>
    </div>

    <%= yield %>
  </body>

Notice the flash partial now replaces the flash related code in the layout.

id="flash" is the Turbo Stream target, you need this outer wrapper so turbo_stream.update("flash", ...) can use it to render the flash partial from the controller.

The export action no longer sends anything back as a file download, so something else has to hand the finished file over. That is the download action, and it does not exist yet. Add it right below export:

# app/controllers/recipes_controller.rb

  def download
    send_data current_user.recipes_export.download,
              filename: current_user.recipes_export.filename.to_s,
              type: current_user.recipes_export.content_type || "text/plain",
              disposition: :attachment
  end

This is what’s happening in the code above:

  1. export action:

    • You replace the immediate export processing with a background job ExportRecipesJob.perform_later(current_user.id) so the user can get an immediate response instead of waiting for the file to process and download.
    • flash.now[:notice] sets the pending message for this same request, then turbo_stream.update("flash", partial: "shared/flash") renders it into a container with id flash without leaving the list page.
  2. download action:

    • download is another collection route, so it defines download_recipes_path for fetching the export file. Same idea as the export route.
    • download reads the attachment back off the user and sends it with the same send_data you used for the export feature in the request thread. The filename and content type come from the attachment now instead of being built on the spot, because the job already decides on those.

Make sure to also remove the render_recipe method from the app/controllers/recipes_controller.rb file as that code has already been extracted to the job:

# app/controllers/recipes_controller.rb
def render_recipe(recipe)
  # ... existing code ...
end

With the updated controller, users can now export recipes to a file but they still need a way to download the exported content. You will wire up the code to do that next.

Replace the button_to "Export recipes" you previously added in app/views/recipes/index.html.erb for the export during the request with the following:

<%# app/views/recipes/index.html.erb %>
<% if authenticated? %>
  <%= turbo_stream_from current_user, :recipes_export %>
  <%= render "recipes/export", user: current_user %>
<% end %>

Two changes:

  • turbo_stream_from enables the real time broadcast via Action Cable (websocket connection). This is what lets the background job update the export container (_export partial) with the download link. The job broadcasts to the same pair of values: current_user and :recipes_export, so the names on this line and the ones you pass to broadcast_replace_to have to match exactly.
  • The inline button moves into a partial. The job needs a container it can replace by ID, and a bare button_to gives it nothing to aim at. render "recipes/export" draws that container so the job can replace it with the download link.

The partial for the export app/views/recipes/_export.html.erb doesn’t exist yet, so create it and add the following content:

<%# app/views/recipes/_export.html.erb %>
<% ready ||= false %>

<div id="<%= dom_id(user, :recipes_export) %>">
  <%= button_to "Export recipes", export_recipes_path, method: :post %>

  <% if ready %>
    <div role="alert" data-controller="removable">
      <span>Your export file is ready.</span>
      <%= link_to "Download", download_recipes_path, data: { turbo: false } %>
      <button type="button" aria-label="Dismiss" data-action="removable#remove">
        <span aria-hidden="true">&times;</span>
      </button>
    </div>
  <% end %>
</div>

This is what’s happening in the code above:

  • By default, ready is false so the export button is shown and the alert with the download link is hidden. The background job will pass true while broadcasting the download link so the alert with the download link is shown.
  • dom_id(user, :recipes_export) becomes recipes_export_user_<id>, using whatever ID that user actually has. The turbo stream broadcast from the background job targets that exact id, so it has to stay the same in both this file and the job.
  • The button sits outside the if ready condition, so it survives the replacement because the user should be able to export again even after downloading the export file once.
  • data-controller="removable" and data-action="removable#remove" are Stimulus attributes that will be used to remove the alert box from the page by clicking the close icon. You will wire up the Stimulus controller required for this in the next section.

Green: Stimulus controller for dismissing the alert box #

After downloading the export file, it can be annoying for users to see the alert box lying around. Create a Stimulus controller app/javascript/controllers/removable_controller.js so the alert box can be removed from the page by clicking the close icon.

// app/javascript/controllers/removable_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  remove() {
    this.element.remove()
  }
}

What’s happening in the code?

  • this.element is whatever carries data-controller="removable". Here that is the export alert container, so the dismiss button removes the alert while leaving the Export button alone.

Green: broadcast from the background job #

The last remaining piece for the download link to appear in the browser is to broadcast the download link to the user’s browser from the background job.

Update the perform method in the app/jobs/export_recipes_job.rb file to broadcast the updated export container with the download link by using .broadcast_replace_to. Append the following code just below the user.recipes_export.attach call:

# app/jobs/export_recipes_job.rb
  def perform(user_id)
    # ... existing code ...
    user.broadcast_replace_to(
      user,
      :recipes_export,
      target: ActionView::RecordIdentifier.dom_id(user, :recipes_export),
      partial: "recipes/export",
      locals: {
        user: user,
        ready: true
      }
    )
  end

This is what’s happening in the code above:

  • .broadcast_replace_to is used to broadcast the download link to the user’s browser.
  • ready: true is the only difference between this render and the one on the recipe list view. That difference is what shows the download link in the alert box in the user’s browser.
  • dom_id(user, :recipes_export) is the HTML id of the export container, and it is what broadcast_replace_to passes as target:. That is separate from turbo_stream_from current_user, :recipes_export, which names the Cable stream the browser listens on. The stream name and the DOM target both use :recipes_export, but they are not the same thing. The stream has to match so the message arrives. The target has to match the div id so Turbo knows which node to replace.

See export feature in action #

Now that the export feature is fully working with background job and turbo updates, you can see it in action:

  • Start the rails server bin/rails server (if not already running)
  • Open the browser and Sign in as Alice
  • Go to the recipe list page
  • Click the “Export recipes” button
  • Wait for the export to finish
  • Click the “Download” link in the alert box to download the export file

Why the alert box is not persisted on page reload #

If you reload the page, you will see the alert box is gone. If you export the recipes again, the alert box also comes back. That is deliberate, and worth discussing because it is a real product tradeoff rather than a bug.

The export container renders the same way for everyone, with the ready flag to differentiate between the initial export button and the download link. This flag only becomes true in the broadcast sent by the background job. And the Download link only becomes visible after that broadcast. If you close that tab, navigate away, or reload before the job finishes, the download link will be gone forever. The file is still attached to the user and download link still exists in the server, but nothing in the interface will offer it again until Alice clicks Export once more.

Keeping it that way is what kept this chapter only about testing background jobs without introducing additional complexity. Persisting the alert means answering questions this chapter does not want to answer:

  • Is this file still fresh, or is it three weeks old?
  • Has Alice already dismissed it?
  • When does it expire?

Every one of those questions needs more tables and columns to be stored plus additional logic to handle them. The moment you want them in the app you are modelling around full export feature, instead of learning Active Job.

How production closes that gap #

The easiest way to handle this in production is to send an email to the user with the download link while broadcasting the download link to the browser. That way, if the user is currently accessing the app, they will see the download link in the browser tab. And if they are not, they will still be able to download the export file by clicking the link in the email.

Green: escape early if the export file is not ready for download #

The download action is responsible for sending the export file to the user. It should only be accessible to signed-in users and it should check if the export file is attached to the user. If it is not, it should redirect the user back to the recipe list with an alert message.

Without that check the download action does not fail in a way you would want a user to see. When nothing is attached, Active Storage hands back nil to send_data so the user will get a empty file. Guard the action so the user knows what actually happened.

Update the download action in the app/controllers/recipes_controller.rb file to return early if the export file is not attached to the user by adding unless current_user.recipes_export.attached? at the beginning of the action:

# app/controllers/recipes_controller.rb
def download
  unless current_user.recipes_export.attached?
    redirect_to recipes_path, alert: "Export file is not ready yet."
    return
  end

  send_data current_user.recipes_export.download,
    filename: current_user.recipes_export.filename.to_s,
    type: current_user.recipes_export.content_type || "text/plain",
    disposition: :attachment
end

What’s happening in the code?

  • unless current_user.recipes_export.attached? checks if the export file is attached to the user. If it is not, the action returns early and redirects the user back to the recipe list with an alert message.

Looking back, we never added a test for this edge case. You need to ensure this edge case is tested and working correctly. It’s fine not to use TDD for this since you have already added an implementation for this, just open the test/integration/recipes_integration_test.rb file and append the following test at the end of the file:

# test/integration/recipes_integration_test.rb
class RecipesIntegrationTest < ActionDispatch::IntegrationTest
  # ... existing tests ...

  # Actor: signed-in alice (browser)
  # Starting point: recipe list, no export file
  # Action: GET download_recipes_url
  # Expected outcome: redirect to recipe list with a not ready alert
  test "redirects to index page if export file is missing for the user" do
    sign_in_as users(:alice)

    get download_recipes_url

    assert_redirected_to recipes_url
    follow_redirect!
    assert_equal "Export file is not ready yet.", flash.alert
  end
end

All assertions and the test code are what you have already seen previously in this chapter. The test first signs in the user and then GETs the download export URL. It asserts the user is redirected to the recipe list with a not ready alert since the export file is not attached to the user yet.

Run the recipes integration tests along with the recipe access integration tests. You want 0 failures and 0 errors.

bin/rails test test/integration/recipes_integration_test.rb
bin/rails test test/integration/recipe_access_integration_test.rb

System tests #

Job and integration tests already proved enqueue, attach, signed-in HTTP, and the guest restrictions. One system smoke will verify if the download link appears in the same open tab and if browser saves the expected bytes.

What counts as working? #

Flow You might say
Export and download recipes Signed in as Alice. Click Export recipes. Download appears in that same tab, without a visit or reload. Browser saves the recipes export file and includes details of all recipes with ingredients and steps.

Add scenarios to the test file #

Open test/system/recipes_test.rb and append the following scenario to the end of the file:

# test/system/recipes_test.rb
  # Actor: signed-in alice (browser)
  # Starting point: recipe list, no export file
  # Action: click Export recipes, wait for Download in this tab, click Download
  # Expected outcome: Browser saves exported recipes file
  # test "exports recipes" do
  # end

Download test helper for managing file downloads in system tests #

Every test so far has stayed inside the Rails app but file downloads are something that happens outside the app. The browser writes a file to disk, and nothing in Capybara knows or cares when that write finishes.

So before the test, you need two things: a place for the browser to put (download) the file, and a way to wait until the file is actually there.

Create test/test_helpers/download_test_helper.rb and add the following code:

# test/test_helpers/download_test_helper.rb
module DownloadTestHelper
  TIMEOUT = 10
  DOWNLOAD_PATH = Rails.root.join("tmp/downloads")

  def downloads
    Dir[DOWNLOAD_PATH.join("*")]
  end

  def download
    downloads.first
  end

  def downloading?
    downloads.grep(/\.crdownload$/).any?
  end

  def downloaded?
    !downloading? && downloads.any?
  end

  def wait_for_download
    Timeout.timeout(TIMEOUT) { sleep 0.1 until downloaded? }
  rescue Timeout::Error
    flunk "No completed download after #{TIMEOUT} seconds. Saw #{downloads.inspect}"
  end

  def clear_downloads
    FileUtils.mkdir_p(DOWNLOAD_PATH)
    FileUtils.rm_f(downloads)
  end
end

This is what’s happening in the code above:

  • downloads lists whatever is sitting in tmp/downloads. Keeping it inside the app means you can open the folder and look when something goes wrong, and tmp/ is already gitignored so downloaded files are not committed to the git repository.
  • downloading? looks for the .crdownload suffix. Chrome and Chromium write to that name while a save is in progress and rename the file only once it finishes, so a .crdownload on disk means “not done yet”. This is one of the few spots where the browser brand matters, because Firefox uses .part instead.
  • downloaded? is the condition you actually care about. Something arrived, and nothing is still being written.
  • wait_for_download polls the download status at fixed intervals until the file has finished saving. You need this because Capybara only ever waits for the page. Nothing in it waits for the filesystem.
  • clear_downloads empties the download folder to ensure clean state for each test run. This is important to ensure a test does not accidentally use a file from a previous run.

Next, you need to tell Capybara to use the download folder from the download_test_helper.rb file. You can do this by using add_preference made available by the selenium-webdriver gem.

Open test/application_system_test_case.rb and append do |options| block to the driven_by :selenium method:

# test/application_system_test_case.rb
require "test_helper"
require_relative "test_helpers/download_test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  include DownloadTestHelper

  driver = ENV["HEADFUL"] == "1" ? :chrome : :headless_chrome
  driven_by :selenium, using: driver, screen_size: [1400, 1400] do |options|
    options.add_preference(
      :download,
      prompt_for_download: false,
      default_directory: DownloadTestHelper::DOWNLOAD_PATH.to_s
    )
    options.add_preference(
      :browser,
      set_download_behavior: {
        behavior: "allow"
      }
    )
  end

  # ... existing code ...
end

Notice the include DownloadTestHelper line? It’s required for two things:

  • To access the DownloadTestHelper::DOWNLOAD_PATH for the default_directory preference.
  • Make wait_for_download and clear_downloads methods available to all system tests so individual test files using the download test helper do not have to include it each time.

Make sure to also require the download test helper in the system test file require_relative "test_helpers/download_test_helper" just below the require "test_helper".

This is what’s happening in the code above:

  • prompt_for_download: false stops the browser from opening a Save As dialog so the file is saved automatically.
  • default_directory is the same constant the helper reads, so the browser and the test can never disagree about where the file went.
  • set_download_behavior permits the browser to download files, which is disabled by default.

Fill in the system smoke #

System tests do not get the Active Job helpers for free the way ActiveJob::TestCase does, so make sure to include it in system test files that rely on active jobs. Without it perform_enqueued_jobs will throw a NoMethodError.

Open test/system/recipes_test.rb and add include ActiveJob::TestHelper inside the class.

Once that’s done, replace the test block for “exports recipes” with the following code:

# test/system/recipes_test.rb
require "application_system_test_case"

class RecipesTest < ApplicationSystemTestCase
  include ActiveJob::TestHelper

  # ... existing tests ...

  test "exports recipes" do
    sign_in_to_ui_as users(:alice)

    visit recipes_url
    assert_no_text "Download"

    click_on "Export recipes"
    assert_text "Your recipes are being exported."

    perform_enqueued_jobs only: ExportRecipesJob

    assert_text "Download"
    click_on "Download"

    wait_for_download
    assert_match(/recipes-export-\d{14}\.txt/, File.basename(download))

    contents = File.read(download)
    assert_includes contents, recipes(:pancakes).title
    assert_includes contents, recipes(:lentil_soup).title
    assert_includes contents, recipes(:pizza).title
    assert_includes contents, "Flour"
  ensure
    clear_downloads
  end
end

This is what’s happening in the code above:

  • The order of the click, the flash, and the immediate job execution (perform_enqueued_jobs only: ExportRecipesJob) matters for this test. Click, wait until the pending notice is visible, then execute the job. That proves the click finished and the Turbo Stream update rendered the notice in #flash before the job runs.
  • assert_text "Your recipes are being exported." checks the notice that arrived via the Turbo Stream. The system test reads the rendered page, so it uses assert_text rather than the flash.notice accessor the integration test uses.
  • There is no visit or reload between job execution and assert_text "Download". This is the same experience as what a real user would see when they use the export feature in the browser.
  • wait_for_download ensures the file is fully written to disk before the test continues. If you skip this, the test might only read a half-written file, or no file at all, and the test will fail for a reason that has nothing to do with your code. Ultimately leading to flaky tests in the app.
  • assert_match(/recipes-export-\d{14}\.txt/, File.basename(download)) ensures the downloaded file has the expected name.
  • ensure clears the download folder to make sure the next test run starts with a clean state.

Run the test, you want 0 failures and 0 errors.

bin/rails test test/system/recipes_test.rb -i test_exports_recipes

Try the export in the browser #

Before we wrap up this chapter, try to export the recipes in the browser and verify the download works as expected.

If you have already deleted the seeded recipes, you can re-run the rake task to get them back again:

bin/rails recipes:seed

Follow the steps to export the recipes in the browser:

  1. Run bin/dev if it is not already running, then open /recipes while signed in as Alice.
  2. Confirm Export recipes is on the page and no Download link is visible.
  3. Click Export recipes. You stay on the list and the pending notice appears in the flash area right away.
  4. When the job finishes, the export alert with Download appears in the recipe list page. Click on the download link to download the exported recipes file.
  5. Click the × to dismiss the alert. Reloading the page does the same thing. Either way, click Export recipes again to get a fresh download link.

One limit to expect, so you do not chase it as a bug:

  • If you leave or reload the page while the job is still building the file, you miss the broadcast for good. The export file is attached to Alice, but the list will not offer it again. As the chapter already discussed, this is deliberate. Click Export recipes again to get a new download link.

When you are done comparing, you can reset the database to use fixture records again.

RAILS_ENV=development bin/rails db:fixtures:load

And remove the rake task as well because you won’t need it in other chapters:

rm lib/tasks/recipes.rake

When perform grows beyond a handful of lines #

Right now the logic to render the export file is in the background job at app/jobs/export_recipes_job.rb. If render_recipe grows later, say to export a PDF or a CSV as well, you can move the exporting logic to a plain Ruby class such as RecipesExporter at lib/recipes_exporter.rb and test it in test/lib/recipes_exporter_test.rb.

The testing habits still stay the same even after the extraction:

  • Prove enqueue from the caller with assert_enqueued_with in an integration test (args: when arguments matter).
  • Call perform_now for job unit tests and assert the edge cases you care about (file attached, every recipe in the app, discarded deleted user).
  • Leave the formatting details to a focused unit test, in this case test/lib/recipes_exporter_test.rb.

Commit your work #

Run the full suite before you commit:

bin/rails test:all

You want 0 failures and 0 errors across the test suite. Then commit your work:

git add .
git commit -m "Queue recipe share email, export recipes via background job, and add tests"

Small commits make it easier to bisect mailer queue changes and export job changes if something breaks in production.

What is next #

Chapter 16 adds a thin JSON Recipes API so clients other than humans can use our Cookbook app. Non humans can include mobile apps, third party integrations or the new shiny talk of the town called “AI agents”. With an API, they can list and create recipes without relying on the web interface.

Continue to Testing JSON APIs in Rails.

Keep Minitest Rails independent

Minitest Rails is an independent educational guide for Rails developers learning automated testing.

Companies can support the guide by sponsoring a chapter (one-time payment) or becoming a Patron with a monthly subscription. This funds new chapters, Rails version updates, and more real-world examples.

If you just want to chip in as a reader, you can also buy me a drink as a thanks.

Disclaimer: This guide is based on hands-on Rails and testing experience and was proofread by AI. I stand by the advice and patterns here.