Getting Started with Phoenix (as a Rails Developer) - Part 3

Comparing testing and development tools for Elixir-Phoenix and Rails apps

Welcome back to this three-part series, which provides an overview of building Elixir apps with Phoenix and how that compares to building Rails apps. Part 3 compares testing and development tools for each framework. Be sure to check out Part 1 and Part 2 as well:

Testing

The Basics

Phoenix tests are written with a built-in testing framework called ExUnit. ExUnit provides a simple testing DSL with a handy set of macros for evaluating the truthiness of a given statement. The most commonly used macros in ExUnit are assert and refute, similar to Rails Minitest.

A basic Phoenix test case reads as follows:

test "strings match" do
 assert 'hello' == 'hello'
end

Here you are asserting that two strings are equal, and will not see any failures when you run the test. If you try to instead assert that “hi” equals “hello”, you’ll see the following failure.

test "strings match" do
 assert 'hi' == 'hello'
end
 
# Failure
test strings match (MyTest)
test/my_test.exs:2
code: 'hi' == 'hello'
lhs:  'hi'
rhs:  'hello'
stacktrace:
 test/my_test.exs:3: (test)
 
Finished in 0.05 seconds
1 test, 1 failure

Phoenix assert and refute macros use pattern matching to determine truthiness. Failure messages provide the values of the left hand side (lhs) and right hand side (rhs) of an attempted match. This makes debugging fairly straightforward. If you want to update this test to verify that “hi” does not equal “hello”, you can use refute and the test will pass with no failures.

test "strings match" do
 refute 'hi' == 'hello'
end

One thing to watch out for is assigning undefined variables in tests. The below example will not cause any failures even though it doesn't really validate anything.

test "strings match" do
 assert hello = 'hello'
end

In this example, the variable hello will simply be bound to the string “hello” and the test will pass. Luckily, you will see a warning that the variable hello is unused, which is a good signal to go back and review your test. This test should read assert hello == ‘hello’, where hello is previously defined.

Having seen the basics, let’s look at a few examples for how you might use ExUnit throughout different Phoenix components.

Testing Views

Phoenix View tests are commonly used to assert that view functions return the expected values. The following test verifies that a title_and_author function in the BookView returns the correct string for a given book.

# Phoenix View Test: test/my_book_app_web/views/book_view_test.exs
defmodule MyBookAppWeb.BookViewTest do
 alias MyBookApp.BookView
 
 test "title_and_author/1" do
   book = insert(:book)
   assert BookView.title_and_author(book) == "East of Eden, by John Steinbeck"
 end
end

Note that you need to import the BookView to be able to call its functions. The ability to insert(:book) comes from ExMachina.Ecto, which is a module for building and inserting factories in Ecto like you can with gems like factory_bot in Rails.

The most comparable tests you’ll find in a Rails app are helper specs. Helper specs correlate to any helper modules you may have built to support a view. Helper specs read very similarly to Phoenix view specs. Here’s an example of a Rails BookHelper spec for comparison:

