# Write ExUnit Tests for a Phoenix App

Yes. An agent finds the plugs, controllers and components in your Phoenix app with no test file, writes ExUnit tests in the project's own style and opens a PR.

[Run this on Rush](https://cloud.getrush.ai/run/write-exunit-tests/phoenix/), in a Project that has your repository.

## What the agent does

The agent starts by mapping `lib/` to `test/`, because in a mature Phoenix app the gaps are specific modules rather than whole layers. In hexpm/hexpm, the app behind hex.pm, `test/` holds 280 test files and mirrors `lib/hexpm_web/` closely: every controller under `controllers/` and `controllers/api/` except two has a `_test.exs`, every LiveView under `live/` has one, and so do six of the sixteen plugs. The modules without a test file of their own are the plugs `Status`, `Vary`, `ReadOnly`, `DashboardAuth`, `SCIMAuth`, `VarselAuth`, `RequestLog`, `CacheRawBody`, `ReadmeContentSecurityPolicy` and `OrganizationSSO`; the controllers `OrganizationAuthController` and `API.SSOAuthorizationController`; the helpers `RepositoryAccess`, `ReadmeToken` and `Session.TTL`; and twenty of the twenty-five function components under `components/`, from `flash.ex` and `input.ex` to `table.ex` and `navbar.ex`.

Then it reads how the project writes tests, so the new ones look like the old ones. `mix.exs` requires Elixir `~> 1.20`, adds `test/support` to `elixirc_paths` in the test env, and aliases `test` to `ecto.create --quiet`, `ecto.load --skip-if-loaded`, `ecto.migrate` and then `test`. `test/support/conn_case.ex` imports `Phoenix.ConnTest`, `Mox`, the factory and `Hexpm.TestHelpers`, enables verified routes, checks out the SQL sandbox and the in-memory store, stubs `Hexpm.Geo.Mock`, and offers `test_login/3` to sign a conn in as a user with an optional sudo timestamp. A plug test like `test/hexpm_web/plugs/sudo_test.exs` uses `build_conn() |> Plug.Test.init_test_session(...)` and calls the plug's functions directly; `attack_test.exs` defines a tiny inline controller that mounts the plug. A component test like `components/confirmation_modal_test.exs` is a plain `ExUnit.Case, async: true` that calls `Phoenix.LiveViewTest.render_component/2` and queries the HTML with `LazyHTML`. `config/test.exs` swaps billing, pwned-password, geolocation and HTTP for Mox mocks, uses in-memory buckets and the Swoosh test adapter, and runs the endpoint with `server: false`.

With that, each gap gets the test its shape calls for. `Plugs.Status` answers 200 and halts on `path_info: ["status"]` and passes every other conn through; `Plugs.Vary` joins its option onto an existing `vary` header in a `register_before_send` callback; `Plugs.ReadOnly` lets `GET`, `HEAD` and `OPTIONS` through, refuses writes with a `Retry-After` of 60 unless the route is in `allowed_routes`, and `write_routes` turns a GET into a write; `Plugs.CacheRawBody` reads up to a million bytes into `conn.assigns[:raw_body]` only for the secret-scanning path; `ReadmeToken.sign/3` and `verify/4` round-trip through `Phoenix.Token` with a 30-minute `max_age`, so the test also checks a tampered token and an expired one; `Session.TTL.within?/2` accepts an ISO 8601 timestamp inside the duration and rejects a malformed string. The two controllers go through `ConnCase`: `GET /organizations/:organization/authenticate` redirects to the return path when no requirement is outstanding, to `/dashboard/security` with a flash when two-factor is required, to the SSO route otherwise, and renders not found for a non-member; `POST /oauth/organization_authorization` answers 201 with a user session token and 422 for a `client_credentials` token, built with `insert(:oauth_token, ...)` from the factory. Components get a `render_component` test each for their attributes and slots.

The agent runs the suite the way `.github/workflows/main.yml` does, with OTP 29.1 and Elixir 1.20.4 against a `postgres:18` service and `MIX_ENV=test`: `mix deps.get`, `mix compile --warnings-as-errors`, `mix format --check-formatted` and `mix test`. Production code under `lib/` stays as it is unless a test exposes a bug, in which case the fix is its own commit and the description says so.

## Start it with one API call

`POST https://api.prix.dev/o/{org}/sessions`

```json
{
  "agent": "claude",
  "input": "Write ExUnit tests for the modules in this Phoenix app that have no test file of their own. Start by listing every module under lib/<app>_web/ (plugs, controllers, live views, components and helpers) and checking for a matching _test.exs under test/; in an app shaped like hexpm/hexpm the gaps are plugs such as Status, Vary, ReadOnly and CacheRawBody, the OrganizationAuthController and the API SSOAuthorizationController, helpers such as ReadmeToken and Session.TTL, and most function components. Read test/support/conn_case.ex, data_case.ex, the factory and two existing tests of each shape first, and match their style: ConnCase with build_conn, test_login and the factory for controllers and request-level plugs; direct calls on a conn from Plug.Test for small plugs; ExUnit.Case with Phoenix.LiveViewTest.render_component and LazyHTML for components. Test behaviour through public functions and every branch the module has, not implementation details, and never stub the module under test. Change nothing under lib/ unless a test reveals a bug; if it does, fix it in a separate commit and say so. Start Postgres as .github/workflows/main.yml does, run mix compile --warnings-as-errors, mix format --check-formatted and mix test, and open a pull request listing each module now covered.",
  "resources": [
    {
      "url": "https://github.com/your-org/your-app"
    }
  ]
}
```

1. **Get an API key.** In the console, open Settings, then API keys, and create a key for your org. Send it in the Authorization header as a Bearer token.
2. **Send the request.** POST the body in request.json to https://api.prix.dev/o/{org}/sessions, with your repository as the resources URL. The run starts in its own cloud sandbox.
3. **Read what came back.** Follow the run on its live stream or wait for the run.completed webhook, then read the result and its trajectory.

## What comes back

What comes back is a pull request on a new branch that adds test files beside the modules it covered, such as `test/hexpm_web/plugs/status_test.exs`, `test/hexpm_web/plugs/read_only_test.exs`, `test/hexpm_web/readme_token_test.exs`, `test/hexpm_web/session/ttl_test.exs`, `test/hexpm_web/controllers/organization_auth_controller_test.exs`, `test/hexpm_web/controllers/api/sso_authorization_controller_test.exs` and one `components/<name>_test.exs` per component taken. Nothing under `lib/` changes unless a test found a bug, and `mix.lock` does not move.

The description lists each module with the branches its tests cover, quotes the `mix test` summary and the results of the compile and format checks, and names any module it left out, such as `VarselAuth`, when a test would need a signing key the config does not provide and says what config the test would need.

Before merging, a reviewer reads the `ReadOnly` and `OrganizationSSO` tests first, since those plugs decide what a request may do, checks that controller tests use `test_login` and the factory rather than inserting rows by hand, and that component tests assert on rendered attributes and text instead of on CSS class lists that change with a redesign.

## Run it on every new plug, controller or component

Create an automation in the Project with this prompt and a `schedule` trigger (`cron` `0 2 * * *` in your `timezone`), so each night the agent diffs `lib/` against `test/` and writes tests for whatever was added that day without a test file. A night with no gap ends with no pull request and a one-line result. Keep `max_runs_per_hour` at 1 so a retried fire does not start two runs on the same tree.

To start it from a pull request instead, give the automation a `github` trigger on `pull_request.opened` and `pull_request.synchronize`; the event follows the prompt, and the agent limits itself to the modules that pull request added. Its runs are not tied to an API key, so read the results from the automation's `recent_runs` or the Project's sessions rather than from a key's webhook.

## Why run it on Rush?

A Phoenix app this size needs Postgres, the sandbox, Mox stubs and the factory before a single controller test runs, and the first `mix test` compiles 158 dependencies; on a laptop that is an afternoon before the first assertion. On Rush the agent takes that setup in its own sandbox with the OTP and Elixir versions CI pins, writes tests in the shapes the project already uses, and iterates until `mix compile --warnings-as-errors` and `mix format --check-formatted` are clean.

The pull request carries the list of modules and branches covered and the exact `mix test` output, so a reviewer reads tests rather than a coverage percentage. The same request runs against every Phoenix app you maintain at once, each in its own sandbox.

## Frequently asked questions

### The suite already has 280 test files. What is left?

Modules with no test file of their own: in hexpm, ten of sixteen plugs, two controllers, three helpers and twenty function components. Some of their lines are exercised indirectly through controller tests, but a plug like `ReadOnly` has branches (`allowed_routes`, `write_routes`, `Retry-After`) that only a direct test pins down.

### Does it need Postgres and the other services?

Postgres, yes: `ConnCase` and `DataCase` check out the Ecto sandbox, and the `test` alias creates and migrates the database. Everything else is swapped in `config/test.exs`: billing, pwned passwords, geolocation and HTTP are Mox mocks, storage is in-memory, and mail goes to the Swoosh test adapter.

### Will it use Mox to stub the module it is testing?

No. Mox is for the behaviours `config/test.exs` already mocks, such as `Hexpm.Billing.Mock`. A plug or controller is tested by sending it a conn, and a component by rendering it; stubbing the module under test would prove nothing.

### How does it test a plug that reads runtime config?

With the project's own `app_env/3` helper from `test/support/test_helpers.ex`, which sets an application environment value for the test and restores it afterwards. `DashboardAuth` reads `:dashboard_user` and `:dashboard_password` that way, and `ReadmeContentSecurityPolicy` reads `:host` and `:img_url`.

### Which Elixir and OTP does it run?

The ones `.github/workflows/main.yml` pins, Elixir 1.20.4 on OTP 29.1, which also match the `Dockerfile`'s build arguments. The description names the versions and the Postgres image used.
