Merge branch 'develop'
diff --git a/.gitignore b/.gitignore
index 46aae59..ed97819 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
+coverage/
 doc/
 pkg/
-*.gem
\ No newline at end of file
+*.gem
+Gemfile.lock
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..1849328
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,6 @@
+language: ruby
+rvm:
+  - 1.9.3
+  - 2.1.1
+script:
+  - rspec
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..832eea6
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,47 @@
+Contributing to the PredictionIO Ruby SDK
+=========================================
+
+Thank you for your interest in contributing to the PredictionIO Ruby SDK!
+
+We are building this software together and strongly encourage contributions
+from the community that are within the guidelines set forth below.
+
+
+Bug Fixes and New Features
+--------------------------
+
+Before starting to write code, look for existing [tickets]
+(https://predictionio.atlassian.net/browse/SDKRUBY) or [create one]
+(https://predictionio.atlassian.net/secure/CreateIssue!default.jspa) 
+for your bug, issue, or feature request. This helps the community
+avoid working on something that might not be of interest or which
+has already been addressed.
+
+
+Environment
+-----------
+
+We highly suggest using [RVM](https://rvm.io/) or [rbenv]
+(https://github.com/sstephenson/rbenv) to set up Ruby development and
+testing environments. In this way, moving between and testing code for
+alternate Ruby versions (besides the one possibly included with your 
+system) is simple. This practice is essential for ensuring the quality
+of the SDK.
+
+
+Pull Requests
+-------------
+
+The SDK follows the [git-flow]
+(http://nvie.com/posts/a-successful-git-branching-model/) model where all
+active development goes to the develop branch, and releases go to the master
+branch. Pull requests should be made against the develop branch and include
+relevant tests, if applicable.
+
+
+Talk To Us
+----------
+
+We love to hear from you. If you want to work on something or have
+questions / feedback, please reach out to us at
+https://groups.google.com/forum/#!forum/predictionio-dev
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..32c3ae0
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,6 @@
+source "https://rubygems.org"
+group :test do
+  gem 'coveralls', require: false
+  gem 'rspec', '~> 2.14.1'
+  gem 'webmock', '~> 1.17.4'
+end
diff --git a/LICENSE b/LICENSE
index 11c8530..37bb9d4 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright 2013 TappingStone Inc.
+Copyright 2014 TappingStone Inc.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
diff --git a/README.md b/README.md
index 247e952..0114abf 100644
--- a/README.md
+++ b/README.md
@@ -1,27 +1,53 @@
 PredictionIO Ruby SDK
 =====================
 
+[![Build Status](https://travis-ci.org/PredictionIO/PredictionIO-Ruby-SDK.svg?branch=develop)](https://travis-ci.org/PredictionIO/PredictionIO-Ruby-SDK)
+[![Code Climate](https://codeclimate.com/github/PredictionIO/PredictionIO-Ruby-SDK.png)](https://codeclimate.com/github/PredictionIO/PredictionIO-Ruby-SDK)
+[![Dependency Status](https://gemnasium.com/PredictionIO/PredictionIO-Ruby-SDK.svg)](https://gemnasium.com/PredictionIO/PredictionIO-Ruby-SDK)
+
 The Ruby SDK provides a convenient API to quickly record your users' behavior
 and retrieve personalized predictions for them.
 
 The SDK requires Ruby 1.9.3+ to function properly.
 
 
+Support
+=======
+
+
+Forum
+-----
+
+https://groups.google.com/group/predictionio-user
+
+
+Issue Tracker
+-------------
+
+https://predictionio.atlassian.net
+
+If you are unsure whether a behavior is an issue, bringing it up in the forum is highly encouraged.
+
+
 Installation
-------------
+============
 
 The module is published to RubyGems and can be installed directly by
 
-    gem install predictionio
+```sh
+gem install predictionio
+```
 
 If you prefer to build and install from source
 
-    gem build predictionio.gemspec
-    gem install predictionio-<version>.gem
+```sh
+gem build predictionio.gemspec
+gem install predictionio-<version>.gem
+```
 
 
 Documentation and Usage
------------------------
+=======================
 
 The latest [documentation](http://rubydoc.info/github/PredictionIO/PredictionIO-Ruby-SDK/frames)
 is generously hosted by RubyDoc.info.
diff --git a/Rakefile b/Rakefile
index debc11c..70a846d 100644
--- a/Rakefile
+++ b/Rakefile
@@ -1,8 +1,5 @@
-require 'rake/testtask'
+require 'rspec/core/rake_task'
 
-Rake::TestTask.new do |t|
-  t.libs << 'test'
-end
+RSpec::Core::RakeTask.new(:spec)
 
-desc "Run tests"
-task :default => :test
+task :default => :spec
diff --git a/lib/predictionio/async_response.rb b/lib/predictionio/async_response.rb
index 724b4ee..6078022 100644
--- a/lib/predictionio/async_response.rb
+++ b/lib/predictionio/async_response.rb
@@ -11,9 +11,7 @@
     def initialize(request, response = nil)
       @request = request
       @response = Queue.new
-      if response != nil then
-        set(response)
-      end
+      set(response) if response
     end
 
     # Save a Net::HTTPResponse instance to the current instance.
diff --git a/lib/predictionio/client.rb b/lib/predictionio/client.rb
index 65b9129..13b8664 100644
--- a/lib/predictionio/client.rb
+++ b/lib/predictionio/client.rb
@@ -45,7 +45,7 @@
   #
   # == Installation
   # The easiest way is to use RubyGems:
-  #     gem install predictionio-0.6.0.gem
+  #     gem install predictionio
   #
   # == Synopsis
   # The recommended usage of the SDK is to fire asynchronous requests as early as you can in your code,
@@ -53,7 +53,7 @@
   #
   # === Instantiate PredictionIO Client
   #     # Include the PredictionIO SDK
-  #     require "PredictionIO"
+  #     require "predictionio"
   #
   #     client = PredictionIO::Client.new(<appkey>)
   #
@@ -124,9 +124,6 @@
     # Appkey can be changed on-the-fly after creation of the client.
     attr_accessor :appkey
 
-    # API version can be changed on-the-fly after creation of the client.
-    attr_accessor :apiversion
-
     # Only JSON is currently supported as API response format.
     attr_accessor :apiformat
 
@@ -164,11 +161,10 @@
     # - API entry point at http://localhost:8000
     # - API return data format of json
     # - 10 concurrent HTTP(S) connections
-    def initialize(appkey, threads = 10, apiurl = "http://localhost:8000", apiversion = "")
+    def initialize(appkey, threads = 10, apiurl = "http://localhost:8000", thread_timeout = 60)
       @appkey = appkey
-      @apiversion = apiversion
       @apiformat = "json"
-      @http = PredictionIO::Connection.new(URI(apiurl), threads)
+      @http = PredictionIO::Connection.new(URI(apiurl), threads, thread_timeout)
     end
 
     # Returns the number of pending requests within the current client.
@@ -196,11 +192,11 @@
       rparams = params
       rparams["pio_appkey"] = @appkey
       rparams["pio_uid"] = uid
-      if params["pio_latitude"] != nil && params["pio_longitude"] != nil then
+      if params["pio_latitude"] && params["pio_longitude"]
         rparams["pio_latlng"] = "#{params["pio_latitude"]},#{params["pio_longitude"]}"
       end
 
-      @http.apost(PredictionIO::AsyncRequest.new(versioned_path("/users.#{@apiformat}"), rparams))
+      @http.apost(PredictionIO::AsyncRequest.new("/users.#{@apiformat}", rparams))
     end
 
     # :category: Synchronous Methods
@@ -213,13 +209,13 @@
     # create_user(async_response)
     def create_user(*args)
       uid_or_res = args[0]
-      if uid_or_res.is_a?(PredictionIO::AsyncResponse) then
+      if uid_or_res.is_a?(PredictionIO::AsyncResponse)
         response = uid_or_res.get
       else
         uid = uid_or_res
         response = acreate_user(*args).get
       end
-      unless response.is_a?(Net::HTTPCreated) then
+      unless response.is_a?(Net::HTTPCreated)
         begin
           msg = response.body
         rescue Exception
@@ -240,7 +236,7 @@
     #
     # See also #get_user.
     def aget_user(uid)
-      @http.aget(PredictionIO::AsyncRequest.new(versioned_path("/users/#{uid}.#{@apiformat}"),
+      @http.aget(PredictionIO::AsyncRequest.new("/users/#{uid}.#{@apiformat}",
                                                 "pio_appkey" => @appkey,
                                                 "pio_uid" => uid))
     end
@@ -258,14 +254,14 @@
     # get_user(uid)
     # get_user(async_response)
     def get_user(uid_or_res)
-      if uid_or_res.is_a?(PredictionIO::AsyncResponse) then
+      if uid_or_res.is_a?(PredictionIO::AsyncResponse)
         response = uid_or_res.get
       else
         response = aget_user(uid_or_res).get
       end
-      if response.is_a?(Net::HTTPOK) then
+      if response.is_a?(Net::HTTPOK)
         res = JSON.parse(response.body)
-        if res["pio_latlng"] != nil then
+        if res["pio_latlng"]
           latlng = res["pio_latlng"]
           res["pio_latitude"] = latlng[0]
           res["pio_longitude"] = latlng[1]
@@ -288,7 +284,7 @@
     #
     # See also #delete_user.
     def adelete_user(uid)
-      @http.adelete(PredictionIO::AsyncRequest.new(versioned_path("/users/#{uid}.#{@apiformat}"),
+      @http.adelete(PredictionIO::AsyncRequest.new("/users/#{uid}.#{@apiformat}",
                                                    "pio_appkey" => @appkey,
                                                    "pio_uid" => uid))
     end
@@ -302,12 +298,12 @@
     # delete_user(uid)
     # delete_user(async_response)
     def delete_user(uid_or_res)
-      if uid_or_res.is_a?(PredictionIO::AsyncResponse) then
+      if uid_or_res.is_a?(PredictionIO::AsyncResponse)
         response = uid_or_res.get
       else
         response = adelete_user(uid_or_res).get
       end
-      unless response.is_a?(Net::HTTPOK) then
+      unless response.is_a?(Net::HTTPOK)
         begin
           msg = response.body
         rescue Exception
@@ -332,17 +328,13 @@
       rescue Exception
         rparams["pio_itypes"] = itypes
       end
-      if params["pio_latitude"] != nil && params["pio_longitude"] != nil then
+      if params["pio_latitude"] && params["pio_longitude"]
         rparams["pio_latlng"] = "#{params["pio_latitude"]},#{params["pio_longitude"]}"
       end
-      if params["pio_startT"] != nil then
-        rparams["pio_startT"] = ((params["pio_startT"].to_r) * 1000).round(0).to_s
-      end
-      if params["pio_endT"] != nil then
-        rparams["pio_endT"] = ((params["pio_endT"].to_r) * 1000).round(0).to_s
-      end
+      rparams["pio_startT"] = ((params["pio_startT"].to_r) * 1000).round(0).to_s if params["pio_startT"]
+      rparams["pio_endT"]   = ((params["pio_endT"].to_r) * 1000).round(0).to_s if params["pio_endT"]
 
-      @http.apost(PredictionIO::AsyncRequest.new(versioned_path("/items.#{@apiformat}"), rparams))
+      @http.apost(PredictionIO::AsyncRequest.new("/items.#{@apiformat}", rparams))
     end
 
     # :category: Synchronous Methods
@@ -355,12 +347,12 @@
     # create_item(async_response)
     def create_item(*args)
       iid_or_res = args[0]
-      if iid_or_res.is_a?(PredictionIO::AsyncResponse) then
+      if iid_or_res.is_a?(PredictionIO::AsyncResponse)
         response = iid_or_res.get
       else
         response = acreate_item(*args).get
       end
-      unless response.is_a?(Net::HTTPCreated) then
+      unless response.is_a?(Net::HTTPCreated)
         begin
           msg = response.body
         rescue Exception
@@ -381,7 +373,7 @@
     #
     # See also #get_item.
     def aget_item(iid)
-      @http.aget(PredictionIO::AsyncRequest.new(versioned_path("/items/#{iid}.#{@apiformat}"),
+      @http.aget(PredictionIO::AsyncRequest.new("/items/#{iid}.#{@apiformat}",
                                                 "pio_appkey" => @appkey,
                                                 "pio_iid" => iid))
     end
@@ -399,23 +391,23 @@
     # get_item(iid)
     # get_item(async_response)
     def get_item(iid_or_res)
-      if iid_or_res.is_a?(PredictionIO::AsyncResponse) then
+      if iid_or_res.is_a?(PredictionIO::AsyncResponse)
         response = iid_or_res.get
       else
         response = aget_item(iid_or_res).get
       end
-      if response.is_a?(Net::HTTPOK) then
+      if response.is_a?(Net::HTTPOK)
         res = JSON.parse(response.body)
-        if res["pio_latlng"] != nil then
+        if res["pio_latlng"]
           latlng = res["pio_latlng"]
           res["pio_latitude"] = latlng[0]
           res["pio_longitude"] = latlng[1]
         end
-        if res["pio_startT"] != nil then
+        if res["pio_startT"]
           startT = Rational(res["pio_startT"], 1000)
           res["pio_startT"] = Time.at(startT)
         end
-        if res["pio_endT"] != nil then
+        if res["pio_endT"]
           endT = Rational(res["pio_endT"], 1000)
           res["pio_endT"] = Time.at(endT)
         end
@@ -437,7 +429,7 @@
     #
     # See also #delete_item.
     def adelete_item(iid)
-      @http.adelete(PredictionIO::AsyncRequest.new(versioned_path("/items/#{iid}.#{@apiformat}"),
+      @http.adelete(PredictionIO::AsyncRequest.new("/items/#{iid}.#{@apiformat}",
                                                    "pio_appkey" => @appkey,
                                                    "pio_iid" => iid))
     end
@@ -451,12 +443,12 @@
     # delete_item(iid)
     # delete_item(async_response)
     def delete_item(iid_or_res)
-      if iid_or_res.is_a?(PredictionIO::AsyncResponse) then
+      if iid_or_res.is_a?(PredictionIO::AsyncResponse)
         response = iid_or_res.get
       else
         response = adelete_item(iid_or_res).get
       end
-      unless response.is_a?(Net::HTTPOK) then
+      unless response.is_a?(Net::HTTPOK)
         begin
           msg = response.body
         rescue Exception
@@ -482,23 +474,26 @@
       rparams["pio_appkey"] = @appkey
       rparams["pio_uid"] = @apiuid
       rparams["pio_n"] = n
-      if params["pio_itypes"] != nil &&
-          params["pio_itypes"].kind_of?(Array) &&
-          params["pio_itypes"].length > 0 then
-        rparams["pio_itypes"] = params["pio_itypes"].join(",")
-      else
-        rparams["pio_itypes"] = params["pio_itypes"]
+      if params["pio_itypes"]
+        if params["pio_itypes"].kind_of?(Array) && params["pio_itypes"].any?
+          rparams["pio_itypes"] = params["pio_itypes"].join(",")
+        else
+          rparams["pio_itypes"] = params["pio_itypes"]
+        end
       end
-      if params["pio_latitude"] != nil && params["pio_longitude"] != nil then
+      if params["pio_latitude"] && params["pio_longitude"]
         rparams["pio_latlng"] = "#{params["pio_latitude"]},#{params["pio_longitude"]}"
       end
-      if params["pio_within"] != nil then
-        rparams["pio_within"] = params["pio_within"]
+      rparams["pio_within"] = params["pio_within"] if params["pio_within"]
+      rparams["pio_unit"] = params["pio_unit"] if params["pio_unit"]
+      if params["pio_attributes"]
+        if params["pio_attributes"].kind_of?(Array) && params["pio_attributes"].any?
+          rparams["pio_attributes"] = params["pio_attributes"].join(",")
+        else
+          rparams["pio_attributes"] = params["pio_attributes"]
+        end
       end
-      if params["pio_unit"] != nil then
-        rparams["pio_unit"] = params["pio_unit"]
-      end
-      @http.aget(PredictionIO::AsyncRequest.new(versioned_path("/engines/itemrec/#{engine}/topn.#{@apiformat}"), rparams))
+      @http.aget(PredictionIO::AsyncRequest.new("/engines/itemrec/#{engine}/topn.#{@apiformat}", rparams))
     end
 
     # :category: Synchronous Methods
@@ -511,14 +506,21 @@
     # aget_itemrec_top_n(async_response)
     def get_itemrec_top_n(*args)
       uid_or_res = args[0]
-      if uid_or_res.is_a?(PredictionIO::AsyncResponse) then
-        response = uid_or_res.get
+      if uid_or_res.is_a?(PredictionIO::AsyncResponse)
+        response = uid_or_res
       else
-        response = aget_itemrec_top_n(*args).get
+        response = aget_itemrec_top_n(*args)
       end
-      if response.is_a?(Net::HTTPOK) then
-        res = JSON.parse(response.body)
-        res["pio_iids"]
+      http_response = response.get
+      if http_response.is_a?(Net::HTTPOK)
+        res = JSON.parse(http_response.body)
+        if response.request.params.has_key?('pio_attributes')
+          attributes = response.request.params['pio_attributes'].split(',')
+          list_of_attribute_values = attributes.map { |attrib| res[attrib] }
+          res["pio_iids"].zip(*list_of_attribute_values).map { |v| Hash[(['pio_iid'] + attributes).zip(v)] }
+        else
+          res["pio_iids"]
+        end
       else
         begin
           msg = response.body
@@ -540,23 +542,26 @@
       rparams["pio_appkey"] = @appkey
       rparams["pio_iid"] = iid
       rparams["pio_n"] = n
-      if params["pio_itypes"] != nil &&
-          params["pio_itypes"].kind_of?(Array) &&
-          params["pio_itypes"].length > 0 then
-        rparams["pio_itypes"] = params["pio_itypes"].join(",")
-      else
-        rparams["pio_itypes"] = params["pio_itypes"]
+      if params["pio_itypes"]
+        if params["pio_itypes"].kind_of?(Array) && params["pio_itypes"].any?
+          rparams["pio_itypes"] = params["pio_itypes"].join(",")
+        else
+          rparams["pio_itypes"] = params["pio_itypes"]
+        end
       end
-      if params["pio_latitude"] != nil && params["pio_longitude"] != nil then
+      if params["pio_latitude"] && params["pio_longitude"]
         rparams["pio_latlng"] = "#{params["pio_latitude"]},#{params["pio_longitude"]}"
       end
-      if params["pio_within"] != nil then
-        rparams["pio_within"] = params["pio_within"]
+      rparams["pio_within"] = params["pio_within"] if params["pio_within"]
+      rparams["pio_unit"] = params["pio_unit"] if params["pio_unit"]
+      if params["pio_attributes"]
+        if params["pio_attributes"].kind_of?(Array) && params["pio_attributes"].any?
+          rparams["pio_attributes"] = params["pio_attributes"].join(",")
+        else
+          rparams["pio_attributes"] = params["pio_attributes"]
+        end
       end
-      if params["pio_unit"] != nil then
-        rparams["pio_unit"] = params["pio_unit"]
-      end
-      @http.aget(PredictionIO::AsyncRequest.new(versioned_path("/engines/itemsim/#{engine}/topn.#{@apiformat}"), rparams))
+      @http.aget(PredictionIO::AsyncRequest.new("/engines/itemsim/#{engine}/topn.#{@apiformat}", rparams))
     end
 
     # :category: Synchronous Methods
@@ -568,15 +573,22 @@
     # aget_itemsim_top_n(engine, iid, n, params = {})
     # aget_itemsim_top_n(async_response)
     def get_itemsim_top_n(*args)
-      iid_or_res = args[0]
-      if iid_or_res.is_a?(PredictionIO::AsyncResponse) then
-        response = iid_or_res.get
+      uid_or_res = args[0]
+      if uid_or_res.is_a?(PredictionIO::AsyncResponse)
+        response = uid_or_res
       else
-        response = aget_itemsim_top_n(*args).get
+        response = aget_itemsim_top_n(*args)
       end
-      if response.is_a?(Net::HTTPOK) then
-        res = JSON.parse(response.body)
-        res["pio_iids"]
+      http_response = response.get
+      if http_response.is_a?(Net::HTTPOK)
+        res = JSON.parse(http_response.body)
+        if response.request.params.has_key?('pio_attributes')
+          attributes = response.request.params['pio_attributes'].split(',')
+          list_of_attribute_values = attributes.map { |attrib| res[attrib] }
+          res["pio_iids"].zip(*list_of_attribute_values).map { |v| Hash[(['pio_iid'] + attributes).zip(v)] }
+        else
+          res["pio_iids"]
+        end
       else
         begin
           msg = response.body
@@ -599,13 +611,11 @@
       rparams["pio_action"] = action
       rparams["pio_uid"] = @apiuid
       rparams["pio_iid"] = iid
-      if params["pio_t"] != nil then
-        rparams["pio_t"] = ((params["pio_t"].to_r) * 1000).round(0).to_s
-      end
-      if params["pio_latitude"] != nil && params["pio_longitude"] != nil then
+      rparams["pio_t"] = ((params["pio_t"].to_r) * 1000).round(0).to_s if params["pio_t"]
+      if params["pio_latitude"] && params["pio_longitude"]
         rparams["pio_latlng"] = "#{params["pio_latitude"]},#{params["pio_longitude"]}"
       end
-      @http.apost(PredictionIO::AsyncRequest.new(versioned_path("/actions/u2i.#{@apiformat}"), rparams))
+      @http.apost(PredictionIO::AsyncRequest.new("/actions/u2i.#{@apiformat}", rparams))
     end
 
     # :category: Synchronous Methods
@@ -614,16 +624,16 @@
     # See also #arecord_action_on_item.
     #
     # call-seq:
-    # record_action_on_item(action, uid, iid, params = {})
+    # record_action_on_item(action, iid, params = {})
     # record_action_on_item(async_response)
     def record_action_on_item(*args)
       action_or_res = args[0]
-      if action_or_res.is_a?(PredictionIO::AsyncResponse) then
+      if action_or_res.is_a?(PredictionIO::AsyncResponse)
         response = action_or_res.get
       else
         response = arecord_action_on_item(*args).get
       end
-      unless response.is_a?(Net::HTTPCreated) then
+      unless response.is_a?(Net::HTTPCreated)
         begin
           msg = response.body
         rescue Exception
@@ -632,14 +642,5 @@
         raise U2IActionNotCreatedError, msg
       end
     end
-
-    # :nodoc: all
-    private
-
-    def versioned_path(path)
-      # disabled for now
-      # "/#{@apiversion}#{path}"
-      path
-    end
   end
 end
diff --git a/lib/predictionio/connection.rb b/lib/predictionio/connection.rb
index e0c642c..9823aff 100644
--- a/lib/predictionio/connection.rb
+++ b/lib/predictionio/connection.rb
@@ -14,7 +14,7 @@
 
     # Spawns a number of threads with persistent HTTP connection to the specified URI.
     # Sets a default timeout of 5 seconds.
-    def initialize(uri, threads = 1, timeout = 5)
+    def initialize(uri, threads = 1, timeout = 60)
       @packages = Queue.new
       @counter_lock = Mutex.new
       @connections = 0
diff --git a/predictionio.gemspec b/predictionio.gemspec
index 6ff8ca9..96f67f6 100644
--- a/predictionio.gemspec
+++ b/predictionio.gemspec
@@ -6,7 +6,7 @@
 provides convenient access of the PredictionIO API to Ruby programmers so that
 they can focus on their application logic.
   EOF
-  s.version = "0.6.0"
+  s.version = "0.7.0"
   s.author = "The PredictionIO Team"
   s.email = "support@prediction.io"
   s.homepage = "http://prediction.io"
diff --git a/spec/predictionio_spec.rb b/spec/predictionio_spec.rb
new file mode 100644
index 0000000..a58cc92
--- /dev/null
+++ b/spec/predictionio_spec.rb
@@ -0,0 +1,74 @@
+require 'predictionio'
+require 'spec_helper'
+
+client = PredictionIO::Client.new("foobar", 10, "http://fakeapi.com:8000")
+
+describe PredictionIO do
+  describe 'Users API' do
+    it 'create_user should create a user' do
+      response = client.create_user("foo")
+      expect(response).to_not raise_error
+    end
+    it 'get_user should get a user' do
+      response = client.get_user("foo")
+      expect(response).to eq({"pio_uid" => "foo"})
+    end
+    it 'delete_user should delete a user' do
+      response = client.delete_user("foo")
+      expect(response).to_not raise_error
+    end
+  end
+
+  describe 'Items API' do
+    it 'create_item should create a item' do
+      response = client.create_item("bar", ["dead", "beef"])
+      expect(response).to_not raise_error
+    end
+    it 'get_item should get a item' do
+      response = client.get_item("bar")
+      expect(response).to eq({"pio_iid" => "bar", "pio_itypes" => ["dead", "beef"]})
+    end
+    it 'delete_item should delete a item' do
+      response = client.delete_item("bar")
+      expect(response).to_not raise_error
+    end
+  end
+
+  describe 'U2I API' do
+    it 'record_action_on_item should record an action' do
+      client.identify("foo")
+      response = client.record_action_on_item("view", "bar")
+      expect(response).to_not raise_error
+    end
+  end
+
+  describe 'Item Recommendation API' do
+    it 'provides recommendations to a user without attributes' do
+      client.identify("foo")
+      response = client.get_itemrec_top_n("itemrec-engine", 10)
+      expect(response).to eq(["x", "y", "z"])
+    end
+    it 'provides recommendations to a user with attributes' do
+      client.identify("foo")
+      response = client.get_itemrec_top_n("itemrec-engine", 10, 'pio_attributes' => 'name')
+      expect(response).to eq([
+        {"pio_iid" => "x", "name" => "a"},
+        {"pio_iid" => "y", "name" => "b"},
+        {"pio_iid" => "z", "name" => "c"}])
+    end
+  end
+
+  describe 'Item Similarity API' do
+    it 'provides similarities to an item without attributes' do
+      response = client.get_itemsim_top_n("itemsim-engine", "bar", 10)
+      expect(response).to eq(["x", "y", "z"])
+    end
+    it 'provides similarities to an item with attributes' do
+      response = client.get_itemsim_top_n("itemsim-engine", "bar", 10, 'pio_attributes' => 'name')
+      expect(response).to eq([
+        {"pio_iid" => "x", "name" => "a"},
+        {"pio_iid" => "y", "name" => "b"},
+        {"pio_iid" => "z", "name" => "c"}])
+    end
+  end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
new file mode 100644
index 0000000..15f35e8
--- /dev/null
+++ b/spec/spec_helper.rb
@@ -0,0 +1,55 @@
+require 'coveralls'
+require 'json'
+require 'webmock/rspec'
+
+Coveralls.wear!
+WebMock.disable_net_connect!(allow_localhost: true)
+
+RSpec.configure do |config|
+  config.before(:each) do
+    # Users API
+    stub_request(:post, "http://fakeapi.com:8000/users.json").
+      with(:body => {"pio_appkey" => "foobar", "pio_uid" => "foo"}).
+      to_return(:status => 201, :body => "", :headers => {})
+    stub_request(:get, "http://fakeapi.com:8000/users/foo.json").
+      with(:query => hash_including({"pio_appkey" => "foobar"})).
+      to_return(:status => 200, :body => JSON.generate({"pio_uid" => "foo"}), :headers => {})
+    stub_request(:delete, "http://fakeapi.com:8000/users/foo.json").
+      with(:query => hash_including({"pio_appkey" => "foobar"})).
+      to_return(:status => 200, :body => "", :headers => {})
+
+    # Items API
+    stub_request(:post, "http://fakeapi.com:8000/items.json").
+      with(:body => {"pio_appkey" => "foobar", "pio_iid" => "bar", "pio_itypes" => "dead,beef"}).
+      to_return(:status => 201, :body => "", :headers => {})
+    stub_request(:get, "http://fakeapi.com:8000/items/bar.json").
+      with(:query => hash_including({"pio_appkey" => "foobar"})).
+      to_return(:status => 200, :body => JSON.generate({"pio_iid" => "bar", "pio_itypes" => ["dead", "beef"]}), :headers => {})
+    stub_request(:delete, "http://fakeapi.com:8000/items/bar.json").
+      with(:query => hash_including({"pio_appkey" => "foobar"})).
+      to_return(:status => 200, :body => "", :headers => {})
+
+    # U2I Actions API
+    stub_request(:post, "http://fakeapi.com:8000/actions/u2i.json").
+      with(:body => {"pio_action" => "view", "pio_appkey" => "foobar", "pio_iid" => "bar", "pio_uid" => "foo"}).
+      to_return(:status => 201, :body => "", :headers => {})
+
+    # Item Recommendation API
+    stub_request(:get, "http://fakeapi.com:8000/engines/itemrec/itemrec-engine/topn.json").
+      with(:query => hash_including("pio_appkey" => "foobar", "pio_n" => "10", "pio_uid" => "foo")).
+      to_return(:status => 200, :body => JSON.generate({"pio_iids" => ["x", "y", "z"]}), :headers => {})
+
+    stub_request(:get, "http://fakeapi.com:8000/engines/itemrec/itemrec-engine/topn.json").
+      with(:query => hash_including("pio_appkey" => "foobar", "pio_n" => "10", "pio_uid" => "foo", 'pio_attributes' => 'name')).
+      to_return(:status => 200, :body => JSON.generate({"pio_iids" => ["x", "y", "z"], "name" => ["a", "b", "c"]}), :headers => {})
+
+    # Item Similarity API
+    stub_request(:get, "http://fakeapi.com:8000/engines/itemsim/itemsim-engine/topn.json").
+      with(:query => hash_including("pio_appkey" => "foobar", "pio_n" => "10", "pio_iid" => "bar")).
+      to_return(:status => 200, :body => JSON.generate({"pio_iids" => ["x", "y", "z"]}), :headers => {})
+
+    stub_request(:get, "http://fakeapi.com:8000/engines/itemsim/itemsim-engine/topn.json").
+      with(:query => hash_including("pio_appkey" => "foobar", "pio_n" => "10", "pio_iid" => "bar", 'pio_attributes' => 'name')).
+      to_return(:status => 200, :body => JSON.generate({"pio_iids" => ["x", "y", "z"], 'name' => ['a', 'b', 'c']}), :headers => {})
+  end
+end
diff --git a/test/test.rb b/test/test.rb
deleted file mode 100644
index f666a0b..0000000
--- a/test/test.rb
+++ /dev/null
@@ -1,103 +0,0 @@
-require 'predictionio'
-require 'date'
-require 'test/unit'
-
-class TestPredictionIO < MiniTest::Unit::TestCase
-  if ENV["APPKEY"] then
-    APPKEY = ENV["APPKEY"]
-  else
-    APPKEY = "k4f6rCV8YTM5x0PbRdIG4yrWiKLhOv16V0Q8COE2AcvnYmSlxbAcXR5pucI5HO21"
-  end
-  if ENV["APIURL"] then
-    APIURL = ENV["APIURL"]
-  else
-    APIURL = "http://localhost:8000"
-  end
-  APITHREADS = 1
-
-  def test_appkey
-    client = PredictionIO::Client.new("foobar", APITHREADS, APIURL)
-    assert_equal(client.appkey, "foobar")
-  end
-
-  def test_get_status
-    client = PredictionIO::Client.new("foobar", APITHREADS, APIURL)
-    assert_equal("PredictionIO Output API is online.", client.get_status)
-  end
-
-  def test_user
-    client = PredictionIO::Client.new(APPKEY, APITHREADS, APIURL)
-    client.create_user("ruby_foobar")
-    assert_equal("ruby_foobar", client.get_user("ruby_foobar")["pio_uid"], "uid: ruby_foobar")
-    client.delete_user("ruby_foobar")
-    client.create_user("ruby_barbaz",
-                       "gender" => "F",
-                       "bday" => "1985-05-05",
-                       "pio_latitude" => 21.109,
-                       "pio_longitude" => -48.7479,
-                       "pio_inactive" => true)
-    ruby_barbaz = client.get_user("ruby_barbaz")
-    assert_equal("ruby_barbaz", ruby_barbaz["pio_uid"], "uid: ruby_barbaz")
-    #assert_equal("F", ruby_barbaz["gender"], "gender: F")
-    assert_equal(21.109, ruby_barbaz["pio_latitude"], "lat: 21.109")
-    assert_equal(-48.7479, ruby_barbaz["pio_longitude"], "lng: -48.7479")
-    #assert_equal("1985-05-05", ruby_barbaz["bday"], "bday: 1985-05-05")
-    assert(ruby_barbaz["pio_inactive"], "inactive: true")
-    client.delete_user("ruby_barbaz")
-  end
-
-  def test_item
-    client = PredictionIO::Client.new(APPKEY, APITHREADS, APIURL)
-    client.create_item("ruby_barbaz",
-                       ["218", "55"],
-                       "pio_latitude" => -58.24089,
-                       "pio_longitude" => 48.17890,
-                       "pio_startT" => Time.at(478308922000))
-    assert_raises(PredictionIO::Client::ItemNotFoundError) { client.get_item("randomstuff") }
-    ruby_barbaz = client.get_item("ruby_barbaz")
-    assert_equal("ruby_barbaz", ruby_barbaz["pio_iid"], "iid: ruby_barbaz")
-    assert(ruby_barbaz["pio_itypes"].include?("218"), "itypes: 218")
-    assert(ruby_barbaz["pio_itypes"].include?("55"), "itypes: 55")
-    assert_equal(-58.24089, ruby_barbaz["pio_latitude"], "lat: -58.24089")
-    assert_equal(48.1789, ruby_barbaz["pio_longitude"], "lng: 48.1789")
-    assert_equal(Time.at(478308922000), ruby_barbaz["pio_startT"], "startT: 478308922000")
-    client.delete_item("ruby_barbaz")
-  end
-
-  def test_u2i
-    client = PredictionIO::Client.new(APPKEY, APITHREADS, APIURL)
-    client.identify("foo1")
-    client.record_action_on_item("rate", "bar2", "pio_rate" => 4)
-    client.identify("foo2")
-    client.record_action_on_item("like", "bar4")
-    client.identify("foo4")
-    client.record_action_on_item("dislike", "bar8")
-    client.identify("foo8")
-    client.record_action_on_item("view", "bar16")
-    client.identify("foo16")
-    client.record_action_on_item("conversion", "bar32")
-  end
-
-  def test_itemrec
-    client = PredictionIO::Client.new(APPKEY, APITHREADS, APIURL)
-    client.identify("218")
-    iids = client.get_itemrec_top_n("test", 5)
-    assert(iids.include?("itemrec"))
-    assert(iids.include?("218"))
-    assert(iids.include?("1"))
-    assert(iids.include?("foo"))
-    assert(iids.include?("bar"))
-    assert_equal(iids.length, 5)
-  end
-
-  def test_itemsim
-    client = PredictionIO::Client.new(APPKEY, APITHREADS, APIURL)
-    iids = client.get_itemsim_top_n("test", "218", 5)
-    assert(iids.include?("itemsim"))
-    assert(iids.include?("218"))
-    assert(iids.include?("1"))
-    assert(iids.include?("foo"))
-    assert(iids.include?("bar"))
-    assert_equal(iids.length, 5)
-  end
-end