Blog

How to Test File Uploads with Minitest Rails

Test Active Storage uploads in Rails with Minitest: fixture files, model attach, integration multipart posts, and optional system tests. No real S3 in CI.

file-uploads active-storage minitest rails integration-tests

Active Storage has made a file upload in Rails a breeze. You pick an image, hit Save, and the page reloads with a thumbnail.

But can you say the same for testing the file uploads? In tests, that same flow turns into questions fast:

  • Where does the file live on disk?
  • How do you test the file upload without opening a browser?
  • How do you check the file actually attached without hitting S3?

This post helps you answer these questions and walks through how to test file uploads with Minitest:

  • a model test for attach logic,
  • an integration test for the create form, and
  • an optional system smoke test when you want a real file input in the browser.

If you use CarrierWave, Shrine, or Paperclip (now deprecated) instead of Active Storage, the syntax and params may differ but the test layers stay the same, so you will be able to carry over test strategies shown here to those gems as well.

Assumptions #

This walkthrough assumes:

  • Rails 8 with Active Storage (bin/rails active_storage:install already run).
  • A Rails model with has_one_attached :file and a create form that accepts the attachment.
  • Minitest and fixtures for test data.
  • The test environment uses Active Storage’s :test service in config/storage.yml.

Note: You will need to adjust model names, param keys, and routes to match your app.

Tested and working in #

The code in this blog post is tested and working in:

  • Ruby 4.0.2
  • Rails 8.1.3
  • Minitest 6.0.6
  • selenium-webdriver 4.45.0

Sample companion app #

Every code block in this post comes from a runnable Rails app: test-file-upload-minitest-rails.

If you are new to testing, clone the app and follow along locally. Copy-pasting snippets into an app that is not set up yet is frustrating, and it is hard to tell whether a failure is your test or a missing Active Storage config. Start here:

git clone git@github.com:minitestrails/test-file-upload-minitest-rails.git
cd test-file-upload-minitest-rails
bin/setup --skip-server
bin/rails test

You should see a green suite. Work through each section below, run the matching test file, and diff your changes against the sample app when something does not match. The README inside the sample project has more detail on structure and cleanup.

If you already know your way around Rails tests, the repo is enough. All upload tests from this walkthrough land on the test-file-upload branch in PR #6.

What you are actually testing #

A file upload touches a few different parts of your app, and each kind of test checks a different one. You do not need all of them for every feature. Pick the test that matches the part you care about:

Test type What it checks for a file upload
Model The file attaches to the record, and your type and size validations pass or fail like they should. No HTTP, no browser.
Integration A form POST with a file reaches your controller, the record saves, the page redirects, and the file ends up attached. This is the closest test to how a real upload arrives. Still no browser.
System The actual file input works in a browser. Slow, so keep it to one happy path as a smoke check.
Real S3 or cloud storage in CI Skip it. Uploading to a real bucket on every test run is slow, flaky, and needs credentials you should not put in CI.

Most of your confidence comes from the model and integration tests. Between them you can prove the record saved, the file actually attached, and the filename and content type are what you expect, all without leaving your machine. Add a single system test only when the upload button itself is worth a browser check.

Put sample files in test/fixtures/files/ #

For testing file uploads, we also need a real file on disk. The convention in Rails for these type of files is to use the folder at test/fixtures/files/.

Add a small image (a few KB is enough) to use as a sample file for the upload. The easiest option to do this if you don’t have something ready is using Rails to generate it:

mkdir -p test/fixtures/files
bin/rails runner "require 'base64'; File.binwrite('test/fixtures/files/sample.jpg', Base64.decode64('/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAAIAAgDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAAB//EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAH/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AIi2L3//Z'))"

That writes a tiny valid JPEG without a need to install any extra tools.

The Recipe model we are testing #

Before writing tests, let’s take a look at the model all tests run against: Recipe. A recipe is one cooking recipe in a small recipe app: it has a title, a short description, how many servings it makes, how long it takes to prep, and an optional photo of the finished dish. Every example in this post uses this same sample recipe model, the one the Minitest Rails guide uses for its chapters. So if you’ve read the guide, this model already looks familiar.

The two lines that matter for uploads are has_one_attached :photo and the custom validation that keeps the photo to a JPEG or PNG:

