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.
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:installalready run). - A Rails model with
has_one_attached :fileand a create form that accepts the attachment. - Minitest and fixtures for test data.
- The test environment uses Active Storage’s
:testservice inconfig/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_tothenfollow_redirect!thenassert_response :successproves 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 :successon 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_filefinds 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’sid/name) on your form. Unlike the integration test,attach_filewants a full path, soRails.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:
-
The fixture file isn’t committed.
If
test/fixtures/files/sample.jpgonly 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. -
The wrong storage service in test.
If
config/environments/test.rbpoints 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, andsample.txtif you test rejected types). Commit them with the tests. - Model test:
file_fixtureplusattach, then assertattached?, filename, and content type. Cover your validation unhappy path here too. - Integration test:
file_fixture_uploadin 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_uploadintest/support/when more than one test posts the same file. - Optional fixture preload: a separate
test_fixturesservice plus Active Storage fixture YAML when tests read an existing attachment instead of uploading one. - Optional system test: Capybara
attach_filefor one browser smoke check. Drop it if it gets flaky. - Clean up uploads after each test with
after_teardownintest/test_helper.rb(integration) andtest/application_system_test_case.rb(system). Addparallelize_setupwhen tests run in parallel. Setconfig.active_job.queue_adapter = :inlineif you assert file purges. - Clear preloaded fixture storage with both
parallelize_teardownandMinitest.after_runintest/test_helper.rb. Only one runs per test run; small suites (below Rails’ 50-test threshold) needMinitest.after_run. - Keep
config.active_storage.service = :testso 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 feedbackDisclaimer: 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.