Ver código fonte

Improve coverage over GraphQL and lib specs

Andrew Swistak 6 anos atrás
pai
commit
920b4e60b0

+ 12 - 4
app/graphql/resolvers/base_resolver.rb

@@ -1,5 +1,7 @@
 # frozen_string_literal: true
 
+require './lib/api_error/base_error'
+
 module Resolvers
   class BaseResolver < GraphQL::Schema::Resolver
     def self.argument_with_plural(field, type, **args)
@@ -19,16 +21,22 @@ module Resolvers
     end
 
     def clobber_id_with_iid!(args)
+      raise APIError::BaseError, "Cannot use 'id' and 'iid' in the same query." if args[:id] && args[:iid]
+
       args[:id] = args.delete(:iid) if args[:iid]
     end
 
-    def clobber_ids_with_iids!(args)
-      args[:ids] = args.delete(:iids) if args[:iids]
+    def combine_ids_with_iids!(args)
+      args[:ids] = [*args[:ids], *args.delete(:iids)] if args[:iids]
     end
 
     def combine_plurals!(args)
       plurals.each do |singular, plural|
-        args[singular] = [args[singular], *args.delete(plural)] if args[plural]
+        next unless args[plural]
+
+        singular_arg = args[singular]
+        args[singular] = [*args.delete(plural)]
+        args[singular].unshift(singular_arg) if singular_arg
       end
     end
 
@@ -50,7 +58,7 @@ module Resolvers
       decode_id(args)
       decode_ids(args)
       clobber_id_with_iid!(args)
-      clobber_ids_with_iids!(args)
+      combine_ids_with_iids!(args)
       combine_plurals!(args)
     end
 

+ 0 - 2
app/graphql/resolvers/pokemon_resolver.rb

@@ -8,8 +8,6 @@ module Resolvers
     argument_with_plural :iid, GraphQL::ID_TYPE, required: false
     argument_with_plural :pokedex_number, GraphQL::INT_TYPE, required: false
     argument_with_plural :nickname, GraphQL::STRING_TYPE, required: false
-    argument :created_at, Types::TimeType, required: false
-    argument :updated_at, Types::TimeType, required: false
 
     def resolve(**args)
       prepare_args!(args)

+ 1 - 1
app/graphql/types/pokemon_type.rb

@@ -4,7 +4,7 @@ module Types
   class PokemonType < Types::BaseObject
     description 'Returns a singular pokemon'
 
-    implements GraphQL::Types::Relay::BaseInterface
+    implements GraphQL::Relay::Node.interface
     global_id_field :id
 
     field :iid, GraphQL::ID_TYPE, null: false, method: :id

+ 1 - 1
app/graphql/types/time_type.rb

@@ -5,7 +5,7 @@ module Types
     description 'Time represented in ISO 8601'
 
     def self.coerce_input(value, _ctx)
-      Time.parse(value).in_time_zone
+      DateTime.parse(value).in_time_zone
     end
 
     def self.coerce_result(value, _ctx)

+ 5 - 3
lib/pkparse/error.rb

@@ -18,8 +18,10 @@ module PKParse
       @message || default_message
     end
 
-    def default_message
-      'An error occurred while attempting to parse one or more pokemon.'
-    end
+    private
+
+      def default_message
+        'An error occurred while attempting to parse one or more pokemon.'
+      end
   end
 end

+ 35 - 0
spec/graphql/pokemon_trade_schema_spec.rb

@@ -10,4 +10,39 @@ RSpec.describe PokemonTradeSchema, type: :graphql do
   it 'has the base query' do
     expect(described_class.query).to eq ::Types::QueryType.to_graphql
   end
