-
-
Save neumachen/6f3f3e5b4fda55743b7dab792e11d3b6 to your computer and use it in GitHub Desktop.
Beginnings of a reusable GraphQL Ruby type testing toolkit.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment