diff --git a/src/ruby/.rubocop.yml b/src/ruby/.rubocop.yml
index 1b255f3963a40a1dd6b7590c1e611938546bd32a..312bdca384e538030d18a3f70e3d9645920031ec 100644
--- a/src/ruby/.rubocop.yml
+++ b/src/ruby/.rubocop.yml
@@ -5,7 +5,7 @@ inherit_from: .rubocop_todo.yml
 AllCops:
   Exclude:
     - 'bin/apis/**/*'
-    - 'bin/interop/test/**/*'
     - 'bin/math.rb'
     - 'bin/math_services.rb'
     - 'pb/grpc/health/v1alpha/*'
+    - 'pb/test/**/*'
diff --git a/src/ruby/pb/grpc/health/v1alpha/health_checker.rb b/src/ruby/bin/grpc_ruby_interop_client
old mode 100644
new mode 100755
similarity index 88%
rename from src/ruby/pb/grpc/health/v1alpha/health_checker.rb
rename to src/ruby/bin/grpc_ruby_interop_client
index f04bf5ecca1f3f2e672d99079672d57359411ceb..e79fd33aa5011146969618b648f7c4a455f0fe5b
--- a/src/ruby/pb/grpc/health/v1alpha/health_checker.rb
+++ b/src/ruby/bin/grpc_ruby_interop_client
@@ -1,3 +1,5 @@
+#!/usr/bin/env ruby
+
 # Copyright 2015, Google Inc.
 # All rights reserved.
 #
@@ -27,13 +29,5 @@
 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
-require 'grpc/health/v1alpha/health_services'
-
-module Grpc
-  # Health contains classes and modules that support providing a health check
-  # service.
-  module Health
-    class Checker
-    end
-  end
-end
+# Provides a gem binary entry point for the interop client.
+require 'test/client'
diff --git a/src/ruby/bin/interop/test/cpp/interop/test.rb b/src/ruby/bin/grpc_ruby_interop_server
old mode 100644
new mode 100755
similarity index 83%
rename from src/ruby/bin/interop/test/cpp/interop/test.rb
rename to src/ruby/bin/grpc_ruby_interop_server
index 5948b50eaa5cddae2ce7fac485b3d3db8bc32be5..656a5f7c998cc8c7be38407d007a9a02ad65a649
--- a/src/ruby/bin/interop/test/cpp/interop/test.rb
+++ b/src/ruby/bin/grpc_ruby_interop_server
@@ -1,3 +1,5 @@
+#!/usr/bin/env ruby
+
 # Copyright 2015, Google Inc.
 # All rights reserved.
 #
@@ -27,17 +29,5 @@
 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