+
+  let(:object) { create(:pokemon) }
+
+  describe '.id_from_object' do
+    subject { described_class.id_from_object(object, Types::PokemonType, {}) }
+
+    it 'encodes the id' do
+      expect(GraphQL::Schema::UniqueWithinType).to receive(:encode)
+      subject
+    end
+  end
+
+  describe '.object_from_id' do
+    subject { described_class.object_from_id('', {}) }
+
+    it 'decodes the id' do
+      expect(GraphQL::Schema::UniqueWithinType).to receive(:decode).and_return([object.class.to_s, object.id])
+      expect(subject).to eq(object)
+    end
+  end
+
+  describe '.resolve_type' do
+    subject { described_class.resolve_type(nil, object, {}) }
+
+    context 'resolves Pokemon' do
+      # GraphQL::Schema wraps the overriden `.resolve_type`, so the return type
+      # isn't actually `Types::PokemonType`
+      specify { expect { subject }.not_to raise_error }
+    end
+
+    context 'errors on other types' do
+      let(:object) { Object }
+      specify { expect { subject }.to raise_error(APIError::BaseError) }
+    end
+  end
 end

+ 169 - 1
spec/graphql/resolvers/base_resolver_spec.rb

@@ -3,7 +3,7 @@
 require 'rails_helper'
 
 RSpec.describe Resolvers::BaseResolver, type: :graphql do
-  let(:resolver) do
+  let!(:resolver) do
     Class.new(described_class) do
       def resolve(**args)
         [args, args]
@@ -11,6 +11,15 @@ RSpec.describe Resolvers::BaseResolver, type: :graphql do
     end
   end
 
+  after do
+    # We need to make sure class instance variables are unset between runs.
+    described_class.instance_variable_set '@plurals', nil
+    resolver.instance_variable_set '@plurals', nil
+  end
+
+  let(:instance) { resolver.new(object: nil, context: {}) }
+  let(:args) { {} }
+
   describe '.single' do
     it 'returns a subclass from the resolver' do
       expect(resolver.single.superclass).to eq(resolver)
@@ -26,4 +35,163 @@ RSpec.describe Resolvers::BaseResolver, type: :graphql do
       expect(result).to eq(test: 1)
     end
   end