# Rails Helper Test: spec/helpers/book_helper_spec.rb
RSpec.describe BookHelper do
 describe "#title_and_author" do
   it "returns expected results" do
     let!(:book) { create(:book) }
     expect(book.title_and_author.to eq("East of Eden, by John Steinbeck")
   end
 end
end

Testing Controllers

Phoenix Controller tests are commonly used to assert the expected app flow and verify the expected HTML appears in a rendered template.

One note before diving in: much like Rails, Phoenix tests can be organized in blocks. You can add a setup block before a set of tests to pull out common setup elements, like creating a book. You can add describe blocks to group and organize tests.

Here is an example of what a Phoenix Controller test might look:

# Phoenix Controller Test: test/my_book_app_web/controllers/book_controller_test.exs
defmodule MyBookAppWeb.BookControllerTest do
 use MyBookAppWeb.ConnCase
 
 setup do
   book = insert(:book)
 end
 
 describe "show" do
   test "renders", %{conn: conn} do
     conn
     |> get(Routes.book_path(conn, :show, book))
 
     assert html_response(conn, 200)
     assert has_selector?(conn, "[data-test='book-show']")
   end
 end
 
 describe "update" do
   test "updates book with valid params" do
     params = %{book: %{title: "East of Eden"}}
 
     conn
     |> post(Routes.book_path(conn, :update, book.id, params))
 
     assert get_flash(conn, :info) =~ "Successfully updated the book."
     assert redirected_to(conn) == Routes.book_path(conn, :show, book.id)
   end
 end
end

The show test verifies a successful HTTP response and that expected HTML elements are actually rendered on the page (there’s a few ways to do this - this example looks for CSS selectors created for this purpose). The update test verifies that posting valid data to update a book struct results in a successful flash message and a redirect to the book’s show page.

Phoenix controller tests have elements that you might see in Rails controller or feature tests. Here’s what a similar controller test might look like in Rails:

# Rails Controller Test: spec/controllers/books_controller_spec.rb
RSpec.describe BooksController, type: :controller do
 let!(:book) { create(:book) }
 
 describe "GET show" do
   it 'successfully renders' do
     get :show, params: { id: book.id }
     expect(reponse).to render_template("show")
     expect(response.code).to eq("200")
   end
 end
 
 describe "POST update" do
   it "can handle invalid data" do
     post :update, params: {id: book.id, {book_params: title: "East of Eden"}}
     expect(response.code).to redirect_to(book_path(book))
   end
 end
end

There is a bit more magic happening in the get and post calls here - Rails infers that these calls are for books based on the reference to the BooksController in the first line. Often some of the flows validated in a Rails controller specs are also validated in feature specs. Feature specs (typically written with Capybara) allow you to walk through core app flows as though you were a user, verifying routing and page element rendered along the way. These are a bit slower to run, so it’s typically good to start with controller tests and add feature tests for higher priority user flows.

Testing the Model Layer

Phoenix schema tests are used to validate underlying data structs and their changesets. Here’s an example of what a Book schema test might look like:

# Phoenix Model Layer Test: test/my_book_app/books/book_test.exs
defmodule MyBookApp.BookTest do
 alias MyBookApp.Books.Book
 alias MyBookApp.Repo
 
 describe "changeset/2" do
   test "requires a title and author" do
     book_params = %{}
     changeset = Book.changeset(%Book{}, book_params)
 
     assert %{title: ["can't be blank"]} = errors_on(changeset)
   end
 
   test "book attributes are saved correctly" do
     {:ok, book} = Book.create_book(valid_book_params)
     book = Repo.get!(Book, book.id)
 
     assert book.title == valid_book_params[:title]
     assert book.author_id == valid_book_params[:author_id]
   end
 end
end

The first test asserts that a changeset will contain errors if any required fields are missing. The second asserts that valid params passed into a changeset are correctly stored in a book struct.

Test modules for functions in Phoenix contexts are commonly found in the same test directory as schema tests (e.g. under test/my_app). These tests feel similar to Phoenix view tests - they typically verify that each function in a context produces the expected value or result. The following test asserts that the new_releases function in the books context returns the correct values.

# Phoenix Model Layer Test: test/my_book_app/books/books_test.exs
defmodule MyBookApp.BooksTest do
 describe "new_releases/0" do
   test "returns books released this month" do
     old_book = insert(:book, release_date: Date.utc_today)
     new_book = insert(:book, release_date: Date.add(Date.utc_today, -90)))
 
     assert new_book in Book.new_releases
     refute old_book in Book.new_releases
   end
 end
end

The equivalent schema and context tests would be covered in a Book model spec in Rails. The shoulda-matchers gem is commonly used to verify validations, like the presence of a book title and author with a should_validate_presence_of method. Tests for model instance and class methods, like #new_releases, are also included in the model spec. The #new_releases test reads very similarly to how it did in the Phoenix test.

# Rails Model Test: spec/models/book_spec.rb
RSpec.describe Book, type: :model do
 it { should_validate_presence_of(:title, :author) }
 
 describe "#new_releases" do
   it "returns books released this month" do
     old_book = create(:book, release_date: DateTime.now - 2.months)
     new_book = create(:book, release_date: DateTime.now)
 
     expect(Book.new_releases).to include(new_book)
     expect(Book.new_releases).not_to include(old_book)
   end
 end
