diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..49e42fe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - gemfile: Gemfile + ruby: 3.3 + steps: + - run: echo BUNDLE_GEMFILE=${{ matrix.gemfile }} > $GITHUB_ENV + - uses: actions/checkout@v4 + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - name: Run tests + run: | + gem install bundler -v 2.4.22 + bundle install --jobs 4 --retry 3 + bundle exec rake test diff --git a/.gitignore b/.gitignore index e3200e0..26b8042 100644 --- a/.gitignore +++ b/.gitignore @@ -9,12 +9,15 @@ /test/tmp/ /test/version_tmp/ /tmp/ - -# Used by dotenv library to load environment variables. -# .env - -# Ignore Byebug command history file. +.envrc .byebug_history +.ruby-version +.rvmrc +.DS_Store + +Gemfile.lock +secrets.json +example/*/*.graphql ## Specific to RubyMotion: .dat* @@ -24,14 +27,6 @@ build/ build-iPhoneOS/ build-iPhoneSimulator/ -## Specific to RubyMotion (use of CocoaPods): -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# vendor/Pods/ - ## Documentation cache and generated files: /.yardoc/ /_yardoc/ @@ -42,15 +37,3 @@ build-iPhoneSimulator/ /.bundle/ /vendor/bundle /lib/bundler/man/ - -# for a library or gem, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# Gemfile.lock -# .ruby-version -# .ruby-gemset - -# unless supporting rvm < 1.11.0 or doing something fancy, ignore this: -.rvmrc - -# Used by RuboCop. Remote config files pulled in from inherit_from directive. -# .rubocop-https?--* diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..173dec1 --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gemspec +gem 'pry' diff --git a/README.md b/README.md index 3626bbf..5195b57 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,29 @@ -# graphql-response_fixture -Validate that GraphQL response fixtures match their test queries +# GraphQL response fixtures + +Testing GraphQL queries using fixture responses runs the risk of false-positive outcomes when a query changes without its response fixture getting updated accordingly. This gem provides a simple utility for loading JSON response fixtures and validating them against the shape of the query to assure they match. + +```shell +gem "graphql-response_fixture" +``` + +## Usage + +Build a test query and its response data into a `GraphQL::ResponseFixture`, then assert that the fixture is correct for the query as part of your test: + +```ruby +def test_my_stuff + query = %|{ widget { id title } }| + result = { + "widget" => { + "id" => "1", + "name" => "My widget", # << incorrect, the query requests `title` + }, + } + + request = GraphQL::Query.new(MySchema, query: query) + response = GraphQL::ResponseFixture.new(request, result) + + assert response.valid?, response.error_message + # Results in: "Expected data to provide field `widget.title`" +end +``` diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..74b0ac7 --- /dev/null +++ b/Rakefile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'rake/testtask' + +Rake::TestTask.new(:test) do |t, args| + puts args + t.libs << "test" + t.libs << "lib" + t.test_files = FileList['test/**/*_test.rb'] +end + +task :default => :test diff --git a/graphql-response_fixture.gemspec b/graphql-response_fixture.gemspec new file mode 100644 index 0000000..cba0c72 --- /dev/null +++ b/graphql-response_fixture.gemspec @@ -0,0 +1,34 @@ +# coding: utf-8 +lib = File.expand_path("../lib", __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require "graphql/response_fixture/version" + +Gem::Specification.new do |spec| + spec.name = "graphql-response_fixture" + spec.version = GraphQL::ResponseFixture::VERSION + spec.authors = ["Greg MacWilliam"] + spec.summary = "Validate that a GraphQL response fixture matches its test query." + spec.description = "Validate that a GraphQL response fixture matches its test query." + spec.homepage = "https://github.com/gmac/graphql-response_fixture" + spec.license = "MIT" + + spec.required_ruby_version = ">= 3.0.0" + + spec.metadata = { + "homepage_uri" => "https://github.com/gmac/graphql-response_fixture", + "changelog_uri" => "https://github.com/gmac/graphql-response_fixture/releases", + "source_code_uri" => "https://github.com/gmac/graphql-response_fixture", + "bug_tracker_uri" => "https://github.com/gmac/graphql-response_fixture/issues", + } + + spec.files = `git ls-files -z`.split("\x0").reject do |f| + f.match(%r{^test/}) + end + spec.require_paths = ["lib"] + + spec.add_runtime_dependency "graphql", ">= 2.0.0" + + spec.add_development_dependency "bundler", "~> 2.0" + spec.add_development_dependency "rake", "~> 12.0" + spec.add_development_dependency "minitest", "~> 5.12" +end diff --git a/lib/graphql/response_fixture.rb b/lib/graphql/response_fixture.rb new file mode 100644 index 0000000..b2145d2 --- /dev/null +++ b/lib/graphql/response_fixture.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require "graphql" + +module GraphQL + class ResponseFixture + SYSTEM_TYPENAME = "__typename__" + SCALAR_VALIDATORS = { + "Boolean" => -> (data) { data.is_a?(TrueClass) || data.is_a?(FalseClass) }, + "Float" => -> (data) { data.is_a?(Numeric) }, + "ID" => -> (data) { data.is_a?(String) || data.is_a?(Integer) }, + "Int" => -> (data) { data.is_a?(Integer) }, + "JSON" => -> (data) { data.is_a?(Hash) }, + "String" => -> (data) { data.is_a?(String) }, + }.freeze + + class ResponseFixtureError < StandardError; end + + attr_reader :error_message + + def initialize( + query, + data, + scalar_validators: SCALAR_VALIDATORS, + system_typename: SYSTEM_TYPENAME + ) + @query = query + @data = data + @valid = nil + @error_message = nil + @scalar_validators = scalar_validators + @system_typename = system_typename + @system_typenames = Set.new + end + + def valid? + return @valid unless @valid.nil? + + op = @query.selected_operation + parent_type = @query.root_type_for_operation(op.operation_type) + validate_selections(parent_type, op, @data) + @valid = true + rescue ResponseFixtureError => e + @error_message = e.message + @valid = false + end + + def prune! + @system_typenames.each { _1.delete(@system_typename) } + self + end + + def to_h + @data + end + + private + + def validate_selections(parent_type, parent_node, data_part, path = []) + if parent_type.non_null? + raise ResponseFixtureError, "Expected non-null selection `#{path.join(".")}` to provide value" if data_part.nil? + return validate_selections(parent_type.of_type, parent_node, data_part, path) + + elsif data_part.nil? + # nullable node with a null value is okay + return true + + elsif parent_type.list? + raise ResponseFixtureError, "Expected list selection `#{path.join(".")}` to provide Array" unless data_part.is_a?(Array) + return data_part.all? { |item| validate_selections(parent_type.of_type, parent_node, item, path) } + + elsif parent_type.kind.leaf? + return validate_leaf(parent_type, data_part, path) + + elsif !data_part.is_a?(Hash) + raise ResponseFixtureError, "Expected composite selection `#{path.join(".")}` to provide Hash" + end + + parent_node.selections.all? do |node| + case node + when GraphQL::Language::Nodes::Field + field_name = node.alias || node.name + path << field_name + raise ResponseFixtureError, "Expected data to provide field `#{path.join(".")}`" unless data_part.key?(path.last) + + next_value = data_part[path.last] + next_type = if node.name == "__typename" + annotation_type = @query.get_type(data_part[field_name]) + unless annotation_type && @query.possible_types(parent_type).include?(annotation_type) + raise ResponseFixtureError, "Expected selection `#{path.join(".")}` to provide a possible type name of `#{parent_type.graphql_name}`" + end + + @query.get_type("String") + else + @query.get_field(parent_type, node.name)&.type + end + raise ResponseFixtureError, "Invalid selection for `#{parent_type.graphql_name}.#{node.name}`" unless next_type + + result = validate_selections(next_type, node, next_value, path) + path.pop + result + + when GraphQL::Language::Nodes::InlineFragment + resolved_type = resolved_type(parent_type, data_part, path) + fragment_type = node.type.nil? ? parent_type : @query.get_type(node.type.name) + return true unless @query.possible_types(fragment_type).include?(resolved_type) + + validate_selections(fragment_type, node, data_part, path) + + when GraphQL::Language::Nodes::FragmentSpread + resolved_type = resolved_type(parent_type, data_part, path) + fragment_def = @query.fragments[node.name] + fragment_type = @query.get_type(fragment_def.type.name) + return true unless @query.possible_types(fragment_type).include?(resolved_type) + + validate_selections(fragment_type, fragment_def, data_part, path) + end + end + end + + def validate_leaf(parent_type, value, path) + valid = if parent_type.kind.enum? + parent_type.values.key?(value) + elsif parent_type.kind.scalar? + validator = @scalar_validators[parent_type.graphql_name] + validator.nil? || validator.call(value) + end + + unless valid + raise ResponseFixtureError, "Expected `#{path.join(".")}` to provide a valid `#{parent_type.graphql_name}` value" + end + true + end + + def resolved_type(parent_type, data_part, path) + return parent_type unless parent_type.kind.abstract? + + typename = data_part["__typename"] || data_part[@system_typename] + if typename.nil? + raise ResponseFixtureError, "Abstract position at `#{path.join(".")}` expects `__typename` or system typename hint" + end + + @system_typenames.add(data_part) if data_part.key?(@system_typename) + annotated_type = @query.get_type(typename) + + if annotated_type.nil? + raise ResponseFixtureError, "Abstract typename `#{typename}` is not a valid type" + elsif !@query.possible_types(parent_type).include?(annotated_type) + raise ResponseFixtureError, "Abstract type `#{typename}` does not belong to `#{parent_type.graphql_name}`" + else + annotated_type + end + end + end +end + +require_relative "./response_fixture/repository" +require_relative "./response_fixture/version" \ No newline at end of file diff --git a/lib/graphql/response_fixture/repository.rb b/lib/graphql/response_fixture/repository.rb new file mode 100644 index 0000000..458b4b3 --- /dev/null +++ b/lib/graphql/response_fixture/repository.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module GraphQL + class ResponseFixture + class Repository + def initialize(base_path: "./", scalar_validators: {}, system_typename: SYSTEM_TYPENAME) + @base_path = base_path + @scalar_validators = SCALAR_VALIDATORS.merge(scalar_validators) + @system_typename = system_typename + end + + def fetch(fixture_name, query) + data = File.read(fixture_file_path(fixture_name)) + fixture = ResponseFixture.new( + query, + data, + scalar_validators: @scalar_validators, + system_typename: @system_typename, + ) + fixture.valid? + fixture.prune! + end + + def write(fixture_name, data) + File.write(fixture_file_path(fixture_name), JSON.generate(data)) + end + + def fixture_file_path(fixture_name) + "#{@base_path}/#{fixture_name}.json" + end + end + end +end diff --git a/lib/graphql/response_fixture/version.rb b/lib/graphql/response_fixture/version.rb new file mode 100644 index 0000000..00a9f20 --- /dev/null +++ b/lib/graphql/response_fixture/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module GraphQL + class ResponseFixture + VERSION = "0.0.1" + end +end \ No newline at end of file diff --git a/test/graphql/response_fixture_test.rb b/test/graphql/response_fixture_test.rb new file mode 100644 index 0000000..c0cd8e1 --- /dev/null +++ b/test/graphql/response_fixture_test.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require "test_helper" + +describe "GraphQL::ResponseFixture" do + def test_invalid_for_missing_fields + query = %|{ widget { id } }| + + assert_invalid(query, { "widget" => {} }) do |error| + assert_equal "Expected data to provide field `widget.id`", error + end + end + + def test_invalid_for_composites_without_hash + query = %|{ widget { id } }| + + assert_invalid(query, { "widget" => "nope" }) do |error| + assert_equal "Expected composite selection `widget` to provide Hash", error + end + end + + def test_invalid_for_bad_selections + query = %|{ widget { nope } }| + + assert_invalid(query, { "widget" => { "nope" => true } }) do |error| + assert_equal "Invalid selection for `Widget.nope`", error + end + end + + def test_nullable_fields_returning_value + query = %|{ widget { id } }| + + assert_valid(query, { "widget" => { "id" => "1" } }) + end + + def test_nullable_fields_returning_null + query = %|{ widget { id } }| + + assert_valid(query, { "widget" => nil }) + end + + def test_non_null_fields_returning_null + query = %|{ thing { ... on Widget { id } } }| + + assert_invalid(query, { "thing" => nil }) do |error| + assert_equal "Expected non-null selection `thing` to provide value", error + end + end + + def test_list_returning_valid_value + query = %|{ widgets { id } }| + + assert_valid(query, { "widgets" => [{ "id" => "1" }] }) + end + + def test_list_returning_non_list_value + query = %|{ widgets { id } }| + + assert_invalid(query, { "widgets" => {} }) do |error| + assert_equal "Expected list selection `widgets` to provide Array", error + end + end + + def test_nullable_list_returning_null + query = %|{ widgets { id } }| + + assert_valid(query, { "widgets" => nil }) + end + + def test_nullable_list_item_returning_null + query = %|{ widgets { id } }| + + assert_valid(query, { "widgets" => [nil] }) + end + + def test_validates_id_scalar + query = %|{ widget { id } }| + + assert_valid(query, { "widget" => { "id" => "1" } }) + assert_valid(query, { "widget" => { "id" => 1 } }) + assert_invalid(query, { "widget" => { "id" => false } }) do |error| + assert_equal "Expected `widget.id` to provide a valid `ID` value", error + end + end + + def test_validates_string_scalar + query = %|{ widget { title } }| + + assert_valid(query, { "widget" => { "title" => "okay" } }) + assert_invalid(query, { "widget" => { "title" => 23 } }) do |error| + assert_equal "Expected `widget.title` to provide a valid `String` value", error + end + end + + def test_validates_int_scalar + query = %|{ widget { weight } }| + + assert_valid(query, { "widget" => { "weight" => 23 } }) + assert_invalid(query, { "widget" => { "weight" => "nope" } }) do |error| + assert_equal "Expected `widget.weight` to provide a valid `Int` value", error + end + end + + def test_validates_float_scalar + query = %|{ widget { diameter } }| + + assert_valid(query, { "widget" => { "diameter" => 23.5 } }) + assert_invalid(query, { "widget" => { "diameter" => "nope" } }) do |error| + assert_equal "Expected `widget.diameter` to provide a valid `Float` value", error + end + end + + def test_validates_boolean_scalar + query = %|{ widget { petFriendly } }| + + assert_valid(query, { "widget" => { "petFriendly" => true } }) + assert_invalid(query, { "widget" => { "petFriendly" => "nope" } }) do |error| + assert_equal "Expected `widget.petFriendly` to provide a valid `Boolean` value", error + end + end + + def test_validates_json_scalar + query = %|{ widget { attributes } }| + + assert_valid(query, { "widget" => { "attributes" => {} } }) + assert_invalid(query, { "widget" => { "attributes" => "nope" } }) do |error| + assert_equal "Expected `widget.attributes` to provide a valid `JSON` value", error + end + end + + def test_validates_enum_value + query = %|{ widget { heat } }| + + assert_valid(query, { "widget" => { "heat" => "SPICY" } }) + assert_invalid(query, { "widget" => { "heat" => "INVALID" } }) do |error| + assert_equal "Expected `widget.heat` to provide a valid `WidgetHeat` value", error + end + end + + def test_typename_with_valid_value + query = %|{ thing { __typename } }| + + assert_valid(query, { "thing" => { "__typename" => "Widget" } }) + end + + def test_typename_with_null_value + query = %|{ thing { __typename } }| + + assert_invalid(query, { "thing" => { "__typename" => nil } }) do |error| + assert_equal "Expected selection `thing.__typename` to provide a possible type name of `Thing`", error + end + end + + def test_typename_with_bogus_value + query = %|{ thing { __typename } }| + + assert_invalid(query, { "thing" => { "__typename" => "Elephant" } }) do |error| + assert_equal "Expected selection `thing.__typename` to provide a possible type name of `Thing`", error + end + end + + private + + def build_fixture(query_string, data) + query = GraphQL::Query.new(TestSchema, query: query_string) + GraphQL::ResponseFixture.new(query, data) + end + + def assert_valid(query_string, data) + fixture = build_fixture(query_string, data) + assert fixture.valid?, fixture.error_message + end + + def assert_invalid(query_string, data) + fixture = build_fixture(query_string, data) + assert !fixture.valid?, "Expected fixture to be invalid" + yield(fixture.error_message) if block_given? + end +end \ No newline at end of file diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..425929d --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path("../../lib", __FILE__) + +require "bundler/setup" +Bundler.require(:default, :test) + +require "minitest/pride" +require "minitest/autorun" + +class TestSchema < GraphQL::Schema + class JsonType < GraphQL::Schema::Scalar + graphql_name("JSON") + end + + class WidgetHeat < GraphQL::Schema::Enum + value("SPICY") + value("MILD") + end + + class Widget < GraphQL::Schema::Object + field :id, ID, null: false + field :title, String, null: false + field :description, String, null: true + field :weight, Int, null: true + field :diameter, Float, null: true + field :pet_friendly, Boolean, null: true + field :attributes, JsonType, null: true + field :heat, WidgetHeat, null: false + end + + class Sprocket < GraphQL::Schema::Object + field :id, ID, null: false + field :length, Int, null: false + field :width, Int, null: false + end + + class Thing < GraphQL::Schema::Union + possible_types(Widget, Sprocket) + end + + class Query < GraphQL::Schema::Object + field :widget, Widget, null: true + field :widgets, [Widget, null: true], null: true + + field :thing, Thing, null: false + field :things, [Thing, null: false], null: false + end + + query(Query) +end \ No newline at end of file