# app/models/recipe.rb
class Recipe < ApplicationRecord
  ALLOWED_PHOTO_TYPES = %w[image/jpeg image/png].freeze

  has_one_attached :photo

  validates :title, presence: true
  validates :servings, numericality: { greater_than: 0 }, allow_nil: true
  validates :prep_time, numericality: { greater_than: 0 }, allow_nil: true
  validate :photo_must_be_an_allowed_image

  private

  def photo_must_be_an_allowed_image
    return unless photo.attached?
    return if ALLOWED_PHOTO_TYPES.include?(photo.content_type)

    errors.add(:photo, "must be a JPEG or PNG")
  end
end

Below is the full description for all the fields a recipe model has:

Field Type Notes
title string The recipe’s name (for example “Pancakes”). Required, every recipe needs one.
description text A short description about the dish. Optional.
servings integer How many people the recipe feeds. Optional, but must be greater than 0 when present.
prep_time integer Prep time in minutes. Optional, but must be greater than 0 when present.
photo Active Storage attachment A picture of the dish. Optional, but if attached it must be a JPEG or PNG. This is the field these tests focus on.
created_at & updated_at datetime Comes default with every model in Rails

Model test: attach a file directly #

Let’s start with the model test first as it’s the fastest to write and run. A model test never touches HTTP, routes, or a browser. You attach a file to the record the same way Active Storage does in production, then check that the attachment is stored and associated correctly with the recipe. If your model has validations on the file (type, size, presence), this is also where you prove they work.

For the Recipe model, that’s two tests:

Test What it proves
Attaches a photo attach links the file to the record, and the filename and content type survive.
Rejects a disallowed type The photo_must_be_an_allowed_image validation fails for a non-image file or invalid file type.

Update the recipe model test to include tests for the file attachment and custom validations:

# test/models/recipe_test.rb
require "test_helper"

class RecipeTest < ActiveSupport::TestCase
  test "attaches a photo" do
    recipe = recipes(:pancakes)
    file = file_fixture("sample.jpg")

    recipe.photo.attach(
      io: File.open(file),
      filename: "sample.jpg",
      content_type: "image/jpeg"
    )

    assert recipe.photo.attached?
    assert_equal "sample.jpg", recipe.photo.filename.to_s
    assert_equal "image/jpeg", recipe.photo.content_type
  end

  test "rejects a invalid content type" do
    recipe = Recipe.new(title: "Bad upload")
    file = file_fixture("sample.txt")

    recipe.photo.attach(
      io: File.open(file),
      filename: "sample.txt",
      content_type: "text/plain"
    )

    assert_not recipe.valid?
    assert_includes recipe.errors[:photo], "must be a JPEG or PNG"
  end
end

Let’s look at what these tests are doing line by line:

test "attaches a photo" #

file_fixture is a Rails helper that returns a Pathname for a file under test/fixtures/files/. Use it instead of hard-coding Rails.root.join("test/fixtures/files/sample.jpg"). It reads better and it fails with a clear message if the file is missing.

recipe.photo.attach(...) is the same call your controller makes after a form submit. Passing io:, filename:, and content_type: mirrors what Active Storage receives from a real upload, so the model behaves the same way it will in production.

The three assertions in this test each prove a different thing.

attached? confirms a blob is linked to the record. The filename check confirms the original name survived. The content_type check confirms Rails stored the MIME type you expect, which matters if other code branches on it later.

test "rejects a invalid content type" #

This test covers the unhappy path. The model only allows JPEG and PNG, so a text file should be rejected.

You need to have a sample.txt in test/fixtures/files/ for it to run, so make sure to add a tiny txt file next to sample.jpg. Any content works, you can create a sample file with the following command:

echo "sample text file" > test/fixtures/files/sample.txt

The error string also has to match what your validation actually adds to errors[:photo]. If you use a gem like active_storage_validations or a custom validator, copy its exact message here, or the test will fail for the wrong reason.

Run the model tests:

bin/rails test test/models/recipe_test.rb

You want 0 failures and 0 errors.

If attached? comes back false, check that your model actually declares has_one_attached :photo (or has_many_attached) with the same name you’re attaching to.

Add attachment to fixtures #

The model test above attaches a file by hand. That’s the right move when the test is about uploading. But sometimes the upload isn’t the point. You want a record that already has a photo so you can test something that reads it: a thumbnail helper, a byte_size check, a “show the image if present, else a placeholder” edge case. Attaching by hand in every one of those tests is noise.

Don’t worry, Rails has you covered. Active Storage lets you attach files through fixtures, so the photo is there before the test even starts.

We can do this in two steps:

