Testing with Phoenix LiveView

Copy Markdown View Source

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.Repo

Drain 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"
end

Two things to know:

  • file_input/3 only registers the upload — render_upload/3 is what triggers consume_uploaded_entries in your LiveView.
  • The form's phx-submit handler is what calls into attached. If your LiveView consumes uploads in the submit handler (the recommended pattern), the blob won't exist until after render_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}
end

It 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"
end

Usage:

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/"
end

For 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"
end

Oban 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.