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...
- The original journey down the rabbithole: https://github.com/BintLopez/reading_meow/commit/63c3dfd3d0ac87fce1790cd884e93ccff9de55a0
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:
- Passing test before pulling form out into a partial: https://github.com/BintLopez/reading_meow/commit/6ffb8ad65ceb7351a66567efaeeef19df984903e
- Extraction into partial successful! Tests Pass! https://github.com/BintLopez/reading_meow/commit/4f634d3414c73778bb421e5de8024d4b93752f48
- Now let's add a test that my partial is rendered from our home page: https://github.com/BintLopez/reading_meow/commit/2523d811e1cd5067a083ca1385ee6a01144c0ffa
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!