Skip to main content

docs/testing_with_liveview.md

# Testing with Phoenix LiveView

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

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

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

```elixir
# config/test.exs
config :my_app, Oban, testing: :manual, repo: MyApp.Repo
```

Drain explicitly when a test wants metadata extracted:

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

```elixir
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):

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

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

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

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

```elixir
{: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:

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

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