-# Generated by the protocol buffer compiler.  DO NOT EDIT!
-# source: test/cpp/interop/test.proto
-
-require 'google/protobuf'
-
-require 'test/cpp/interop/empty'
-require 'test/cpp/interop/messages'
-Google::Protobuf::DescriptorPool.generated_pool.build do
-end
-
-module Grpc
-  module Testing
-  end
-end
+# Provides a gem binary entry point for the interop server
+require 'test/server'
diff --git a/src/ruby/bin/interop/README.md b/src/ruby/bin/interop/README.md
deleted file mode 100644
index 84fc66362032d62738504e5055ca7ea8845500d9..0000000000000000000000000000000000000000
--- a/src/ruby/bin/interop/README.md
+++ /dev/null
@@ -1,8 +0,0 @@
-Interop test protos
-===================
-
-These ruby classes were generated with protoc v3, using grpc's ruby compiler
-plugin.
-
-- As of 2015/01 protoc v3 is available in the
-[google-protobuf](https://github.com/google/protobuf) repo
diff --git a/src/ruby/bin/interop/interop_client.rb b/src/ruby/bin/interop/interop_client.rb
index 78ae217fa5638fc490b35098e82a9b7b8df25ead..239083f37f6e239cdc702ded10bd03a5c54428cb 100755
--- a/src/ruby/bin/interop/interop_client.rb
+++ b/src/ruby/bin/interop/interop_client.rb
@@ -29,6 +29,12 @@
 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
+# #######################################################################
+# DEPRECATED: The behaviour in this file has been moved to pb/test/client.rb
+#
+# This file remains to support existing tools and scripts that use it.
+# ######################################################################
+#
 # interop_client is a testing tool that accesses a gRPC interop testing
 # server and runs a test on it.
 #
@@ -39,397 +45,7 @@
 #                                    --test_case=<testcase_name>
 
 this_dir = File.expand_path(File.dirname(__FILE__))
-lib_dir = File.join(File.dirname(File.dirname(this_dir)), 'lib')
-$LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)
-$LOAD_PATH.unshift(this_dir) unless $LOAD_PATH.include?(this_dir)
-
-require 'optparse'
-require 'minitest'
-require 'minitest/assertions'
-
-require 'grpc'
-require 'googleauth'
-require 'google/protobuf'
-
-require 'test/cpp/interop/test_services'
-require 'test/cpp/interop/messages'
-require 'test/cpp/interop/empty'
-
-require 'signet/ssl_config'
-
-AUTH_ENV = Google::Auth::CredentialsLoader::ENV_VAR
-
-# loads the certificates used to access the test server securely.
-def load_test_certs
-  this_dir = File.expand_path(File.dirname(__FILE__))
-  data_dir = File.join(File.dirname(File.dirname(this_dir)), 'spec/testdata')
-  files = ['ca.pem', 'server1.key', 'server1.pem']
-  files.map { |f| File.open(File.join(data_dir, f)).read }
-end
-
-# loads the certificates used to access the test server securely.
-def load_prod_cert
-  fail 'could not find a production cert' if ENV['SSL_CERT_FILE'].nil?
-  GRPC.logger.info("loading prod certs from #{ENV['SSL_CERT_FILE']}")
-  File.open(ENV['SSL_CERT_FILE']).read
-end
-
-# creates SSL Credentials from the test certificates.
-def test_creds
-  certs = load_test_certs
-  GRPC::Core::Credentials.new(certs[0])
-end
-
-# creates SSL Credentials from the production certificates.
-def prod_creds
-  cert_text = load_prod_cert
-  GRPC::Core::Credentials.new(cert_text)
-end
-
-# creates the SSL Credentials.
-def ssl_creds(use_test_ca)
-  return test_creds if use_test_ca
-  prod_creds
-end
-
-# creates a test stub that accesses host:port securely.
-def create_stub(opts)
-  address = "#{opts.host}:#{opts.port}"
-  if opts.secure
-    stub_opts = {
-      :creds => ssl_creds(opts.use_test_ca),
-      GRPC::Core::Channel::SSL_TARGET => opts.host_override
-    }
-
-    # Add service account creds if specified
-    wants_creds = %w(all compute_engine_creds service_account_creds)
-    if wants_creds.include?(opts.test_case)
-      unless opts.oauth_scope.nil?
-        auth_creds = Google::Auth.get_application_default(opts.oauth_scope)
-        stub_opts[:update_metadata] = auth_creds.updater_proc
-      end
-    end
-
-    if opts.test_case == 'oauth2_auth_token'
-      auth_creds = Google::Auth.get_application_default(opts.oauth_scope)
-      kw = auth_creds.updater_proc.call({})  # gives as an auth token
-
-      # use a metadata update proc that just adds the auth token.
-      stub_opts[:update_metadata] = proc { |md| md.merge(kw) }
-    end
-
-    if opts.test_case == 'jwt_token_creds'  # don't use a scope
-      auth_creds = Google::Auth.get_application_default
-      stub_opts[:update_metadata] = auth_creds.updater_proc
-    end
-
-    GRPC.logger.info("... connecting securely to #{address}")
-    Grpc::Testing::TestService::Stub.new(address, **stub_opts)
-  else
-    GRPC.logger.info("... connecting insecurely to #{address}")
-    Grpc::Testing::TestService::Stub.new(address)
-  end
-end
-
-# produces a string of null chars (\0) of length l.
-def nulls(l)
-  fail 'requires #{l} to be +ve' if l < 0
-  [].pack('x' * l).force_encoding('utf-8')
-end
-
-# a PingPongPlayer implements the ping pong bidi test.
-class PingPongPlayer
-  include Minitest::Assertions
-  include Grpc::Testing
-  include Grpc::Testing::PayloadType
-  attr_accessor :assertions # required by Minitest::Assertions
-  attr_accessor :queue
-  attr_accessor :canceller_op
-
-  # reqs is the enumerator over the requests
-  def initialize(msg_sizes)
-    @queue = Queue.new
-    @msg_sizes = msg_sizes
-    @assertions = 0  # required by Minitest::Assertions
-    @canceller_op = nil  # used to cancel after the first response
-  end
-
-  def each_item
-    return enum_for(:each_item) unless block_given?
-    req_cls, p_cls = StreamingOutputCallRequest, ResponseParameters  # short
-    count = 0
-    @msg_sizes.each do |m|
-      req_size, resp_size = m
-      req = req_cls.new(payload: Payload.new(body: nulls(req_size)),
-                        response_type: :COMPRESSABLE,
-                        response_parameters: [p_cls.new(size: resp_size)])
-      yield req
-      resp = @queue.pop
-      assert_equal(:COMPRESSABLE, resp.payload.type, 'payload type is wrong')
-      assert_equal(resp_size, resp.payload.body.length,
-                   "payload body #{count} has the wrong length")
-      p "OK: ping_pong #{count}"
-      count += 1
-      unless @canceller_op.nil?
-        canceller_op.cancel
-        break
-      end
-    end
-  end
-end
-
-# defines methods corresponding to each interop test case.
-class NamedTests
-  include Minitest::Assertions
-  include Grpc::Testing
-  include Grpc::Testing::PayloadType
-  attr_accessor :assertions # required by Minitest::Assertions
-
-  def initialize(stub, args)
-    @assertions = 0  # required by Minitest::Assertions
-    @stub = stub
-    @args = args
-  end
-
-  def empty_unary
-    resp = @stub.empty_call(Empty.new)
-    assert resp.is_a?(Empty), 'empty_unary: invalid response'
-    p 'OK: empty_unary'
-  end
-
-  def large_unary
-    perform_large_unary
-    p 'OK: large_unary'
-  end
-
-  def service_account_creds
-    # ignore this test if the oauth options are not set
-    if @args.oauth_scope.nil?
-      p 'NOT RUN: service_account_creds; no service_account settings'
-      return
-    end
-    json_key = File.read(ENV[AUTH_ENV])
-    wanted_email = MultiJson.load(json_key)['client_email']
-    resp = perform_large_unary(fill_username: true,
-                               fill_oauth_scope: true)
-    assert_equal(wanted_email, resp.username,
-                 'service_account_creds: incorrect username')
-    assert(@args.oauth_scope.include?(resp.oauth_scope),
-           'service_account_creds: incorrect oauth_scope')
-    p 'OK: service_account_creds'
-  end
-
-  def jwt_token_creds
-    json_key = File.read(ENV[AUTH_ENV])
-    wanted_email = MultiJson.load(json_key)['client_email']
-    resp = perform_large_unary(fill_username: true)
-    assert_equal(wanted_email, resp.username,
-                 'service_account_creds: incorrect username')
-    p 'OK: jwt_token_creds'
-  end
-
-  def compute_engine_creds
-    resp = perform_large_unary(fill_username: true,
-                               fill_oauth_scope: true)
-    assert_equal(@args.default_service_account, resp.username,
-                 'compute_engine_creds: incorrect username')
-    p 'OK: compute_engine_creds'
-  end
-
-  def oauth2_auth_token
-    resp = perform_large_unary(fill_username: true,
-                               fill_oauth_scope: true)
-    json_key = File.read(ENV[AUTH_ENV])
-    wanted_email = MultiJson.load(json_key)['client_email']
-    assert_equal(wanted_email, resp.username,
-                 "#{__callee__}: incorrect username")
-    assert(@args.oauth_scope.include?(resp.oauth_scope),
-           "#{__callee__}: incorrect oauth_scope")
-    p "OK: #{__callee__}"
-  end
-
-  def per_rpc_creds
-    auth_creds = Google::Auth.get_application_default(@args.oauth_scope)
-    kw = auth_creds.updater_proc.call({})
-    resp = perform_large_unary(fill_username: true,
-                               fill_oauth_scope: true,
-                               **kw)
-    json_key = File.read(ENV[AUTH_ENV])
-    wanted_email = MultiJson.load(json_key)['client_email']
-    assert_equal(wanted_email, resp.username,
-                 "#{__callee__}: incorrect username")
-    assert(@args.oauth_scope.include?(resp.oauth_scope),
-           "#{__callee__}: incorrect oauth_scope")
-    p "OK: #{__callee__}"
-  end
-
-  def client_streaming
-    msg_sizes = [27_182, 8, 1828, 45_904]
-    wanted_aggregate_size = 74_922
-    reqs = msg_sizes.map do |x|
-      req = Payload.new(body: nulls(x))
-      StreamingInputCallRequest.new(payload: req)
-    end
-    resp = @stub.streaming_input_call(reqs)
-    assert_equal(wanted_aggregate_size, resp.aggregated_payload_size,
-                 'client_streaming: aggregate payload size is incorrect')
-    p 'OK: client_streaming'
-  end
-
-  def server_streaming
-    msg_sizes = [31_415, 9, 2653, 58_979]
-    response_spec = msg_sizes.map { |s| ResponseParameters.new(size: s) }
-    req = StreamingOutputCallRequest.new(response_type: :COMPRESSABLE,
-                                         response_parameters: response_spec)
-    resps = @stub.streaming_output_call(req)
-    resps.each_with_index do |r, i|
-      assert i < msg_sizes.length, 'too many responses'
-      assert_equal(:COMPRESSABLE, r.payload.type,
-                   'payload type is wrong')
-      assert_equal(msg_sizes[i], r.payload.body.length,
-                   'payload body #{i} has the wrong length')
-    end
-    p 'OK: server_streaming'
-  end
-
-  def ping_pong
-    msg_sizes = [[27_182, 31_415], [8, 9], [1828, 2653], [45_904, 58_979]]
-    ppp = PingPongPlayer.new(msg_sizes)
-    resps = @stub.full_duplex_call(ppp.each_item)
-    resps.each { |r| ppp.queue.push(r) }
-    p 'OK: ping_pong'
-  end
-
-  def timeout_on_sleeping_server
-    msg_sizes = [[27_182, 31_415]]
-    ppp = PingPongPlayer.new(msg_sizes)
-    resps = @stub.full_duplex_call(ppp.each_item, timeout: 0.001)
-    resps.each { |r| ppp.queue.push(r) }
-    fail 'Should have raised GRPC::BadStatus(DEADLINE_EXCEEDED)'
-  rescue GRPC::BadStatus => e
-    assert_equal(e.code, GRPC::Core::StatusCodes::DEADLINE_EXCEEDED)
-    p "OK: #{__callee__}"
-  end
-
-  def empty_stream
-    ppp = PingPongPlayer.new([])
-    resps = @stub.full_duplex_call(ppp.each_item)
-    count = 0
-    resps.each do
-      |r| ppp.queue.push(r)
-      count += 1
-    end
-    assert_equal(0, count, 'too many responses, expect 0')
-    p 'OK: empty_stream'
-  end
-
-  def cancel_after_begin
-    msg_sizes = [27_182, 8, 1828, 45_904]
-    reqs = msg_sizes.map do |x|
-      req = Payload.new(body: nulls(x))
-      StreamingInputCallRequest.new(payload: req)
-    end
-    op = @stub.streaming_input_call(reqs, return_op: true)
-    op.cancel
-    assert_raises(GRPC::Cancelled) { op.execute }
-    assert(op.cancelled, 'call operation should be CANCELLED')
-    p 'OK: cancel_after_begin'
-  end
-
-  def cancel_after_first_response
-    msg_sizes = [[27_182, 31_415], [8, 9], [1828, 2653], [45_904, 58_979]]
-    ppp = PingPongPlayer.new(msg_sizes)
-    op = @stub.full_duplex_call(ppp.each_item, return_op: true)
-    ppp.canceller_op = op  # causes ppp to cancel after the 1st message
-    assert_raises(GRPC::Cancelled) { op.execute.each { |r| ppp.queue.push(r) } }
-    op.wait
-    assert(op.cancelled, 'call operation was not CANCELLED')
-    p 'OK: cancel_after_first_response'
-  end
-
-  def all
-    all_methods = NamedTests.instance_methods(false).map(&:to_s)
-    all_methods.each do |m|
-      next if m == 'all' || m.start_with?('assert')
-      p "TESTCASE: #{m}"
-      method(m).call
-    end
-  end
-
-  private
-
-  def perform_large_unary(fill_username: false, fill_oauth_scope: false, **kw)
-    req_size, wanted_response_size = 271_828, 314_159
-    payload = Payload.new(type: :COMPRESSABLE, body: nulls(req_size))
-    req = SimpleRequest.new(response_type: :COMPRESSABLE,
-                            response_size: wanted_response_size,
-                            payload: payload)
-    req.fill_username = fill_username
-    req.fill_oauth_scope = fill_oauth_scope
-    resp = @stub.unary_call(req, **kw)
-    assert_equal(:COMPRESSABLE, resp.payload.type,
-                 'large_unary: payload had the wrong type')
-    assert_equal(wanted_response_size, resp.payload.body.length,
-                 'large_unary: payload had the wrong length')
-    assert_equal(nulls(wanted_response_size), resp.payload.body,
-                 'large_unary: payload content is invalid')
-    resp
-  end
-end
-
-# Args is used to hold the command line info.
-Args = Struct.new(:default_service_account, :host, :host_override,
-                  :oauth_scope, :port, :secure, :test_case,
-                  :use_test_ca)
-
-# validates the the command line options, returning them as a Hash.
-def parse_args
-  args = Args.new
-  args.host_override = 'foo.test.google.fr'
-  OptionParser.new do |opts|
-    opts.on('--oauth_scope scope',
-            'Scope for OAuth tokens') { |v| args['oauth_scope'] = v }
-    opts.on('--server_host SERVER_HOST', 'server hostname') do |v|
-      args['host'] = v
-    end
-    opts.on('--default_service_account email_address',
-            'email address of the default service account') do |v|
-      args['default_service_account'] = v
-    end
-    opts.on('--server_host_override HOST_OVERRIDE',
-            'override host via a HTTP header') do |v|
-      args['host_override'] = v
-    end
-    opts.on('--server_port SERVER_PORT', 'server port') { |v| args['port'] = v }
-    # instance_methods(false) gives only the methods defined in that class
-    test_cases = NamedTests.instance_methods(false).map(&:to_s)
-    test_case_list = test_cases.join(',')
-    opts.on('--test_case CODE', test_cases, {}, 'select a test_case',
-            "  (#{test_case_list})") { |v| args['test_case'] = v }
-    opts.on('-s', '--use_tls', 'require a secure connection?') do |v|
-      args['secure'] = v
-    end
-    opts.on('-t', '--use_test_ca',
-            'if secure, use the test certificate?') do |v|
-      args['use_test_ca'] = v
-    end
-  end.parse!
-  _check_args(args)
-end
-
-def _check_args(args)
-  %w(host port test_case).each do |a|
-    if args[a].nil?
-      fail(OptionParser::MissingArgument, "please specify --#{arg}")
-    end
-  end
-  args
-end
-
-def main
-  opts = parse_args
-  stub = create_stub(opts)
-  NamedTests.new(stub, opts).method(opts['test_case']).call
-end
+pb_dir = File.join(File.dirname(File.dirname(this_dir)), 'pb')
+$LOAD_PATH.unshift(pb_dir) unless $LOAD_PATH.include?(pb_dir)
 
