Rails Testing
Objectives |
---|
Validate model data |
Use flash messages to notify users of success and errors |
Review RSpec setup in a Rails app |
Write controller specs for new and create actions |
Why We Test
- save time when we create or update a project
- provide a better user experience
- create secure, functioning apps
- avoid unintentionally killing people, crashing the stock market, or blowing things up#Launch_failure)
Failures can be very costly. Even if your app doesn't make life or death decisions or keep track of financial information, you will lose users when they have a negative experience with a buggy product.
Types of Tests
- Unit Tests -- do small, low-level things work independetly? (e.g. a function or Model logic)
- Integration Tests -- do multiple smaller things work together correctly? (e.g. Controller logic)
- Acceptance Tests -- do elaborate, high-level things work as intended? (e.g. View logic).
How We Test
Automated Testing
As web developers, we'll use "automated testing" to check that our code is working as expected. Every time we make a change, we can re-run the automated tests to make sure we haven't broken anything.
Automated testing is awesome! In the long term, you:
- Save time you would have had to use checking through your program manually
- Avoid bugs by catching tricky edge cases
- Make it much easier to maintain, refactor, or extend your codebase
- Organize your tests and testing procedure in a way that's easy to find and understand
Automated testing is a pain! In the short term, you:
- Invest time up front deciding what to test
- Invest time up front to create good tests that
- accurately check whether your code is working
- test the right things about your system
- Invest time to create new tests when you plan to add feature sets
Development Patterns
We've looked at a few software development approaches so far.
For the first part of the course, we used a relaxed version of Behavior Driven Development (BDD). BDD has us build towards expected behaviors of our project both at the level of user interactions (building towards user stories) and at the level of code (writing out comments to say what each part of our code should do and building toward those).
We've also talked about Error Driven Development (EDD), using error messages as clues to help us build out a project. This is more feasible with tools like Rails that give us good error messages. Error messages are used often, but EDD is not considered as effective a form of development as BDD or TDD.
For Rails apps, we'll emphasize Test Driven Development. We'll start by making a few goals for an app's behavoir. Then, we'll write tests that can check whether we've met those goals... before we start to code! Finally, we'll write code we hope enables each behavior and check whether our code passes the test. TDD tries to ensure that you understand the problem before coding a solution. It's also meant to keep programmers on track. If you write tests firsts, you're less likely to forget an important functionality. As another benefit, TDD works well with pair programming. You can "ping pong" the tests, having one person write them and another person write the code to pass them.
Four Phase Tests
As we practice TDD, we'll focus on a four-phase testing methodology. Each test should set up some scenario, run an exercise starting from that scenario, verify that the intended effect happened, and tear down any changes that were made for the purpose of the test.
Testing Tips
Don't try to test everything, unless you have a really critical mission (space travel, nuclear missile defense, pacemaker timing...). Full testing, called "100% test coverage" is almost unheard of in software development. It would be overkill for the projects we're doing and for the work many companies do.
Write tests that help you be more efficient and effective as a developer.
Testing Rails Applications
Though Rails has built-in tools for testing, Rails developers often use rspec-rails
, capybara
, and Factory Girl
instead of those default tools. Read the first 6 sections (down to and including "Factory Girl") of this thoughtbot post about rails testing that goes over these tools.
Model Validations
Model-level validations are the best way to ensure that only valid data is saved into your database. They are database agnostic, cannot be bypassed by end users, and are convenient to test and maintain. Rails makes them easy to use and provides built-in helpers for common needs.
Docs: Active Record Validations
#
# app/models/recipe.rb
#
class Recipe < ActiveRecord::Base
belongs_to :user
# add validations
validates :name, :instructions,
presence: true,
length: { maximum: 255 }
end
Note: The database columns name
and instructions
are strings. The string
datatype is restricted to 255 characters in the database. Because of this, it's a good idea to put a length validation on any string column, so we can handle the error if the user tries to enter in more than 255 characters.
Testing Validations in the Console
$ rails c
irb(main):001:0> recipe = Recipe.create(name: nil, instructions: nil)
=> #<Recipe id: nil, name: nil, instructions: nil, user_id: nil, created_at: nil, updated_at: nil>
irb(main):002:0> recipe.valid?
=> false
irb(main):003:0> recipe.errors.full_messages
=> ["Name can't be blank", "Instructions can't be blank"]
Error-Handling
Model validations prevent users from saving invalid data to the database, but in order to provide a good experience for our users, we should do some error-handling in the controller.
#
# app/controllers/recipe_controller.rb
#
class RecipesController < ApplicationController
before_filter :authorize, except: [:index, :show]
...
def create
# recipe = current_user.recipes.create(recipe_params)
# redirect_to recipe_path(recipe)
# refactor
recipe = current_user.recipes.new(recipe_params)
if recipe.save
redirect_to recipe_path(recipe)
else
redirect_to new_recipe_path
end
end
...
end
Flash Messages
As part of our error-handling, it would be nice to let our users know when their actions succeed or fail - this is where flash messages come in.
The flash provides a way to pass temporary primitive-types (String, Array, Hash) between actions. Anything you place in the flash will be exposed to the very next action and then cleared out. This is a great way of doing notices and alerts before redirecting to a display action that can then expose the flash to its template.
Docs: Flash
#
# app/controllers/recipe_controller.rb
#
class RecipesController < ApplicationController
before_filter :authorize, except: [:index, :show]
...
def create
recipe = current_user.recipes.new(recipe_params)
if recipe.save
flash[:notice] = "Successfully created recipe."
redirect_to recipe_path(recipe)
else
flash[:error] = recipe.errors.full_messages.join(", ")
redirect_to new_recipe_path
end
end
...
end
Setting flash messages in the controller makes them available in our view, but doesn't actually render them. To render flash messages, we need to explicitly display them in the view. It makes sense to put this in the application layout so it's rendered on every view.
#
# app/views/layouts/application.html.erb
#
<body>
<% flash.each do |name, msg| %>
<%= content_tag :div, msg, class: "alert #{name == 'error' ? 'alert-danger' : 'alert-notice'}" %>
<% end %>
</body>
Review: RSpec Setup
Add
rspec-rails
to yourGemfile
in the development and test groups. We'll also go ahead and add ffaker</a> and factory_girl_rails:# # Gemfile # group :development, :test do gem 'rspec-rails' gem 'ffaker' gem 'factory_girl_rails' end
Run
bundle
install to add the new gems to your project:$ bundle
Create the
spec
folder and set uprspec-rails
configuration:$ rails g rspec:install
This creates a
spec
directory. It also adds the configuration filesspec/spec_helper.rb
,spec/rails_helper.rb
and.rspec
. See those files for more information.Configure your specs by going into the
.rspec
file and removing the line that says--warnings
if there is one.For any existing models or controllers you'd like to test, you'll have to manually create spec files:
$ rails g rspec:model recipe $ rails g rspec:controller recipes
Note: Spec files for any models or controllers you create AFTER you install
rspec-rails
will automatically be generated as long as you userails g ...
to create models/controllers.To run all test specs, you can type
rspec
orbundle exec rspec
in the terminal. To run only a specific set of tests, typerspec
and the file path for the tests you want to run:# run only model specs rspec spec/models # run only specs for `RecipesController` rspec spec/controllers/recipes_controller_spec.rb
Controller Specs
Our goal is to write request specs for the new
and create
controller actions. It helps to first write out in plain English what exactly we'd like to test for each action.
Testing new
We want to test the following assertions:
- It should respond with an HTTP status code of 200
- It should assign a new instance of
Recipe
in memory (allows us to use theform_for
syntax in the view) - It should render the
new
view (form to create a new recipe)
#
# spec/controllers/recipes_controller_spec.rb
#
require 'rails_helper'
RSpec.describe RecipesController, type: :controller do
describe "#new" do
before do
get :new
end
it "should respond with 200 success" do
expect(response.status).to be(200)
end
it "should assign @recipe" do
expect(assigns(:recipe)).to be_instance_of(Recipe)
end
it "should render the :new view" do
expect(response).to render_template(:new)
end
end
end
If we run our tests for new
now, they're going to fail. This is because accessing new
depends on having a current_user
, since users must be logged in to see the view to create a new recipe. Our tests don't have any concept of current_user
yet, so let's fix that. We could do something like the below, where we create and log in current_user
before each test:
#
# spec/controllers/recipes_controller_spec.rb
#
require 'rails_helper'
RSpec.describe RecipesController, type: :controller do
before do
user_params = Hash.new
user_params[:first_name] = Faker::Name.first_name
user_params[:last_name] = Faker::Name.last_name
user_params[:email] = Faker::Internet.email
user_params[:password] = Faker::Lorem.words(2).join
# create and log in current_user
current_user = User.create(user_params)
session[:user_id] = current_user.id
end
...
end
This will work, but it's not very DRY, since we may need to repeat the same logic in specs for other controllers that also require a current_user
. We can solve this problem by using a factory to set up a user
instance that's reuseable across all specs.
Note: Another benefit of using factories is that they can store a valid record in memory, without having to save it to our test database. Not saving records to the database significantly speeds up our tests. Read more about the .build
(non-persistent) and .create
(persistent) methods in the Factory Girl Getting Started Guide.
Factory Setup
Create a new folder in your
spec
directory calledfactories
. Each factory should have its own file within thefactories
folder. We'll be creating auser
factory in this example, so your folder structure should look like this:| spec | factories - user.rb
Inside the
user
factory, we'll move over some of the logic from our spec file used to create a new user:# # spec/factories/user.rb # FactoryGirl.define do factory :user do first_name Faker::Name.first_name last_name Faker::Name.last_name email Faker::Internet.email password Faker::Lorem.words(2).join end end
In our controller spec, we can refactor our
current_user
logic to use the factory instead:# # spec/controllers/recipes_controller_spec.rb # require 'rails_helper' RSpec.describe RecipesController, type: :controller do before do # create and log in current_user current_user = FactoryGirl.create(:user) session[:user_id] = current_user.id end ... end
Testing create
We want to test the following assertions:
- When successful, it should add a new recipe to the database
- When successful, it should respond with an HTTP status code of 302 (found, or redirected)
- When successful, it should redirect to
recipe_path
(show page) - When fails, it should respond with an HTTP status code of 302 (found, or redirected)
- When fails, it should redirect to
new_recipe_path
- When fails, it should flash an error message
#
# spec/controllers/recipes_controller_spec.rb
#
require 'rails_helper'
RSpec.describe RecipesController, type: :controller do
...
describe "#create" do
context "success" do
before do
@recipes_count = Recipe.count
post :create, recipe: {name: "Kale Salad", instructions: "Toss kale with apples and walnuts."}
end
it "should add new recipe" do
expect(Recipe.count).to eq(@recipes_count + 1)
end
it "should respond with 302 found" do
expect(response.status).to be(302)
end
it "should redirect_to 'recipe_path'" do
expect(response.location).to match(/\/recipes\/\d+/)
end
end
context "failure" do
before do
post :create, recipe: {name: nil, instructions: nil}
end
it "should respond with 302 found" do
expect(response.status).to be(302)
end
it "should redirect to 'new_recipe_path'" do
expect(response).to redirect_to(new_recipe_path)
end
it "should flash an error message" do
expect(flash[:error]).to be_present
end
end
end
end