Patterns for testing LiveViews that use attached for file uploads. Covers
the test-helper setup, the upload-flow assertion pattern, a fixture helper
for tests that don't care about the upload UI, and common pitfalls.
Setup
Isolated storage per test run
Attached.Test.setup_storage!/1 configures the Disk backend against a
unique tmp directory and registers an at_exit cleanup. Call it once
from test_helper.exs:
# test/test_helper.exs
Attached.Test.setup_storage!()
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual)One directory is shared across the whole mix test invocation —
parallel async: true tests don't need per-test isolation because blob
keys are already unique. The dir lives under System.tmp_dir!(), so
there's nothing to gitignore.
Pass :root to pin the path (useful if you'd rather have a stable,
project-local storage dir):
Attached.Test.setup_storage!(root: "test/tmp/storage")Oban in testing mode
Attached.Blobs.extract_metadata_later/1 enqueues an Oban job after every
upload. In tests you want jobs enqueued but not executed by default, so
they don't race with assertions:
# config/test.exs
config :my_app, Oban, testing: :manual, repo: MyApp.RepoDrain explicitly when a test wants metadata extracted:
Oban.drain_queue(queue: :default)Testing the upload flow
Phoenix.LiveViewTest exposes file_input/3 and render_upload/3 for
driving live_file_input components. The pattern: render the LiveView,
build an upload, submit, then assert a %Blob{} exists on the owner.
test "user can upload an avatar", %{conn: conn, user: user} do
{:ok, lv, _html} = live(conn, ~p"/users/#{user}/edit")
avatar =
file_input(lv, "#user-form", :avatar, [
%{
last_modified: 1_594_171_879_000,
name: "avatar.png",
content: File.read!("test/support/fixtures/avatar.png"),
type: "image/png"
}
])
assert render_upload(avatar, "avatar.png") =~ "100%"
lv
|> form("#user-form", user: %{name: "Updated"})
|> render_submit()
user = MyApp.Repo.get!(User, user.id) |> MyApp.Repo.preload(:avatar_attached_blob)
assert user.avatar_attached_blob.filename == "avatar.png"
assert user.avatar_attached_blob.content_type == "image/png"
endTwo things to know:
file_input/3only registers the upload —render_upload/3is what triggersconsume_uploaded_entriesin your LiveView.- The form's
phx-submithandler is what calls intoattached. If your LiveView consumes uploads in the submit handler (the recommended pattern), the blob won't exist until afterrender_submit/2.
Fixture helper for non-upload tests
Most tests that touch attached records aren't testing the upload itself — they're testing the page that displays an already-uploaded file. Going through the full upload flow for those tests is slow and noisy.
Attached.Test.attach!/3 bypasses the LiveView flow and attaches a file
directly. It honors the schema's resolved FK (per-field :foreign_key
or the global :default_foreign_key_suffix config):
setup do
user =
user_fixture()
|> Attached.Test.attach!(:avatar, "test/support/fixtures/avatar.png")
{:ok, user: user}
endIt accepts a file path (filename and content type are inferred), an
upload-shaped map (%{path:, filename:, content_type:}), a %Plug.Upload{},
or an existing %Attached.Blobs.Blob{} for re-attachment without storage I/O.
With ExMachina
ExMachina factories can't return a record with an attachment in one step
because Attached.Test.attach!/3 persists, while ExMachina's build/1
returns an unsaved struct and insert/1 calls Repo.insert/1. Expose
the attachment step as a separate piping helper instead:
# test/support/factory.ex
defmodule MyApp.Factory do
use ExMachina.Ecto, repo: MyApp.Repo
def user_factory do
%MyApp.User{
name: "Test User",
email: sequence(:email, &"user-#{&1}@example.com")
}
end
@doc """
Attaches a fixture file to `record.field`. Pipe after `insert/1`:
user = insert(:user) |> with_attachment(:avatar)
"""
def with_attachment(record, field, path \\ default_fixture(field)) do
Attached.Test.attach!(record, field, path)
end
defp default_fixture(:avatar), do: "test/support/fixtures/avatar.png"
defp default_fixture(:cover_image), do: "test/support/fixtures/cover.jpg"
defp default_fixture(:document), do: "test/support/fixtures/sample.pdf"
endUsage:
import MyApp.Factory
user = insert(:user) # no attachment
user = insert(:user) |> with_attachment(:avatar) # default fixture
user = insert(:user) |> with_attachment(:avatar, "test/support/fixtures/red.png")The helper isn't a factory — Attached.Test.attach!/3 already persists,
so adding insert(:user_with_avatar) would either skip the attachment
or double-insert. Keeping it as a pipe-friendly post-insert step avoids
that ambiguity.
Testing variant generation
Variants are generated lazily on first Attached.Variants.process/3 call.
In tests, just call them — the Disk backend writes a real file, libvips
or ImageMagick produces a real variant, and you can assert on the result:
test "preview variant is generated for an image", %{user: user} do
user = MyApp.Repo.preload(user, :avatar_attached_blob)
assert {:ok, url} = Attached.Variants.preview_url(user.avatar_attached_blob)
assert url =~ "/storage/"
endFor variants that go through the rendered HTML, assert on the markup:
{:ok, _lv, html} = live(conn, ~p"/users/#{user}")
assert html =~ "avatar-variant.webp"Variants are cached as Attached.Variants.Variant rows in
attached_variants, so subsequent renders in the same test are cheap —
no re-encoding.
Attached.url(record, field, :variant_name) requires :variants to be
preloaded on the blob — it raises with a helpful message if you forget.
Use Attached.with_attached/2 in your queries (it preloads the blob and
its variants together) or Repo.preload(record, avatar_attached_blob: :variants)
explicitly.
Common pitfalls
libvips / ImageMagick missing on CI. If your tests use variants, the
CI image needs libvips or imagemagick installed. The transformer
registry skips unavailable transformers, so a missing binary doesn't
crash — it just silently produces no variant. Add an explicit
available?/0 assertion in test setup to catch missing deps early:
unless Attached.Processors.Transformers.Vix.available?() do
raise "libvips not installed — variants will not be generated in tests"
endOban jobs racing with assertions. Default testing: :manual means
extract_metadata_later/1 jobs sit in the queue. If a test asserts on
blob.metadata, drain the queue first with Oban.drain_queue/1 — or run
the worker directly:
Attached.Blobs.BlobExtractMetadataWorker.perform(%Oban.Job{args: %{"blob_id" => blob.id}})Stale storage between runs. If you skip the at_exit cleanup and run
many test suites locally, /tmp fills up. The unique-per-run directory
pattern above prevents test-to-test interference; the at_exit callback
handles long-term cleanup. If a test crashes mid-run, the directory
sticks around — periodic rm -rf /tmp/attached-test-* is fine.
Async tests + shared owner records. attached blobs themselves are
isolated per test via the SQL Sandbox, but if two tests both upload to
the same singleton-style owner record (a Site row, say), you'll get
sandbox conflicts. Use async: false for tests that mutate shared
fixtures, or scope each test to its own owner.