Last active
March 21, 2022 16:02
-
-
Save bessey/91063148696633ba92204b7bfc750c5e 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 |
I have implemented a system like this at 2 companies now. My current
approach is distilled down using a union type I call *Any*, and a
*Query.__test* field which returns *Any*, and is only added to the schema
in test mode. In this way the system does not require any changes to the
production schema.
I'm afraid none of it's open source at the moment. Something for me to
consider for the future
…On Mon, 26 Jul 2021 at 20:25, Jonas Grau ***@***.***> wrote:
***@***.**** commented on this gist.
------------------------------
@bessey <https://github.com/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. :)
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<https://gist.github.com/91063148696633ba92204b7bfc750c5e#gistcomment-3829311>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AAFM42GV2LQPDC62JXFMRA3TZWZB7ANCNFSM5BAXOTNQ>
.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@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. :)