[Active Storage](https://guides.rubyonrails.org/active_storage_overview.html?utm_source=minitestrails.com) 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](https://github.com/minitestrails/test-file-upload-minitest-rails?utm_source=minitestrails.com).

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:

```bash
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](https://github.com/minitestrails/test-file-upload-minitest-rails/pull/6/changes?utm_source=minitestrails.com).

## 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:

```bash
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.

<%= render Shared::Tip.new(
  title: "Keep fixtures tiny",
  markdown: "Use the smallest file that satisfies your validation rules. Large binaries slow CI and add noise to diffs."
) %>

## 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](/guide/introduction/) 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:

```ruby
# 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

<%= render Shared::Tip.new(
  title: "Note",
  markdown: "`photo` is not a column in the `recipes` table. `has_one_attached :photo` stores the link in Active Storage's own tables instead. That's why the tests check `photo.attached?` rather than reading a `recipe.photo` column."
) %>

## 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:

```ruby
# 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:

```bash
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:

```bash
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](https://guides.rubyonrails.org/active_storage_overview.html?utm_source=minitestrails.com#adding-attachments-to-fixtures) 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:

```yaml
# 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:

```yaml
# 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:

```yaml
# 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):

```ruby
# 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:

```bash
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:

```ruby
# 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
```

<%= render Shared::Tip.new(
  title: "Tip",
  markdown: "The helper that makes it easy for sending the file to the controller is `file_fixture_upload`. `ActionDispatch::IntegrationTest` includes it, and it wraps a file from `test/fixtures/files/` so it looks like a real upload in your params. After that, Rails treats the file like any other parameter."
) %>

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.

<%= render Shared::Tip.new(
  title: "\"multipart\" form data",
  markdown: "A normal form post sends fields as plain text. But you can't cram a binary file into a text field cleanly, so file uploads use a different encoding called multipart form data. It splits the request body into parts: one part per text field, and one part per file (with its filename and content type). Rails sets this on a `form_with` form automatically when a file field is present. In tests, `file_fixture_upload` builds that file part for you."
) %>

Run the test to ensure 0 failures and 0 errors:

```bash
bin/rails test test/integration/recipes_integration_test.rb
```

<%= render Shared::Tip.new(
  title: "file_fixture_upload vs fixture_file_upload",
  markdown: "These are the same method. The Rails guide now uses [`file_fixture_upload`](https://api.rubyonrails.org/classes/ActionDispatch/TestProcess/FixtureFile.html?utm_source=minitestrails.com#method-i-file_fixture_upload); older code and tutorials use `fixture_file_upload`. Pick one and stay consistent."
) %>

## 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:

```ruby
# 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:

```ruby
# 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:

```ruby
# 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`:

```ruby
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:

```ruby
# 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:

```bash
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:

```bash
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:

```ruby
# 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`:

```ruby
# 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`:

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

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

```ruby
# 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:

```ruby
# 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.

<%= render Shared::Tip.new(
  title: "How do I know if I'm running parallel tests?",
  markdown: "You can know if you are running parallel test or not with three quick checks:\n\n1. Open `test/test_helper.rb` and look for `parallelize(workers: ...)` inside `ActiveSupport::TestCase`. Rails adds this by default on new apps.\n2. Read the first line when tests start. `Running X tests in parallel using Y processes` means parallel. `Running X tests in a single process` means serial (common when the suite is below Rails' parallelization threshold, usually 50 tests).\n3. Force parallel on a small suite with `PARALLEL_WORKERS=4 bin/rails test` and watch the output switch to the parallel line.\n\nFor system tests specifically, parallel workers each boot their own Puma server for Capybara. While tests are running, you may see several `Capybara starting Puma` lines in the output, or multiple `puma` processes if you check with `ps` in another terminal."
) %>

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

```bash
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](/blog/testing-file-uploads-rails-minitest/#add-attachment-to-fixtures) 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](https://guides.rubyonrails.org/active_storage_overview.html?utm_source=minitestrails.com#cleaning-up-fixtures) 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:

```ruby
# 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:

    ```ruby
    # 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:

```bash
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](/guide/testing-models/) chapter goes deeper on the same model (built-in validations, custom rules, scopes). Chapters [5](/guide/your-first-test/) through [7](/guide/testing-simple-crud-system-tests/) 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](/guide/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](/guide/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](/guide/running-tests-in-ci/) walks through a boring green workflow.

**Runnable reference.**
Every snippet in this post lives in [test-file-upload-minitest-rails](https://github.com/minitestrails/test-file-upload-minitest-rails?utm_source=minitestrails.com) on the `test-file-upload` branch ([PR #6](https://github.com/minitestrails/test-file-upload-minitest-rails/pull/6/changes?utm_source=minitestrails.com)). 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](/guide/introduction/).

## References

- [Testing section of Rails active storage guide](https://guides.rubyonrails.org/active_storage_overview.html?utm_source=minitestrails.com#testing)