1. Add a separate storage service for test environment #

This keeps fixture files in their own folder, away from files uploaded during a test, so the two never get mixed up. Update config/storage.yml to include text_fixtures block:

# config/storage.yml
test_fixtures:
  service: Disk
  root: <%= Rails.root.join("tmp/storage_fixtures") %>

2. Create fixtures for the two Active Storage tables #

attachments.yml links a blob to a record, and blobs.yml describes the actual file. The record: pancakes (Recipe) line points at the pancakes fixture in your test/fixtures/recipes.yml.

Create a new fixture for attachment with mkdir -p test/fixtures/active_storage && nano test/fixtures/active_storage/attachments.yml in the terminal and add the following:

# test/fixtures/active_storage/attachments.yml
pancakes_with_photo:
  name: photo
  record: pancakes (Recipe)
  blob: pancakes_photo_blob

Create a new fixture for blobs with nano test/fixtures/active_storage/blobs.yml in the terminal and add the following:

# test/fixtures/active_storage/blobs.yml
pancakes_photo_blob: <%= ActiveStorage::FixtureSet.blob filename: "sample.jpg", service_name: "test_fixtures" %>

ActiveStorage::FixtureSet.blob reads the file from test/fixtures/files/ and writes the blob metadata for you. So put sample.jpg there (the same file you generated/added previously).

Once that’s wired up, every test gets a pancakes recipe with a photo already attached.

You can test the pancake record already has a photo attached from the fixture by adding the following to you model test (optional, you can skip it if you want):

# test/models/recipe_test.rb
test "pancakes fixture has a photo" do
  photo = recipes(:pancakes).photo

  assert photo.attached?
  assert_not_nil photo.download
end

Test should still be passing and show 0 failures and 0 errors:

bin/rails test test/models/recipe_test.rb

Integration test: create with file_fixture_upload #

This is the test that earns the most trust for file upload. That’s because it drives the same path a real user takes:

a form POST with a file –> through your routes and controller –> into the database –> ending on a redirect

The model test proved the file can attach. The integration test proves your controller actually accepts an upload and saves it.

For the create and edit forms, that’s two tests:

Test What it proves
Creates a recipe with a photo The multipart POST creates exactly one recipe, redirects to its page, and the file ends up attached with the right filename.
Updates a recipe’s photo A PATCH with a new file replaces the existing attachment instead of stacking a second one.

Update the recipe integration test with the following code to test file upload for create and update:

# test/integration/recipes_integration_test.rb
require "test_helper"

class RecipesIntegrationTest < ActionDispatch::IntegrationTest
  # .... Existing tests ....

  test "creates a recipe with a photo" do
    assert_difference("Recipe.count", 1) do
      post recipes_path, params: {
        recipe: {
          title: "Pancakes with photo",
          photo: file_fixture_upload("sample.jpg", "image/jpeg")
        }
      }
    end

    recipe = Recipe.last
    assert_redirected_to recipe_path(recipe)
    follow_redirect!
    assert_response :success

    assert recipe.photo.attached?
    assert_equal "sample.jpg", recipe.photo.filename.to_s
  end

  test "updates a recipe's photo" do
    recipe = recipes(:lentil_soup)
    recipe.photo.attach(
      io: file_fixture("sample.jpg").open,
      filename: "old.jpg",
      content_type: "image/jpeg"
    )
    original_blob_id = recipe.photo.blob.id

    patch recipe_path(recipe), params: {
      recipe: { photo: file_fixture_upload("sample.jpg", "image/jpeg") }
    }

    assert_redirected_to recipe_path(recipe)
    recipe.reload

    assert recipe.photo.attached?
    assert_equal "sample.jpg", recipe.photo.filename.to_s
    assert_not_equal original_blob_id, recipe.photo.blob.id
  end
end

Let’s go through the two integration tests above line by line.

test "creates a recipe with a photo" #

  • assert_difference("Recipe.count", 1) proves the POST created exactly one recipe, not zero (silent failure) and not two (a double submit bug).
  • assert_redirected_to then follow_redirect! then assert_response :success proves the happy path ends where a user expects, on the new recipe’s page, not back on the form with errors.
  • The last two lines prove the file came along for the ride and kept its name. A plain assert_response :success on its own would pass even if nothing saved, so the count and attachment checks are what actually test the upload.

test "updates a recipe's photo" #

The update test starts by attaching a photo named old.jpg so there’s something to replace, then saves its blob id.

