Skip to content

Instantly share code, notes, and snippets.

@bessey
Last active March 21, 2022 16:02
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save bessey/91063148696633ba92204b7bfc750c5e to your computer and use it in GitHub Desktop.
Save bessey/91063148696633ba92204b7bfc750c5e to your computer and use it in GitHub Desktop.
Beginnings of a reusable GraphQL Ruby type testing toolkit.
# Assumptions
# 1. Your schema implements the Relay Query.node(id:) field, Schema.id_from_object, and Schema.object_from_id
# 2. The types you wish to test implement the Node interface
# 3. (In order to be compatible with Node) the object you test resolves to a single type
# 4. The object you wish to test has a unique identifier
# TODO
# Maybe we can remove 3. by making use of an override in context?
# Since Schema.resolve_type takes a context object, perhaps if you must test an object that is
# presented via multiple types in the schema, you can force the decision via context. I have not
# needed this yet, so not implemented, but seems straightforward.
#
# Maybe we can avoid the need for all this Relay business by re-opening the schema at test runtime and just
# creating the resolvers we need on Query to run the test. This would remove the need for 1. and 2.
#
# I suspect that all object type field tests will end up being of the format `myAssociation { id __typename }`.
# Perhaps we can use schema introspection to know when a field is an object that implements `id`, and do this
# ourselves. This seems a little too magical to me I think. Like, does this make sense to you?:
# user = User.create(friends: 3.times.map { User.create })
# result = MySchemaTypeTester.new.resolve_field("friends", user)
# expect(result).to eq(["1", "2", "3"]) # << How did we know to compare IDs here?
module GraphQL
module Testing
class TypeTester
# GraphQL::Testing::TypeTester provides a convenience API for testing field resolvers via the
# Relay `Query.node(id:)` field. To use it, extend and specify the schema to be tested:
#
# class MySchemaTypeTester < GraphQL::Testing::TypeTester
# schema MySchema
# end
#
#
# Usage:
#
# @example Scalar types
# user = User.create(email: "test@test.com")
# result = MySchemaTypeTester.new.resolve_field(user, "email")
# expect(result).to eq("test@test.com")
#
# @example Scalar types, with context
# user = User.create(email: "test@test.com")
# result = MySchemaTypeTester.new(context: {user: user}).resolve_field(user, "email")
# expect(result).to eq("test@test.com")
#
# @example Scalar type, with variables:
# user = User.create(email: "test@test.com")
# result = MySchemaTypeTester.new.resolve_field(user, "email(only: DOMAIN)")
# expect(result).to eq("test.com")
#
# @example Object types
# user = User.create(friends: 3.times.map { User.create })
# result = MySchemaTypeTester.new.resolve_field(user, "friends { id }")
# expect(result).to eq(["1", "2", "3"])
#
attr_reader :context
class << self
def schema(schema = nil)
@schema = schema if schema
@schema
end
end
def initialize(context: {}, account: nil)
assert_absract_class_extended!
context[:account] ||= account
@context = context
end
# Executes a query fetching the given fields. Infers the type that the fields belong using
# `GraphQL::Schema#resolve_type(object, context)`.
#
# @param object the object used to infer the type, and passed into the resolver for the `field`.
# @param field_and_subfields [String] the field (with subfields for object types) to resolve
# @param context [Hash] field specific context, merged into shared context from `#initialize`
# @return the result of querying for the given field (and optional subfields)
def resolve_field(object, field_and_subfields, context: {})
account ||= context[:account]
field_context = shared_context.merge(context)
type = resolve_type_from_object(object, context)
field_and_subfields = field_and_subfields.camelize(:lower)
# If the user supplied an object field, extract the top level field name
# E.g. "photos { account { id } }" => "photos"
field = field_and_subfields.match(/\A\w+/)[0]
assert_field_exists!(type, field)
query = node_query(type, field_and_subfields)
variables = node_variables(object, type, context)
result = run_graphql_query(query, variables: variables, context: field_context)
if result.errors
raise_query_errors(result.errors)
else
result.data.node.public_send(field)
end
end
private
def schema
self.class.schema
end
def assert_absract_class_extended!
if self === GraphQL::Testing::TypeTester
raise(NotImplementedError, "GraphQL::Testing::TypeTester is an abstract class, extend and set #schema to use")
end
end
def resolve_type_from_object(object, context)
if type = schema.resolve_type(object, context)
return type
end
raise QueryError.new("Could not find infer Type for #{object}")
end
def assert_field_exists!(type, field)
type.fields.fetch(field)
rescue KeyError => message
raise QueryError.new("Could not find Field #{field}\n\n#{message}")
end
def raise_query_errors(errors)
error = QueryError.new(<<~EOT, errors)
GraphQL Query Error!
If you were expecting an error, change your spec to:
expect { result }.to raise_error(GraphQL::QueryError)
#{errors&.map(&:to_hash)}"
EOT
raise error
end
def node_query(type, field)
<<~GRAPHQL
query($id: ID!) {
# Use the Relay node API to fetch the instance by ID from the root node.
node(id: $id) {
... on #{type} {
#{field}
}
}
}
GRAPHQL
end
def node_variables(object, type, context)
{id: schema.id_from_object(object, type, context).to_s}
end
def shared_context
context
end
def run_graphql_query(query, variables: {}, context: {})
account = context[:account]
context = context.reverse_merge(
current_user: account,
account: account,
ability: AbilityDelegator.new(account)
)
result = DirectorySchema.execute(query, context: context, variables: variables)
result = Hashie::Mash.new(result)
end
end
end
end
@jgrau
Copy link

jgrau commented Jul 26, 2021

@bessey I’ve been struggling to find a good way to test my graphql-ruby application and found this gist when looking for inspiration. I’m wondering if you pursued this way of testing any further or if you abandoned it… no matter what: thanks for this gist - it’s been inspirational. :)

@bessey
Copy link
Author

bessey commented Aug 3, 2021 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment