Skip to content

Instantly share code, notes, and snippets.

@dbalatero
Last active March 23, 2022 17:04
Show Gist options
  • Star 14 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
  • Save dbalatero/d3e0ead69724aaaa1cb4 to your computer and use it in GitHub Desktop.
Save dbalatero/d3e0ead69724aaaa1cb4 to your computer and use it in GitHub Desktop.
This is an example of how I combine interaction/service classes with Wisper event broadcasting inside Rails.

This is an example of how I combine interaction/service classes with Wisper event broadcasting in Rails.

In this example, I show a UsersController#create API, a corresponding service object, and all the test code/listeners to make it all happen.

The outcome is:

  • Concepts in your system ("Signing up a user", "Creating an order") have a single entry point in your codebase, vs. making raw ActiveRecord calls to object.save in dozens of places.
  • Since your concept has one entry point (the service class), you can easily grep for usage of it.
  • Stupid easy to attach listeners to the service class
  • All event listeners are very small and easily unit tested
  • Controllers have zero if/else logic and are very dumb
  • Unit tests stay fast
  • Acceptance tests still exercise the whole system, including event listeners
  • Plain old Ruby objects rule
class UsersController < ApplicationController
def create
# In some sense, we don't really care about the controller at all, except that we
# need it for emitting the correct HTTP responses at the end (redirect_to, render, head, etc)
#
# By telling the SignupUser what to do in each case (failure/success), we can ensure that
# the controller outputs the right response, while hiding the logic of success/failure from
# the controller itself.
#
# (see: Tell, Don't Ask)
SignupUser
.new(user_attributes)
.on(:user_signup_succeeded) { |user| render json: { user: user }, status: 201 }
.on(:user_signup_failed) { |errors| render json: { errors: errors }, status: 400 }
.run
end
private
def user_attributes
params.require(:user).permit(:email, :password)
end
end
class SignupUser
include Wisper::Publisher
def initialize(attributes = {})
@attributes = attributes
# Listeners are clearly outlined in this file, vs. global observers
# which make it hard to reason about who is subscribed to what.
subscribe(UserEmailListener.new)
subscribe(AnalyticsListener.new)
end
def run
# This code doesn't do much, it's true. However, as business requirements shift
# around signing up a user, it's easy to do more here if we really need to, and
# that's the real key.
#
# If you don't adopt this approach of creating a single API for major actions in
# your system, you end up forced to do crap like this:
#
# class User < ActiveRecord::Base
# after_save :report_analytics
#
# private
#
# def report_analytics
# report_to_mixpanel(type: "user_signup", id: id)
# end
# end
#
# Attaching behavior to database writes sucks, because whenever you need
# to create a new User (for example, in isolated tests) you end up pulling in
# a bunch of secondary behavior with it, which slows down your tests, forces you
# to stub things you shouldn't care about, etc. For more reasons why it sucks,
# google it!
if user.save
broadcast :user_signup_succeeded, user
else
broadcast :user_signup_failed, user.errors
end
self
end
private
attr_reader :attributes
def user
@user ||= User.new(attributes)
end
end
class UserEmailListener
# Each listener does a tiny amount of work, and makes it really easy to
# unit test.
def user_signup_succeeded(user)
UserMailer.signup(user).deliver
end
end
class AnalyticsListener
# Each listener does a tiny amount of work, and makes it really easy to
# unit test.
def user_signup_succeeded(user)
# do some analytics here, fakin' it
report_to_mixpanel(type: 'user_signup', id: user.id)
end
end
require 'spec_helper'
describe "user signup (integration spec)" do
# In this integration spec, we exercise the full stack, including Wisper
# listeners. This is a great place to ensure that all the things you wanted
# to happen on user signup actually happened (analytics, email, creation, etc)
before do
ActionMailer::Base.deliveries.clear
end
let(:attributes) do
{
email: "test@test.com",
password: 'fdsafdsa'
}
end
it "should handle success" do
post users_path, attributes
json = JSON.parse(response.body)
expect(response).to be_success
expect(json['user']).to include("email" => "test@test.com")
expect_to_have_users(count: 1)
expect_to_have_sent_emails(count: 1)
expect_to_have_reported_analytics(type: "user_signup", id: json['user']['id'])
end
it "should handle failure" do
post users_path, attributes.merge(email: "an invalid email hahahahahha")
json = JSON.parse(response.body)
expect(response).to_not be_success
expect(json['errors']).to_not be_empty
expect_to_have_users(count: 0)
expect_to_have_sent_emails(count: 0)
expect_to_not_have_reported_analytics(type: "user_signup")
end
def expect_to_have_users(count:)
expect(User.count).to eq(count)
end
def expect_to_have_sent_emails(count:)
expect(ActionMailer::Base.deliveries.size).to eq(count)
end
def expect_to_have_reported_analytics(type:, **options)
# implement me!! sorry :)
end
def expect_to_not_have_reported_analytics(type:, **options)
# implement me as well!
end
end
require 'spec_helper'
describe UsersController do
let(:attributes) do
{
email: "test@test.com",
password: 'fdsafdsa'
}
end
describe '#create' do
it "should not be successful if signup fails" do
# Many controller tests I see in production code test complex behavior. I disagree
# with this approach. Really, all we should care about here is testing whatever special
# thing the controller brings to the table.
#
# Since functionality is pushed into the `SignupUser` object, all the controller does
# is render and send back status codes.
#
# Therefore, that's all we need to test at this time.
#
# Notice how we didn't have to stub a bunch complex dependencies out, which leak
# implementation.
#
# Also, the test is super fast!
stub_wisper_publisher("SignupUser", :run, :user_signup_failed, ['error'])
post :create, attributes
expect(response).to_not be_success
end
it "should be successful if the signup works" do
user = object_double(User.new, to_json: { foo: 'foo' })
stub_wisper_publisher("SignupUser", :run, :user_signup_succeeded, user)
post :create, attributes
expect(response).to be_success
end
end
end
require 'spec_helper'
describe SignupUser do
let(:user) { object_double(User.new) }
let(:attributes) do
{
email: 'test@test.com',
password: 'hahahaha'
}
end
before do
allow(User).to receive(:new) { user }
end
it "should broadcast user_signup_succeeded if signup succeeds" do
allow(user).to receive(:save) { true }
# Thanks to the disable_wisper_broadcast.rb file below, this will not actually
# call any listeners attached to :user_signup_succeeded
#
# This keeps our unit test dependencies extremely small.
expect { UserSignup.new(attributes).run }.to broadcast(:user_signup_succeeded, user)
end
it "should broadcast user_signup_failed if the signup fails" do
allow(user).to receive(:save) { false }
allow(user).to receive(:errors) { ['error'] }
expect { UserSignup.new(attributes).run }.to broadcast(:user_signup_failed, ['error'])
end
end
gem "wisper"
gem "wisper-rspec" # for matchers
# In addition to your normal spec_helper.rb...
# we need to set up wisper-rspec
require 'wisper/rspec/matchers'
require 'wisper/rspec/stub_wisper_publisher'
RSpec.configure do |config|
config.include Wisper::RSpec::BroadcastMatcher
end
# Put this in spec/support/disable_wisper_broadcast.rb
# The goal of this file is to make it so Wisper doesn't actually call into Wisper listeners
# when running unit tests/controller tests.
#
# We only want to enable the Wisper listeners when we are in an acceptance/full-stack testing
# context.
#
# Ideally this could be merged into wisper-rspec as more of a first-class option, to avoid this
# patching.
require 'wisper'
class Wisper::ObjectRegistration
prepend Module.new {
def self.prepended(base)
base.class_attribute :_broadcast_disabled
end
def should_broadcast?(*)
super if !_broadcast_disabled ||
listener.is_a?(Wisper::RSpec::EventRecorder)
end
}
end
RSpec.configure do |config|
# By default, we disable global listeners on Wisper unless we are
# in acceptance tests.
config.before :each do |example|
unless %i[acceptance integration request].include?(example.metadata[:type])
Wisper::ObjectRegistration._broadcast_disabled = true
end
end
config.after :each do |example|
Wisper::ObjectRegistration._broadcast_disabled = false
end
end
@kwokster10
Copy link

kwokster10 commented Jun 1, 2017

Thanks for the examples. I believe L24 and L31 in 07_signup_user_spec.rb should be SignupUser and not UserSignup.

I am having issues testing async Listener actions like report_to_mixpanel. Did you get expect_to_have_reported_analytics to work for you?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment