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!

Yay

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.

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.