Welcome to the first blog post in our cycle directed to less-experienced developers. We will be diving into the world of more advanced concepts - however, each one should be easily digestible by junior+ - mid developer. It’s one of the ways we want to give back to the community.
In this blog post we will focus on solving a simple task:
Retrieve a random quote from API https://talaikis.com/api/quotes/random/ and display it in terminal (in some pretty format).
What is the catch here? We want our code to be thoroughly tested and use proper Object Oriented Programming (OOP).
Side note: the techniques shown here will be a case of overengineering, given the complexity of the task
Let’s start coding! How about starting with some simple code that will do the job well?
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'httparty'
end
require 'json'
server_raw_output = HTTParty.get('https://talaikis.com/api/quotes/random/').body
quote_hash = JSON.parse(server_raw_output)
print quote_hash['author']
print ': '
puts quote_hash['quote']
Run it:
$ ruby quotes.rb
Laurence J. Peter: The best intelligence test is what we do with our leisure.
Yay! It works!
Please take a note that we’ve used inline bundler syntax - which is helpful for simple scripts.
However, this code has some problems:
- it is barely testable (it would require a lot of mocking monkey-patch-style)
- it isn’t very object-oriented
So, let’s start some refactoring! Let me stress again that we will be doing overengineering here.
Let’s look at our code and start by wrapping it all in a class and changing print to simply returning string (always try to separate side-effects of functions as much as you can):
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'httparty'
end
require 'json'
class Quote
def random_quote
server_raw_output = HTTParty.get('https://talaikis.com/api/quotes/random/').body
quote_hash = JSON.parse(server_raw_output)
quote_hash['author'] + ': ' + quote_hash['quote'] + "\n"
end
end
# this is a pythonic way of only running this code
# if it's executed directly and
# not required from elsewhere
# we do this for the sake of testing later
print Quote.new.random_quote if $PROGRAM_NAME == __FILE__
(Why instance method instead of class one? You will see soon, in this state it doesn’t matter, but in the end it will make a difference).
Let’s stop here for a moment and think about the first of rules for good object-oriented design SOLID principles, i.e., Single Responsibility Principle.
Single Responsibility Principle
Every module or class should have responsibility over a single part of the functionality provided by the software, and the class should entirely encapsulate that responsibility.
In other words (more practical): classes should have only one reason to change. Let’s take a look… oops! When we’re looking at our class, we see A LOT of reasons to change. Starting from switching connection protocol, through changing the parsing method to different printing style. We need to do something about it and stop violating SRP so… violently.
Great step for make it compliant with SRP is to work on untangling implicit classes connections, one by one. First of all, looking from the top, we encounter the line:
server_raw_output = HTTParty.get('https://talaikis.com/api/quotes/random/').body
Yep, we have an implicit class usage. We will use very strict OOP here, meaning an object can use only classes explicitly provided in the constructor (usually you would also allow stdlib classes). So, let’s write the first of series of our small classes - that one will be called QuoteConnector
. It will serve only as a method of requesting data from a remote server and returning a string representing its body.
# lib/quote_connector.rb
require 'httparty'
class QuoteConnector
URL = 'https://talaikis.com/api/quotes/random/'.freeze
# since this is constant, we REALLY don't want to mutate it
def initialize(adapter: HTTParty)
@adapter = adapter
end
def call
adapter.get(URL).body
end
private
attr_reader :adapter
end
Let’s stop here for a second. We did something in the constructor - something that should be familiar with people who coded in Java. It’s called Dependency Injection. That way in a typical environment we will be using the HTTParty library and in our test environment, we will inject our custom, mocked class (without re-opening existing classes or injecting our mocks elsewhere). Otherwise, it’s an elementary class - this is how we roll.
Let’s look at irb usage of our class:
irb(main):017:0> QuoteConnector.new.call
=> "{\"quote\":\"That which is so universal as death must be a benefit.\",\"author\":\"Friedrich Schiller\",\"cat\":\"death\"}"
So, everything works as expected. Time to write some tests! I will be using Minitest - because I like it.
# test/quote_connector_test.rb
require 'minitest/autorun'
require_relative '../lib/quote_connector'
require 'ostruct'
class DummyRequest
def self.get(*)
OpenStruct.new(body: 'test')
end
end
class TestConnector < Minitest::Test
def test_call
connector = QuoteConnector.new(adapter: DummyRequest)
assert_equal 'test', connector.call
end
end
We need a fake HTTParty
connector to provide as a mock to our QuoteConnector
class. But! We aren’t reopening the existing class, nor modifying anything in the code. That’s the beauty of DI.
Run tests:
$ ruby test/quote_connector_test.rb
Run options: --seed 62844
# Running:
.
Finished in 0.001075s, 1860.4651 runs/s, 1860.4651 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
Great! Since it’s correct, let’s plug QuoteConnector into the existing code:
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'httparty'
end
require 'json'
require_relative 'lib/quote_connector'
class Quote
def initialize(connector: QuoteConnector)
@connector = connector
end
def random_quote
server_raw_output = connector.new.call
quote_hash = JSON.parse(server_raw_output)
quote_hash['author'] + ': ' + quote_hash['quote'] + "\n"
end
private
attr_reader :connector
end
print Quote.new.random_quote if $PROGRAM_NAME == __FILE__
A little better. We need to provide QuoteConnector
in the constructor to be compliant with our own rules.
Let’s look down… A-ha! We see a reference to the JSON
class which isn’t specified anywhere. Time to change that! Let’s write another class - this time called QuoteParser
.
# lib/quote_parser.rb
# let's take note that it's a bit of overkill
# since JSON is in stdlib
# but hey, we're doing EXOOP (extreme oop)!
class QuoteParser
def initialize(object_to_parse:, parser_class: JSON)
@object_to_parse = object_to_parse
@parser_class = parser_class
end
def call
parser_class.parse(object_to_parse)
end
private
attr_reader :object_to_parse, :parser_class
end
How does it work? Let’s look into irb…
irb(main):033:0> QuoteParser.new(object_to_parse: QuoteConnector.new.call).call
=> {"quote"=>"Beauty is only skin deep. I think what's really important is finding a balance of mind, body and spirit.", "author"=>"Jennifer Lopez", "cat"=>"beauty"}
Looking good! And it seems the classes are working together, excellent.
Time to test it, similar to testing QuoteConnector
:
# test/quote_parser_test.rb
require 'minitest/autorun'
require_relative '../lib/quote_parser'
require 'ostruct'
class DummyQuoteParser
def self.parse(hash_to_parse)
hash_to_parse
end
end
class TestQuoteParser < Minitest::Test
def test_parser_call
test_hash = { 'author' => 'Rainbow Dash', 'quote' => '[So] Awesome!' }
parser = QuoteParser.new(
object_to_parse: test_hash, parser_class: DummyQuoteParser
)
assert_equal(test_hash, parser.call)
end
end
And, as tests are passing, time to plug it into our entry point class:
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'httparty'
end
require 'json'
require_relative 'lib/quote_connector'
require_relative 'lib/quote_parser'
class Quote
def initialize(connector: QuoteConnector, parser: QuoteParser)
@connector = connector
@parser = parser
end
def random_quote
server_raw_output = connector.new.call
quote_hash = parser.new(object_to_parse: server_raw_output).call
quote_hash['author'] + ': ' + quote_hash['quote'] + "\n"
end
private
attr_reader :connector, :parser
end
print Quote.new.random_quote if $PROGRAM_NAME == __FILE__
So far, so good! It seems like we’re only missing one class in the random_quote
method - some presenter, that will transform our JSON data into strings ready to be displayed. Time to write it!
# lib/quote_presenter.rb
class QuotePresenter
def initialize(quote_hash:)
@quote_hash = quote_hash
end
def to_s
"#{quote_hash['author']}: #{quote_hash['quote']}\n"
end
def present
# we combine two pretty useful conventions here:
# classes should implement to_s method that
# will return a string representation of them ✓
# presenters should implement present method
# that will return their data formatted ✓
to_s
end
private
attr_reader :quote_hash
end
How does it work?
irb(main):055:0> QuotePresenter.new(quote_hash: QuoteParser.new(object_to_parse: QuoteConnector.new.call).call).present
=> "Joseph Jackson: It's all about the money.\n"
Time for a little bit of test:
# test/quote_presenter_test.rb
require 'minitest/autorun'
require_relative '../lib/quote_presenter'
require 'ostruct'
class TestQuotePresenter < Minitest::Test
def setup
@test_hash = { 'author' => 'Rainbow Dash', 'quote' => '[So] Awesome!' }
@output = "Rainbow Dash: [So] Awesome!\n"
end
def test_presenter_to_s
presenter = QuotePresenter.new(quote_hash: @test_hash)
assert_equal(@output, presenter.to_s)
end
def test_presenter_present
test_hash = { 'author' => 'Rainbow Dash', 'quote' => '[So] Awesome!' }
presenter = QuotePresenter.new(quote_hash: @test_hash)
assert_equal(@output, presenter.present)
end
end
Nothing new here - only take note that we’re testing both to_s
and present
methods.
And let’s of course plug it back into our quote class:
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'httparty'
end
require 'json'
require_relative 'lib/quote_connector'
require_relative 'lib/quote_parser'
require_relative 'lib/quote_presenter'
class Quote
def initialize(connector: QuoteConnector,
parser: QuoteParser,
presenter: QuotePresenter)
@connector = connector
@parser = parser
@presenter = presenter
end
def random_quote
server_raw_output = connector.new.call
quote_hash = parser.new(object_to_parse: server_raw_output).call
presenter.new(quote_hash: quote_hash).present
end
private
attr_reader :connector, :parser, :presenter
end
print Quote.new.random_quote if $PROGRAM_NAME == __FILE__
Yay! We’ve got the whole enchilada working. And for our final touch - let’s write tests for the Quote
class that will check the integration of all classes along the way:
# test/quote_test.rb
require 'minitest/autorun'
require_relative '../quote'
require 'ostruct'
class DummyConnector
def call
{ 'a' => 'b' }
end
end
class DummyParser
def initialize(object_to_parse:)
@object_to_parse = object_to_parse
end
def call
object_to_parse
end
private
attr_reader :object_to_parse
end
class DummyPresenter
def initialize(quote_hash:)
@quote_hash = quote_hash
end
def present
quote_hash
end
attr_reader :quote_hash
end
class TestQuote < Minitest::Test
def test_random_quote
quote_object = Quote.new(connector: DummyConnector,
parser: DummyParser,
presenter: DummyPresenter)
assert_equal({ 'a' => 'b' }, quote_object.random_quote)
end
end
We need three mocked classes to inject them into Quote
, and then we’re checking if the chain of classes is returning the message as we intend it to.
In conclusion - we’ve written four small classes, each having exactly one responsibility and one reason to change. We’ve tested them extensively and tested classes that integrated them together. Pretty neat I think!
What could be changed here? For example, we could have all these classes in a single namespace, like a Quote
module. Also, we could use some testing library to use verified mocks (with only real methods with correct arity, i.e., the number of arguments they take).
Whole code can be found in repository on our github including entire history of commits, showing different steps of refactoring.