← docs.speechwave.live

Lesson 6: testing with ExUnit

Reading and writing tests for Speechwave

Primary source: ExUnit docs, which cover all assertion macros and configuration options. For LiveView testing: Phoenix.LiveViewTest docs.

Test file organization

Speechwave's test directory mirrors the lib/ structure:

test/ ├── test_helper.exs ← Runs once before all tests ├── support/ │ ├── data_case.ex ← Base for context tests (DB access) │ ├── conn_case.ex ← Base for web tests (HTTP + LiveView) │ ├── channel_case.ex ← Base for channel tests │ └── fixtures/ │ ├── accounts_fixtures.ex ← Factory functions for users │ └── talks_fixtures.ex ← Factory functions for talks ├── speechwave/ │ ├── talks_test.exs ← Tests for Speechwave.Talks context │ ├── reactions_test.exs ← Tests for Speechwave.Reactions context │ └── ... └── speechwave_web/ ├── live/ │ ├── dashboard_live_test.exs ← Tests for DashboardLive │ ├── talk_live_test.exs ← Tests for TalkLive │ └── ... └── controllers/
Rails mapping:
ExUnit / PhoenixRSpec / Rails
test/speechwave/talks_test.exsspec/models/talk_spec.rb
test/speechwave_web/live/spec/features/ or spec/system/
test/support/fixtures/spec/factories/ (FactoryBot)
DataCaserails_helper.rb with DB cleaning
ConnCaserails_helper.rb with request helpers

Anatomy of a test file

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:

1. The base case: use Speechwave.DataCase

This does three things:

For web/LiveView tests, you use SpeechwaveWeb.ConnCase instead, which does the same sandbox setup plus provides a conn (test HTTP connection) and login helpers.

Key insight: The database sandbox is why you don't need a 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.

2. Fixtures: the factory pattern

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().

Rails equivalent:
# 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.

3. setup: shared test data

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

Rails equivalent:
# RSpec
let(:user) { create(:user) }
before { sign_in(user) }

4. describe and test

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

Assertions

ExUnit uses assert for everything. There's no expect(...).to DSL like RSpec. Pattern matching does the heavy lifting.

ExUnitRSpec equivalentWhat it checks
assert x == 42expect(x).to eq(42)Equality
assert xexpect(x).to be_truthyTruthy
refute xexpect(x).to be_falsyFalsy
assert {:ok, val} = exprn/aPattern match + bind
assert x =~ "sub"expect(x).to include("sub")String contains / regex match
assert x in listexpect(list).to include(x)Membership
assert_raise Error, fnexpect { }.to raise_errorException raised
assert_receive msgn/aProcess received message
refute_receive msgn/aProcess did not receive message

Pattern matching in assertions

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.

Key insight: 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.

Testing for expected failures

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.

Testing LiveViews

Phoenix provides Phoenix.LiveViewTest, a module that lets you interact with LiveViews from tests without a browser.

Mounting a LiveView

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:

Simulating user actions

The key functions from Phoenix.LiveViewTest:

Test functionSimulatesTemplate attribute
render_click(view, event, params)Button clickphx-click
render_submit(view_or_form)Form submitphx-submit
render_change(view_or_form, params)Form field changephx-change
element(view, selector)Targets a specific DOM elementn/a
form(view, selector, data)Targets a form with datan/a
render(view)Returns current HTMLn/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.

Clicking specific elements

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.

Asserting on the DOM

AssertionChecks
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

Testing PubSub

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

Key insight: 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.

Running tests

# 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
Rails: mix test = bundle exec rspec. The line number targeting works the same way as RSpec's spec/file.rb:48.

Practice

Question 1

What does this assertion do?

assert {:ok, talk} = Talks.create_talk(scope(user), attrs)
Right. The = 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.
Remember that = 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.

Question 2

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
Correct. The 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.
Elixir doesn't have instance variables or self in this context. Look at how the test function's second argument relates to the map returned by setup. Try again.

Question 3

What does this test verify?

render_click(view, "react", %{"emoji" => "❤️"})
refute_receive %Phoenix.Socket.Broadcast{event: "new_reaction"}, 200
Exactly. 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.

Question 4

Why does DataCase wrap tests in a database sandbox?

Right. Each test runs inside a database transaction. When the test ends (pass or fail), the transaction is rolled back, and all inserted data disappears. This means tests are fully isolated without needing to truncate tables or run cleanup hooks. It's the same concept as DatabaseCleaner in Ruby, but built into the framework.
Think about what happens to the data that tests create (users, talks, sessions). How does each test start with a clean slate? Try again.

Your next step

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.