-main
+require 'test/client'
diff --git a/src/ruby/bin/interop/interop_server.rb b/src/ruby/bin/interop/interop_server.rb
index 2ba8d2c19ea52d15f26de462bdaa961e4b23c6da..c6b0d00ec6361daa69d0460b267c516b82d7f602 100755
--- a/src/ruby/bin/interop/interop_server.rb
+++ b/src/ruby/bin/interop/interop_server.rb
@@ -29,6 +29,12 @@
 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
+# #######################################################################
+# DEPRECATED: The behaviour in this file has been moved to pb/test/server.rb
+#
+# This file remains to support existing tools and scripts that use it.
+# ######################################################################
+#
 # interop_server is a Testing app that runs a gRPC interop testing server.
 #
 # It helps validate interoperation b/w gRPC in different environments
@@ -38,157 +44,7 @@
 # Usage: $ path/to/interop_server.rb --port
 
 this_dir = File.expand_path(File.dirname(__FILE__))
-lib_dir = File.join(File.dirname(File.dirname(this_dir)), 'lib')
-$LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)
-$LOAD_PATH.unshift(this_dir) unless $LOAD_PATH.include?(this_dir)
-
-require 'forwardable'
-require 'optparse'
-
-require 'grpc'
-
-require 'test/cpp/interop/test_services'
-require 'test/cpp/interop/messages'
-require 'test/cpp/interop/empty'
-
-# loads the certificates by the test server.
-def load_test_certs
-  this_dir = File.expand_path(File.dirname(__FILE__))
-  data_dir = File.join(File.dirname(File.dirname(this_dir)), 'spec/testdata')
-  files = ['ca.pem', 'server1.key', 'server1.pem']
-  files.map { |f| File.open(File.join(data_dir, f)).read }
-end
-
-# creates a ServerCredentials from the test certificates.
-def test_server_creds
-  certs = load_test_certs
-  GRPC::Core::ServerCredentials.new(nil, certs[1], certs[2])
-end
-
-# produces a string of null chars (\0) of length l.
-def nulls(l)
-  fail 'requires #{l} to be +ve' if l < 0
-  [].pack('x' * l).force_encoding('utf-8')
-end
-
-# A EnumeratorQueue wraps a Queue yielding the items added to it via each_item.
-class EnumeratorQueue
-  extend Forwardable
-  def_delegators :@q, :push
-
-  def initialize(sentinel)
-    @q = Queue.new
-    @sentinel = sentinel
-  end
-
-  def each_item
-    return enum_for(:each_item) unless block_given?
-    loop do
-      r = @q.pop
-      break if r.equal?(@sentinel)
-      fail r if r.is_a? Exception
-      yield r
-    end
-  end
-end
-
-# A runnable implementation of the schema-specified testing service, with each
-# service method implemented as required by the interop testing spec.
-class TestTarget < Grpc::Testing::TestService::Service
-  include Grpc::Testing
-  include Grpc::Testing::PayloadType
-
-  def empty_call(_empty, _call)
-    Empty.new
-  end
-
-  def unary_call(simple_req, _call)
-    req_size = simple_req.response_size
-    SimpleResponse.new(payload: Payload.new(type: :COMPRESSABLE,
-                                            body: nulls(req_size)))
-  end
-
-  def streaming_input_call(call)
-    sizes = call.each_remote_read.map { |x| x.payload.body.length }
-    sum = sizes.inject { |s, x| s + x }
-    StreamingInputCallResponse.new(aggregated_payload_size: sum)
-  end
-
-  def streaming_output_call(req, _call)
-    cls = StreamingOutputCallResponse
-    req.response_parameters.map do |p|
-      cls.new(payload: Payload.new(type: req.response_type,
-                                   body: nulls(p.size)))
-    end
-  end
-
-  def full_duplex_call(reqs)
-    # reqs is a lazy Enumerator of the requests sent by the client.
-    q = EnumeratorQueue.new(self)
-    cls = StreamingOutputCallResponse
-    Thread.new do
-      begin
-        GRPC.logger.info('interop-server: started receiving')
-        reqs.each do |req|
-          resp_size = req.response_parameters[0].size
-          GRPC.logger.info("read a req, response size is #{resp_size}")
-          resp = cls.new(payload: Payload.new(type: req.response_type,
-                                              body: nulls(resp_size)))
-          q.push(resp)
-        end
-        GRPC.logger.info('interop-server: finished receiving')
-        q.push(self)
-      rescue StandardError => e
-        GRPC.logger.info('interop-server: failed')
-        GRPC.logger.warn(e)
-        q.push(e)  # share the exception with the enumerator
-      end
-    end
-    q.each_item
-  end
-
-  def half_duplex_call(reqs)
-    # TODO: update with unique behaviour of the half_duplex_call if that's
-    # ever required by any of the tests.
-    full_duplex_call(reqs)
-  end
-end
-
-# validates the the command line options, returning them as a Hash.
-def parse_options
-  options = {
-    'port' => nil,
-    'secure' => false
-  }
-  OptionParser.new do |opts|
-    opts.banner = 'Usage: --port port'
-    opts.on('--port PORT', 'server port') do |v|
-      options['port'] = v
-    end
-    opts.on('-s', '--use_tls', 'require a secure connection?') do |v|
-      options['secure'] = v
-    end
-  end.parse!
-
-  if options['port'].nil?
-    fail(OptionParser::MissingArgument, 'please specify --port')
-  end
-  options
-end
-
-def main
-  opts = parse_options
-  host = "0.0.0.0:#{opts['port']}"
-  s = GRPC::RpcServer.new
-  if opts['secure']
-    s.add_http2_port(host, test_server_creds)
-    GRPC.logger.info("... running securely on #{host}")
-  else
-    s.add_http2_port(host)
-    GRPC.logger.info("... running insecurely on #{host}")
-  end
-  s.handle(TestTarget)
-  s.run_till_terminated
-end
+pb_dir = File.join(File.dirname(File.dirname(this_dir)), 'pb')
+$LOAD_PATH.unshift(pb_dir) unless $LOAD_PATH.include?(pb_dir)
 
-main
+require 'test/server'
diff --git a/src/ruby/bin/interop/test/cpp/interop/empty.rb b/src/ruby/bin/interop/test/cpp/interop/empty.rb
deleted file mode 100644
index 3579fa5ded80e6d32b500947d0e202857865f2f0..0000000000000000000000000000000000000000
--- a/src/ruby/bin/interop/test/cpp/interop/empty.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-# Copyright 2015, Google Inc.
-# All rights reserved.
-#
-# Redistribution and use in source and binary forms, with or without
-# modification, are permitted provided that the following conditions are
-# met:
-#
-#     * Redistributions of source code must retain the above copyright
-# notice, this list of conditions and the following disclaimer.
-#     * Redistributions in binary form must reproduce the above
-# copyright notice, this list of conditions and the following disclaimer
-# in the documentation and/or other materials provided with the
-# distribution.
-#     * Neither the name of Google Inc. nor the names of its
-# contributors may be used to endorse or promote products derived from
-# this software without specific prior written permission.
-#
-# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-# Generated by the protocol buffer compiler.  DO NOT EDIT!
-# source: test/cpp/interop/empty.proto
-
-require 'google/protobuf'
-
-Google::Protobuf::DescriptorPool.generated_pool.build do
-  add_message "grpc.testing.Empty" do
-  end
-end
-
-module Grpc
-  module Testing
-    Empty = Google::Protobuf::DescriptorPool.generated_pool.lookup("grpc.testing.Empty").msgclass
-  end
-end
diff --git a/src/ruby/bin/interop/test/cpp/interop/test_services.rb b/src/ruby/bin/interop/test/cpp/interop/test_services.rb
deleted file mode 100644
index 5a3146c581b24b2ab7656f0237c1071e6cac963f..0000000000000000000000000000000000000000
--- a/src/ruby/bin/interop/test/cpp/interop/test_services.rb
+++ /dev/null
@@ -1,60 +0,0 @@
-# Copyright 2015, Google Inc.
-# All rights reserved.
-#
-# Redistribution and use in source and binary forms, with or without
-# modification, are permitted provided that the following conditions are
-# met:
-#
-#     * Redistributions of source code must retain the above copyright
-# notice, this list of conditions and the following disclaimer.
-#     * Redistributions in binary form must reproduce the above
-# copyright notice, this list of conditions and the following disclaimer
-# in the documentation and/or other materials provided with the
-# distribution.
-#     * Neither the name of Google Inc. nor the names of its
-# contributors may be used to endorse or promote products derived from
-# this software without specific prior written permission.
-#
-# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-# Generated by the protocol buffer compiler.  DO NOT EDIT!
-# Source: test/cpp/interop/test.proto for package 'grpc.testing'
-
-require 'grpc'
-require 'test/cpp/interop/test'
-
-module Grpc
-  module Testing
-    module TestService
-
-      # TODO: add proto service documentation here
-      class Service
-
-        include GRPC::GenericService
-
-        self.marshal_class_method = :encode
-        self.unmarshal_class_method = :decode
-        self.service_name = 'grpc.testing.TestService'
-
-        rpc :EmptyCall, Empty, Empty
-        rpc :UnaryCall, SimpleRequest, SimpleResponse
-        rpc :StreamingOutputCall, StreamingOutputCallRequest, stream(StreamingOutputCallResponse)
-        rpc :StreamingInputCall, stream(StreamingInputCallRequest), StreamingInputCallResponse
-        rpc :FullDuplexCall, stream(StreamingOutputCallRequest), stream(StreamingOutputCallResponse)
-        rpc :HalfDuplexCall, stream(StreamingOutputCallRequest), stream(StreamingOutputCallResponse)
-      end
-
-      Stub = Service.rpc_stub_class
-    end
-  end
-end
diff --git a/src/ruby/grpc.gemspec b/src/ruby/grpc.gemspec
index 22fafe1b50bd4b4ed524019d6371d6a0d51718ef..18f62adfd559c2765123ad47ab0d9999d470481a 100755
--- a/src/ruby/grpc.gemspec
+++ b/src/ruby/grpc.gemspec
@@ -24,13 +24,13 @@ Gem::Specification.new do |s|
   %w(math noproto).each do |b|
     s.executables += ["#{b}_client.rb", "#{b}_server.rb"]
   end
+  s.executables += %w(grpc_ruby_interop_client grpc_ruby_interop_server)
   s.require_paths = %w( bin lib pb )
   s.platform      = Gem::Platform::RUBY
 
   s.add_dependency 'google-protobuf', '~> 3.0.0alpha.1.1'
-  s.add_dependency 'googleauth', '~> 0.4'  # reqd for interop tests
+  s.add_dependency 'googleauth', '~> 0.4'
   s.add_dependency 'logging', '~> 2.0'
-  s.add_dependency 'minitest', '~> 5.4'  # reqd for interop tests
 
   s.add_development_dependency 'simplecov', '~> 0.9'
   s.add_development_dependency 'bundler', '~> 1.9'
diff --git a/src/ruby/pb/README.md b/src/ruby/pb/README.md
index 0b067edd0e90ae1ed21ba01bedfaa6215c38ee7f..84644e1098896e2c0ae5b9bb22cbc9dab18bf8fa 100644
--- a/src/ruby/pb/README.md
+++ b/src/ruby/pb/README.md
@@ -25,3 +25,18 @@ $ protoc -I . grpc/health/v1alpha/health.proto \
     --ruby_out=. \
     --plugin=protoc-gen-grpc=`which grpc_ruby_plugin`
 ```
+
+test
+----
+
+This package defines the surface of the gRPC interop test service and client
+To re-generate the surface, it's necessary to have checked-out versions of
+the grpc interop test proto, e.g, by having the full gRPC repository. E.g,
+
+```bash
+$ # (from this directory within the grpc repo)
+$ protoc -I../../.. ../../../test/proto/{messages,test,empty}.proto \
+    --grpc_out=. \
+    --ruby_out=. \
+    --plugin=protoc-gen-grpc=`which grpc_ruby_plugin`
+```
diff --git a/src/ruby/pb/test/client.rb b/src/ruby/pb/test/client.rb
new file mode 100755
index 0000000000000000000000000000000000000000..164e304b4d799ca4946db9fd840937a9495be466
--- /dev/null
+++ b/src/ruby/pb/test/client.rb
@@ -0,0 +1,453 @@
+#!/usr/bin/env ruby
+
+# Copyright 2015, Google Inc.
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+#     * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+#     * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+# client is a testing tool that accesses a gRPC interop testing server and runs
+# a test on it.
+#
+# Helps validate interoperation b/w different gRPC implementations.
+#
+# Usage: $ path/to/client.rb --server_host=<hostname> \
+#                            --server_port=<port> \
+#                            --test_case=<testcase_name>
+
+this_dir = File.expand_path(File.dirname(__FILE__))
+lib_dir = File.join(File.dirname(File.dirname(this_dir)), 'lib')
+pb_dir = File.dirname(File.dirname(this_dir))
+$LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)
+$LOAD_PATH.unshift(pb_dir) unless $LOAD_PATH.include?(pb_dir)
+$LOAD_PATH.unshift(this_dir) unless $LOAD_PATH.include?(this_dir)
+
+require 'optparse'
+
+require 'grpc'
+require 'googleauth'
+require 'google/protobuf'
+
+require 'test/proto/empty'
+require 'test/proto/messages'
+require 'test/proto/test_services'
+
+require 'signet/ssl_config'
+
+AUTH_ENV = Google::Auth::CredentialsLoader::ENV_VAR
+
+# AssertionError is use to indicate interop test failures.
+class AssertionError < RuntimeError; end
+
+# Fails with AssertionError if the block does evaluate to true
+def assert(msg = 'unknown cause')
+  fail 'No assertion block provided' unless block_given?
+  fail AssertionError, msg unless yield
+end
+
+# loads the certificates used to access the test server securely.
+def load_test_certs
+  this_dir = File.expand_path(File.dirname(__FILE__))
+  data_dir = File.join(File.dirname(File.dirname(this_dir)), 'spec/testdata')
+  files = ['ca.pem', 'server1.key', 'server1.pem']
+  files.map { |f| File.open(File.join(data_dir, f)).read }
+end
+
+# loads the certificates used to access the test server securely.
+def load_prod_cert
+  fail 'could not find a production cert' if ENV['SSL_CERT_FILE'].nil?
+  GRPC.logger.info("loading prod certs from #{ENV['SSL_CERT_FILE']}")
+  File.open(ENV['SSL_CERT_FILE']).read
+end
+
+# creates SSL Credentials from the test certificates.
+def test_creds
+  certs = load_test_certs
+  GRPC::Core::Credentials.new(certs[0])
+end
+
+# creates SSL Credentials from the production certificates.
+def prod_creds
+  cert_text = load_prod_cert
+  GRPC::Core::Credentials.new(cert_text)
+end
+
+# creates the SSL Credentials.
+def ssl_creds(use_test_ca)
+  return test_creds if use_test_ca
+  prod_creds
+end
+
+# creates a test stub that accesses host:port securely.
+def create_stub(opts)
+  address = "#{opts.host}:#{opts.port}"
+  if opts.secure
+    stub_opts = {
+      :creds => ssl_creds(opts.use_test_ca),
+      GRPC::Core::Channel::SSL_TARGET => opts.host_override
+    }
+
+    # Add service account creds if specified
+    wants_creds = %w(all compute_engine_creds service_account_creds)
+    if wants_creds.include?(opts.test_case)
+      unless opts.oauth_scope.nil?
+        auth_creds = Google::Auth.get_application_default(opts.oauth_scope)
+        stub_opts[:update_metadata] = auth_creds.updater_proc
+      end
+    end
+
+    if opts.test_case == 'oauth2_auth_token'
+      auth_creds = Google::Auth.get_application_default(opts.oauth_scope)
+      kw = auth_creds.updater_proc.call({})  # gives as an auth token
+
+      # use a metadata update proc that just adds the auth token.
+      stub_opts[:update_metadata] = proc { |md| md.merge(kw) }
+    end
+
+    if opts.test_case == 'jwt_token_creds'  # don't use a scope
+      auth_creds = Google::Auth.get_application_default
+      stub_opts[:update_metadata] = auth_creds.updater_proc
+    end
+
+    GRPC.logger.info("... connecting securely to #{address}")
+    Grpc::Testing::TestService::Stub.new(address, **stub_opts)
+  else
+    GRPC.logger.info("... connecting insecurely to #{address}")
+    Grpc::Testing::TestService::Stub.new(address)
+  end
+end
+
+# produces a string of null chars (\0) of length l.
+def nulls(l)
+  fail 'requires #{l} to be +ve' if l < 0
+  [].pack('x' * l).force_encoding('utf-8')
+end
+
+# a PingPongPlayer implements the ping pong bidi test.
+class PingPongPlayer
+  include Grpc::Testing
+  include Grpc::Testing::PayloadType
+  attr_accessor :queue
+  attr_accessor :canceller_op
+
+  # reqs is the enumerator over the requests
+  def initialize(msg_sizes)
+    @queue = Queue.new
+    @msg_sizes = msg_sizes
+    @canceller_op = nil  # used to cancel after the first response
+  end
+
+  def each_item
+    return enum_for(:each_item) unless block_given?
+    req_cls, p_cls = StreamingOutputCallRequest, ResponseParameters  # short
+    count = 0
+    @msg_sizes.each do |m|
+      req_size, resp_size = m
+      req = req_cls.new(payload: Payload.new(body: nulls(req_size)),
+                        response_type: :COMPRESSABLE,
+                        response_parameters: [p_cls.new(size: resp_size)])
+      yield req
+      resp = @queue.pop
+      assert('payload type is wrong') { :COMPRESSABLE == resp.payload.type }
+      assert("payload body #{count} has the wrong length") do
+        resp_size == resp.payload.body.length
+      end
+      p "OK: ping_pong #{count}"
+      count += 1
+      unless @canceller_op.nil?
+        canceller_op.cancel
+        break
+      end
+    end
+  end
+end
+
+# defines methods corresponding to each interop test case.
+class NamedTests
+  include Grpc::Testing
+  include Grpc::Testing::PayloadType
+
+  def initialize(stub, args)
+    @stub = stub
+    @args = args
+  end
+
+  def empty_unary
+    resp = @stub.empty_call(Empty.new)
+    assert('empty_unary: invalid response') { resp.is_a?(Empty) }
+    p 'OK: empty_unary'
+  end
+
+  def large_unary
+    perform_large_unary
+    p 'OK: large_unary'
+  end
+
+  def service_account_creds
+    # ignore this test if the oauth options are not set
+    if @args.oauth_scope.nil?
+      p 'NOT RUN: service_account_creds; no service_account settings'
+      return
+    end
+    json_key = File.read(ENV[AUTH_ENV])
+    wanted_email = MultiJson.load(json_key)['client_email']
+    resp = perform_large_unary(fill_username: true,
+                               fill_oauth_scope: true)
+    assert("#{__callee__}: bad username") { wanted_email == resp.username }
+    assert("#{__callee__}: bad oauth scope") do
+      @args.oauth_scope.include?(resp.oauth_scope)
+    end
+    p "OK: #{__callee__}"
+  end
+
+  def jwt_token_creds
+    json_key = File.read(ENV[AUTH_ENV])
+    wanted_email = MultiJson.load(json_key)['client_email']
+    resp = perform_large_unary(fill_username: true)
+    assert("#{__callee__}: bad username") { wanted_email == resp.username }
+    p "OK: #{__callee__}"
+  end
+
+  def compute_engine_creds
+    resp = perform_large_unary(fill_username: true,
+                               fill_oauth_scope: true)
+    assert("#{__callee__}: bad username") do
+      @args.default_service_account == resp.username
+    end
+    p "OK: #{__callee__}"
+  end
+
+  def oauth2_auth_token
+    resp = perform_large_unary(fill_username: true,
+                               fill_oauth_scope: true)
+    json_key = File.read(ENV[AUTH_ENV])
+    wanted_email = MultiJson.load(json_key)['client_email']
+    assert("#{__callee__}: bad username") { wanted_email == resp.username }
+    assert("#{__callee__}: bad oauth scope") do
+      @args.oauth_scope.include?(resp.oauth_scope)
+    end
+    p "OK: #{__callee__}"
+  end
+
+  def per_rpc_creds
+    auth_creds = Google::Auth.get_application_default(@args.oauth_scope)
+    kw = auth_creds.updater_proc.call({})
+    resp = perform_large_unary(fill_username: true,
+                               fill_oauth_scope: true,
+                               **kw)
+    json_key = File.read(ENV[AUTH_ENV])
+    wanted_email = MultiJson.load(json_key)['client_email']
+    assert("#{__callee__}: bad username") { wanted_email == resp.username }
+    assert("#{__callee__}: bad oauth scope") do
+      @args.oauth_scope.include?(resp.oauth_scope)
+    end
+    p "OK: #{__callee__}"
+  end
+
+  def client_streaming
+    msg_sizes = [27_182, 8, 1828, 45_904]
+    wanted_aggregate_size = 74_922
+    reqs = msg_sizes.map do |x|
+      req = Payload.new(body: nulls(x))
+      StreamingInputCallRequest.new(payload: req)
+    end
+    resp = @stub.streaming_input_call(reqs)
+    assert("#{__callee__}: aggregate payload size is incorrect") do
+      wanted_aggregate_size == resp.aggregated_payload_size
+    end
+    p "OK: #{__callee__}"
+  end
+
+  def server_streaming
+    msg_sizes = [31_415, 9, 2653, 58_979]
+    response_spec = msg_sizes.map { |s| ResponseParameters.new(size: s) }
+    req = StreamingOutputCallRequest.new(response_type: :COMPRESSABLE,
+                                         response_parameters: response_spec)
+    resps = @stub.streaming_output_call(req)
+    resps.each_with_index do |r, i|
+      assert("#{__callee__}: too many responses") { i < msg_sizes.length }
+      assert("#{__callee__}: payload body #{i} has the wrong length") do
+        msg_sizes[i] == r.payload.body.length
+      end
+      assert("#{__callee__}: payload type is wrong") do
+        :COMPRESSABLE == r.payload.type
+      end
+    end
+    p "OK: #{__callee__}"
+  end
+
+  def ping_pong
+    msg_sizes = [[27_182, 31_415], [8, 9], [1828, 2653], [45_904, 58_979]]
+    ppp = PingPongPlayer.new(msg_sizes)
+    resps = @stub.full_duplex_call(ppp.each_item)
+    resps.each { |r| ppp.queue.push(r) }
+    p "OK: #{__callee__}"
+  end
+
+  def timeout_on_sleeping_server
+    msg_sizes = [[27_182, 31_415]]
+    ppp = PingPongPlayer.new(msg_sizes)
+    resps = @stub.full_duplex_call(ppp.each_item, timeout: 0.001)
+    resps.each { |r| ppp.queue.push(r) }
+    fail 'Should have raised GRPC::BadStatus(DEADLINE_EXCEEDED)'
+  rescue GRPC::BadStatus => e
+    assert("#{__callee__}: status was wrong") do
+      e.code == GRPC::Core::StatusCodes::DEADLINE_EXCEEDED
+    end
+    p "OK: #{__callee__}"
+  end
+
+  def empty_stream
+    ppp = PingPongPlayer.new([])
+    resps = @stub.full_duplex_call(ppp.each_item)
+    count = 0
+    resps.each do |r|
+      ppp.queue.push(r)
+      count += 1
+    end
+    assert("#{__callee__}: too many responses expected 0") do
+      count == 0
+    end
+    p "OK: #{__callee__}"
+  end
+
+  def cancel_after_begin
+    msg_sizes = [27_182, 8, 1828, 45_904]
+    reqs = msg_sizes.map do |x|
+      req = Payload.new(body: nulls(x))
+      StreamingInputCallRequest.new(payload: req)
+    end
+    op = @stub.streaming_input_call(reqs, return_op: true)
+    op.cancel
+    op.execute
+    fail 'Should have raised GRPC:Cancelled'
+  rescue GRPC::Cancelled
+    assert("#{__callee__}: call operation should be CANCELLED") { op.cancelled }
+    p "OK: #{__callee__}"
+  end
+
+  def cancel_after_first_response
+    msg_sizes = [[27_182, 31_415], [8, 9], [1828, 2653], [45_904, 58_979]]
+    ppp = PingPongPlayer.new(msg_sizes)
+    op = @stub.full_duplex_call(ppp.each_item, return_op: true)
+    ppp.canceller_op = op  # causes ppp to cancel after the 1st message
+    op.execute.each { |r| ppp.queue.push(r) }
+    fail 'Should have raised GRPC:Cancelled'
+  rescue GRPC::Cancelled
+    assert("#{__callee__}: call operation should be CANCELLED") { op.cancelled }
+    op.wait
+    p "OK: #{__callee__}"
+  end
+
+  def all
+    all_methods = NamedTests.instance_methods(false).map(&:to_s)
+    all_methods.each do |m|
+      next if m == 'all' || m.start_with?('assert')
+      p "TESTCASE: #{m}"
+      method(m).call
+    end
+  end
+
+  private
+
+  def perform_large_unary(fill_username: false, fill_oauth_scope: false, **kw)
+    req_size, wanted_response_size = 271_828, 314_159
+    payload = Payload.new(type: :COMPRESSABLE, body: nulls(req_size))
+    req = SimpleRequest.new(response_type: :COMPRESSABLE,
+                            response_size: wanted_response_size,
+                            payload: payload)
+    req.fill_username = fill_username
+    req.fill_oauth_scope = fill_oauth_scope
+    resp = @stub.unary_call(req, **kw)
+    assert('payload type is wrong') do
+      :COMPRESSABLE == resp.payload.type
+    end
+    assert('payload body has the wrong length') do
+      wanted_response_size == resp.payload.body.length
+    end
+    assert('payload body is invalid') do
+      nulls(wanted_response_size) == resp.payload.body
+    end
+    resp
+  end
+end
+
+# Args is used to hold the command line info.
+Args = Struct.new(:default_service_account, :host, :host_override,
+                  :oauth_scope, :port, :secure, :test_case,
+                  :use_test_ca)
+
+# validates the the command line options, returning them as a Hash.
+def parse_args
+  args = Args.new
+  args.host_override = 'foo.test.google.fr'
+  OptionParser.new do |opts|
+    opts.on('--oauth_scope scope',
+            'Scope for OAuth tokens') { |v| args['oauth_scope'] = v }
+    opts.on('--server_host SERVER_HOST', 'server hostname') do |v|
+      args['host'] = v
+    end
+    opts.on('--default_service_account email_address',
+            'email address of the default service account') do |v|
+      args['default_service_account'] = v
+    end
+    opts.on('--server_host_override HOST_OVERRIDE',
+            'override host via a HTTP header') do |v|
+      args['host_override'] = v
+    end
+    opts.on('--server_port SERVER_PORT', 'server port') { |v| args['port'] = v }
+    # instance_methods(false) gives only the methods defined in that class
+    test_cases = NamedTests.instance_methods(false).map(&:to_s)
+    test_case_list = test_cases.join(',')
+    opts.on('--test_case CODE', test_cases, {}, 'select a test_case',
+            "  (#{test_case_list})") { |v| args['test_case'] = v }
+    opts.on('-s', '--use_tls', 'require a secure connection?') do |v|
+      args['secure'] = v
+    end
+    opts.on('-t', '--use_test_ca',
+            'if secure, use the test certificate?') do |v|
+      args['use_test_ca'] = v
+    end
+  end.parse!
+  _check_args(args)
+end
+
+def _check_args(args)
+  %w(host port test_case).each do |a|
+    if args[a].nil?
+      fail(OptionParser::MissingArgument, "please specify --#{a}")
+    end
+  end
+  args
+end
+
+def main
+  opts = parse_args
+  stub = create_stub(opts)
+  NamedTests.new(stub, opts).method(opts['test_case']).call
+end
+
+main
diff --git a/src/ruby/pb/test/proto/empty.rb b/src/ruby/pb/test/proto/empty.rb
new file mode 100644
index 0000000000000000000000000000000000000000..559adcc85e7d6a2827c3150c6277656bc7238c09
--- /dev/null
+++ b/src/ruby/pb/test/proto/empty.rb
@@ -0,0 +1,15 @@
+# Generated by the protocol buffer compiler.  DO NOT EDIT!
+# source: test/proto/empty.proto
+
+require 'google/protobuf'
+
+Google::Protobuf::DescriptorPool.generated_pool.build do
+  add_message "grpc.testing.Empty" do
+  end
+end
+
+module Grpc
+  module Testing
+    Empty = Google::Protobuf::DescriptorPool.generated_pool.lookup("grpc.testing.Empty").msgclass
+  end
+end
diff --git a/src/ruby/bin/interop/test/cpp/interop/messages.rb b/src/ruby/pb/test/proto/messages.rb
similarity index 64%
rename from src/ruby/bin/interop/test/cpp/interop/messages.rb
rename to src/ruby/pb/test/proto/messages.rb
index 89c349b4060011b7cc1589a5aad6df8d4ca0d896..9b7f97728599005b364454e7000e9d6c28ed7bcf 100644
--- a/src/ruby/bin/interop/test/cpp/interop/messages.rb
+++ b/src/ruby/pb/test/proto/messages.rb
@@ -1,34 +1,5 @@
-# Copyright 2015, Google Inc.
-# All rights reserved.
-#
-# Redistribution and use in source and binary forms, with or without
-# modification, are permitted provided that the following conditions are
-# met:
-#
-#     * Redistributions of source code must retain the above copyright
-# notice, this list of conditions and the following disclaimer.
-#     * Redistributions in binary form must reproduce the above
-# copyright notice, this list of conditions and the following disclaimer
-# in the documentation and/or other materials provided with the
-# distribution.
-#     * Neither the name of Google Inc. nor the names of its
-# contributors may be used to endorse or promote products derived from
-# this software without specific prior written permission.
-#
-# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
-# source: test/cpp/interop/messages.proto
+# source: test/proto/messages.proto
 
 require 'google/protobuf'
 
@@ -37,12 +8,18 @@ Google::Protobuf::DescriptorPool.generated_pool.build do
     optional :type, :enum, 1, "grpc.testing.PayloadType"
     optional :body, :string, 2
   end
+  add_message "grpc.testing.EchoStatus" do
+    optional :code, :int32, 1
+    optional :message, :string, 2
+  end
   add_message "grpc.testing.SimpleRequest" do
     optional :response_type, :enum, 1, "grpc.testing.PayloadType"
     optional :response_size, :int32, 2
     optional :payload, :message, 3, "grpc.testing.Payload"
     optional :fill_username, :bool, 4
     optional :fill_oauth_scope, :bool, 5
+    optional :response_compression, :enum, 6, "grpc.testing.CompressionType"
+    optional :response_status, :message, 7, "grpc.testing.EchoStatus"
   end
   add_message "grpc.testing.SimpleResponse" do
     optional :payload, :message, 1, "grpc.testing.Payload"
@@ -63,20 +40,32 @@ Google::Protobuf::DescriptorPool.generated_pool.build do
     optional :response_type, :enum, 1, "grpc.testing.PayloadType"
     repeated :response_parameters, :message, 2, "grpc.testing.ResponseParameters"
     optional :payload, :message, 3, "grpc.testing.Payload"
+    optional :response_compression, :enum, 6, "grpc.testing.CompressionType"
+    optional :response_status, :message, 7, "grpc.testing.EchoStatus"
   end
   add_message "grpc.testing.StreamingOutputCallResponse" do
     optional :payload, :message, 1, "grpc.testing.Payload"
   end
+  add_message "grpc.testing.ReconnectInfo" do
+    optional :passed, :bool, 1
+    repeated :backoff_ms, :int32, 2
+  end
   add_enum "grpc.testing.PayloadType" do
     value :COMPRESSABLE, 0
     value :UNCOMPRESSABLE, 1
     value :RANDOM, 2
   end
+  add_enum "grpc.testing.CompressionType" do
+    value :NONE, 0
+    value :GZIP, 1
+    value :DEFLATE, 2
+  end
 end
 
 module Grpc
   module Testing
     Payload = Google::Protobuf::DescriptorPool.generated_pool.lookup("grpc.testing.Payload").msgclass
+    EchoStatus = Google::Protobuf::DescriptorPool.generated_pool.lookup("grpc.testing.EchoStatus").msgclass
     SimpleRequest = Google::Protobuf::DescriptorPool.generated_pool.lookup("grpc.testing.SimpleRequest").msgclass
     SimpleResponse = Google::Protobuf::DescriptorPool.generated_pool.lookup("grpc.testing.SimpleResponse").msgclass
     StreamingInputCallRequest = Google::Protobuf::DescriptorPool.generated_pool.lookup("grpc.testing.StreamingInputCallRequest").msgclass
@@ -84,6 +73,8 @@ module Grpc
     ResponseParameters = Google::Protobuf::DescriptorPool.generated_pool.lookup("grpc.testing.ResponseParameters").msgclass
     StreamingOutputCallRequest = Google::Protobuf::DescriptorPool.generated_pool.lookup("grpc.testing.StreamingOutputCallRequest").msgclass
     StreamingOutputCallResponse = Google::Protobuf::DescriptorPool.generated_pool.lookup("grpc.testing.StreamingOutputCallResponse").msgclass
+    ReconnectInfo = Google::Protobuf::DescriptorPool.generated_pool.lookup("grpc.testing.ReconnectInfo").msgclass
     PayloadType = Google::Protobuf::DescriptorPool.generated_pool.lookup("grpc.testing.PayloadType").enummodule
+    CompressionType = Google::Protobuf::DescriptorPool.generated_pool.lookup("grpc.testing.CompressionType").enummodule
   end
 end
diff --git a/src/ruby/pb/test/proto/test.rb b/src/ruby/pb/test/proto/test.rb
new file mode 100644
index 0000000000000000000000000000000000000000..100eb6505c95bb0ee8bd03fdb4db19cdf2c707d5
--- /dev/null
+++ b/src/ruby/pb/test/proto/test.rb
@@ -0,0 +1,14 @@
+# Generated by the protocol buffer compiler.  DO NOT EDIT!
+# source: test/proto/test.proto
+
+require 'google/protobuf'
+
+require 'test/proto/empty'
+require 'test/proto/messages'
+Google::Protobuf::DescriptorPool.generated_pool.build do
+end
+
+module Grpc
+  module Testing
+  end
+end
diff --git a/src/ruby/pb/test/proto/test_services.rb b/src/ruby/pb/test/proto/test_services.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9df9cc5860b027758386054fbc87b7803c8757ba
--- /dev/null
+++ b/src/ruby/pb/test/proto/test_services.rb
@@ -0,0 +1,64 @@
+# Generated by the protocol buffer compiler.  DO NOT EDIT!
+# Source: test/proto/test.proto for package 'grpc.testing'
+
+require 'grpc'
+require 'test/proto/test'
+
+module Grpc
+  module Testing
+    module TestService
+
+      # TODO: add proto service documentation here
+      class Service
+
+        include GRPC::GenericService
+
+        self.marshal_class_method = :encode
+        self.unmarshal_class_method = :decode
+        self.service_name = 'grpc.testing.TestService'
+
+        rpc :EmptyCall, Empty, Empty
+        rpc :UnaryCall, SimpleRequest, SimpleResponse
+        rpc :StreamingOutputCall, StreamingOutputCallRequest, stream(StreamingOutputCallResponse)
+        rpc :StreamingInputCall, stream(StreamingInputCallRequest), StreamingInputCallResponse
+        rpc :FullDuplexCall, stream(StreamingOutputCallRequest), stream(StreamingOutputCallResponse)
+        rpc :HalfDuplexCall, stream(StreamingOutputCallRequest), stream(StreamingOutputCallResponse)
+      end
+
+      Stub = Service.rpc_stub_class
+    end
+    module UnimplementedService
+
+      # TODO: add proto service documentation here
+      class Service
+
+        include GRPC::GenericService
+
+        self.marshal_class_method = :encode
+        self.unmarshal_class_method = :decode
+        self.service_name = 'grpc.testing.UnimplementedService'
+
+        rpc :UnimplementedCall, Empty, Empty
+      end
+
+      Stub = Service.rpc_stub_class
+    end
+    module ReconnectService
+
+      # TODO: add proto service documentation here
+      class Service
+
+        include GRPC::GenericService
+
+        self.marshal_class_method = :encode
+        self.unmarshal_class_method = :decode
+        self.service_name = 'grpc.testing.ReconnectService'
+
+        rpc :Start, Empty, Empty
+        rpc :Stop, Empty, ReconnectInfo
+      end
+
+      Stub = Service.rpc_stub_class
+    end
+  end
+end
diff --git a/src/ruby/pb/test/server.rb b/src/ruby/pb/test/server.rb
new file mode 100755
index 0000000000000000000000000000000000000000..e2e1ecbd6293879ba863b79873300d3406aabb67
--- /dev/null
+++ b/src/ruby/pb/test/server.rb
@@ -0,0 +1,196 @@
+#!/usr/bin/env ruby
+
+# Copyright 2015, Google Inc.
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+#     * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+#     * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+# interop_server is a Testing app that runs a gRPC interop testing server.
+#
+# It helps validate interoperation b/w gRPC in different environments
+#
+# Helps validate interoperation b/w different gRPC implementations.
+#
+# Usage: $ path/to/interop_server.rb --port
+
+this_dir = File.expand_path(File.dirname(__FILE__))
+lib_dir = File.join(File.dirname(File.dirname(this_dir)), 'lib')
+pb_dir = File.dirname(File.dirname(this_dir))
+$LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)
+$LOAD_PATH.unshift(pb_dir) unless $LOAD_PATH.include?(pb_dir)
+$LOAD_PATH.unshift(this_dir) unless $LOAD_PATH.include?(this_dir)
+
+require 'forwardable'
+require 'optparse'
+
+require 'grpc'
+
+require 'test/proto/empty'
+require 'test/proto/messages'
+require 'test/proto/test_services'
+
+# loads the certificates by the test server.
+def load_test_certs
+  this_dir = File.expand_path(File.dirname(__FILE__))
+  data_dir = File.join(File.dirname(File.dirname(this_dir)), 'spec/testdata')
+  files = ['ca.pem', 'server1.key', 'server1.pem']
+  files.map { |f| File.open(File.join(data_dir, f)).read }
+end
+
+# creates a ServerCredentials from the test certificates.
+def test_server_creds
+  certs = load_test_certs
+  GRPC::Core::ServerCredentials.new(nil, certs[1], certs[2])
+end
+
+# produces a string of null chars (\0) of length l.
+def nulls(l)
+  fail 'requires #{l} to be +ve' if l < 0
+  [].pack('x' * l).force_encoding('utf-8')
+end
+
+# A EnumeratorQueue wraps a Queue yielding the items added to it via each_item.
+class EnumeratorQueue
+  extend Forwardable
+  def_delegators :@q, :push
+
+  def initialize(sentinel)
+    @q = Queue.new
+    @sentinel = sentinel
+  end
+
+  def each_item
+    return enum_for(:each_item) unless block_given?
+    loop do
+      r = @q.pop
+      break if r.equal?(@sentinel)
+      fail r if r.is_a? Exception
+      yield r
+    end
+  end
+end
+
+# A runnable implementation of the schema-specified testing service, with each
+# service method implemented as required by the interop testing spec.
+class TestTarget < Grpc::Testing::TestService::Service
+  include Grpc::Testing
+  include Grpc::Testing::PayloadType
+
+  def empty_call(_empty, _call)
+    Empty.new
+  end
+
+  def unary_call(simple_req, _call)
+    req_size = simple_req.response_size
+    SimpleResponse.new(payload: Payload.new(type: :COMPRESSABLE,
+                                            body: nulls(req_size)))
+  end
+
+  def streaming_input_call(call)
+    sizes = call.each_remote_read.map { |x| x.payload.body.length }
+    sum = sizes.inject { |s, x| s + x }
+    StreamingInputCallResponse.new(aggregated_payload_size: sum)
+  end
+
+  def streaming_output_call(req, _call)
+    cls = StreamingOutputCallResponse
+    req.response_parameters.map do |p|
+      cls.new(payload: Payload.new(type: req.response_type,
+                                   body: nulls(p.size)))
+    end
+  end
+
+  def full_duplex_call(reqs)
+    # reqs is a lazy Enumerator of the requests sent by the client.
+    q = EnumeratorQueue.new(self)
+    cls = StreamingOutputCallResponse
+    Thread.new do
+      begin
+        GRPC.logger.info('interop-server: started receiving')
+        reqs.each do |req|
+          resp_size = req.response_parameters[0].size
+          GRPC.logger.info("read a req, response size is #{resp_size}")
+          resp = cls.new(payload: Payload.new(type: req.response_type,
+                                              body: nulls(resp_size)))
+          q.push(resp)
+        end
+        GRPC.logger.info('interop-server: finished receiving')
+        q.push(self)
+      rescue StandardError => e
+        GRPC.logger.info('interop-server: failed')
+        GRPC.logger.warn(e)
+        q.push(e)  # share the exception with the enumerator
+      end
+    end
+    q.each_item
+  end
+
+  def half_duplex_call(reqs)
+    # TODO: update with unique behaviour of the half_duplex_call if that's
+    # ever required by any of the tests.
+    full_duplex_call(reqs)
+  end
+end
+
+# validates the the command line options, returning them as a Hash.
+def parse_options
+  options = {
+    'port' => nil,
+    'secure' => false
+  }
+  OptionParser.new do |opts|
+    opts.banner = 'Usage: --port port'
+    opts.on('--port PORT', 'server port') do |v|
+      options['port'] = v
+    end
+    opts.on('-s', '--use_tls', 'require a secure connection?') do |v|
+      options['secure'] = v
+    end
+  end.parse!
+
+  if options['port'].nil?
+    fail(OptionParser::MissingArgument, 'please specify --port')
+  end
+  options
+end
+
+def main
+  opts = parse_options
+  host = "0.0.0.0:#{opts['port']}"
+  s = GRPC::RpcServer.new
+  if opts['secure']
+    s.add_http2_port(host, test_server_creds)
+    GRPC.logger.info("... running securely on #{host}")
+  else
+    s.add_http2_port(host)
+    GRPC.logger.info("... running insecurely on #{host}")
+  end
+  s.handle(TestTarget)
+  s.run_till_terminated
+end
+
+main
diff --git a/src/ruby/spec/pb/health/checker_spec.rb b/src/ruby/spec/pb/health/checker_spec.rb
index 0aeae444fc5e9b6f9c1049d266686c7c7dd87859..6999a691058bedd88d914907280ed5896241bef7 100644
--- a/src/ruby/spec/pb/health/checker_spec.rb
+++ b/src/ruby/spec/pb/health/checker_spec.rb
@@ -30,6 +30,54 @@
 require 'grpc'
 require 'grpc/health/v1alpha/health'
 require 'grpc/health/checker'
+require 'open3'
+
+def can_run_codegen_check
+  system('which grpc_ruby_plugin') && system('which protoc')
+end
+
+describe 'Health protobuf code generation' do
+  context 'the health service file used by grpc/health/checker' do
+    if !can_run_codegen_check
+      skip 'protoc || grpc_ruby_plugin missing, cannot verify health code-gen'
+    else
+      it 'should already be loaded indirectly i.e, used by the other specs' do
+        expect(require('grpc/health/v1alpha/health_services')).to be(false)
+      end
+
+      it 'should have the same content as created by code generation' do
+        root_dir = File.dirname(
+          File.dirname(File.dirname(File.dirname(__FILE__))))
+        pb_dir = File.join(root_dir, 'pb')
+
+        # Get the current content
+        service_path = File.join(pb_dir, 'grpc', 'health', 'v1alpha',
+                                 'health_services.rb')
+        want = nil
+        File.open(service_path) { |f| want = f.read }
+
+        # Regenerate it
+        plugin, = Open3.capture2('which', 'grpc_ruby_plugin')
+        plugin = plugin.strip
+        got = nil
+        Dir.mktmpdir do |tmp_dir|
+          gen_out = File.join(tmp_dir, 'grpc', 'health', 'v1alpha',
+                              'health_services.rb')
+          pid = spawn(
+            'protoc',
+            '-I.',
+            'grpc/health/v1alpha/health.proto',
+            "--grpc_out=#{tmp_dir}",
+            "--plugin=protoc-gen-grpc=#{plugin}",
+            chdir: pb_dir)
+          Process.wait(pid)
+          File.open(gen_out) { |f| got = f.read }
+        end
+        expect(got).to eq(want)
+      end
+    end
+  end
+end
 
 describe Grpc::Health::Checker do
   StatusCodes = GRPC::Core::StatusCodes