+
+  describe '.argument_with_plural' do
+    let(:field) { :foo }
+    let(:type) { String }
+    let(:args) { {required: true} }
+
+    subject { resolver.argument_with_plural(field, type, args) }
+
+    it 'adds the plural and base fields' do
+      expect(described_class).to receive(:argument).with(field, type, args)
+      expect(described_class).to receive(:argument).with(:foos, [type], required: false)
+      subject
+    end
+
+    it 'updates plurals list' do
+      subject
+      expect(resolver.plurals).to eq(foo: :foos)
+    end
+  end
+
+  describe '.plurals' do
+    subject { resolver.plurals }
+
+    it 'returns hash of plurals' do
+      expect(subject).to eq({})
+      resolver.argument_with_plural(:foo, String, required: false)
+      expect(subject).to eq(foo: :foos)
+    end
+  end
+
+  describe '#plurals' do
+    subject { instance.plurals }
+    before { resolver.argument_with_plural(:foo, String, required: false) }
+    it { is_expected.to eq(foo: :foos) }
+  end
+
+  describe '#clobber_id_with_iid!' do
+    context 'args has both id and iid' do
+      let(:args) { {id: '1', iid: '3'} }
+      specify { expect { instance.clobber_id_with_iid!(args) }.to raise_error(APIError::BaseError) }
+    end
+
+    context 'args has only id or iid' do
+      before { instance.clobber_id_with_iid!(args) }
+      subject { args }
+
+      context 'args has only id' do
+        let(:args) { {id: '1'} }
+        it { is_expected.to eq(id: '1') }
+      end
+
+      context 'args has only iid' do
+        let(:args) { {iid: '3'} }
+        it { is_expected.to eq(id: '3') }
+      end
+    end
+  end
+
+  describe '#combine_ids_with_iids!' do
+    before { instance.combine_ids_with_iids!(args) }
+    subject { args }
+
+    context 'args has both ids and iids' do
+      let(:args) { {ids: '1', iids: %w[3 5]} }
+      it { is_expected.to eq(ids: %w[1 3 5]) }
+    end
+
+    context 'args has only ids' do
+      let(:args) { {ids: ['1']} }
+      it { is_expected.to eq(ids: ['1']) }
+    end
+
+    context 'args has only iids' do
+      let(:args) { {iids: %w[3 5]} }
+      it { is_expected.to eq(ids: %w[3 5]) }
+    end
+  end
+
+  describe '#combine_plurals!' do
+    subject { args }
+
+    before do
+      resolver.argument_with_plural(:foo, String, required: false)
+      resolver.argument_with_plural(:bar, String, required: false)
+      instance.combine_plurals!(args)
+    end
+
+    context 'args has both singular and plural' do
+      let!(:args) { {foo: '1', foos: %w[3 5], bar: 'a', bars: %w[b c]} }
+      it { is_expected.to eq(foo: %w[1 3 5], bar: %w[a b c]) }
+    end
+
+    context 'args has only singular' do
+      let!(:args) { {foo: ['1']} }
+      it { is_expected.to eq(foo: ['1']) }
+    end
+
+    context 'args has only plural' do
+      let!(:args) { {foos: %w[3 5]} }
+      it { is_expected.to eq(foo: %w[3 5]) }
+    end
+  end
+
+  describe '#decode_id' do
+    subject { instance.decode_id(args) }
+
+    context 'with id given' do
+      let(:args) { {id: '1'} }
+
+      it 'uses the GraphQL decode built in' do
+        expect(GraphQL::Schema::UniqueWithinType).to receive(:decode).with(args[:id]).and_return([nil, 'not 1'])
+        subject
+        expect(args[:id]).to eq('not 1')
+      end
+    end
+
+    context 'without id given' do
+      it 'changes nothing' do
+        expect(GraphQL::Schema::UniqueWithinType).not_to receive(:decode)
+        subject
+        expect(args[:id]).to be_nil
+      end
+    end
+  end
+
+  describe '#decode_ids' do
+    subject { instance.decode_ids(args) }
+
+    context 'with ids given' do
+      let(:args) { {ids: ['1']} }
+
+      it 'uses the GraphQL decode built in' do
+        expect(GraphQL::Schema::UniqueWithinType).to receive(:decode).with(args[:ids].first).and_return([nil, 'not 1'])
+        subject
+        expect(args[:ids]).to eq(['not 1'])
+      end
+    end
+
+    context 'without ids given' do
+      it 'uses the GraphQL decode built in' do
+        expect(GraphQL::Schema::UniqueWithinType).not_to receive(:decode)
+        subject
+        expect(args[:ids]).to be_nil
+      end
+    end
+  end
+
+  describe '#prepare_args!' do
+    subject { instance.prepare_args!(args) }
+
+    it 'prepares arguments' do
+      expect(instance).to receive(:decode_id).once
+      expect(instance).to receive(:decode_ids).once
+      expect(instance).to receive(:clobber_id_with_iid!).once
+      expect(instance).to receive(:combine_ids_with_iids!).once
+      expect(instance).to receive(:combine_plurals!).once
+      subject
+    end
+  end
 end

+ 29 - 3
spec/graphql/resolvers/pokemon_resolver_spec.rb

@@ -5,7 +5,23 @@ require 'rails_helper'
 RSpec.describe Resolvers::PokemonResolver, type: :graphql do
   subject { described_class.new(object: nil, context: {}) }
 
-  it { is_expected.to have_graphql_arguments(:id, :pokedex_number, :nickname) }
+  after do
+    # We need to make sure class instance variables are unset between runs.
+    Resolvers::BaseResolver.instance_variable_set '@plurals', nil
+  end
+
+  it {
+    is_expected.to have_graphql_arguments(
+      :id,
+      :ids,
+      :iid,
+      :iids,
+      :pokedex_number,
+      :pokedex_numbers,
+      :nickname,
+      :nicknames,
+    )
+  }
 
   describe '#resolve' do
     let!(:pokemon1) { create(:pokemon, nickname: 'Bulbasaur', pokedex_number: 1) }
@@ -14,15 +30,25 @@ RSpec.describe Resolvers::PokemonResolver, type: :graphql do
     let!(:pokemon4) { create(:pokemon, nickname: 'Pikachu', pokedex_number: 25) }
 
     it 'selects by id' do