After the PATCH, the key check is assert_not_equal original_blob_id, recipe.photo.blob.id. Because has_one_attached swaps the attachment rather than keeping both, the new blob id proves the old photo was actually replaced. Without that check, a test that only asserts attached? would still pass even if the update quietly did nothing.

The recipe.reload matters too: it forces the assertions to read from the database instead of the copy already in memory.

file_fixture_upload takes the filename only (it already knows to look in test/fixtures/files/) and the content type as a string. When Rails sees that upload object in the params, it sends the request as multipart form data.

Run the test to ensure 0 failures and 0 errors:

bin/rails test test/integration/recipes_integration_test.rb

Helper: reuse the same upload file #

The first test that posts a file is fine inline. The second one copies file_fixture_upload("sample.jpg", "image/jpeg") again, and now you’ve got the same filename and content type spread across files. This is not a very big deal for many projects but for some it can be a nightmare if you need to swap the sample file. Because as soon as you want to swap the file, you’re now hunting through tests to fix tests by replacing the static file name.

It’s a good idea to pull the photo upload logic into a separate helper instead.

Create a new file under test/support with mkdir -p test/support && nano test/support/upload_test_helper.rb and add the following:

# test/support/upload_test_helper.rb
module UploadTestHelper
  def sample_photo_upload
    file_fixture_upload("sample.jpg", "image/jpeg")
  end
end

Make sure to include this new helper in the test/test_helper.rb as well:

# test/test_helper.rb
# other code
module ActiveSupport
  class TestCase
    # other code

    include UploadTestHelper
  end
end

Rails doesn’t load test/support/ on its own, so tell it to load all helper files from test/test_helper.rb. You can skip this line if your app already has it:

# test/test_helper.rb
# other code ...
Dir[Rails.root.join("test/support/**/*.rb")].sort.each { |f| require f }

module ActiveSupport
  class TestCase
    # other code

    include UploadTestHelper
  end
end

That finds every .rb file under test/support/ and requires it in sorted order, so a module like UploadTestHelper is defined before any test runs. Because it’s included into ActiveSupport::TestCase, both model and integration tests can call sample_photo_upload with no extra setup.

Now the upload in the integration test is one obvious method call, update the code in both test "creates a recipe with a photo" and test "updates a recipe's photo" test to include photo: sample_photo_upload:

post recipes_path, params: {
  recipe: { title: "Photo pancakes", photo: sample_photo_upload }
}

When you need a second file shape (a PNG, an oversized file to trip a validation), add another small method like oversized_photo_upload next to this one. One place to look, one place to change; and very easy to refactor.

System test (optional smoke) #

The integration test already proved the upload works end to end on the server. A system test answers a narrower question: does the file input work in a real browser, with whatever JavaScript your form runs? That’s worth one test and a single happy path.

Test What it proves
Visitor uploads a photo on create The file field accepts a file in a real browser, the form submits, and the recipe ends up with a photo attached.

Update the recipe system test with the following code:

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

class RecipesTest < ApplicationSystemTestCase
  # other tests ...

  test "visitor uploads a photo on create" do
    visit new_recipe_path
    fill_in "Title", with: "Brownie with photo"
    attach_file "Photo", Rails.root.join("test/fixtures/files/sample.jpg")
    click_on "Create Recipe"

    assert_text "Brownie with photo"
    assert Recipe.last.photo.attached?
  end
end

What’s happening in the test?

  • attach_file finds the file field and selects a file, it’s the same as a user clicking “Choose file” but driven by Capybara automatically.
  • attach_file "Photo", ... locates the field by its label, so "Photo" has to be the visible label (or the field’s id/name) on your form. Unlike the integration test, attach_file wants a full path, so Rails.root.join(...) is correct here rather than a bare filename.
  • click_on "Create Recipe" clicks on the button to submit the form. Make sure to match your submit button text for this to succeed.

Run the test to ensure 0 failures and 0 errors:

bin/rails test:system test/system/recipes_test.rb

System tests are the slowest and flakiest tool in the box, since they boot a real browser. If this one starts failing intermittently in CI, it’s fine to delete it. Your integration test is the real proof that uploads work; this is only a smoke check that the browser piece is wired up.

Clean up files created during tests #

Most Rails tests clean up after themselves. The database rolls back, fixtures reload, and the next test starts fresh. File uploads are the exception. Active Storage writes real files to disk, and a rolled-back transaction never calls destroy, so blobs stick around between runs.

See the problem #

So, what’s the problem? Why does it matter if the files stay or not after the test run?

Because your tests still pass. That is the trap. Nothing fails on run two or run twenty, so tmp/storage quietly fills up while the database looks clean. Leftover blobs eat disk space, slow down CI when that folder gets cached between runs, and make it harder to tell whether a file on disk came from the test you just ran or from yesterday. In parallel runs, workers can even step on each other’s storage folders if you never clear them.

You can see it yourself. Clear the folder, run your upload integration tests once, and count the files:

rm -rf tmp/storage
bin/rails test test/integration/recipes_integration_test.rb
find tmp/storage -type f | wc -l

Run the same command again and the count goes up. Same green tests but more files on disk. That is what the cleanup below fixes.

Files uploaded during tests #

Integration and system tests need an after_teardown callback for clearing files uploaded during tests. Doing cleanup there (instead of mid-test) waits until every database connection from the test has finished, so Active Storage won’t complain about a missing file.

Add the following to test/test_helper.rb for cleaning up files uploaded during integration tests:

# test/test_helper.rb
module ActiveSupport
  class TestCase
    # existing code
  end
end

class ActionDispatch::IntegrationTest
  def after_teardown
    super
    FileUtils.rm_rf(ActiveStorage::Blob.service.root)
  end
end

For system tests, add the following to test/application_system_test_case.rb:

# test/application_system_test_case.rb
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  # existing code
  
  def after_teardown
    super
    FileUtils.rm_rf(ActiveStorage::Blob.service.root)
  end
end

If you run tests in parallel (see the tip below), you will need to give each process its own storage folder. Otherwise cleanup in one process can delete files another process still needs. Add parallelize_setup to test/test_helper.rb and test/application_system_test_case.rb:

# test/test_helper.rb
class ActionDispatch::IntegrationTest
  # existing code

  parallelize_setup do |i|
    ActiveStorage::Blob.service.root = "#{ActiveStorage::Blob.service.root}-#{i}"
  end
end
# test/application_system_test_case.rb
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  # existing code

  parallelize_setup do |i|
    ActiveStorage::Blob.service.root = "#{ActiveStorage::Blob.service.root}-#{i}"
  end
end

And then set the active_job queue_adapter in test environment to inline so the job to delete the file runs immediately:

# config/environments/test.rb
config.active_job.queue_adapter = :inline

Without that, the file will still be on disk when your assertion runs, and the test might fail even though the app is working correctly.

Run the test again and check for file count, you will see 0 files are left after test succeeds:

bin/rails test test/integration/recipes_integration_test.rb
find tmp/storage -type f | wc -l

Fixture files (if you preload attachments) #

Files uploaded during a test will now get cleaned up after each test with the configuration we have updated above in the after_teardown. But what about preloaded attachments we used for pancakes?

Unlike files uploaded during tests, fixture files from the optional test_fixtures service only need clearing once when the whole suite finishes. That is because fixture attachments are loaded once at the start of the run and shared by every test that reads them. You are not creating a new file on disk in each test, so there is nothing to wipe after every example. Clearing them in after_teardown would delete the photo before the next test could use recipes(:pancakes) which is not desirable.

The Rails guide uses two different hooks depending on how Rails runs your suite. But it’s better to add both to test/test_helper.rb so cleanup works now and still works when the suite grows:

When you see this at test start Hook that clears fixture storage
Running X tests in parallel using Y processes parallelize_teardown
Running X tests in a single process (parallelization threshold is 50) Minitest.after_run

That second line is easy to miss. Rails only forks parallel workers when your suite is big enough (50 tests by default). Below that, everything runs in one process and parallelize_teardown never runs. If you only add that hook, blobs in tmp/storage_fixtures stick around after every run even though your upload cleanup works fine.

Update the test_helper file with the following:

# test/test_helper.rb
class ActiveSupport
  class TestCase
    # existing code

    parallelize_teardown do |_i|
      FileUtils.rm_rf(ActiveStorage::Blob.services.fetch(:test_fixtures).root)
    end
  end
end

class ActionDispatch::IntegrationTest
  # existing code
end

Minitest.after_run do
  FileUtils.rm_rf(ActiveStorage::Blob.services.fetch(:test_fixtures).root)
end

parallelize_teardown runs once in each parallel process. Minitest.after_run runs once after a single-process run. Only one of them fires on any given test run, so keeping both is safe. Skip both blocks if you are not using the optional fixture preload section above.

