Reading and writing tests for Speechwave
Primary source: ExUnit docs, which cover all assertion macros and configuration options. For LiveView testing: Phoenix.LiveViewTest docs.
Speechwave's test directory mirrors the lib/ structure:
| ExUnit / Phoenix | RSpec / Rails |
|---|---|
test/speechwave/talks_test.exs | spec/models/talk_spec.rb |
test/speechwave_web/live/ | spec/features/ or spec/system/ |
test/support/fixtures/ | spec/factories/ (FactoryBot) |
DataCase | rails_helper.rb with DB cleaning |
ConnCase | rails_helper.rb with request helpers |
test/speechwave/reactions_test.exs
defmodule Speechwave.ReactionsTest do
use Speechwave.DataCase # 1. Base case
import Speechwave.AccountsFixtures # 2. Fixture imports
import Speechwave.TalksFixtures
alias Speechwave.{Reactions, Talks} # 3. Aliases
setup do # 4. Shared setup
user = user_fixture()
talk = talk_fixture(user)
{:ok, session} = Talks.start_session(talk)
%{session: session}
end
describe "create_reaction/3" do # 5. Describe block
test "creates a reaction with default slide 0", %{session: session} do
assert {:ok, reaction} = Reactions.create_reaction(session, "❤️")
assert reaction.emoji == "❤️"
assert reaction.slide_number == 0
end
end
end
Let's unpack each piece:
use Speechwave.DataCaseThis does three things:
Ecto, Ecto.Changeset, Ecto.Query, and the Repo alias, so you can write queries in testserrors_on/1, a helper for asserting on changeset validation errorsFor web/LiveView tests, you use SpeechwaveWeb.ConnCase instead, which does the same sandbox setup plus provides a conn (test HTTP connection) and login helpers.
DatabaseCleaner gem. Every test automatically gets a clean database. Insert whatever you want, it's all rolled back. This is set up in data_case.ex and you never need to think about it.
Speechwave uses fixture modules instead of a factory library. They work like simple factory functions:
test/support/fixtures/accounts_fixtures.ex:17-24
def user_fixture(attrs \\ %{}) do
{:ok, user} =
attrs
|> valid_user_attributes()
|> Accounts.register_user()
user
end
Call user_fixture() for defaults, or user_fixture(%{email: "specific@example.com"}) to override. Each call generates a unique email using System.unique_integer().
# FactoryBot
create(:user)
create(:user, email: "specific@example.com")
Same idea. Phoenix just uses plain functions instead of a DSL. The import Speechwave.AccountsFixtures at the top of the test file makes user_fixture() available.
setup: shared test dataThe setup block runs before each test in the module (or in the enclosing describe). It returns a map that gets merged into the test context, the %{session: session} that tests receive via pattern matching.
setup do
user = user_fixture()
talk = talk_fixture(user)
{:ok, session} = Talks.start_session(talk)
%{session: session} # ← this map becomes the test context
end
test "uses the session", %{session: session} do # ← pattern match to extract it
assert session.label == "Session 1"
end
You can also reference setup functions by name:
test/speechwave_web/live/dashboard_live_test.exs:10-12
setup %{conn: conn} do
user = user_fixture()
%{conn: log_in_user(conn, user), user: user}
end
This setup receives the conn that ConnCase already provided, logs in a user, and passes both to the tests.
# RSpec
let(:user) { create(:user) }
before { sign_in(user) }
describe and testdescribe groups related tests. test defines a single test case. Both are exactly what you'd expect from RSpec's describe/it:
describe "create_reaction/3" do
test "creates a reaction with default slide 0", %{session: session} do
# ...
end
test "requires emoji", %{session: session} do
# ...
end
end
Each describe can have its own setup that runs in addition to the module-level setup.
ExUnit uses assert for everything. There's no expect(...).to DSL like RSpec. Pattern matching does the heavy lifting.
| ExUnit | RSpec equivalent | What it checks |
|---|---|---|
assert x == 42 | expect(x).to eq(42) | Equality |
assert x | expect(x).to be_truthy | Truthy |
refute x | expect(x).to be_falsy | Falsy |
assert {:ok, val} = expr | n/a | Pattern match + bind |
assert x =~ "sub" | expect(x).to include("sub") | String contains / regex match |
assert x in list | expect(list).to include(x) | Membership |
assert_raise Error, fn | expect { }.to raise_error | Exception raised |
assert_receive msg | n/a | Process received message |
refute_receive msg | n/a | Process did not receive message |
The most powerful ExUnit pattern. When you write assert with =, it's a pattern match. It asserts the match succeeds and binds variables for further assertions:
test/speechwave/talks_test.exs:48-52
assert {:ok, talk} =
Talks.create_talk(scope(user), %{
title: "Elixir for Rubyists",
slug: "elixir-for-rubyists"
})
assert talk.title == "Elixir for Rubyists"
assert talk.user_id == user.id
The first assert does two things: confirms the function returned {:ok, _} (not {:error, _}), and binds talk so the next assertions can inspect it.
assert {:ok, talk} = ... is the idiomatic way to test functions that return {:ok, value} / {:error, changeset}. If it returns an error, the pattern match fails and you get a clear test failure showing what was actually returned.
test/speechwave/talks_test.exs:62-63
assert {:error, cs} = Talks.create_talk(scope(user), %{title: "Talk 2", slug: "my-talk"})
assert "has already been taken" in errors_on(cs).slug
Pattern match on {:error, cs} to confirm it failed, then use errors_on/1 (from DataCase) to check the specific validation message.
test/speechwave/talks_test.exs:91-93
assert_raise Ecto.NoResultsError, fn ->
Talks.get_talk!(scope(user_b), talk.id)
end
assert_raise wraps the code in a function and asserts it raises the specified exception.
Phoenix provides Phoenix.LiveViewTest, a module that lets you interact with LiveViews from tests without a browser.
test/speechwave_web/live/dashboard_live_test.exs:22-24
test "renders new talk form", %{conn: conn} do
{:ok, view, _html} = live(conn, "/dashboard")
assert has_element?(view, "#talk-form")
end
live(conn, path) mounts the LiveView and returns three things:
view: a test handle for interacting with the LiveView processhtml: the initial rendered HTML (often ignored with _html){:ok, ...} tuple, which you can match on to verify it mounted successfullyThe key functions from Phoenix.LiveViewTest:
| Test function | Simulates | Template attribute |
|---|---|---|
render_click(view, event, params) | Button click | phx-click |
render_submit(view_or_form) | Form submit | phx-submit |
render_change(view_or_form, params) | Form field change | phx-change |
element(view, selector) | Targets a specific DOM element | n/a |
form(view, selector, data) | Targets a form with data | n/a |
render(view) | Returns current HTML | n/a |
Here's a complete interaction: creating a talk.
test/speechwave_web/live/dashboard_live_test.exs:36-46
test "creates talk, shows banner, and selects talk with QR code", %{conn: conn} do
{:ok, view, _html} = live(conn, "/dashboard")
view
|> form("#talk-form", talk: %{title: "Elixir for Rubyists", slug: "elixir-for-rubyists"})
|> render_submit()
assert has_element?(view, "#created-talk")
refute has_element?(view, "#qr-code")
assert has_element?(view, "#selected-talk-qr")
end
Read this as: mount the dashboard, fill in and submit the talk form, then assert the right elements appeared. The form |> render_submit pipe triggers the "save" event in DashboardLive's handle_event.
test/speechwave_web/live/dashboard_live_test.exs:82
view |> element("#talk-list button", "Prime Talk") |> render_click()
element(view, selector, text_filter) finds a DOM element matching the CSS selector and containing the given text. Then render_click() simulates clicking it.
| Assertion | Checks |
|---|---|
has_element?(view, "#id") | Element exists |
has_element?(view, "#id", "text") | Element exists with text content |
render(view) =~ "text" | HTML contains string |
refute has_element?(view, "#id") | Element does not exist |
The TalkLive tests verify real-time behavior by subscribing to PubSub from the test process:
test/speechwave_web/live/talk_live_test.exs:29-36
test "react event broadcasts via Endpoint", %{conn: conn, talk: talk} do
{:ok, view, _html} = live(conn, "/t/#{talk.slug}")
Phoenix.PubSub.subscribe(Speechwave.PubSub, "reactions:#{talk.slug}")
render_click(view, "react", %{"emoji" => "❤️"})
assert_receive %Phoenix.Socket.Broadcast{
event: "new_reaction",
payload: %{emoji: "❤️"}
}, 500
end
The test subscribes to the same PubSub topic, triggers the event, then assert_receive checks that the broadcast arrived within 500ms. The opposite, refute_receive, verifies a message was not sent (used for the rate limiting test).
assert_receive and refute_receive are process mailbox assertions. In Elixir, every process has a mailbox. PubSub delivers messages to the subscribing process's mailbox. The test process is a process, so it can subscribe and check its own mailbox. This is unique to Elixir. No Ruby equivalent exists.
# Run all tests
mix test
# Run a specific file
mix test test/speechwave/talks_test.exs
# Run a specific test by line number
mix test test/speechwave/talks_test.exs:48
# Run tests matching a string
mix test --only describe:"create_talk"
# Run with verbose output (shows each test name)
mix test --trace
mix test = bundle exec rspec. The line number targeting works the same way as RSpec's spec/file.rb:48.
What does this assertion do?
assert {:ok, talk} = Talks.create_talk(scope(user), attrs)
= inside assert is a pattern match. If create_talk returns {:error, changeset}, the match fails and the test fails with a clear message showing the actual return value. If it returns {:ok, talk}, talk is bound and available for the rest of the test.= in Elixir is the match operator. Inside assert, a failed match causes a test failure. The left side is a pattern, not a plain variable assignment. Try again.What does the setup block return, and how do tests access it?
setup do
user = user_fixture()
%{user: user}
end
test "does something", %{user: user} do
setup block returns a map. ExUnit merges it into the test context (along with anything from the base case, like conn from ConnCase). Each test pattern matches the context in its argument: %{user: user} extracts the user. You only need to match the keys you use. Extra keys are ignored.self in this context. Look at how the test function's second argument relates to the map returned by setup. Try again.What does this test verify?
render_click(view, "react", %{"emoji" => "❤️"})
refute_receive %Phoenix.Socket.Broadcast{event: "new_reaction"}, 200
refute_receive waits the specified timeout (200ms) and asserts that no matching message arrived in the test process's mailbox. Since the test subscribed to PubSub earlier, this confirms the broadcast was suppressed, meaning the rate limiter blocked it.refute_receive is the opposite of assert_receive. It asserts that a message was not received. Think about what PubSub broadcast the test is checking for (or rather, checking is absent). Try again.Why does DataCase wrap tests in a database sandbox?
DatabaseCleaner in Ruby, but built into the framework.Try writing a test yourself. Open test/speechwave/talks_test.exs and add a test to the "generate_slug/1" describe block. For example, test what happens with leading/trailing spaces, or with all-special-character input. Run it with:
mix test test/speechwave/talks_test.exs --trace
Then read through test/speechwave_web/live/dashboard_live_test.exs. It's the most comprehensive LiveView test file in the project. For each test, trace the interaction: what setup provides, what user action is simulated, and what DOM assertion confirms the result.