commit 192195375c0f27a3dc30060ad5f4997cc045ccc6 Author: Aaron Weiker Date: Wed Apr 29 23:10:15 2026 -0700 docs: idiomatic Phoenix patterns with verified source citations diff --git a/README.md b/README.md new file mode 100644 index 0000000..1315ffb --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Phoenix Patterns + +Idiomatic Phoenix patterns extracted from the [Phoenix source code](https://github.com/phoenixframework/phoenix) with verified file:line citations. + +## Structure + +- `patterns/` — Phoenix-specific patterns (Endpoint, Router, Controller, Channel, Plug pipeline, Telemetry) +- `patterns/deviations.md` — Where Phoenix deliberately differs from Elixir core conventions and why +- `patterns/comparison.md` — Side-by-side Elixir core vs Phoenix patterns +- `changelog/` — Daily digest of merged Phoenix PRs with discussion summaries + +## Philosophy + +These rules are derived from what the Phoenix source code *actually does*, not opinions or blog posts. Every pattern cites specific files and line numbers. + +When unsure how to do something in Phoenix, look at how Phoenix itself does it. diff --git a/changelog/.gitkeep b/changelog/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/patterns/comparison.md b/patterns/comparison.md new file mode 100644 index 0000000..e2d437c --- /dev/null +++ b/patterns/comparison.md @@ -0,0 +1,149 @@ +# Elixir Core vs Phoenix: Side-by-Side Comparison + +How the same concepts are approached differently (or similarly) between Elixir core and Phoenix. + +## Process Lifecycle + +| Aspect | Elixir Core | Phoenix | +|--------|-------------|---------| +| **Default restart** | `:permanent` (GenServer, Supervisor) | `:temporary` (Channel) | +| **Hibernation** | Not set by default | 15s idle → hibernate (Channel) | +| **Process identity** | Registry `:via` tuples | Topic-based (channels identified by topic) | +| **Supervision** | Direct supervisor reference | Endpoint supervisor manages all | + +**Source (Elixir):** `lib/elixir/lib/gen_server.ex:911-919` (child_spec defaults to :permanent via Supervisor.child_spec) +**Source (Phoenix):** `lib/phoenix/channel.ex:464-472` (explicit restart: :temporary) + +--- + +## Error Handling + +| Aspect | Elixir Core | Phoenix | +|--------|-------------|---------| +| **Exception design** | Minimal struct fields | HTTP-aware (`plug_status`) | +| **Bang functions** | `File.read!` raises | `broadcast!` raises | +| **Failure response** | `{:error, reason}` tuple | `{:error, reason}` + HTTP status | +| **Recovery** | Supervisor restart | Client reconnection | + +**Source (Elixir):** `lib/elixir/lib/agent.ex:187` (standard on_start type: `{:ok, pid} | {:error, ...}`) +**Source (Phoenix):** `lib/phoenix/router.ex:2-6` (NoRouteError with plug_status: 404) + +--- + +## Behaviour Design + +| Aspect | Elixir Core | Phoenix | +|--------|-------------|---------| +| **Required callbacks** | Most are optional | Only `join/3` required (Channel) | +| **`__using__` generates** | `child_spec/1` + `@behaviour` | child_spec + behaviour + config + imports | +| **Configuration** | Via `use Module, opts` | Via `use Module, opts` + module attributes | +| **Before-compile** | Rarely used | Heavily used (routes, intercepts) | + +**Source (Elixir):** `lib/elixir/lib/gen_server.ex:899-919` (__using__ generates child_spec + @behaviour) +**Source (Phoenix):** `lib/phoenix/channel.ex:450-485` (__using__ generates child_spec + behaviour + DSL setup) + +--- + +## Macro Usage + +| Aspect | Elixir Core | Phoenix | +|--------|-------------|---------| +| **Philosophy** | Minimal, prefer functions | Justified by performance | +| **`__using__`** | Generates 1-2 functions | Generates functions + sets up DSL | +| **DSL creation** | Avoided (except Kernel/SpecialForms) | Embraced (Router DSL) | +| **Attribute accumulation** | Rare | Central pattern (routes, sockets) | + +**Source (Elixir):** `lib/elixir/lib/gen_server.ex:899` — simple `__using__` (behaviour + child_spec + defaults) +**Source (Phoenix):** `lib/phoenix/router.ex:288-312` — complex DSL setup with attribute accumulation, imports, and @before_compile + +--- + +## Module Organization + +| Aspect | Elixir Core | Phoenix | +|--------|-------------|---------| +| **File naming** | `gen_server.ex` (snake_case) | `controller.ex` (snake_case) | +| **Nesting** | 2 levels max (`Task.Supervised`) | 2-3 levels (`Phoenix.Channel.Server`) | +| **Internal modules** | `@moduledoc false` | `@moduledoc false` | +| **Public API** | Functions on the main module | Functions + macros on the main module | + +Both follow the same convention: public API on the parent module, implementation details in nested submodules with `@moduledoc false`. + +--- + +## State Management + +| Aspect | Elixir Core | Phoenix | +|--------|-------------|---------| +| **Agent** | Simple state, function-based access | Socket assigns (`assign/2`) | +| **GenServer** | Full control, handle_call/cast/info | Channel handles (same callbacks) | +| **State shape** | Any term (developer's choice) | `%Socket{}` struct (framework-defined) | +| **State access** | Direct in callbacks | Via `socket.assigns` | + +**Source (Elixir):** `lib/elixir/lib/agent.ex:62-82` (compute in server vs client pattern) +**Source (Phoenix):** `lib/phoenix/channel.ex:463` (`import Phoenix.Socket, only: [assign: 3, assign: 2]`) + +--- + +## Documentation + +| Aspect | Elixir Core | Phoenix | +|--------|-------------|---------| +| **Moduledoc size** | Very large (GenServer: 530 lines) | Large (Router: ~260 lines) | +| **Examples** | Doctests (verified by tests) | Examples in docs (not always doctests) | +| **Admonitions** | Info blocks for `use` | Info blocks for `use` | +| **Guides** | Linked from moduledoc | Linked from moduledoc | +| **Deprecation** | `@doc deprecated: "Use X instead"` | Inline comments (TODO markers) | + +Both use the same documentation infrastructure (ExDoc), but Elixir core tends toward more exhaustive docs (GenServer's moduledoc is essentially a tutorial). + +--- + +## Configuration + +| Aspect | Elixir Core | Phoenix | +|--------|-------------|---------| +| **Compile-time** | Module attributes | `Application.compile_env` | +| **Runtime** | Application env / init args | `config/2` callback + Application env | +| **Per-instance** | Options to `start_link` | Endpoint config per environment | + +**Source (Phoenix):** `lib/phoenix/endpoint.ex:422-430` (compile-time config checking) + +```elixir +var!(code_reloading?) = + Application.compile_env(@otp_app, [__MODULE__, :code_reloader], false) +``` + +This pattern — reading config at compile time and validating it against runtime — is Phoenix-specific. Elixir core reads config only at runtime. + +--- + +## Telemetry + +| Aspect | Elixir Core | Phoenix | +|--------|-------------|---------| +| **Built-in events** | None (telemetry is a separate library) | Extensive event catalog | +| **Instrumentation** | Manual by library authors | Baked into router, endpoint, socket | +| **Event naming** | Varies by library | `[:phoenix, :component, :phase]` convention | +| **Logging** | `Logger` calls | Telemetry → Logger adapter (`Phoenix.Logger`) | + +**Source (Phoenix):** `lib/phoenix/logger.ex:7-50` (telemetry event catalog) +**Source (Phoenix):** `lib/phoenix/router.ex:400-438` (telemetry in router dispatch) + +Phoenix wraps every request dispatch in telemetry start/stop/exception events. This provides distributed tracing, monitoring, and logging without any application code changes. + +--- + +## Testing + +| Aspect | Elixir Core | Phoenix | +|--------|-------------|---------| +| **Test helper** | `ExUnit.Case` | `Phoenix.ConnTest`, `Phoenix.ChannelTest` | +| **Test subject** | Module functions | Endpoint (full plug pipeline) | +| **Communication** | Direct function calls | HTTP verbs (ConnTest), messages (ChannelTest) | +| **Isolation** | Process per test | Process per test + sandbox (Ecto) | + +**Source (Phoenix):** `lib/phoenix/test/conn_test.ex:1-30` (endpoint-based integration testing) +**Source (Phoenix):** `lib/phoenix/test/channel_test.ex:1-30` (process-based channel testing) + +Phoenix test helpers test at the integration level by default — `ConnTest` dispatches through the full plug pipeline, `ChannelTest` exercises the full channel lifecycle via message passing. This catches middleware bugs that unit tests miss. diff --git a/patterns/deviations.md b/patterns/deviations.md new file mode 100644 index 0000000..98a7b32 --- /dev/null +++ b/patterns/deviations.md @@ -0,0 +1,170 @@ +# Phoenix Deviations from Elixir Core + +Where Phoenix deliberately differs from Elixir core patterns and why. + +## 1. Heavy Macro Usage for Performance + +**Elixir core philosophy:** Keep macro usage minimal. From the Router source: + +> Phoenix does its best to keep the usage of macros low. + +**Phoenix deviation:** The Router uses macros extensively. + +**Source:** `lib/phoenix/router.ex:106-128` + +> We use `get`, `post`, `put`, and `delete` to define your routes. We use macros +> for two purposes: +> +> * They define the routing engine... Phoenix compiles all of your routes to a +> single case-statement with pattern matching rules +> +> * For each route you define, we also define metadata to implement +> `Phoenix.VerifiedRoutes` + +**Why the deviation:** Performance. Elixir core uses macros sparingly because they add cognitive complexity. Phoenix justifies them because routing is the hottest path in a web app — compile-time optimization yields measurable request/second gains. + +--- + +## 2. `import` without Restriction in Router + +**Elixir core pattern:** Always use `import Module, only: [...]` to be explicit. + +**Phoenix deviation:** The Router imports entire modules: + +**Source:** `lib/phoenix/router.ex:303-306` + +```elixir +import Phoenix.Router + +# TODO v2: No longer automatically import dependencies +import Plug.Conn +import Phoenix.Controller +``` + +**Why the deviation:** The Router is a DSL. Users need `get`, `post`, `pipe_through`, `scope`, `resources`, `plug`, `fetch_session`, etc. — all available without qualification. Restricting imports would make the DSL unusable. + +--- + +## 3. Compile-Time State Accumulation + +**Elixir core pattern:** Modules are generally stateless during compilation. Functions are defined and that's it. + +**Phoenix deviation:** Aggressive use of module attribute accumulation. + +**Source:** `lib/phoenix/router.ex:297-312` + +```elixir +defp prelude(opts) do + quote do + Module.register_attribute(__MODULE__, :phoenix_routes, accumulate: true) + @phoenix_helpers Keyword.get(unquote(opts), :helpers, true) + + import Phoenix.Router + import Plug.Conn + import Phoenix.Controller + + # Set up initial scope + @phoenix_pipeline nil + Phoenix.Router.Scope.init(__MODULE__) + @before_compile unquote(__MODULE__) + end +end +``` + +**Why the deviation:** The Router needs to collect ALL routes, then compile them into a single dispatch function. This requires building up state during module compilation, then consuming it all at `@before_compile`. + +--- + +## 4. Channel Restart Strategy: `:temporary` + +**Elixir core GenServer default:** `:permanent` (always restart). + +**Phoenix Channel default:** `:temporary` (never restart). + +**Source:** `lib/phoenix/channel.ex:464-472` + +```elixir +def child_spec(init_arg) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, [init_arg]}, + shutdown: @phoenix_shutdown, + restart: :temporary + } +end +``` + +**Why the deviation:** A crashed channel should NOT auto-restart — the client needs to explicitly reconnect and rejoin. Auto-restarting would create a channel without a connected client, which is meaningless. + +--- + +## 5. Auto-Hibernation + +**Elixir core GenServer:** No default hibernation — processes stay in memory. + +**Phoenix Channel:** Defaults to hibernate after 15 seconds of inactivity. + +**Source:** `lib/phoenix/channel.ex:459` + +```elixir +@phoenix_hibernate_after Keyword.get(opts, :hibernate_after, 15_000) +``` + +```elixir +def start_link(triplet) do + GenServer.start_link(Phoenix.Channel.Server, triplet, + hibernate_after: @phoenix_hibernate_after + ) +end +``` + +**Why the deviation:** Web apps have many idle connections. Channels for users who are "connected but not active" are common. Hibernation reclaims memory for the heap without killing the process. A chat app with 10,000 connected users benefits enormously. + +--- + +## 6. `Plug.Builder` vs Raw Behaviour + +**Elixir core:** Behaviours define contracts. Implementations are manual. + +**Phoenix Endpoint:** Uses `Plug.Builder` — a macro that generates the `call/2` pipeline by chaining plugs at compile time. + +**Source:** `lib/phoenix/endpoint.ex:478-480` + +```elixir +defp plug() do + quote location: :keep do + use Plug.Builder, init_mode: Phoenix.plug_init_mode() + ... + end +end +``` + +**Why the deviation:** The Plug specification (`init/1` + `call/2`) is too low-level for composing dozens of middleware. `Plug.Builder` provides the `plug` macro that chains them automatically. It's a higher-level abstraction over the raw behaviour pattern. + +--- + +## 7. Exception Structs with HTTP Status Codes + +**Elixir core exceptions:** Pure data — message, maybe some context fields. + +**Phoenix exceptions:** Include `plug_status` for HTTP response mapping. + +**Source:** `lib/phoenix/router.ex:2-26` + +```elixir +defmodule NoRouteError do + @moduledoc """ + Exception raised when no route is found. + """ + defexception plug_status: 404, message: "no route found", conn: nil, router: nil +end + +defmodule MalformedURIError do + @moduledoc """ + Exception raised when the URI is malformed on matching. + """ + defexception [:message, plug_status: 400] +end +``` + +**Why the deviation:** In a web context, exceptions need to map to HTTP status codes. Plug's error handling middleware reads `plug_status` to determine the response code. This bridges the gap between Elixir's exception system and HTTP semantics. diff --git a/patterns/patterns.md b/patterns/patterns.md new file mode 100644 index 0000000..3a5c9e0 --- /dev/null +++ b/patterns/patterns.md @@ -0,0 +1,334 @@ +# Phoenix Patterns + +Patterns specific to Phoenix extracted from the framework source code. + +## 1. Endpoint as Supervision Tree Root + Plug Pipeline + +**Source:** `lib/phoenix/endpoint.ex:1-40` (moduledoc) + +> The endpoint is the boundary where all requests to your web application start. +> It is also the interface your application provides to the underlying web servers. +> +> Overall, an endpoint has three responsibilities: +> - to provide a wrapper for starting and stopping the endpoint as part of a supervision tree +> - to define an initial plug pipeline for requests to pass through +> - to host web specific configuration for your application + +**Source:** `lib/phoenix/endpoint.ex:408-418` (`__using__` macro) + +```elixir +defmacro __using__(opts) do + quote do + @behaviour Phoenix.Endpoint + unquote(config(opts)) + unquote(pubsub()) + unquote(plug()) + unquote(server()) + end +end +``` + +The endpoint is four things composed together: +1. **Config** — compile-time and runtime configuration +2. **PubSub** — subscribe/broadcast interface +3. **Plug** — request pipeline (via `Plug.Builder`) +4. **Server** — supervision and HTTP server management + +**Why:** The Endpoint is a supervisor, a plug pipeline, AND a configuration host — all in one module. This unification means one place to configure and start the entire web layer. + +**Anti-pattern:** Splitting endpoint responsibilities across multiple unrelated modules — Phoenix deliberately consolidates the "boundary" concept. + +--- + +## 2. Router: Compile-Time Route Optimization + +**Source:** `lib/phoenix/router.ex:106-128` (Why the macros? info block) + +> We use macros for two purposes: +> +> * They define the routing engine, used on every request, to choose which +> controller to dispatch the request to. Thanks to macros, Phoenix compiles +> all of your routes to a single case-statement with pattern matching rules, +> which is heavily optimized by the Erlang VM +> +> * For each route you define, we also define metadata to implement `Phoenix.VerifiedRoutes`. + +**Source:** `lib/phoenix/router.ex:299` (route accumulation) + +```elixir +Module.register_attribute(__MODULE__, :phoenix_routes, accumulate: true) +``` + +**Why:** Routes are defined with macros that accumulate route data at compile time. At `@before_compile`, all routes are compiled into a single pattern-match dispatch function. This is O(1) routing, not O(n) list scanning. + +**Anti-pattern:** Runtime route tables (like maps or lists that are scanned per-request) — compile-time pattern matching is orders of magnitude faster. + +--- + +## 3. Pipeline and `pipe_through` for Request Processing + +**Source:** `lib/phoenix/router.ex:243-270` (Pipelines and plugs section) + +```elixir +pipeline :browser do + plug :fetch_session + plug :accepts, ["html"] +end + +scope "/" do + pipe_through :browser + # routes +end +``` + +**Why:** Pipelines are named, composable groups of plugs. Routes declare which pipelines they pass through. This separates concerns: +- Pipeline definition (what transformations exist) +- Route definition (which routes use which pipelines) + +**Anti-pattern:** Putting plug logic directly in controllers or duplicating plug chains across routes. + +--- + +## 4. Controller as Thin Dispatch Layer + +**Source:** `lib/phoenix/controller.ex:28-45` (moduledoc examples) + +```elixir +defmodule MyAppWeb.UserController do + use MyAppWeb, :controller + + def show(conn, %{"id" => id}) do + user = Repo.get(User, id) + render(conn, :show, user: user) + end +end +``` + +Controllers: +- Pattern match on params (destructure what you need) +- Call domain logic (the Repo/context layer) +- Render the result + +**Source:** `lib/phoenix/controller.ex:1-5` (imports) + +```elixir +defmodule Phoenix.Controller do + import Plug.Conn + alias Plug.Conn.AlreadySentError + require Logger +``` + +**Why:** Controllers import `Plug.Conn` for connection manipulation. They're pluggable themselves — a controller IS a plug. The action is just the last step in the plug pipeline. + +**Anti-pattern:** Fat controllers with business logic — controllers should delegate to context modules. + +--- + +## 5. Channel as GenServer with Topic-Based Routing + +**Source:** `lib/phoenix/channel.ex:1-25` (topic pattern) + +```elixir +channel "room:*", MyAppWeb.RoomChannel +``` + +Then in the channel: +```elixir +def join("room:lobby", _payload, socket) do + {:ok, socket} +end + +def join("room:" <> room_id, _payload, socket) do + {:ok, socket} +end +``` + +**Source:** `lib/phoenix/channel.ex:474-478` (channels are GenServers) + +```elixir +def start_link(triplet) do + GenServer.start_link(Phoenix.Channel.Server, triplet, + hibernate_after: @phoenix_hibernate_after + ) +end +``` + +**Why:** Each channel join creates a process. Pattern matching on the topic string provides natural routing. The GenServer backing means channels get supervision, hibernation, and all OTP semantics. + +**Anti-pattern:** Managing channel state in shared ETS or external state — each channel IS its own process with its own state. + +--- + +## 6. PubSub Integration via Endpoint + +**Source:** `lib/phoenix/endpoint.ex:437-475` (pubsub macro) + +```elixir +def subscribe(topic, opts \\ []) when is_binary(topic) do + Phoenix.PubSub.subscribe(pubsub_server!(), topic, opts) +end + +def broadcast(topic, event, msg) do + Phoenix.Channel.Server.broadcast(pubsub_server!(), topic, event, msg) +end + +defp pubsub_server! do + config(:pubsub_server) || + raise ArgumentError, "no :pubsub_server configured for #{inspect(__MODULE__)}" +end +``` + +**Why:** PubSub is wired through the endpoint — `MyAppWeb.Endpoint.broadcast!("topic", "event", payload)`. The endpoint knows its pubsub server from config; channels broadcast through it transparently. + +**Anti-pattern:** Passing PubSub server names around manually — the endpoint already knows and exposes the interface. + +--- + +## 7. Socket as Authentication Boundary + +**Source:** `lib/phoenix/socket.ex:1-30` (moduledoc: connect callback pattern) + +```elixir +defmodule MyAppWeb.UserSocket do + use Phoenix.Socket + + channel "room:*", MyAppWeb.RoomChannel + + def connect(params, socket, _connect_info) do + {:ok, assign(socket, :user_id, params["user_id"])} + end + + def id(socket), do: "users_socket:#{socket.assigns.user_id}" +end +``` + +**Why:** Authentication happens ONCE at socket connection. All channels on that socket inherit the authenticated identity. `id/1` enables targeted disconnection — `Endpoint.broadcast("users_socket:123", "disconnect", %{})`. + +**Anti-pattern:** Authenticating in every `join/3` callback instead of at the socket level. + +--- + +## 8. Plug Pattern: `init/1` + `call/2` + +**Source:** `lib/phoenix/router/route.ex:47-57` + +```elixir +@doc "Used as a plug on forwarding" +def init(opts), do: opts + +@doc "Used as a plug on forwarding" +def call(%{path_info: path, script_name: script} = conn, {fwd_segments, plug, opts}) do + new_path = path -- fwd_segments + {base, ^new_path} = Enum.split(path, length(path) - length(new_path)) + conn = %{conn | path_info: new_path, script_name: script ++ base} + conn = plug.call(conn, plug.init(opts)) + %{conn | path_info: path, script_name: script} +end +``` + +**Why:** The Plug specification splits work into: +- `init/1` — compile-time setup (called once, result cached) +- `call/2` — runtime execution (called per-request, must be fast) + +This is Phoenix's fundamental composition pattern. Everything is a plug. + +**Anti-pattern:** Doing expensive setup work in `call/2` instead of `init/1` — it runs on every request. + +--- + +## 9. Telemetry Integration in Router Dispatch + +**Source:** `lib/phoenix/router.ex:400-438` (telemetry instrumentation) + +```elixir +def __call__(conn, metadata, prepare, pipeline, {plug, opts}) do + conn = prepare.(conn, metadata) + start = System.monotonic_time() + measurements = %{system_time: System.system_time()} + metadata = %{metadata | conn: conn} + :telemetry.execute([:phoenix, :router_dispatch, :start], measurements, metadata) + + case pipeline.(conn) do + %Plug.Conn{halted: true} = halted_conn -> + measurements = %{duration: System.monotonic_time() - start} + metadata = %{metadata | conn: halted_conn} + :telemetry.execute([:phoenix, :router_dispatch, :stop], measurements, metadata) + halted_conn + + %Plug.Conn{} = piped_conn -> + try do + plug.call(piped_conn, plug.init(opts)) + else + conn -> + measurements = %{duration: System.monotonic_time() - start} + metadata = %{metadata | conn: conn} + :telemetry.execute([:phoenix, :router_dispatch, :stop], measurements, metadata) + conn + rescue + e in Plug.Conn.WrapperError -> + measurements = %{duration: System.monotonic_time() - start} + :telemetry.execute([:phoenix, :router_dispatch, :exception], measurements, metadata) + Plug.Conn.WrapperError.reraise(e) + end + end +end +``` + +**Source:** `lib/phoenix/logger.ex:7-50` (telemetry event catalog) + +Phoenix emits these telemetry events: +- `[:phoenix, :endpoint, :init]` — endpoint supervision tree started +- `[:phoenix, :endpoint, :start]` — request begins (via `Plug.Telemetry`) +- `[:phoenix, :endpoint, :stop]` — response sent +- `[:phoenix, :router_dispatch, :start]` — route dispatch begins +- `[:phoenix, :router_dispatch, :stop]` — route dispatch succeeds +- `[:phoenix, :router_dispatch, :exception]` — route dispatch raises +- `[:phoenix, :socket_connected]` — socket connection established + +**Why:** Telemetry is baked into every request path. The router wraps ALL dispatches in start/stop/exception telemetry, enabling monitoring, tracing (OpenTelemetry), and logging without modifying application code. + +**Anti-pattern:** Manual timing/logging in controllers — telemetry provides this automatically at the infrastructure level. + +--- + +## 10. ConnTest Pattern: Endpoint-Based Integration Testing + +**Source:** `lib/phoenix/test/conn_test.ex:1-30` (moduledoc) + +```elixir +@endpoint MyAppWeb.Endpoint + +test "says welcome on the home page" do + conn = get(build_conn(), "/") + assert conn.resp_body =~ "Welcome!" +end + +test "logs in" do + conn = post(build_conn(), "/login", [username: "john", password: "doe"]) + assert conn.resp_body =~ "Logged in!" +end +``` + +**Why:** `Phoenix.ConnTest` tests against the full endpoint stack (plugs, router, controller) without starting an HTTP server. `build_conn()` creates a test connection, HTTP verb functions dispatch through the endpoint. This gives integration-level confidence with unit-test speed. + +**Anti-pattern:** Testing controllers in isolation without the plug pipeline — you miss middleware bugs (auth, CSRF, sessions). + +--- + +## 11. ChannelTest Pattern: Process-Based Channel Testing + +**Source:** `lib/phoenix/test/channel_test.ex:1-30` (moduledoc) + +```elixir +{:ok, _, socket} = + socket(UserSocket, "user:id", %{some_assigns: 1}) + |> subscribe_and_join(RoomChannel, "room:lobby", %{"id" => 3}) + +# Or using connect/3 to call your UserSocket.connect callback: +{:ok, socket} = connect(UserSocket, %{"some" => "params"}, %{}) +{:ok, _, socket} = subscribe_and_join(socket, "room:lobby", %{"id" => 3}) +``` + +**Why:** Channel tests communicate via messages (not HTTP). `subscribe_and_join/4` connects a test process to the channel, and you can assert on broadcasts (`assert_broadcast`), pushes (`assert_push`), and replies (`assert_reply`). The test process subscribes to the same PubSub topic, so it sees everything the channel broadcasts. + +**Anti-pattern:** Testing channels by connecting real WebSocket clients — too slow, too brittle, tests the transport layer unnecessarily.