Direct file uploads #

We only covered test for file uploads that get saved later. But what about direct uploads, where Active Storage’s JavaScript uploads the file as soon as someone picks it?

Your tests still work as written. On the backend, create and update still attach the file and save the record. That’s the same job whether the browser used direct upload or a normal multipart form.

The only difference is timing on the client. With direct upload, the file goes up right away when the user selects it. With multipart, the file rides along in the same POST as the title and other fields. By the time your controller runs, Active Storage is doing the same attach either way. In test you still use the :test disk service, so you don’t need S3 or cloud credentials in CI for direct uploads either.

CI gotchas #

Active Storage ships a :test service that writes to tmp/storage on the local disk. Your tests use that, not S3, so you don’t need AWS credentials, buckets, or network access in CI.

But two things still trip people up when the suite passes locally but fails in CI:

  1. The fixture file isn’t committed.

    If test/fixtures/files/sample.jpg only exists on your laptop, every upload test fails in CI with a “file not found” error. The fixture file is part of the test, so it has to be in the repo.

  2. The wrong storage service in test.

    If config/environments/test.rb points at a real cloud service, tests try to talk to it and either hang or fail. Make sure it uses the local test service:

     # config/environments/test.rb
     config.active_storage.service = :test
    

What to commit #

Once the model, integration and system tests are green locally, commit the tests and the fixture file together:

bin/rails test:all
git add .
git commit -m "Add file upload tests for recipe photos"

Recap #

The big idea is that testing file uploads in Rails is an Active Storage attach problem, not an “upload to S3 in CI” problem. Everything happens on the local disk with a committed sample file. Here’s the path this post walked:

  • Put sample files in test/fixtures/files/ (sample.jpg, and sample.txt if you test rejected types). Commit them with the tests.
  • Model test: file_fixture plus attach, then assert attached?, filename, and content type. Cover your validation unhappy path here too.
  • Integration test: file_fixture_upload in a multipart POST for create, and a PATCH to replace an existing photo. Assert the count changed, the redirect happened, the attachment landed, and the blob id changes on update.
  • Optional helper: sample_photo_upload in test/support/ when more than one test posts the same file.
  • Optional fixture preload: a separate test_fixtures service plus Active Storage fixture YAML when tests read an existing attachment instead of uploading one.
  • Optional system test: Capybara attach_file for one browser smoke check. Drop it if it gets flaky.
  • Clean up uploads after each test with after_teardown in test/test_helper.rb (integration) and test/application_system_test_case.rb (system). Add parallelize_setup when tests run in parallel. Set config.active_job.queue_adapter = :inline if you assert file purges.
  • Clear preloaded fixture storage with both parallelize_teardown and Minitest.after_run in test/test_helper.rb. Only one runs per test run; small suites (below Rails’ 50-test threshold) need Minitest.after_run.
  • Keep config.active_storage.service = :test so CI stays offline. Direct uploads on the client do not change these backend tests.

Where to go next #

You now have upload coverage on the same Recipe app the guide uses: model attach and validation, integration create and update, optional fixture preload, optional system smoke, and disk cleanup. Where you go depends on what you are building next.

Stay on the Recipes path. If you are working through the guide, the testing models chapter goes deeper on the same model (built-in validations, custom rules, scopes). Chapters 5 through 7 are where list, create, show, update, and destroy integration tests grew before this post added the photo layer. If you kept the optional system test, Chapter 7 is the reference for how many browser smokes are enough.

Uploads that call out. When a saved file triggers work outside your app (virus scan, image resize API, S3 policy check), do not hit that service in tests. Stub the boundary. The guide’s mock external services chapter covers that pattern. If the work runs in a background job (for example Active Storage purge after delete), read testing background jobs and keep config.active_job.queue_adapter = :inline in test when you need the job to run during the example.

Ship it in CI. Commit test/fixtures/files/ with the tests, keep config.active_storage.service = :test, and run the same commands locally and in the pipeline. Running tests in CI walks through a boring green workflow.

Runnable reference. Every snippet in this post lives in test-file-upload-minitest-rails on the test-file-upload branch (PR #6). Diff there when something in your app does not match.

For the full Minitest Rails arc (fixtures, integration-first HTTP, system smokes, auth, jobs), start at the guide introduction.

References #

Something unclear in this post?

Send feedback

Disclaimer: This blog is written in tandem with AI but reviewed and enhanced by me based on my experience with Rails and testing. I stand by the advice and patterns here.