-      id = pokemon1.id
+      id = GraphQL::Schema::UniqueWithinType.encode(Types::PokemonType.name, pokemon1.id)
       res = resolve(described_class, args: {id: id})
       expect(res).to contain_exactly(pokemon1)
 
-      id = pokemon2.id
+      id = GraphQL::Schema::UniqueWithinType.encode(Types::PokemonType.name, pokemon2.id)
       res = resolve(described_class, args: {id: id})
       expect(res).to contain_exactly(pokemon2)
     end
 
+    it 'selects by id when given iid' do
+      iid = pokemon1.id
+      res = resolve(described_class, args: {iid: iid})
+      expect(res).to contain_exactly(pokemon1)
+
+      iid = pokemon2.id
+      res = resolve(described_class, args: {iid: iid})
+      expect(res).to contain_exactly(pokemon2)
+    end
+
     it 'selects by nickname' do
       nickname = pokemon1.nickname
       res = resolve(described_class, args: {nickname: nickname})

+ 23 - 1
spec/graphql/types/pokemon_type_spec.rb

@@ -7,7 +7,7 @@ RSpec.describe Types::PokemonType, type: :graphql do
   # because we want to change the subject
   let!(:described_class) { PokemonTradeSchema.types['Pokemon'] }
 
-  it { is_expected.to have_graphql_fields(:id, :nickname, :pokedex_number) }
+  it { is_expected.to have_graphql_fields(:id, :iid, :nickname, :pokedex_number, :created_at, :updated_at) }
 
   describe 'id field' do
     subject { described_class.fields[GraphQLHelpers.fieldnamerize(:id)] }
@@ -15,6 +15,12 @@ RSpec.describe Types::PokemonType, type: :graphql do
     it { is_expected.to have_graphql_type(GraphQL::ID_TYPE) }
   end
 
+  describe 'iid field' do
+    subject { described_class.fields[GraphQLHelpers.fieldnamerize(:iid)] }
+
+    it { is_expected.to have_graphql_type(GraphQL::ID_TYPE) }
+  end
+
   describe 'nickname field' do
     subject { described_class.fields[GraphQLHelpers.fieldnamerize(:nickname)] }
 
@@ -30,4 +36,20 @@ RSpec.describe Types::PokemonType, type: :graphql do
       is_expected.to have_graphql_type(GraphQL::INT_TYPE)
     end
   end
+
+  describe 'created_at field' do
+    subject { described_class.fields[GraphQLHelpers.fieldnamerize(:created_at)] }
+
+    it 'returns many pokemon' do
+      is_expected.to have_graphql_type(Types::TimeType)
+    end
+  end
+
+  describe 'updated_at field' do
+    subject { described_class.fields[GraphQLHelpers.fieldnamerize(:updated_at)] }
+
+    it 'returns many pokemon' do
+      is_expected.to have_graphql_type(Types::TimeType)
+    end
+  end
 end

+ 14 - 4
spec/graphql/types/query_type_spec.rb

@@ -7,25 +7,35 @@ RSpec.describe Types::QueryType, type: :graphql do
   # because we want to change the subject
   let!(:described_class) { PokemonTradeSchema.types['Query'] }
 
-  it { is_expected.to have_graphql_fields(:pokemon, :many_pokemon) }
+  it { is_expected.to have_graphql_fields(:pokemon, :many_pokemon, :pokemon_connection, :node, :nodes) }
 
   describe 'pokemon field' do
     subject { described_class.fields[GraphQLHelpers.fieldnamerize(:pokemon)] }
 
     it 'finds pokemon' do
-      is_expected.to have_graphql_arguments(:id, :pokedex_number, :nickname)
+      is_expected.to include_graphql_arguments_with_plurals(:id, :iid, :pokedex_number, :nickname)
       is_expected.to have_graphql_type(Types::PokemonType)
       is_expected.to have_graphql_resolver(Resolvers::PokemonResolver.single)
     end
   end
 
   describe 'many_pokemon field' do
-    subject { described_class.fields[GraphQLHelpers.fieldnamerize(:manyPokemon)] }
+    subject { described_class.fields[GraphQLHelpers.fieldnamerize(:many_pokemon)] }
 
     it 'returns many pokemon' do
