101: Advanced OOP structure
source link: https://www.tuicool.com/articles/hit/ZNrQbab
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
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.
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK