Testing Views

We've gotten farther along in the app development process, and we have books now! We want to build out a nice UI to show the books that are currently checked out by our kitties. We've just scaffolded out a few book resources... so let's run our tests to get our bearings.

Failures:

  1) books/index renders a list of books
     Failure/Error:
       Book.create!(
         :author => "Author",
         :title => "Title",
         :library => nil,
         :status => "Status",
         :condition => "Condition"
       ),

     ActiveRecord::RecordInvalid:
       Validation failed: Library must exist
     # ./spec/views/books/index.html.erb_spec.rb:6:in `block (2 levels) in <top (required)>'

By taking a look at the error messaging, we have a pretty good idea of what's going on here: ActiveRecord::RecordInvalid: Validation failed: Library must exist. Looks like we're not creating valid books in our test!

Look at the books index spec

Navigate to the books index spec spec/views/books/index.html.erb_spec.rb

Let's change Book.create! to use our book factory instead, since we know that will create a valid book.

  # in spec/views/books/index.html.erb_spec.rb

  # Change your before block to the below
  before(:each) do
    assign(:books, [
      FactoryBot.create(:book),
      FactoryBot.create(:book)
    ])
  end

Run that test -- and it still fails? What?!

Failures:

  1) books/index renders a list of books
     Failure/Error: assert_select "tr>td", :text => "Author".to_s, :count => 2

     Minitest::Assertion:
       <Author> expected but was
       <Miss Cruz Hartmann>..
       Expected: 2
         Actual: 0
     # ./spec/views/books/index.html.erb_spec.rb:13:in `block (2 levels) in <top (required)>'

Let's take a closer look...

What is this actually testing?

require 'rails_helper'

RSpec.describe "books/index", type: :view do
  before(:each) do
    assign(:books, [
      FactoryBot.create(:book),
      FactoryBot.create(:book)
    ])
  end

  it "renders a list of books" do
    render
    assert_select "tr>td", :text => "Author".to_s, :count => 2
    assert_select "tr>td", :text => "Title".to_s, :count => 2
    assert_select "tr>td", :text => nil.to_s, :count => 2
    assert_select "tr>td", :text => "Status".to_s, :count => 2
    assert_select "tr>td", :text => "Condition".to_s, :count => 2
  end
end

From what I understand, assert_select allows us to test whether elements meeting certain criteria exist in the DOM. Read more about assert_select here.

This test relies on these two books being identical in order to assert that two of them exist on the page. Now we could just make this test pass by passing identical sets of attributes to our factory, or maybe we could try using a double instead.

Hmmm... could we use doubles here?

  • Change your test set up data to the following...
  let(:book_double) do
    instance_double(Book,
      author: "Author",
      title: "Title",
      status: "Status",
      library: nil,
      condition: "Condition"
    )
  end

  before(:each) do
    assign(:books, [book_double, book_double])
  end

Now run your tests. Failure...

1) books/index renders a list of books
     Failure/Error: <td><%= link_to 'Show', book %></td>
       #<InstanceDouble(Book) (anonymous)> received unexpected message :to_model with (no args)
     # ./app/views/books/index.html.erb:25:in `block in _app_views_books_index_html_erb___2073712497573963908_70145046468500'
     # ./app/views/books/index.html.erb:18:in `each'
     # ./app/views/books/index.html.erb:18:in `_app_views_books_index_html_erb___2073712497573963908_70145046468500'
     # ./spec/views/books/index.html.erb_spec.rb:25:in `block (2 levels) in <top (required)>'

Based on that message, we know our double received a method call that we didn't mock out. Let's fix that by modifying our double to allow and return something for to_model.

  let(:book_double) do
    instance_double(Book,
      author: "Author",
      title: "Title",
      status: "Status",
      library: nil,
      condition: "Condition",
      to_model: nil # who knows what to_model is supposed to return but let's try nil
    )
  end

Run your tests again... another failure.

1) books/index renders a list of books
     Failure/Error: <td><%= link_to 'Show', book %></td>

     ActionView::Template::Error:
       undefined method `model_name' for nil:NilClass
     # ./app/views/books/index.html.erb:25:in `block in _app_views_books_index_html_erb__2836684479995140429_70152024039760'
     # ./app/views/books/index.html.erb:18:in `each'
     # ./app/views/books/index.html.erb:18:in `_app_views_books_index_html_erb__2836684479995140429_70152024039760'
     # ./spec/views/books/index.html.erb_spec.rb:27:in `block (2 levels) in <top (required)>'
     # ------------------
     # --- Caused by: ---
     # NoMethodError:
     #   undefined method `model_name' for nil:NilClass
     #   ./app/views/books/index.html.erb:25:in `block in _app_views_books_index_html_erb__2836684479995140429_70152024039760'

From this error message, we know that model_name should return something that responds to model_name. We're dealing with books here, so maybe it's that. Instead of nil, let's use an anonymous double that returns 'Book' for model_name.

  let(:book_double) do
    instance_double(Book,
      author: "Author",
      title: "Title",
      status: "Status",
      library: nil,
      condition: "Condition",
      to_model: double(model_name: 'Book')
    )
  end

And more errors... we could chase those... or we could not. To see how far I went down that rabbit hole, check out this commit...

Doubles are great because they make your dependencies ultra-explicit! A double does nothing without you knowing it, and because we were using doubles, we know that rendering our view content relies on an actual Book object.

Is this test worth it?

We could re-evaluate the worth of this test. For starters, let's look at our to 10 slowest examples. How many of them are views? Also, why do we need to create records in the database to assert that some content renders on a page.

Top 10 slowest examples (0.76305 seconds, 80.2% of total time):
  Application GET /home successfully gets the home page
    0.49296 seconds ./spec/requests/application_spec.rb:5
  devise/registrations/new displays the sign up title
    0.0581 seconds ./spec/views/devise/registrations/new.html.erb_spec.rb:4
  books/new renders new book form
    0.05314 seconds ./spec/views/books/new.html.erb_spec.rb:14
  User#role When the user has a cat returns cat
    0.03168 seconds ./spec/models/user_spec.rb:14
  Checkout has a valid factory
    0.02769 seconds ./spec/models/checkout_spec.rb:7
  Book has a valid factory
    0.02269 seconds ./spec/models/book_spec.rb:4
  books/index renders a list of books
    0.02262 seconds ./spec/views/books/index.html.erb_spec.rb:11
  BookRequest has a valid factory
    0.0193 seconds ./spec/models/book_request_spec.rb:7
  ApplicationController GET #dashboard When a user is logged in returns a success response
    0.01809 seconds ./spec/controllers/application_controller_spec.rb:38
  application/home renders the new account sign up form
    0.01678 seconds ./spec/views/application/home.html.erb_spec.rb:10

Are views tests worth it?

It depends...

  • Does your view display something conditionally?
  • Are you trying to test that a partial was/was not rendered?
  • Do you need to test styling?
  • Are you refactoring?

I used view tests while creating this repo to test that I was correctly creating and rendering a partial:

Could we test it a different way?

  • Say we wanted to only display books currently check out, or only books that have been reviewed by our cats.
  • If it's hard to test, listen to that
  • Instead of filtering a set of books at the view level, you could...
    • Add a scope to the books model -- test that in the model
    • Fetch the books via your scope in the controller -- test that your controller calls the correct scope
    • Rely on rails rendering

In our case with book views, our tests are not worth it. Let's delete them!

results matching ""

    No results matching ""