-      is_expected.to have_graphql_arguments(:id, :pokedex_number, :nickname)
+      is_expected.to include_graphql_arguments_with_plurals(:id, :iid, :pokedex_number, :nickname)
       is_expected.to have_graphql_type([Types::PokemonType])
       is_expected.to have_graphql_resolver(Resolvers::PokemonResolver)
     end
   end
+
+  describe 'pokemon_connection field' do
+    subject { described_class.fields[GraphQLHelpers.fieldnamerize(:pokemon_connection)] }
+
+    it 'returns a pokemon connection' do
+      is_expected.to include_graphql_arguments_with_plurals(:id, :iid, :pokedex_number, :nickname)
+      is_expected.to have_graphql_type(Types::PokemonType.connection_type)
+      is_expected.to have_graphql_resolver(Resolvers::PokemonResolver)
+    end
+  end
 end

+ 20 - 0
spec/graphql/types/time_type_spec.rb

@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Types::TimeType, type: :graphql do
+  let!(:described_class) { PokemonTradeSchema.types['Time'] }
+
+  let(:iso) { '2018-06-04T15:23:50Z' }
+  let(:time) { DateTime.parse(iso).in_time_zone }
+
+  it { expect(described_class.graphql_name).to eq('Time') }
+
+  it 'coerces Time object into ISO 8601' do
+    expect(described_class.coerce_isolated_result(time)).to eq(iso)
+  end
+
+  it 'coerces an ISO-time into Time object' do
+    expect(described_class.coerce_isolated_input(iso)).to eq(time)
+  end
+end

+ 26 - 0
spec/lib/pkparse/error_spec.rb

@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe PKParse::Error, type: :lib do
+  let(:message) { 'foo' }
+  let(:original_exception) { StandardError.new }
+
+  subject(:error) { described_class.new(original_exception, message) }
+
+  describe '#to_s' do
+    subject { error.to_s }
+    it { is_expected.to eq('foo') }
+  end
+
+  describe '#message' do
+    subject { error.message }
+    it { is_expected.to eq('foo') }
+
+    context 'no message given' do
+      let(:error) { described_class.new(original_exception, nil) }
+
+      it { is_expected.to eq(error.send(:default_message)) }
+    end
+  end
+end

+ 79 - 0
spec/lib/pkparse/pokemon_spec.rb

@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe PKParse::Pokemon, type: :lib do
+  let(:attrs) do
+    {
+      pokedex_number: rand(1..151),
+      nickname: Faker::Games::Pokemon.name,
+      raw_nickname: 'raw',
+      raw_pokemon: 'also raw',
+    }
+  end
+
+  subject(:pokemon) { described_class.new(attrs) }
+
+  describe '.new' do
+    subject { described_class.new(attrs) }
+
+    context 'with valid attributes given' do
+      it 'sets all attributes' do
+        hash = subject.to_h
+        hash.keys.each do |key|
+          expect(hash[key]).to eq(pokemon.send(key))
+        end
+      end
+    end
+
+    context 'with missing attributes' do
+      let(:attrs) do
+        {
+          pokedex_number: rand(1..151),
+          raw_nickname: 'raw',
+          raw_pokemon: 'also raw',
+        }
+      end
+
+      it 'ignores the missing attributs' do
+        expect(subject.nickname).to be_nil
+      end
+    end
+
+    context 'with invalid attributes given' do
+      let(:attrs) do
+        {
+          pokedex_number: rand(1..151),
+          nickname: Faker::Games::Pokemon.name,
+          raw_nickname: 'raw',
+          raw_pokemon: 'also raw',
+          foo: 'bar',
+        }
+      end
+
+      it 'ignores the extra attributs' do
+        expect(subject.to_h[:foo]).to be_nil
+      end
+    end
+  end
+
+  describe '#to_h' do
+    subject { pokemon.to_h }
+
+    it { is_expected.to have_key(:pokedex_number) }
+    it { is_expected.to have_key(:nickname) }
+    it { is_expected.to have_key(:raw_nickname) }
+    it { is_expected.to have_key(:raw_pokemon) }
+
+    it 'sets keys to appropriate values' do
+      subject.keys.each do |key|
+        expect(subject[key]).to eq(pokemon.send(key))
+      end
+    end
+  end
+
+  describe '#to_json' do
+    subject { pokemon.to_json }
+    specify { expect(pokemon).to receive(:to_h); subject }
+  end
+end

