I have a small application in Ruby that addresses a similar issue using Alistair Cockburn's Ports and Adapters (hexagonal) design pattern. The application sends alerts to individual recipients on their communication platform of choice (e.g. SMS, email, Twitter, etc.). Here's what it looks like (in Ruby/Rails):
class Recipient < ActiveModel
# Recipient model has two attributes:
# - channel: one of sms, email, twitter, messenger, whatsapp
# - address: one of phone number, email address, twitter handle, etc.
NOTIFIER_CLASSES = {
sms: SMSNotifier,
email: EmailNotifier,
twitter: TwitterNotifier,
messenger: FacebookMessengerNotifier,
whatsapp: WhatsNotifier
}.freeze
def notify(message)
notifier.notify(address, message)
end
private
def notifier
NOTIFIER_CLASSES[channel].new(address)
end
end
Here, I test that I'm instantiating the correct notifier and that I'm passing it the correct arguments. Like so (in RSpec):
describe Recipient do
subject(:recipient) { described_class.new(recipient_attributes) }
let(:recipient_attributes) { { channel: channel, address: address } }
let(:channel) { 'some channel' }
let(:address) { 'some address' }
let(:message) { 'some message' }
describe "#notify" do
subject(:notify) { recipient.notify(message) }
context 'when channel is sms' do
let(:channel) { :sms }
before do
allow(SMSNotifier).to receive(:notify)
notify
end
it 'uses the correct notifier with the correct attributes' do
expect(SMSNotifier).to have_received(:notify).with(address, message)
end
end
context 'when channel is email' do
let(:channel) { :email }
before do
allow(EmailNotifier).to receive(:notify)
notify
end
it 'uses the correct notifier with the correct attributes' do
expect(EmailNotifier).to have_received(:notify).with(address, message)
end
end
end
end
And, so on... (Note: in reality, these tests would be dried up with shared examples.)
The notifiers all define the same notify
method (duck typing in Ruby, though you would want to use an interface in a statically typed language). They look like this:
class SMSNotifier
FROM = '+18005551212'.freeze
def notify(address, message)
messages.create(from: FROM, to: address, body: message)
end
private
def messages
client.api.account.messages
end
def client
Twilio::REST::Client.new(ENV['ACCOUNT_SID'], ENV['AUTH_TOKEN'])
end
end
class EmailNotifier
SENDGRID_ENDPOINT = 'https://api.sendgrid.com/v3/mail/send'.freeze
FROM = '[email protected]'.freeze
def notify(address, message)
RestClient.post(
SENDGRID_ENDPOINT,
{
to: [{ email: address }],
subject: 'Alert',
content: [{ value: message, type: 'text/plain' }]
},
'Authorization' => "Bearer #{ENV['SENDGRID_API_KEY']}"
)
end
end
As you can see, the internals are quite different for each Notifier, depending on the underlying API. In these cases, I use mocks to ensure that I am making the correct API calls with the correct parameters. I trust that those APIs are fully tested. There's no need for me to make a round trip in a unit test.
describe SMSNotifier do
subject(:notifier) { described_class.new }
describe "#notify" do
subject(:notify) { notifier.notify(address, message) }
let(:address) { '+12345678900' }
let(:message) { 'some message' }
let(:client) { instance_double(Twilio::REST::Client, api: api) }
let(:api) { instance_double('api', account: account) }
let(:account) { instance_double('account', messages: messages) }
let(:messages) { instance_double('messages', create: true) }
before do
allow(Twilio::REST::Client).to receive(:new).and_return(client)
notify
end
it 'uses the correct notifier with the correct attributes' do
expect(Twilio::REST::Client).to have_received(:new).with(account_sid, auth_token)
end
it 'makes the correct api call with the correct attributes' do
expect(messages).to have_received(:create).with(address, message)
end
end
end
describe EmailNotifier do
subject(:notifier) { described_class.new }
describe "#notify" do
subject(:notify) { notifier.notify(address, message) }
let(:address) { '[email protected]' }
let(:message) { 'some message' }
let(:email_attributes) do
{
to: [{ email: address }],
subject: 'Alert',
content: [{ type: 'text/plain', value: message }]
}
end
let(:headers) { { 'Authorization' => "Bearer sendgrid_api_key" } }
before do
allow(RestClient).to receive(:post)
notify
end
it 'uses the correct notifier with the correct attributes' do
expect(RestClient).to have_received(:post).with(
'https://api.sendgrid.com/v3/mail/send',
email_attributes,
headers
)
end
end
end
So, the pattern boils down to ports and adapters, where the Notifiers are the ports, and the Twilio and SendGrid APIs are adapters for the specific services.
Task
fromHttpClient
? Set a new abstraction between these two. Something likeDatasource
. But do it only ifTask
has any logic (business) that worth to test. That might not be the case of the example.