end

The biggest difference I've found between writing Phoenix and Rails tests is that Phoenix tests have more emphasis on validating data through pure functions where Rails tests feel a bit more scenario driven overall. This is further emphasized by the hexdocs which highlight testing schemas, controllers and views, but have little on integration testing. This isn't really surprising since Elixir is a functional programming language, but it's worth noting that integration test tools like phoenix_integration are available for more scenario-driven testing.

Further reading:

Development Tools

Overall I’ve found the development tools fairly comparable between Rails and Phoenix - it’s just a matter of learning the right commands to get what you need done. Working in the Rails console is a bit different than working in a Phoenix console, but that’s largely due to the differences in writing functional Elixir code vs. object-oriented Ruby code.

Build Tools

Elixir and Rails both come with a build tool for creating, testing and managing projects. Elixir’s tool is Mix, which serves many of the same functions as Ruby’s Rake and built-in Rails commands. Here’s some common commands I use day-to-day:


Elixir

Ruby

Create a new app

mix phoenix.new my_new_app

rails new my_new_app

Generate migration

mix phoenix.gen.migration create_books_table

rails generate migration create_books_table

Run migrations

mix ecto.migrate

rake db:migrate

Rollback migrations

mix ecto.rollback

rake db:rollback

Run local server

mix phoenix.server

rails server

Run tests

mix test test/test_file/...

rspec spec/spec_file/...

Interactive Console

Elixir has an interactive console, IEx, that will feel familiar if you’ve ever used Rails irb. Running iex in the terminal will load a basic Elixir console. Running iex -S mix from a project directory will load your project in an interactive console (like rails console does for Rails apps).

When working on a Rails app, I often use the interactive console to validate data and try things out as I build. Because Elixir is a functional programming language, I’ve found the console a bit harder to get used to. It’s easy to grab and manipulate data in Rails through simple object methods, but even grabbing a single model struct in Elixir requires quite a bit of typing. A few things to make your life easier:

  1. Add aliases to an .iex.exs file in the project root. If you add alias Myapp.Repo you can simply call Repo.all in place of MyApp.Repo.all in the interactive console.
  2. It’s worth adding helper functions in contexts that you can call from the console. Grabbing the first user in a list is not as simple as calling User.first like in Rails. In Elixir it would look more like User |> Repo.all |> List.first. If you are going to grab any structs frequently during buildout or debugging, it’s worth writing functions for them to avoid lots of typing.

Debugging

It’s fairly easy to debug Rails apps by dropping a binding.pry anywhere you want to inspect. Elixir has an equivalent IEx.pry. The main difference in use cases is that you have to also require IEx anywhere you want to drop an IEx.pry. You can do this is one line: require IEx; IEx.pry. It’s a bit more typing, but it’s easy to drop this line anywhere for debugging just like a binding.pry in Rails.

If you want to drop a pry in an Elixir test, you’ll need to run your text like this: iex -S mix test test/test_file… instead of just mix test test/test_file. If you don’t add iex -S then the test won’t stop at your pry.

Closing Thoughts

Elixir / Phoenix and Rails apps both have a lot to offer. I’ve found that Elixir / Phoenix apps often seem more straight forward on paper. The simple, explicit nature of functional programming and the flexibility of composable plugs make Elixir / Phoenix apps extremely easy to work with and debug. However, I can’t help but miss object-oriented programming when I step away from Ruby, or magic like implicit rendering and data interaction through ActiveRecord when I step away from Rails. I expect that I’ll grow more comfortable with functional programming as I spend more time with it but the transition does require a mental shift. Although Ruby / Rails and Elixir / Phoenix apps do have a lot in common, they are fundamentally different languages and frameworks with different approaches to development. I've found it easier to start learning Elixir on a clean slate, rather than bringing any expectations you may have from Rails.

Further reading:

Thanks for reading! You can read more on building Elixir apps with Phoenix vs. Rails in Part 1 and Part 2 of this series:

Elizabeth Karst

Elizabeth is a developer in Boulder, CO. She picked up web development to help write code that deeply reflects human needs, and finds joy in designing databases and optimizing query performance.

More articles by Elizabeth