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
@krisleech
Copy link

Hey - I like the code and premise behind it 👍 I have some questions; isn't turning off broadcasting in tests (which are a clients of your services, SignupUser) in some way defeating the object of having publisher and subscriber which aren't hard coupled? Personally I prefer to subscribe listeners to an instance in the same context (e.g. controller) in which the service is run.

I understand this can be inconvenient for cross-cutting concerns like gathering statistics where you want to be sure that the listener is subscribed is all cases where the service is used. In which case I'd normally reach for a global subscriber. I know you have reservations, perhaps using a global subscriber scoped to a service class would help give some meaning as to which listeners are subscribed to which publishers, e.g. SignupUser.subscribe(AnalyticsListener.new). I do this in an initalizer in Rails apps.

On the other hand I wouldn't globally subscribe UserEmailListener as this would not allow me to sign up a user without sending an email - which might be the case for things like importing data.

Overall I'd be careful with the pattern of subscribing listeners within a publisher, it is convenient, but comes at a cost - revealed later as the application grows.

@jhoffner
Copy link

I agree with Kris. I would look into temporary global subscribers, which I find to be the best balance between global DRYness and flexability. For example you could always attach all of your common listeners to all web requests like so:

class ApplicationController
  around_filter :web_listeners

  def web_listeners(&block)
    Wisper.subscribe(AnalyticsListener.new, &block)
  end
end

Another thing that I do is make it possible to easiliy attach listeners to each test case, so that when I'm not running my accceptance tests I can still whitelist which listeners should be attached on a per test level.

rails_helper.rb:

config.around :each do | example |
    begin
      example.run
    ensure
      # just to be sure
      Wisper::TemporaryListeners.registrations.clear
    end
  end

  config.around :each do |example|
    if example.metadata[:listeners] == :web
      Listeners.with_web { example.run }
    elsif example.metadata[:listeners] == :core
      Listeners.with_core { example.run }
    elsif example.metadata[:listener].is_a? Class
      Listeners.with(example.metadata[:listener].instance) { example.run }
    elsif example.metadata[:listeners].is_a? Array
      Listeners.with(*example.metadata[:listeners].map(&:instance)) { example.run }
    else
      example.run
    end
  end

Note that in my last example I have a Listeners class which is a helper class that I use for easily subscribing groups of listeners. Hopefully you get the idea.

@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