+ 11 - 0
spec/lib/pkparse/response_error_spec.rb

@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe PKParse::ResponseError, type: :lib do
+  subject(:error) { described_class.new(double(http_body: '{"error": "some error"}')) }
+
+  it 'sets the error message from response body' do
+    expect(error.message).to eq('some error')
+  end
+end

+ 59 - 0
spec/lib/tagged_logger_spec.rb

@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe TaggedLogger, type: :lib do
+  let!(:backing_logger) { double(debug: nil, info: nil, warn: nil, error: nil, fatal: nil) }
+  let!(:tag) { 'Test' }
+  subject(:logger) { described_class.new(backing_logger, tag) }
+
+  describe '#debug' do
+    subject { logger.debug('foo') }
+
+    it 'logs with tag' do
+      expect(backing_logger).to receive(:tagged).once.with(tag).and_yield
+      expect(backing_logger).to receive(:debug).once.with('foo')
+      subject
+    end
+  end
+
+  describe '#info' do
+    subject { logger.info('foo') }
+
+    it 'logs with tag' do
+      expect(backing_logger).to receive(:tagged).once.with(tag).and_yield
+      expect(backing_logger).to receive(:info).once.with('foo')
+      subject
+    end
+  end
+
+  describe '#warn' do
+    subject { logger.warn('foo') }
+
+    it 'logs with tag' do
+      expect(backing_logger).to receive(:tagged).once.with(tag).and_yield
+      expect(backing_logger).to receive(:warn).once.with('foo')
+      subject
+    end
+  end
+
+  describe '#error' do
+    subject { logger.error('foo') }
+
+    it 'logs with tag' do
+      expect(backing_logger).to receive(:tagged).once.with(tag).and_yield
+      expect(backing_logger).to receive(:error).once.with('foo')
+      subject
+    end
+  end
+
+  describe '#fatal' do
+    subject { logger.fatal('foo') }
+
+    it 'logs with tag' do
+      expect(backing_logger).to receive(:tagged).once.with(tag).and_yield
+      expect(backing_logger).to receive(:fatal).once.with('foo')
+      subject
+    end
+  end
+end

+ 7 - 0
spec/rails_helper.rb

@@ -86,6 +86,13 @@ RSpec.configure do |config|
 
     driven_by selenium_driver
   end
+
+  config.after(:each, type: :graphql) do
+    if described_class.is_a? Resolvers::BaseResolver
+      # binding.pry
+      described_class.instance_variable_set('@plurals', nil) if described_class.is_a? Resolvers::BaseResolver
+    end
+  end
 end
 
 Shoulda::Matchers.configure do |config|

+ 21 - 0
spec/support/helpers/graphql_helpers.rb

@@ -28,6 +28,27 @@ module GraphQLHelpers
     end
   end
 
+  def self.plural_arguments_for_field(field)
+    keys = arguments_for_field(field)
+    # Connections mandate extra fields, which are not owned by a plural based
+    # resolver.
+    # keys -= %w[after before first last]
+
+    keys.map do |key|
+      type_class = field.arguments[key.to_s].metadata[:type_class]
+      plurals = type_class.owner.try(:plurals)
+
+      if plurals
+        arg = plurals[key.underscore.to_sym]
+        fieldnamerize(arg.to_s) if arg
+      end
+    end.compact
+  end
+
+  def self.plural_argument_for_field(field, argument)
+    field.arguments[argument.to_s].metadata[:type_class].owner.plurals[argument.to_sym]
+  end
+
   # Shortcut for running resolvers #resolve methods
   def resolve(resolver_class, obj: nil, args: {}, ctx: {})
     resolver_class.new(object: obj, context: ctx).resolve(args)

+ 67 - 7
spec/support/matchers/graphql_matchers.rb

@@ -1,15 +1,14 @@
 # frozen_string_literal: true
 
-RSpec::Matchers.define :have_graphql_fields do |*_expected|
-  def expected_field_names
-    expected.map { |name| GraphQLHelpers.fieldnamerize(name) }
-  end
-
+RSpec::Matchers.define :have_graphql_fields do |*expected|
   match do |klass|
+    expected_field_names = expected.map { |name| GraphQLHelpers.fieldnamerize(name) }
     expect(GraphQLHelpers.keys_for_klass(klass)).to contain_exactly(*expected_field_names)
   end
 
   failure_message do |klass|
+    expected_field_names = expected.map { |name| GraphQLHelpers.fieldnamerize(name) }
+
     keys = GraphQLHelpers.keys_for_klass(klass)
     missing = expected_field_names - keys
     extra = keys - expected_field_names
@@ -38,10 +37,71 @@ end
 
 RSpec::Matchers.define :have_graphql_arguments do |*expected|
   match do |field|
+    expected_argument_names = expected.map { |name| GraphQLHelpers.fieldnamerize(name) }
+
     actual = GraphQLHelpers.arguments_for_field(field)
-    argument_names = expected.map { |name| GraphQLHelpers.fieldnamerize(name) }
+    argument_names = expected_argument_names
     expect(actual).to contain_exactly(*argument_names)
   end
+
+  failure_message do |field|
+    expected_argument_names = expected.map { |name| GraphQLHelpers.fieldnamerize(name) }
+
+    keys = GraphQLHelpers.arguments_for_field(field)
+    missing = expected_argument_names - keys
+    extra = keys - expected_argument_names
+
+    message = []
+
+    message << "is missing arguments: <#{missing.inspect}>" if missing.any?
+    message << "contained unexpected arguments: <#{extra.inspect}>" if extra.any?
+
+    message.join("\n")
+  end
+end
+
+RSpec::Matchers.define :include_graphql_arguments do |*expected|
+  match do |field|
+    expected_argument_names = expected.map { |name| GraphQLHelpers.fieldnamerize(name) }
+
+    actual = GraphQLHelpers.arguments_for_field(field)
+    argument_names = expected_argument_names
+    expect(actual).to include(*argument_names)
+  end
+
+  failure_message do |field|
+    expected_argument_names = expected.map { |name| GraphQLHelpers.fieldnamerize(name) }
+
+    keys = GraphQLHelpers.arguments_for_field(field)
+    missing = expected_argument_names - keys
+
+    "is missing arguments: <#{missing.inspect}>" if missing.any?
+  end
+end
+
+RSpec::Matchers.define :include_graphql_arguments_with_plurals do |*expected|
+  match do |field|
+    expected_argument_names = expected.map { |name| GraphQLHelpers.fieldnamerize(name) }
+
+    all = GraphQLHelpers.arguments_for_field(field)
+    plurals = GraphQLHelpers.plural_arguments_for_field(field)
+    expected = all - plurals
+    argument_names = expected_argument_names
+
+    expect(expected).to include(*argument_names)
+  end
+
+  failure_message do |field|
+    expected_argument_names = expected.map { |name| GraphQLHelpers.fieldnamerize(name) }
+
+    all = GraphQLHelpers.arguments_for_field(field)
+    plurals = GraphQLHelpers.plural_arguments_for_field(field)
+    keys = all - plurals
+
+    missing = expected_argument_names - keys
+
+    "is missing arguments: <#{missing.inspect}>"
+  end
 end
 
 RSpec::Matchers.define :have_graphql_type do |expected|
@@ -79,7 +139,7 @@ RSpec::Matchers.define :have_graphql_type do |expected|
     parsed_type = parsed_type.to_graphql unless expected.is_a? GraphQL::ScalarType
 
     expect(graphql_type).to eq(parsed_type)
-    expect(type_class.instance_variable_get('@return_type_expr')).to eq(expected)
+    expect(type_class.instance_variable_get('@return_type_expr').to_s).to eq(expected.to_s)
   end
 end