Controller Testing: You're Doing It Wrong

Preview:

DESCRIPTION

Talk for RubyKaig 2014

Citation preview

Controller Testing

“You’re doing it wrong”

Jonathan Mukai-Heidt

Hello!

Groundwork

Let’s talk controller tests

Almost no one knows how to test controllers

Many, many, many different projects

Two years consulting at Pivotal Labs

Kicked around NYC start up scene

Freelance software developer

Controller Testing Hall of Shame

Wait, testing… why?

Catching regressions

Developing code in isolation!!!

Driving modular, composable code!!!

Back to the Hall of Shame

Stub all the things

describe "#show" do subject { -> { get :show, id: id } } let(:id) { '77' } let(:pizza) { Pizza.new }

context "with an existing pizza" do before { Pizza.should_receive(:find).with(id).and_return(pizza) } it { assigns(:pizza).should == pizza } end

context "with a non-existent pizza" do before { Pizza.should_receive(:find).with(id).and_raise_error(ActiveRecord::RecordNotFound) it { should raise_error(ActiveRecord::RecordNotFound) } endend

Everything is integration

As a userGiven there is a pepperoni pizzaWhen I visit the pizza index pageAnd I click on "pepperoni"Then I should see the pepperoni pizza

render_views

No tests at all…

Often the things that really matter are untested

Controllers often have a “big action” and “small details”

“Big” concerns should be the same!

Fetch or create/update a resource

“Small” concerns are actually very important

Require authentication?

Who is authorized?

What formats?

I was also confused

One day…

Rails controllers (+ responders) are awesomely declarative

What do we really mean when we say declarative

Imperative / Declarative

Describe the properties we want

No logic (really!)

Imperative“When deleting a user, if the current user is an admin user, then allow the deletion; if the current user is not an admin, do not allow the deletion to finish.”

Declarative“Only admin users can delete another user.”

Imperative“When a request for a resource comes in, if the request is for JSON, then fetch the resource and render it from the JSON template; if the request is for HTML, then fetch the resource and render the HTML template; if the request is for another format like PDF, return an error.”

Declarative“This controller returns a resource represented as JSON or HTML.”

Ruby is imperative but it lets us write declarative code

Look at how declarative Rails controllers can be

before_filter

# let's us do things like

before_filter :authenticate_user!, except: :show

before_filter

# ...or...

before_filter :load_some_model, except: [:new, :index]

Authorization

# Using Authority gem

authorize_actions_for SomeResource

Authorization

# Using CanCan gem

load_and_authorize_resource :some_resource

Rails 4 + Responders

respond_to

# quickly declare formats

respond_to :html, :json

SHOW

def new respond_with(@pizza)end

CREATE

def create respond_with(@pizza = Pizza.create(pizza_params))end

…and so on…

Little to no logic in controllers

And this is great!

But it’s not what 90% of the controllers I come across look like

Because of muddying these nice declarative controllers with business logic

Business logic belongs in modelsYou’ve heard this many times already

What do we really care about in controllers?

Authentication

Authorization

Presence of resourceWhat resource are we working with

Response

Tests should help us write better code

Declarative controller? Declarative tests!

What does it look like in action?

Shared examples cover the “small” details

“Big” actions can be simple…

# e.g. a show actionit { should assign(:some_resource) }

# e.g. a create actionit { should change(Pizza, :count).by(+1) }

Authentication

describe CommentsController do let(:current_user) { users(:claude) } let(:blog_post) { blog_posts(:top_ten_pizzas) }

describe "#new" do subject { -> { get :new, blog_post_id: blog_post } }

context "with a logged in user" do before { sign_in(:user, current_user) }

it "should not redirect to the login page" do response.should_not be_redirect end end

context "with an unauthenticated user" do it "should redirect to the login page" do response.should be_redirect_to(sign_in_path) end end endend

Authentication shared example

shared_examples_for "an action that requires a login" do before { sign_out :user } it { should respond_with_redirect_to(sign_in_path) }end

Authentication shared example in action

describe CommentsController do let(:current_user) { users(:claude) } let(:blog_post) { blog_posts(:top_ten_pizzas) }

before { sign_in(:user, current_user) }

describe "#new" do subject { -> { get :new, blog_post_id: blog_post } }

it_should_behave_like "an action that requires a login" endend

Authorization

describe "#create" do subject { -> { post :create, blog_post_id: blog_post, comment: params } }

let(:params) { { body: "What a great post. I loved the part about shared examples." } }

before { sign_in :user, current_user }

context "with an authorized user" do let(:current_user) { users(:bob) }

it "should respond with created" do response.should respond_with 201 end end

context "with an unauthorized user" do let(:current_user) { users(:mallory) }

it "should respond with 404" do response.should respond_with 404 end endend

“Malicious Mallory”

Authorization shared example

shared_examples_for "an action that requires authorization" do before { sign_in :user, users(:mallory) } it { should respond_with 404 }end

Authorization shared example in action

describe "#create" do subject { -> { post :create, blog_post_id: blog_post, comment: params } }

let(:params) { { body: "What a great post. I loved the part about shared examples." } }

before { sign_in :user, users(:bob) }

it_should_behave_like "a non-navigation action that requires a login" it_should_behave_like "an action that requires authorization"end

Presence of Resource

Presence shared example

shared_examples_for "an action that requires" do |*resources| resources.each do |resource| context "with an invalid or missing #{resource}" do let(resource) { double(to_param: "does-not-exist", reload: nil) } it { should respond_with 404 } end endend

Presence shared example in action

describe PizzaController do describe "#show" do subject { -> { get :show, id: pizza, format: format } } let(:pizza) { pizzas(:pepperoni) }

it_should_behave_like "an action that requires", :pizza endend

Response

Response shared example

shared_examples_for "an action that returns" do |*acceptable_formats| acceptable_formats.each do |acceptable_format| context "expecting a response in #{acceptable_format} format" do let(:format) { acceptable_format } it { should_not respond_with_status(:not_acceptable) } end end

(%i(html js json xml csv) - acceptable_formats.collect(&:to_sym)).each do |unacceptable_format| context "expecting a response in #{unacceptable_format} format" do let(:format) { unacceptable_format } it { should respond_with_status(:not_acceptable) } end endend

Response shared example in action

describe CommentsController do let(:current_user) { users(:claude) } let(:blog_post) { blog_posts(:top_ten_pizzas) } let(:format) { :html }

before { sign_in :user, current_user }

describe "#show" do subject { -> { get :show, id: comment, format: format } } let(:comment) { blog_post.comments.first }

it_should_behave_like "an action that returns", :html end

describe "#create" do subject { -> { post :create, blog_post_id: blog_post, comment: params, format: format } } let(:params) { { body: "What a great post. I loved the part about shared examples." } }

it_should_behave_like "an action that returns", :html, :json endend

Your test is like a check list

But what about…Likes/Bookmarks/Ratings

Bulk creates

Merging records

Actions that touch several models

“Skinny controller, fat model”

Ever since I began Rails work people have been saying this

5/6 projects suffer from bloated controllers

ActiveModel

Use it!

There is no resource too small

Models are cheap, especially ones not tied to the DB

An illustrative example

Password ResetClient wanted to overhaul a legacy password reset workflow

Suspend your dis-belief, they are not using Devise yet

Too simple to break out into a model?

Requirements always change“Ah but wait, we want to tell users if they put in their e-mail wrong.”

Of course requirements change again“If the user is locked out of their account, we shouldn’t send a password reset.”

Suddenly, a fat controller

ActiveModel makes it simple

Think nouns (resources), not verbs

HTTP gives you all the verbs you need

FootworkNo gem!

This will vary from project to project

Figure out how your project will handle these situations

Habbits

Hence the controller checklist

The rewards are great!

Easier to test

Drives good design

Keep controllers simple

Logic goes in models where it belongs

No confusion about where things go (bulk creates, likes, etc)

Uniform controllers == less dev time

Thanks!Get in touch!

Jonathan Mukai-Heidt

Groundwork

@johnnymukai

johnny@buildgroundwork.com