Repository: kamilkisiela/graphql-inspector
Pull request #147 (2019-03-29 14:15:58)

New optional argument or input field is dangerous + tests


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 95a0ce10..9b1b35b6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 ### vNEXT
 
+- **core**: New optional argument or an optional input field is now treated as a dangerous change [PR #147](https://github.com/kamilkisiela/graphql-inspector/pull/147)
+
 ### v1.16.0
 
 - **core**: Required field becoming nullable should be a non-breaking change [PR #139](https://github.com/kamilkisiela/graphql-inspector/pull/139)
diff --git a/jest.config.js b/jest.config.js
index a6594e79..d6dd5b3c 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -28,4 +28,6 @@ module.exports = {
   transform: {
     '^.+\\.(ts|tsx)$': 'ts-jest',
   },
+
+  errorOnDeprecated: true,
 };
diff --git a/packages/cli/src/typings.d.ts b/packages/cli/src/typings.d.ts
index 51759c73..20c49fb7 100644
--- a/packages/cli/src/typings.d.ts
+++ b/packages/cli/src/typings.d.ts
@@ -1,7 +1,5 @@
 declare module 'is-valid-path' {
-  namespace isValidPath {
-
-  }
+  namespace isValidPath {}
   function isValidPath(val: string): boolean;
   export = isValidPath;
 }
diff --git a/packages/core/src/diff/argument.ts b/packages/core/src/diff/argument.ts
index f695249f..ab35c315 100644
--- a/packages/core/src/diff/argument.ts
+++ b/packages/core/src/diff/argument.ts
@@ -5,7 +5,7 @@ import {
   GraphQLInterfaceType,
 } from 'graphql';
 
-import {notEqual} from './common/compare';
+import {isNotEqual} from './common/compare';
 import {Change} from './changes/change';
 import {
   fieldArgumentDescriptionChanged,
@@ -22,11 +22,11 @@ export function changesInArgument(
 ): Change[] {
   const changes: Change[] = [];
 
-  if (notEqual(oldArg.description, newArg.description)) {
+  if (isNotEqual(oldArg.description, newArg.description)) {
     changes.push(fieldArgumentDescriptionChanged(type, field, oldArg, newArg));
   }
 
-  if (notEqual(oldArg.defaultValue, newArg.defaultValue)) {
+  if (isNotEqual(oldArg.defaultValue, newArg.defaultValue)) {
     if (
       Array.isArray(oldArg.defaultValue) &&
       Array.isArray(newArg.defaultValue)
@@ -43,7 +43,7 @@ export function changesInArgument(
     }
   }
 
-  if (notEqual(oldArg.type.toString(), newArg.type.toString())) {
+  if (isNotEqual(oldArg.type.toString(), newArg.type.toString())) {
     changes.push(fieldArgumentTypeChanged(type, field, oldArg, newArg));
   }
 
diff --git a/packages/core/src/diff/changes/change.ts b/packages/core/src/diff/changes/change.ts
index 96c3f1fa..cb4748bd 100644
--- a/packages/core/src/diff/changes/change.ts
+++ b/packages/core/src/diff/changes/change.ts
@@ -23,13 +23,21 @@ export enum ChangeType {
   FieldRemoved = 'FIELD_REMOVED',
   FieldAdded = 'FIELD_ADDED',
   FieldDescriptionChanged = 'FIELD_DESCRIPTION_CHANGED',
+  FieldDescriptionAdded = 'FIELD_DESCRIPTION_ADDED',
+  FieldDescriptionRemoved = 'FIELD_DESCRIPTION_REMOVED',
+  FieldDeprecationAdded = 'FIELD_DEPRECATION_ADDED',
+  FieldDeprecationRemoved = 'FIELD_DEPRECATION_REMOVED',
   FieldDeprecationReasonChanged = 'FIELD_DEPRECATION_REASON_CHANGED',
+  FieldDeprecationReasonAdded = 'FIELD_DEPRECATION_REASON_ADDED',
+  FieldDeprecationReasonRemoved = 'FIELD_DEPRECATION_REASON_REMOVED',
   FieldTypeChanged = 'FIELD_TYPE_CHANGED',
   FieldArgumentAdded = 'FIELD_ARGUMENT_ADDED',
   FieldArgumentRemoved = 'FIELD_ARGUMENT_REMOVED',
   // Input
   InputFieldRemoved = 'INPUT_FIELD_REMOVED',
   InputFieldAdded = 'INPUT_FIELD_ADDED',
+  InputFieldDescriptionAdded = 'INPUT_FIELD_DESCRIPTION_ADDED',
+  InputFieldDescriptionRemoved = 'INPUT_FIELD_DESCRIPTION_REMOVED',
   InputFieldDescriptionChanged = 'INPUT_FIELD_DESCRIPTION_CHANGED',
   InputFieldDefaultValueChanged = 'INPUT_FIELD_DEFAULT_VALUE_CHANGED',
   InputFieldTypeChanged = 'INPUT_FIELD_TYPE_CHANGED',
diff --git a/packages/core/src/diff/changes/field.ts b/packages/core/src/diff/changes/field.ts
index f83ffc64..ae4f3ad8 100644
--- a/packages/core/src/diff/changes/field.ts
+++ b/packages/core/src/diff/changes/field.ts
@@ -4,6 +4,7 @@ import {
   GraphQLArgument,
   isNonNullType,
   GraphQLInterfaceType,
+  isInterfaceType,
 } from 'graphql';
 
 import {Change, CriticalityLevel, ChangeType} from './change';
@@ -13,6 +14,7 @@ export function fieldRemoved(
   type: GraphQLObjectType | GraphQLInterfaceType,
   field: GraphQLField<any, any, any>,
 ): Change {
+  const entity = isInterfaceType(type) ? 'interface' : 'object type';
   return {
     criticality: {
       level: CriticalityLevel.Breaking,
@@ -21,9 +23,7 @@ export function fieldRemoved(
         : `Removing a field is a breaking change. It is preferable to deprecate the field before removing it.`,
     },
     type: ChangeType.FieldRemoved,
-    message: `Field '${field.name}' was removed from object type '${
-      type.name
-    }'`,
+    message: `Field '${field.name}' was removed from ${entity} '${type.name}'`,
     path: [type.name, field.name].join('.'),
   };
 }
@@ -32,12 +32,13 @@ export function fieldAdded(
   type: GraphQLObjectType | GraphQLInterfaceType,
   field: GraphQLField<any, any, any>,
 ): Change {
+  const entity = isInterfaceType(type) ? 'interface' : 'object type';
   return {
     criticality: {
       level: CriticalityLevel.NonBreaking,
     },
     type: ChangeType.FieldAdded,
-    message: `Field '${field.name}' was added to object type '${type.name}'`,
+    message: `Field '${field.name}' was added to ${entity} '${type.name}'`,
     path: [type.name, field.name].join('.'),
   };
 }
@@ -59,6 +60,64 @@ export function fieldDescriptionChanged(
   };
 }
 
+export function fieldDescriptionAdded(
+  type: GraphQLObjectType | GraphQLInterfaceType,
+  field: GraphQLField<any, any>,
+): Change {
+  return {
+    criticality: {
+      level: CriticalityLevel.NonBreaking,
+    },
+    type: ChangeType.FieldDescriptionAdded,
+    message: `Field '${type.name}.${field.name}' has description '${
+      field.description
+    }'`,
+    path: [type.name, field.name].join('.'),
+  };
+}
+
+export function fieldDescriptionRemoved(
+  type: GraphQLObjectType | GraphQLInterfaceType,
+  field: GraphQLField<any, any>,
+): Change {
+  return {
+    criticality: {
+      level: CriticalityLevel.NonBreaking,
+    },
+    type: ChangeType.FieldDescriptionRemoved,
+    message: `Description was removed from field '${type.name}.${field.name}'`,
+    path: [type.name, field.name].join('.'),
+  };
+}
+
+export function fieldDeprecationAdded(
+  type: GraphQLObjectType | GraphQLInterfaceType,
+  field: GraphQLField<any, any>,
+): Change {
+  return {
+    criticality: {
+      level: CriticalityLevel.NonBreaking,
+    },
+    type: ChangeType.FieldDeprecationAdded,
+    message: `Field '${type.name}.${field.name}' is deprecated`,
+    path: [type.name, field.name].join('.'),
+  };
+}
+
+export function fieldDeprecationRemoved(
+  type: GraphQLObjectType | GraphQLInterfaceType,
+  field: GraphQLField<any, any>,
+): Change {
+  return {
+    criticality: {
+      level: CriticalityLevel.Dangerous,
+    },
+    type: ChangeType.FieldDeprecationRemoved,
+    message: `Field '${type.name}.${field.name}' is no longer deprecated`,
+    path: [type.name, field.name].join('.'),
+  };
+}
+
 export function fieldDeprecationReasonChanged(
   type: GraphQLObjectType | GraphQLInterfaceType,
   oldField: GraphQLField<any, any>,
@@ -78,6 +137,38 @@ export function fieldDeprecationReasonChanged(
   };
 }
 
+export function fieldDeprecationReasonAdded(
+  type: GraphQLObjectType | GraphQLInterfaceType,
+  field: GraphQLField<any, any>,
+): Change {
+  return {
+    criticality: {
+      level: CriticalityLevel.NonBreaking,
+    },
+    type: ChangeType.FieldDeprecationReasonAdded,
+    message: `Field '${type.name}.${field.name}' has deprecation reason '${
+      field.deprecationReason
+    }'`,
+    path: [type.name, field.name].join('.'),
+  };
+}
+
+export function fieldDeprecationReasonRemoved(
+  type: GraphQLObjectType | GraphQLInterfaceType,
+  field: GraphQLField<any, any>,
+): Change {
+  return {
+    criticality: {
+      level: CriticalityLevel.NonBreaking,
+    },
+    type: ChangeType.FieldDeprecationReasonRemoved,
+    message: `Deprecation reason was removed from field '${type.name}.${
+      field.name
+    }'`,
+    path: [type.name, field.name].join('.'),
+  };
+}
+
 export function fieldTypeChanged(
   type: GraphQLObjectType | GraphQLInterfaceType,
   oldField: GraphQLField<any, any, any>,
@@ -109,7 +200,7 @@ export function fieldArgumentAdded(
           reason: `Adding a required argument to an existing field is a breaking change because it will cause existing uses of this field to error.`,
         }
       : {
-          level: CriticalityLevel.NonBreaking,
+          level: CriticalityLevel.Dangerous,
         },
     type: ChangeType.FieldArgumentAdded,
     message: `Argument '${arg.name}: ${arg.type}' added to field '${
diff --git a/packages/core/src/diff/changes/input.ts b/packages/core/src/diff/changes/input.ts
index 1e746dad..d8a84624 100644
--- a/packages/core/src/diff/changes/input.ts
+++ b/packages/core/src/diff/changes/input.ts
@@ -37,7 +37,7 @@ export function inputFieldAdded(
             'Adding a possible type to Unions may break existing clients that were not programming defensively against a new possible type.',
         }
       : {
-          level: CriticalityLevel.NonBreaking,
+          level: CriticalityLevel.Dangerous,
         },
     type: ChangeType.InputFieldAdded,
     message: `Input field '${field.name}' was added to input object type '${
@@ -47,6 +47,38 @@ export function inputFieldAdded(
   };
 }
 
+export function inputFieldDescriptionAdded(
+  type: GraphQLInputObjectType,
+  field: GraphQLInputField,
+): Change {
+  return {
+    criticality: {
+      level: CriticalityLevel.NonBreaking,
+    },
+    type: ChangeType.InputFieldDescriptionAdded,
+    message: `Input field '${type.name}.${field.name}' has description '${
+      field.description
+    }'`,
+    path: [type.name, field.name].join('.'),
+  };
+}
+
+export function inputFieldDescriptionRemoved(
+  type: GraphQLInputObjectType,
+  field: GraphQLInputField,
+): Change {
+  return {
+    criticality: {
+      level: CriticalityLevel.NonBreaking,
+    },
+    type: ChangeType.InputFieldDescriptionRemoved,
+    message: `Description was removed from input field '${type.name}.${
+      field.name
+    }'`,
+    path: [type.name, field.name].join('.'),
+  };
+}
+
 export function inputFieldDescriptionChanged(
   input: GraphQLInputObjectType,
   oldField: GraphQLInputField,
diff --git a/packages/core/src/diff/common/compare.ts b/packages/core/src/diff/common/compare.ts
index fc791018..efac9fdc 100644
--- a/packages/core/src/diff/common/compare.ts
+++ b/packages/core/src/diff/common/compare.ts
@@ -1,7 +1,11 @@
-export function equal<T>(a: T, b: T): boolean {
+export function isEqual<T>(a: T, b: T): boolean {
   return a === b || (!a && !b);
 }
 
-export function notEqual<T>(a: T, b: T): boolean {
-  return !equal(a, b);
+export function isNotEqual<T>(a: T, b: T): boolean {
+  return !isEqual(a, b);
+}
+
+export function isVoid<T>(a: T): boolean {
+  return typeof a === 'undefined' || a === null;
 }
diff --git a/packages/core/src/diff/directive.ts b/packages/core/src/diff/directive.ts
index 88e4882c..b8f4c1ae 100644
--- a/packages/core/src/diff/directive.ts
+++ b/packages/core/src/diff/directive.ts
@@ -4,7 +4,7 @@ import {
   GraphQLArgument,
 } from 'graphql';
 
-import {notEqual} from './common/compare';
+import {isNotEqual} from './common/compare';
 import {Change} from './changes/change';
 import {
   directiveDescriptionChanged,
@@ -24,7 +24,7 @@ export function changesInDirective(
 ): Change[] {
   const changes: Change[] = [];
 
-  if (notEqual(oldDirective.description, newDirective.description)) {
+  if (isNotEqual(oldDirective.description, newDirective.description)) {
     changes.push(directiveDescriptionChanged(oldDirective, newDirective));
   }
 
@@ -90,19 +90,19 @@ function changesInDirectiveArgument(
 ): Change[] {
   const changes: Change[] = [];
 
-  if (notEqual(oldArg.description, newArg.description)) {
+  if (isNotEqual(oldArg.description, newArg.description)) {
     changes.push(
       directiveArgumentDescriptionChanged(directive, oldArg, newArg),
     );
   }
 
-  if (notEqual(oldArg.defaultValue, newArg.defaultValue)) {
+  if (isNotEqual(oldArg.defaultValue, newArg.defaultValue)) {
     changes.push(
       directiveArgumentDefaultValueChanged(directive, oldArg, newArg),
     );
   }
 
-  if (notEqual(oldArg.type.toString(), newArg.type.toString())) {
+  if (isNotEqual(oldArg.type.toString(), newArg.type.toString())) {
     changes.push(directiveArgumentTypeChanged(directive, oldArg, newArg));
   }
 
diff --git a/packages/core/src/diff/enum.ts b/packages/core/src/diff/enum.ts
index e0ec7059..dd7621a4 100644
--- a/packages/core/src/diff/enum.ts
+++ b/packages/core/src/diff/enum.ts
@@ -1,6 +1,6 @@
 import {GraphQLEnumType, GraphQLEnumValue} from 'graphql';
 
-import {notEqual} from './common/compare';
+import {isNotEqual} from './common/compare';
 import {
   enumValueRemoved,
   enumValueAdded,
@@ -33,11 +33,11 @@ export function changesInEnum(
   changes.push(...removed.map(v => enumValueRemoved(oldEnum, v)));
 
   common.forEach(({oldValue, newValue}) => {
-    if (notEqual(oldValue.description, newValue.description)) {
+    if (isNotEqual(oldValue.description, newValue.description)) {
       changes.push(enumValueDescriptionChanged(newEnum, oldValue, newValue));
     }
 
-    if (notEqual(oldValue.deprecationReason, newValue.deprecationReason)) {
+    if (isNotEqual(oldValue.deprecationReason, newValue.deprecationReason)) {
       changes.push(
         enumValueDeprecationReasonChanged(newEnum, oldValue, newValue),
       );
diff --git a/packages/core/src/diff/field.ts b/packages/core/src/diff/field.ts
index 5147dbbd..d006a185 100644
--- a/packages/core/src/diff/field.ts
+++ b/packages/core/src/diff/field.ts
@@ -5,11 +5,17 @@ import {
   GraphQLInterfaceType,
 } from 'graphql';
 
-import {notEqual} from './common/compare';
+import {isNotEqual, isVoid} from './common/compare';
 import {Change} from './changes/change';
 import {
   fieldDescriptionChanged,
+  fieldDescriptionAdded,
+  fieldDescriptionRemoved,
+  fieldDeprecationAdded,
+  fieldDeprecationRemoved,
   fieldDeprecationReasonChanged,
+  fieldDeprecationReasonAdded,
+  fieldDeprecationReasonRemoved,
   fieldTypeChanged,
   fieldArgumentAdded,
   fieldArgumentRemoved,
@@ -24,15 +30,35 @@ export function changesInField(
 ): Change[] {
   const changes: Change[] = [];
 
-  if (notEqual(oldField.description, newField.description)) {
-    changes.push(fieldDescriptionChanged(type, oldField, newField));
+  if (isNotEqual(oldField.description, newField.description)) {
+    if (isVoid(oldField.description)) {
+      changes.push(fieldDescriptionAdded(type, newField));
+    } else if (isVoid(newField.description)) {
+      changes.push(fieldDescriptionRemoved(type, oldField));
+    } else {
+      changes.push(fieldDescriptionChanged(type, oldField, newField));
+    }
   }
 
-  if (notEqual(oldField.deprecationReason, newField.deprecationReason)) {
-    changes.push(fieldDeprecationReasonChanged(type, oldField, newField));
+  if (isNotEqual(oldField.isDeprecated, newField.isDeprecated)) {
+    if (newField.isDeprecated) {
+      changes.push(fieldDeprecationAdded(type, newField));
+    } else {
+      changes.push(fieldDeprecationRemoved(type, oldField));
+    }
   }
 
-  if (notEqual(oldField.type.toString(), newField.type.toString())) {
+  if (isNotEqual(oldField.deprecationReason, newField.deprecationReason)) {
+    if (isVoid(oldField.deprecationReason)) {
+      changes.push(fieldDeprecationReasonAdded(type, newField));
+    } else if (isVoid(newField.deprecationReason)) {
+      changes.push(fieldDeprecationReasonRemoved(type, oldField));
+    } else {
+      changes.push(fieldDeprecationReasonChanged(type, oldField, newField));
+    }
+  }
+
+  if (isNotEqual(oldField.type.toString(), newField.type.toString())) {
     changes.push(fieldTypeChanged(type, oldField, newField));
   }
 
diff --git a/packages/core/src/diff/input.ts b/packages/core/src/diff/input.ts
index 961aaf06..d72596d4 100644
--- a/packages/core/src/diff/input.ts
+++ b/packages/core/src/diff/input.ts
@@ -1,12 +1,14 @@
 import {GraphQLInputObjectType, GraphQLInputField} from 'graphql';
 
-import {notEqual} from './common/compare';
+import {isNotEqual, isVoid} from './common/compare';
 import {Change} from './changes/change';
 import {diffArrays, unionArrays} from '../utils/arrays';
 import {
   inputFieldAdded,
   inputFieldRemoved,
   inputFieldDescriptionChanged,
+  inputFieldDescriptionAdded,
+  inputFieldDescriptionRemoved,
   inputFieldDefaultValueChanged,
   inputFieldTypeChanged,
 } from './changes/input';
@@ -45,11 +47,17 @@ function changesInInputField(
 ): Change[] {
   const changes: Change[] = [];
 
-  if (notEqual(oldField.description, newField.description)) {
-    changes.push(inputFieldDescriptionChanged(input, oldField, newField));
+  if (isNotEqual(oldField.description, newField.description)) {
+    if (isVoid(oldField.description)) {
+      changes.push(inputFieldDescriptionAdded(input, newField));
+    } else if (isVoid(newField.description)) {
+      changes.push(inputFieldDescriptionRemoved(input, oldField));
+    } else {
+      changes.push(inputFieldDescriptionChanged(input, oldField, newField));
+    }
   }
 
-  if (notEqual(oldField.defaultValue, newField.defaultValue)) {
+  if (isNotEqual(oldField.defaultValue, newField.defaultValue)) {
     if (
       Array.isArray(oldField.defaultValue) &&
       Array.isArray(newField.defaultValue)
@@ -65,7 +73,7 @@ function changesInInputField(
     }
   }
 
-  if (notEqual(oldField.type.toString(), newField.type.toString())) {
+  if (isNotEqual(oldField.type.toString(), newField.type.toString())) {
     changes.push(inputFieldTypeChanged(input, oldField, newField));
   }
 
diff --git a/packages/core/src/diff/schema.ts b/packages/core/src/diff/schema.ts
index a84e1f70..0d1b11ca 100644
--- a/packages/core/src/diff/schema.ts
+++ b/packages/core/src/diff/schema.ts
@@ -11,7 +11,7 @@ import {
   isScalarType,
 } from 'graphql';
 
-import {notEqual} from './common/compare';
+import {isNotEqual} from './common/compare';
 import {unionArrays, diffArrays} from '../utils/arrays';
 import {isPrimitive} from '../utils/graphql';
 import {Change} from './changes/change';
@@ -153,15 +153,15 @@ function changesInSchema(
       .name,
   };
 
-  if (notEqual(oldRoot.query, newRoot.query)) {
+  if (isNotEqual(oldRoot.query, newRoot.query)) {
     changes.push(schemaQueryTypeChanged(oldSchema, newSchema));
   }
 
-  if (notEqual(oldRoot.mutation, newRoot.mutation)) {
+  if (isNotEqual(oldRoot.mutation, newRoot.mutation)) {
     changes.push(schemaMutationTypeChanged(oldSchema, newSchema));
   }
 
-  if (notEqual(oldRoot.subscription, newRoot.subscription)) {
+  if (isNotEqual(oldRoot.subscription, newRoot.subscription)) {
     changes.push(schemaSubscriptionTypeChanged(oldSchema, newSchema));
   }
 
@@ -190,7 +190,7 @@ function changesInType(
     changes = [typeKindChanged(oldType, newType)];
   }
 
-  if (notEqual(oldType.description, newType.description)) {
+  if (isNotEqual(oldType.description, newType.description)) {
     changes.push(typeDescriptionChanged(oldType, newType));
   }
 
diff --git a/renovate.json b/renovate.json
index ff45eca0..76f7801c 100644
--- a/renovate.json
+++ b/renovate.json
@@ -1,4 +1,3 @@
-
 {
   "extends": ["config:base"],
   "pathRules": [
diff --git a/website/live/src/diff/Diff.js b/website/live/src/diff/Diff.js
index bd855890..92eb28bf 100644
--- a/website/live/src/diff/Diff.js
+++ b/website/live/src/diff/Diff.js
@@ -73,16 +73,13 @@ export default function Diff() {
   const [code, setCode] = useState(newSchemaString);
   const [changes, setChanges] = useState([]);
 
-  useEffect(
-    () => {
-      try {
-        setChanges(diff(oldSchema, buildSchema(code)));
-      } catch (e) {
-        console.error(e);
-      }
-    },
-    [code],
-  );
+  useEffect(() => {
+    try {
+      setChanges(diff(oldSchema, buildSchema(code)));
+    } catch (e) {
+      console.error(e);
+    }
+  }, [code]);
 
   return (
     <Container>


Test patch:
diff --git a/packages/core/__tests__/coverage/coverage.ts b/packages/core/__tests__/coverage/coverage.ts
index 32f277cd..14ce6e95 100644
--- a/packages/core/__tests__/coverage/coverage.ts
+++ b/packages/core/__tests__/coverage/coverage.ts
@@ -1,46 +1,48 @@
-import {buildASTSchema, Source, print} from 'graphql';
+import {buildSchema, Source, print} from 'graphql';
 import gql from 'graphql-tag';
 
 import {coverage} from '../../src/index';
 
-test('coverage', () => {
-  const schemaA = buildASTSchema(gql`
-    type Post {
-      id: ID
-      title: String
-      author: String
-      createdAt: Int
-    }
+describe('coverage', () => {
+  test('basic', () => {
+    const a = buildSchema(/* GraphQL */ `
+      type Post {
+        id: ID
+        title: String
+        author: String
+        createdAt: Int
+      }
 
-    type Query {
-      post: Post
-      posts: [Post!]
-    }
+      type Query {
+        post: Post
+        posts: [Post!]
+      }
 
-    schema {
-      query: Query
-    }
-  `);
+      schema {
+        query: Query
+      }
+    `);
 
-  const doc = gql`
-    query getPost {
-      post {
-        id
-        title
+    const doc = gql`
+      query getPost {
+        post {
+          id
+          title
+        }
       }
-    }
-  `;
+    `;
 
-  const results = coverage(schemaA, [new Source(print(doc))]);
+    const results = coverage(a, [new Source(print(doc))]);
 
-  // Query
-  expect(results.types.Query.hits).toEqual(1);
-  expect(results.types.Query.children.posts.hits).toEqual(0);
-  expect(results.types.Query.children.post.hits).toEqual(1);
-  // Post
-  expect(results.types.Post.hits).toEqual(2);
-  expect(results.types.Post.children.id.hits).toEqual(1);
-  expect(results.types.Post.children.title.hits).toEqual(1);
-  expect(results.types.Post.children.author.hits).toEqual(0);
-  expect(results.types.Post.children.createdAt.hits).toEqual(0);
+    // Query
+    expect(results.types.Query.hits).toEqual(1);
+    expect(results.types.Query.children.posts.hits).toEqual(0);
+    expect(results.types.Query.children.post.hits).toEqual(1);
+    // Post
+    expect(results.types.Post.hits).toEqual(2);
+    expect(results.types.Post.children.id.hits).toEqual(1);
+    expect(results.types.Post.children.title.hits).toEqual(1);
+    expect(results.types.Post.children.author.hits).toEqual(0);
+    expect(results.types.Post.children.createdAt.hits).toEqual(0);
+  });
 });
diff --git a/packages/core/__tests__/diff/directive.ts b/packages/core/__tests__/diff/directive.ts
new file mode 100644
index 00000000..94d8a630
--- /dev/null
+++ b/packages/core/__tests__/diff/directive.ts
@@ -0,0 +1,351 @@
+import {buildSchema} from 'graphql';
+
+import {findFirstChangeByPath} from '../../utils/testing';
+import {diff} from '../../src/index';
+import {CriticalityLevel} from '../../src/diff/changes/change';
+
+describe('directive', () => {
+  test('added', () => {
+    const a = buildSchema(/* GraphQL */ `
+      type Dummy {
+        field: String
+      }
+    `);
+    const b = buildSchema(/* GraphQL */ `
+      directive @foo on FIELD
+
+      type Dummy {
+        field: String
+      }
+    `);
+
+    const change = findFirstChangeByPath(diff(a, b), '@foo');
+
+    expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+    expect(change.type).toEqual('DIRECTIVE_ADDED');
+    expect(change.message).toEqual(`Directive \'foo\' was added`);
+  });
+  test('removed', () => {
+    const a = buildSchema(/* GraphQL */ `
+      directive @foo on FIELD
+
+      type Dummy {
+        field: String
+      }
+    `);
+    const b = buildSchema(/* GraphQL */ `
+      type Dummy {
+        field: String
+      }
+    `);
+
+    const change = findFirstChangeByPath(diff(a, b), '@foo');
+
+    expect(change.criticality.level).toEqual(CriticalityLevel.Breaking);
+    expect(change.type).toEqual('DIRECTIVE_REMOVED');
+    expect(change.message).toEqual(`Directive \'foo\' was removed`);
+  });
+
+  test('description', () => {
+    const a = buildSchema(/* GraphQL */ `
+      """
+      AAA
+      """
+      directive @a on FIELD
+      directive @b on FIELD
+      """
+      Ccc
+      """
+      directive @c on FIELD
+
+      type Dummy {
+        field: String
+      }
+    `);
+    const b = buildSchema(/* GraphQL */ `
+      """
+      aaa
+      """
+      directive @a on FIELD
+      """
+      Bbb
+      """
+      directive @b on FIELD
+      directive @c on FIELD
+
+      type Dummy {
+        field: String
+      }
+    `);
+
+    const changes = diff(a, b);
+    const change = {
+      a: findFirstChangeByPath(changes, '@a'),
+      b: findFirstChangeByPath(changes, '@b'),
+      c: findFirstChangeByPath(changes, '@c'),
+    };
+
+    // changed
+    expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+    expect(change.a.type).toEqual('DIRECTIVE_DESCRIPTION_CHANGED');
+    expect(change.a.message).toEqual(
+      `Directive 'a' description changed from 'AAA' to 'aaa'`,
+    );
+    // added
+    expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+    expect(change.b.type).toEqual('DIRECTIVE_DESCRIPTION_CHANGED');
+    expect(change.b.message).toEqual(
+      `Directive 'b' description changed from 'undefined' to 'Bbb'`,
+    );
+    // removed
+    expect(change.c.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+    expect(change.c.type).toEqual('DIRECTIVE_DESCRIPTION_CHANGED');
+    expect(change.c.message).toEqual(
+      `Directive 'c' description changed from 'Ccc' to 'undefined'`,
+    );
+  });
+
+  describe('location', () => {
+    test('added', () => {
+      const a = buildSchema(/* GraphQL */ `
+        directive @a on FIELD
+
+        type Dummy {
+          field: String
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        directive @a on FIELD | ENUM_VALUE
+
+        type Dummy {
+          field: String
+        }
+      `);
+
+      const changes = diff(a, b);
+      const change = findFirstChangeByPath(changes, '@a');
+
+      expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.type).toEqual('DIRECTIVE_LOCATION_ADDED');
+      expect(change.message).toEqual(
+        `Location 'ENUM_VALUE' was added to directive 'a'`,
+      );
+    });
+
+    test('removed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        directive @a on FIELD | ENUM_VALUE
+
+        type Dummy {
+          field: String
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        directive @a on FIELD
+
+        type Dummy {
+          field: String
+        }
+      `);
+
+      const changes = diff(a, b);
+      const change = findFirstChangeByPath(changes, '@a');
+
+      expect(change.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.type).toEqual('DIRECTIVE_LOCATION_REMOVED');
+      expect(change.message).toEqual(
+        `Location 'ENUM_VALUE' was removed from directive 'a'`,
+      );
+    });
+  });
+
+  describe('arguments', () => {
+    test('added', () => {
+      const a = buildSchema(/* GraphQL */ `
+        directive @a on FIELD
+        directive @b on FIELD
+
+        type Dummy {
+          field: String
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        directive @a(name: String) on FIELD
+        directive @b(name: String!) on FIELD
+
+        type Dummy {
+          field: String
+        }
+      `);
+
+      const changes = diff(a, b);
+      const change = {
+        a: findFirstChangeByPath(changes, '@a'),
+        b: findFirstChangeByPath(changes, '@b'),
+      };
+
+      // Nullable
+      expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.a.type).toEqual('DIRECTIVE_ARGUMENT_ADDED');
+      expect(change.a.message).toEqual(
+        `Argument 'name' was added to directive 'a'`,
+      );
+      // Non-nullable
+      expect(change.b.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.b.type).toEqual('DIRECTIVE_ARGUMENT_ADDED');
+      expect(change.b.message).toEqual(
+        `Argument 'name' was added to directive 'b'`,
+      );
+    });
+
+    test('removed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        directive @a(name: String) on FIELD
+        directive @b(name: String!) on FIELD
+
+        type Dummy {
+          field: String
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        directive @a on FIELD
+        directive @b on FIELD
+
+        type Dummy {
+          field: String
+        }
+      `);
+
+      const changes = diff(a, b);
+      const change = {
+        a: findFirstChangeByPath(changes, '@a.name'),
+        b: findFirstChangeByPath(changes, '@b.name'),
+      };
+
+      // Nullable
+      expect(change.a.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.a.type).toEqual('DIRECTIVE_ARGUMENT_REMOVED');
+      expect(change.a.message).toEqual(
+        `Argument 'name' was removed from directive 'a'`,
+      );
+      // Non-nullable
+      expect(change.b.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.b.type).toEqual('DIRECTIVE_ARGUMENT_REMOVED');
+      expect(change.b.message).toEqual(
+        `Argument 'name' was removed from directive 'b'`,
+      );
+    });
+
+    test('changed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        directive @a(name: String) on FIELD
+        directive @b(name: String!) on FIELD
+        directive @c(name: String) on FIELD
+
+        type Dummy {
+          field: String
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        directive @a(name: Int) on FIELD
+        directive @b(name: String) on FIELD
+        directive @c(name: String!) on FIELD
+
+        type Dummy {
+          field: String
+        }
+      `);
+
+      const changes = diff(a, b);
+      const change = {
+        a: findFirstChangeByPath(changes, '@a.name'),
+        b: findFirstChangeByPath(changes, '@b.name'),
+        c: findFirstChangeByPath(changes, '@c.name'),
+      };
+
+      // Type
+      expect(change.a.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.a.type).toEqual('DIRECTIVE_ARGUMENT_TYPE_CHANGED');
+      expect(change.a.message).toEqual(
+        `Type for argument 'name' on directive 'a' changed from 'String' to 'Int'`,
+      );
+      // Non-nullable
+      expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.b.type).toEqual('DIRECTIVE_ARGUMENT_TYPE_CHANGED');
+      expect(change.b.message).toEqual(
+        `Type for argument 'name' on directive 'b' changed from 'String!' to 'String'`,
+      );
+      // Nullable
+      expect(change.c.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.c.type).toEqual('DIRECTIVE_ARGUMENT_TYPE_CHANGED');
+      expect(change.c.message).toEqual(
+        `Type for argument 'name' on directive 'c' changed from 'String' to 'String!'`,
+      );
+    });
+  });
+
+  test('default value', () => {
+    const a = buildSchema(/* GraphQL */ `
+      directive @a(name: String! = "aaa") on FIELD
+      directive @b(name: String) on FIELD
+      directive @c(name: String!) on FIELD
+      directive @d(name: String! = "Ddd") on FIELD
+      directive @e(name: String = "Eee") on FIELD
+
+      type Dummy {
+        field: String
+      }
+    `);
+    const b = buildSchema(/* GraphQL */ `
+      directive @a(name: String! = "AAA") on FIELD
+      directive @b(name: String = "Bbb") on FIELD
+      directive @c(name: String! = "Ccc") on FIELD
+      directive @d(name: String!) on FIELD
+      directive @e(name: String) on FIELD
+
+      type Dummy {
+        field: String
+      }
+    `);
+
+    const changes = diff(a, b);
+    const change = {
+      a: findFirstChangeByPath(changes, '@a.name'),
+      b: findFirstChangeByPath(changes, '@b.name'),
+      c: findFirstChangeByPath(changes, '@c.name'),
+      d: findFirstChangeByPath(changes, '@d.name'),
+      e: findFirstChangeByPath(changes, '@e.name'),
+    };
+
+    // changed
+    expect(change.a.criticality.level).toEqual(CriticalityLevel.Dangerous);
+    expect(change.a.type).toEqual('DIRECTIVE_ARGUMENT_DEFAULT_VALUE_CHANGED');
+    expect(change.a.message).toEqual(
+      `Default value for argument 'name' on directive 'a' changed from 'aaa' to 'AAA'`,
+    );
+    // added on nullable
+    expect(change.b.criticality.level).toEqual(CriticalityLevel.Dangerous);
+    expect(change.b.type).toEqual('DIRECTIVE_ARGUMENT_DEFAULT_VALUE_CHANGED');
+    expect(change.b.message).toEqual(
+      `Default value 'Bbb' was added to argument 'name' on directive 'b'`,
+    );
+    // added on non-nullable
+    expect(change.c.criticality.level).toEqual(CriticalityLevel.Dangerous);
+    expect(change.c.type).toEqual('DIRECTIVE_ARGUMENT_DEFAULT_VALUE_CHANGED');
+    expect(change.c.message).toEqual(
+      `Default value 'Ccc' was added to argument 'name' on directive 'c'`,
+    );
+    // removed from non-nullable
+    expect(change.d.criticality.level).toEqual(CriticalityLevel.Dangerous);
+    expect(change.d.type).toEqual('DIRECTIVE_ARGUMENT_DEFAULT_VALUE_CHANGED');
+    expect(change.d.message).toEqual(
+      `Default value for argument 'name' on directive 'd' changed from 'Ddd' to 'undefined'`,
+    );
+    // removed from nullable
+    expect(change.e.criticality.level).toEqual(CriticalityLevel.Dangerous);
+    expect(change.e.type).toEqual('DIRECTIVE_ARGUMENT_DEFAULT_VALUE_CHANGED');
+    expect(change.e.message).toEqual(
+      `Default value for argument 'name' on directive 'e' changed from 'Eee' to 'undefined'`,
+    );
+  });
+});
diff --git a/packages/core/__tests__/diff/enum.ts b/packages/core/__tests__/diff/enum.ts
index c7d9441e..a8d436d2 100644
--- a/packages/core/__tests__/diff/enum.ts
+++ b/packages/core/__tests__/diff/enum.ts
@@ -1,145 +1,145 @@
-import {buildASTSchema} from 'graphql';
-import gql from 'graphql-tag';
+import {buildSchema} from 'graphql';
 
+import {findFirstChangeByPath} from '../../utils/testing';
 import {diff} from '../../src/index';
 import {CriticalityLevel} from '../../src/diff/changes/change';
 
-test('enum with new value', () => {
-  const schemaA = buildASTSchema(gql`
-    type Query {
-      fieldA: String
-    }
-
-    enum enumA {
-      A
-      B
-    }
-  `);
-
-  const schemaB = buildASTSchema(gql`
-    type Query {
-      fieldA: String
-    }
-
-    enum enumA {
-      A
-      B
-      C
-    }
-  `);
-
-  const changes = diff(schemaA, schemaB);
-
-  expect(changes.length).toEqual(1);
-  expect(changes[0].criticality.level).toEqual(CriticalityLevel.Dangerous);
-  expect(changes[0].criticality.reason).toBeDefined();
-  expect(changes[0].message).toEqual(
-    `Enum value 'C' was added to enum 'enumA'`,
-  );
-  expect(changes[0].path).toEqual('enumA.C');
-});
-
-test('enum with removed value', () => {
-  const schemaA = buildASTSchema(gql`
-    type Query {
-      fieldA: String
-    }
-
-    enum enumA {
-      A
-      B
-    }
-  `);
-
-  const schemaB = buildASTSchema(gql`
-    type Query {
-      fieldA: String
-    }
-
-    enum enumA {
-      A
-    }
-  `);
-
-  const changes = diff(schemaA, schemaB);
-
-  expect(changes.length).toEqual(1);
-  expect(changes[0].criticality.level).toEqual(CriticalityLevel.Breaking);
-  expect(changes[0].criticality.reason).toBeDefined();
-  expect(changes[0].message).toEqual(
-    `Enum value 'B' was removed from enum 'enumA'`,
-  );
-  expect(changes[0].path).toEqual('enumA.B');
-});
-
-test('enum with description changed', () => {
-  const schemaA = buildASTSchema(gql`
-    type Query {
-      fieldA: String
-    }
-
-    """
-    Old Description
-    """
-    enum enumA {
-      A
-      B
-    }
-  `);
-
-  const schemaB = buildASTSchema(gql`
-    type Query {
-      fieldA: String
-    }
-
-    """
-    New Description
-    """
-    enum enumA {
-      A
-      B
-    }
-  `);
-
-  const changes = diff(schemaA, schemaB);
-
-  expect(changes.length).toEqual(1);
-  expect(changes[0].criticality.level).toEqual(CriticalityLevel.NonBreaking);
-  expect(changes[0].message).toEqual(
-    `Description 'Old Description' on type 'enumA' has changed to 'New Description'`,
-  );
-  expect(changes[0].path).toEqual('enumA');
-});
-
-test('enum with deprecationReason changed', () => {
-  const schemaA = buildASTSchema(gql`
-    type Query {
-      fieldA: String
-    }
-
-    enum enumA {
-      A @deprecated(reason: "Old Reason")
-      B
-    }
-  `);
-
-  const schemaB = buildASTSchema(gql`
-    type Query {
-      fieldA: String
-    }
-
-    enum enumA {
-      A @deprecated(reason: "New Reason")
-      B
-    }
-  `);
-
-  const changes = diff(schemaA, schemaB);
-
-  expect(changes.length).toEqual(1);
-  expect(changes[0].criticality.level).toEqual(CriticalityLevel.NonBreaking);
-  expect(changes[0].message).toEqual(
-    `Enum value 'enumA.A' deprecation reason changed from 'Old Reason' to 'New Reason'`,
-  );
-  expect(changes[0].path).toEqual('enumA.A');
+describe('enum', () => {
+  test('value added', () => {
+    const a = buildSchema(/* GraphQL */ `
+      type Query {
+        fieldA: String
+      }
+
+      enum enumA {
+        A
+        B
+      }
+    `);
+
+    const b = buildSchema(/* GraphQL */ `
+      type Query {
+        fieldA: String
+      }
+
+      enum enumA {
+        A
+        B
+        C
+      }
+    `);
+
+    const changes = diff(a, b);
+    const change = findFirstChangeByPath(changes, 'enumA.C');
+
+    expect(changes.length).toEqual(1);
+    expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous);
+    expect(change.criticality.reason).toBeDefined();
+    expect(change.message).toEqual(`Enum value 'C' was added to enum 'enumA'`);
+  });
+
+  test('value removed', () => {
+    const a = buildSchema(/* GraphQL */ `
+      type Query {
+        fieldA: String
+      }
+
+      enum enumA {
+        A
+        B
+      }
+    `);
+
+    const b = buildSchema(/* GraphQL */ `
+      type Query {
+        fieldA: String
+      }
+
+      enum enumA {
+        A
+      }
+    `);
+
+    const changes = diff(a, b);
+    const change = findFirstChangeByPath(changes, 'enumA.B');
+
+    expect(changes.length).toEqual(1);
+    expect(change.criticality.level).toEqual(CriticalityLevel.Breaking);
+    expect(change.criticality.reason).toBeDefined();
+    expect(change.message).toEqual(
+      `Enum value 'B' was removed from enum 'enumA'`,
+    );
+  });
+
+  test('description changed', () => {
+    const a = buildSchema(/* GraphQL */ `
+      type Query {
+        fieldA: String
+      }
+
+      """
+      Old Description
+      """
+      enum enumA {
+        A
+        B
+      }
+    `);
+
+    const b = buildSchema(/* GraphQL */ `
+      type Query {
+        fieldA: String
+      }
+
+      """
+      New Description
+      """
+      enum enumA {
+        A
+        B
+      }
+    `);
+
+    const changes = diff(a, b);
+    const change = findFirstChangeByPath(changes, 'enumA');
+
+    expect(changes.length).toEqual(1);
+    expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+    expect(change.message).toEqual(
+      `Description 'Old Description' on type 'enumA' has changed to 'New Description'`,
+    );
+  });
+
+  test('deprecation reason changed', () => {
+    const a = buildSchema(/* GraphQL */ `
+      type Query {
+        fieldA: String
+      }
+
+      enum enumA {
+        A @deprecated(reason: "Old Reason")
+        B
+      }
+    `);
+
+    const b = buildSchema(/* GraphQL */ `
+      type Query {
+        fieldA: String
+      }
+
+      enum enumA {
+        A @deprecated(reason: "New Reason")
+        B
+      }
+    `);
+
+    const changes = diff(a, b);
+    const change = findFirstChangeByPath(changes, 'enumA.A');
+
+    expect(changes.length).toEqual(1);
+    expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+    expect(change.message).toEqual(
+      `Enum value 'enumA.A' deprecation reason changed from 'Old Reason' to 'New Reason'`,
+    );
+  });
 });
diff --git a/packages/core/__tests__/diff/input.ts b/packages/core/__tests__/diff/input.ts
new file mode 100644
index 00000000..ce09a693
--- /dev/null
+++ b/packages/core/__tests__/diff/input.ts
@@ -0,0 +1,270 @@
+import {buildSchema} from 'graphql';
+
+import {diff, CriticalityLevel} from '../../src/index';
+import {findFirstChangeByPath} from '../../utils/testing';
+
+describe('input', () => {
+  describe('fields', () => {
+    test('added', () => {
+      const a = buildSchema(/* GraphQL */ `
+        input Foo {
+          a: String!
+          b: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        input Foo {
+          a: String!
+          b: String!
+          c: String!
+          d: String
+        }
+      `);
+
+      const change = {
+        c: findFirstChangeByPath(diff(a, b), 'Foo.c'),
+        d: findFirstChangeByPath(diff(a, b), 'Foo.d'),
+      };
+
+      // Non-nullable
+      expect(change.c.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.c.type).toEqual('INPUT_FIELD_ADDED');
+      expect(change.c.message).toEqual(
+        "Input field 'c' was added to input object type 'Foo'",
+      );
+      // Nullable
+      expect(change.d.criticality.level).toEqual(CriticalityLevel.Dangerous);
+      expect(change.d.type).toEqual('INPUT_FIELD_ADDED');
+      expect(change.d.message).toEqual(
+        "Input field 'd' was added to input object type 'Foo'",
+      );
+    });
+    test('removed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        input Foo {
+          a: String!
+          b: String!
+          c: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        input Foo {
+          a: String!
+          b: String!
+        }
+      `);
+
+      const change = findFirstChangeByPath(diff(a, b), 'Foo.c');
+
+      expect(change.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.type).toEqual('INPUT_FIELD_REMOVED');
+      expect(change.message).toEqual(
+        "Input field 'c' was removed from input object type 'Foo'",
+      );
+    });
+
+    test('order changed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        input Foo {
+          a: String!
+          b: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        input Foo {
+          b: String!
+          a: String!
+        }
+      `);
+
+      expect(diff(a, b)).toHaveLength(0);
+    });
+
+    test('type changed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        input Foo {
+          a: String!
+          b: String
+          c: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        input Foo {
+          a: Int!
+          b: String!
+          c: String
+        }
+      `);
+
+      const changes = diff(a, b);
+      const change = {
+        a: findFirstChangeByPath(changes, 'Foo.a'),
+        b: findFirstChangeByPath(changes, 'Foo.b'),
+        c: findFirstChangeByPath(changes, 'Foo.c'),
+      };
+
+      // Whole new type
+      expect(change.a.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.a.type).toEqual('INPUT_FIELD_TYPE_CHANGED');
+      expect(change.a.message).toEqual(
+        "Input field 'Foo.a' changed type from 'String!' to 'Int!'",
+      );
+      // Nullable to non-nullable
+      expect(change.b.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.b.type).toEqual('INPUT_FIELD_TYPE_CHANGED');
+      expect(change.b.message).toEqual(
+        "Input field 'Foo.b' changed type from 'String' to 'String!'",
+      );
+      // Non-nullable to nullable
+      expect(change.c.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.c.type).toEqual('INPUT_FIELD_TYPE_CHANGED');
+      expect(change.c.message).toEqual(
+        "Input field 'Foo.c' changed type from 'String!' to 'String'",
+      );
+    });
+
+    test('description changed / added / removed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        input Foo {
+          """
+          OLD
+          """
+          a: String!
+          """
+          BBB
+          """
+          b: String!
+          c: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        input Foo {
+          """
+          NEW
+          """
+          a: String!
+          b: String!
+          """
+          CCC
+          """
+          c: String!
+        }
+      `);
+
+      const changes = diff(a, b);
+      const change = {
+        a: findFirstChangeByPath(changes, 'Foo.a'),
+        b: findFirstChangeByPath(changes, 'Foo.b'),
+        c: findFirstChangeByPath(changes, 'Foo.c'),
+      };
+
+      // Changed
+      expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.a.type).toEqual('INPUT_FIELD_DESCRIPTION_CHANGED');
+      expect(change.a.message).toEqual(
+        "Input field 'Foo.a' description changed from 'OLD' to 'NEW'",
+      );
+      // Removed
+      expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.b.type).toEqual('INPUT_FIELD_DESCRIPTION_REMOVED');
+      expect(change.b.message).toEqual(
+        "Description was removed from input field 'Foo.b'",
+      );
+      // Added
+      expect(change.c.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.c.type).toEqual('INPUT_FIELD_DESCRIPTION_ADDED');
+      expect(change.c.message).toEqual(
+        "Input field 'Foo.c' has description 'CCC'",
+      );
+    });
+
+    test('default value added', () => {
+      const a = buildSchema(/* GraphQL */ `
+        input Foo {
+          a: String!
+          b: String
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        input Foo {
+          a: String! = "Aaa"
+          b: String = "Bbb"
+        }
+      `);
+
+      const change = {
+        a: findFirstChangeByPath(diff(a, b), 'Foo.a'),
+        b: findFirstChangeByPath(diff(a, b), 'Foo.b'),
+      };
+
+      // Non-nullable
+      expect(change.a.criticality.level).toEqual(CriticalityLevel.Dangerous);
+      expect(change.a.type).toEqual('INPUT_FIELD_DEFAULT_VALUE_CHANGED');
+      expect(change.a.message).toEqual(
+        "Input field 'Foo.a' default value changed from 'undefined' to 'Aaa'",
+      );
+      // Nullable
+      expect(change.b.criticality.level).toEqual(CriticalityLevel.Dangerous);
+      expect(change.b.type).toEqual('INPUT_FIELD_DEFAULT_VALUE_CHANGED');
+      expect(change.b.message).toEqual(
+        "Input field 'Foo.b' default value changed from 'undefined' to 'Bbb'",
+      );
+    });
+
+    test('default value removed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        input Foo {
+          a: String! = "Aaa"
+          b: String = "Bbb"
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        input Foo {
+          a: String!
+          b: String
+        }
+      `);
+
+      const change = {
+        a: findFirstChangeByPath(diff(a, b), 'Foo.a'),
+        b: findFirstChangeByPath(diff(a, b), 'Foo.b'),
+      };
+
+      // Non-nullable
+      expect(change.a.criticality.level).toEqual(CriticalityLevel.Dangerous);
+      expect(change.a.type).toEqual('INPUT_FIELD_DEFAULT_VALUE_CHANGED');
+      expect(change.a.message).toEqual(
+        "Input field 'Foo.a' default value changed from 'Aaa' to 'undefined'",
+      );
+      // Nullable
+      expect(change.b.criticality.level).toEqual(CriticalityLevel.Dangerous);
+      expect(change.b.type).toEqual('INPUT_FIELD_DEFAULT_VALUE_CHANGED');
+      expect(change.b.message).toEqual(
+        "Input field 'Foo.b' default value changed from 'Bbb' to 'undefined'",
+      );
+    });
+    test('field removed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        input Foo {
+          a: String!
+          b: String!
+          c: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        input Foo {
+          a: String!
+          b: String!
+        }
+      `);
+
+      const change = findFirstChangeByPath(diff(a, b), 'Foo.c');
+
+      expect(change.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.type).toEqual('INPUT_FIELD_REMOVED');
+      expect(change.message).toEqual(
+        "Input field 'c' was removed from input object type 'Foo'",
+      );
+    });
+  });
+});
diff --git a/packages/core/__tests__/diff/interface.ts b/packages/core/__tests__/diff/interface.ts
new file mode 100644
index 00000000..1f3f5d3a
--- /dev/null
+++ b/packages/core/__tests__/diff/interface.ts
@@ -0,0 +1,239 @@
+import {buildSchema} from 'graphql';
+
+import {diff, CriticalityLevel} from '../../src/index';
+import {findFirstChangeByPath, findChangesByPath} from '../../utils/testing';
+
+describe('interface', () => {
+  describe('fields', () => {
+    test('added', () => {
+      const a = buildSchema(/* GraphQL */ `
+        interface Foo {
+          a: String!
+          b: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        interface Foo {
+          a: String!
+          b: String!
+          c: String!
+        }
+      `);
+
+      const change = findFirstChangeByPath(diff(a, b), 'Foo.c');
+
+      expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.type).toEqual('FIELD_ADDED');
+      expect(change.message).toEqual("Field 'c' was added to interface 'Foo'");
+    });
+    test('removed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        interface Foo {
+          a: String!
+          b: String!
+          c: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        interface Foo {
+          a: String!
+          b: String!
+        }
+      `);
+
+      const change = findFirstChangeByPath(diff(a, b), 'Foo.c');
+
+      expect(change.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.type).toEqual('FIELD_REMOVED');
+      expect(change.message).toEqual(
+        "Field 'c' was removed from interface 'Foo'",
+      );
+    });
+
+    test('order changed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        interface Foo {
+          a: String!
+          b: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        interface Foo {
+          b: String!
+          a: String!
+        }
+      `);
+
+      expect(diff(a, b)).toHaveLength(0);
+    });
+
+    test('type changed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        interface Foo {
+          a: String!
+          b: String
+          c: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        interface Foo {
+          a: Int!
+          b: String!
+          c: String
+        }
+      `);
+
+      const changes = diff(a, b);
+      const change = {
+        a: findFirstChangeByPath(changes, 'Foo.a'),
+        b: findFirstChangeByPath(changes, 'Foo.b'),
+        c: findFirstChangeByPath(changes, 'Foo.c'),
+      };
+
+      // Whole new type
+      expect(change.a.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.a.type).toEqual('FIELD_TYPE_CHANGED');
+      expect(change.a.message).toEqual(
+        "Field 'Foo.a' changed type from 'String!' to 'Int!'",
+      );
+      // Nullable to non-nullable
+      expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.b.type).toEqual('FIELD_TYPE_CHANGED');
+      expect(change.b.message).toEqual(
+        "Field 'Foo.b' changed type from 'String' to 'String!'",
+      );
+      // Non-nullable to nullable
+      expect(change.c.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.c.type).toEqual('FIELD_TYPE_CHANGED');
+      expect(change.c.message).toEqual(
+        "Field 'Foo.c' changed type from 'String!' to 'String'",
+      );
+    });
+
+    test('description changed / added / removed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        interface Foo {
+          """
+          OLD
+          """
+          a: String!
+          """
+          BBB
+          """
+          b: String!
+          c: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        interface Foo {
+          """
+          NEW
+          """
+          a: String!
+          b: String!
+          """
+          CCC
+          """
+          c: String!
+        }
+      `);
+
+      const changes = diff(a, b);
+      const change = {
+        a: findFirstChangeByPath(changes, 'Foo.a'),
+        b: findFirstChangeByPath(changes, 'Foo.b'),
+        c: findFirstChangeByPath(changes, 'Foo.c'),
+      };
+
+      // Changed
+      expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.a.type).toEqual('FIELD_DESCRIPTION_CHANGED');
+      expect(change.a.message).toEqual(
+        "Field 'Foo.a' description changed from 'OLD' to 'NEW'",
+      );
+      // Removed
+      expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.b.type).toEqual('FIELD_DESCRIPTION_REMOVED');
+      expect(change.b.message).toEqual(
+        "Description was removed from field 'Foo.b'",
+      );
+      // Added
+      expect(change.c.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.c.type).toEqual('FIELD_DESCRIPTION_ADDED');
+      expect(change.c.message).toEqual("Field 'Foo.c' has description 'CCC'");
+    });
+
+    test('deprecation reason changed / added / removed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        interface Foo {
+          a: String! @deprecated(reason: "OLD")
+          b: String! @deprecated(reason: "BBB")
+          c: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        interface Foo {
+          a: String! @deprecated(reason: "NEW")
+          b: String!
+          c: String! @deprecated(reason: "CCC")
+        }
+      `);
+
+      const changes = diff(a, b);
+      const change = {
+        a: findFirstChangeByPath(changes, 'Foo.a'),
+        b: findChangesByPath(changes, 'Foo.b')[1],
+        c: findChangesByPath(changes, 'Foo.c')[1],
+      };
+
+      // Changed
+      expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.a.type).toEqual('FIELD_DEPRECATION_REASON_CHANGED');
+      expect(change.a.message).toEqual(
+        "Deprecation reason on field 'Foo.a' has changed from 'OLD' to 'NEW'",
+      );
+      // Removed
+      expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.b.type).toEqual('FIELD_DEPRECATION_REASON_REMOVED');
+      expect(change.b.message).toEqual(
+        "Deprecation reason was removed from field 'Foo.b'",
+      );
+      // Added
+      expect(change.c.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.c.type).toEqual('FIELD_DEPRECATION_REASON_ADDED');
+      expect(change.c.message).toEqual(
+        "Field 'Foo.c' has deprecation reason 'CCC'",
+      );
+    });
+
+    test('deprecation added / removed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        interface Foo {
+          a: String! @deprecated
+          b: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        interface Foo {
+          a: String!
+          b: String! @deprecated
+        }
+      `);
+
+      const changes = diff(a, b);
+      const change = {
+        a: findFirstChangeByPath(changes, 'Foo.a'),
+        b: findFirstChangeByPath(changes, 'Foo.b'),
+      };
+
+      // Changed
+      expect(change.a.criticality.level).toEqual(CriticalityLevel.Dangerous);
+      expect(change.a.type).toEqual('FIELD_DEPRECATION_REMOVED');
+      expect(change.a.message).toEqual("Field 'Foo.a' is no longer deprecated");
+      // Removed
+      expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.b.type).toEqual('FIELD_DEPRECATION_ADDED');
+      expect(change.b.message).toEqual("Field 'Foo.b' is deprecated");
+    });
+  });
+});
diff --git a/packages/core/__tests__/diff/object.ts b/packages/core/__tests__/diff/object.ts
new file mode 100644
index 00000000..0ce548dc
--- /dev/null
+++ b/packages/core/__tests__/diff/object.ts
@@ -0,0 +1,434 @@
+import {buildSchema} from 'graphql';
+
+import {diff, CriticalityLevel} from '../../src/index';
+import {findFirstChangeByPath, findChangesByPath} from '../../utils/testing';
+
+describe('object', () => {
+  describe('interfaces', () => {
+    test('added', () => {
+      const a = buildSchema(/* GraphQL */ `
+        interface A {
+          a: String!
+        }
+
+        interface B {
+          b: String!
+        }
+
+        type Foo implements A & B {
+          a: String!
+          b: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        interface A {
+          a: String!
+        }
+
+        interface B {
+          b: String!
+        }
+
+        interface C {
+          c: String!
+        }
+
+        type Foo implements A & B & C {
+          a: String!
+          b: String!
+          c: String!
+        }
+      `);
+
+      const change = findFirstChangeByPath(diff(a, b), 'Foo');
+
+      expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous);
+      expect(change.type).toEqual('OBJECT_TYPE_INTERFACE_ADDED');
+      expect(change.message).toEqual("'Foo' object implements 'C' interface");
+    });
+
+    test('removed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        interface A {
+          a: String!
+        }
+
+        interface B {
+          b: String!
+        }
+
+        interface C {
+          c: String!
+        }
+
+        type Foo implements A & B & C {
+          a: String!
+          b: String!
+          c: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        interface A {
+          a: String!
+        }
+
+        interface B {
+          b: String!
+        }
+
+        type Foo implements A & B {
+          a: String!
+          b: String!
+        }
+      `);
+
+      const change = findFirstChangeByPath(diff(a, b), 'Foo');
+
+      expect(change.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.type).toEqual('OBJECT_TYPE_INTERFACE_REMOVED');
+      expect(change.message).toEqual(
+        "'Foo' object type no longer implements 'C' interface",
+      );
+    });
+  });
+
+  describe('fields', () => {
+    test('added', () => {
+      const a = buildSchema(/* GraphQL */ `
+        type Foo {
+          a: String!
+          b: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        type Foo {
+          a: String!
+          b: String!
+          c: String!
+        }
+      `);
+
+      const change = findFirstChangeByPath(diff(a, b), 'Foo.c');
+
+      expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.type).toEqual('FIELD_ADDED');
+      expect(change.message).toEqual(
+        "Field 'c' was added to object type 'Foo'",
+      );
+    });
+    test('removed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        type Foo {
+          a: String!
+          b: String!
+          c: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        type Foo {
+          a: String!
+          b: String!
+        }
+      `);
+
+      const change = findFirstChangeByPath(diff(a, b), 'Foo.c');
+
+      expect(change.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.type).toEqual('FIELD_REMOVED');
+      expect(change.message).toEqual(
+        "Field 'c' was removed from object type 'Foo'",
+      );
+    });
+
+    test('order changed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        type Foo {
+          a: String!
+          b: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        type Foo {
+          b: String!
+          a: String!
+        }
+      `);
+
+      expect(diff(a, b)).toHaveLength(0);
+    });
+
+    test('type changed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        type Foo {
+          a: String!
+          b: String
+          c: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        type Foo {
+          a: Int!
+          b: String!
+          c: String
+        }
+      `);
+
+      const changes = diff(a, b);
+      const change = {
+        a: findFirstChangeByPath(changes, 'Foo.a'),
+        b: findFirstChangeByPath(changes, 'Foo.b'),
+        c: findFirstChangeByPath(changes, 'Foo.c'),
+      };
+
+      // Whole new type
+      expect(change.a.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.a.type).toEqual('FIELD_TYPE_CHANGED');
+      expect(change.a.message).toEqual(
+        "Field 'Foo.a' changed type from 'String!' to 'Int!'",
+      );
+      // Nullable to non-nullable
+      expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.b.type).toEqual('FIELD_TYPE_CHANGED');
+      expect(change.b.message).toEqual(
+        "Field 'Foo.b' changed type from 'String' to 'String!'",
+      );
+      // Non-nullable to nullable
+      expect(change.c.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.c.type).toEqual('FIELD_TYPE_CHANGED');
+      expect(change.c.message).toEqual(
+        "Field 'Foo.c' changed type from 'String!' to 'String'",
+      );
+    });
+
+    test('description changed / added / removed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        type Foo {
+          """
+          OLD
+          """
+          a: String!
+          """
+          BBB
+          """
+          b: String!
+          c: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        type Foo {
+          """
+          NEW
+          """
+          a: String!
+          b: String!
+          """
+          CCC
+          """
+          c: String!
+        }
+      `);
+
+      const changes = diff(a, b);
+      const change = {
+        a: findFirstChangeByPath(changes, 'Foo.a'),
+        b: findFirstChangeByPath(changes, 'Foo.b'),
+        c: findFirstChangeByPath(changes, 'Foo.c'),
+      };
+
+      // Changed
+      expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.a.type).toEqual('FIELD_DESCRIPTION_CHANGED');
+      expect(change.a.message).toEqual(
+        "Field 'Foo.a' description changed from 'OLD' to 'NEW'",
+      );
+      // Removed
+      expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.b.type).toEqual('FIELD_DESCRIPTION_REMOVED');
+      expect(change.b.message).toEqual(
+        "Description was removed from field 'Foo.b'",
+      );
+      // Added
+      expect(change.c.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.c.type).toEqual('FIELD_DESCRIPTION_ADDED');
+      expect(change.c.message).toEqual("Field 'Foo.c' has description 'CCC'");
+    });
+
+    test('deprecation reason changed / added / removed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        type Foo {
+          a: String! @deprecated(reason: "OLD")
+          b: String! @deprecated(reason: "BBB")
+          c: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        type Foo {
+          a: String! @deprecated(reason: "NEW")
+          b: String!
+          c: String! @deprecated(reason: "CCC")
+        }
+      `);
+
+      const changes = diff(a, b);
+      const change = {
+        a: findFirstChangeByPath(changes, 'Foo.a'),
+        b: findChangesByPath(changes, 'Foo.b')[1],
+        c: findChangesByPath(changes, 'Foo.c')[1],
+      };
+
+      // Changed
+      expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.a.type).toEqual('FIELD_DEPRECATION_REASON_CHANGED');
+      expect(change.a.message).toEqual(
+        "Deprecation reason on field 'Foo.a' has changed from 'OLD' to 'NEW'",
+      );
+      // Removed
+      expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.b.type).toEqual('FIELD_DEPRECATION_REASON_REMOVED');
+      expect(change.b.message).toEqual(
+        "Deprecation reason was removed from field 'Foo.b'",
+      );
+      // Added
+      expect(change.c.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.c.type).toEqual('FIELD_DEPRECATION_REASON_ADDED');
+      expect(change.c.message).toEqual(
+        "Field 'Foo.c' has deprecation reason 'CCC'",
+      );
+    });
+
+    test('deprecation added / removed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        type Foo {
+          a: String! @deprecated
+          b: String!
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        type Foo {
+          a: String!
+          b: String! @deprecated
+        }
+      `);
+
+      const changes = diff(a, b);
+      const change = {
+        a: findFirstChangeByPath(changes, 'Foo.a'),
+        b: findFirstChangeByPath(changes, 'Foo.b'),
+      };
+
+      // Changed
+      expect(change.a.criticality.level).toEqual(CriticalityLevel.Dangerous);
+      expect(change.a.type).toEqual('FIELD_DEPRECATION_REMOVED');
+      expect(change.a.message).toEqual("Field 'Foo.a' is no longer deprecated");
+      // Removed
+      expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.b.type).toEqual('FIELD_DEPRECATION_ADDED');
+      expect(change.b.message).toEqual("Field 'Foo.b' is deprecated");
+    });
+  });
+
+  describe('arguments', () => {
+    test('type changed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        type Foo {
+          foo(a: String, b: String, c: String!): String
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        type Foo {
+          foo(a: Int, b: String!, c: String): String
+        }
+      `);
+
+      const changes = diff(a, b);
+      const change = {
+        a: findFirstChangeByPath(changes, 'Foo.foo.a'),
+        b: findFirstChangeByPath(changes, 'Foo.foo.b'),
+        c: findFirstChangeByPath(changes, 'Foo.foo.c'),
+      };
+
+      // Whole new type
+      expect(change.a.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.a.type).toEqual('FIELD_ARGUMENT_TYPE_CHANGED');
+      expect(change.a.message).toEqual(
+        "Type for argument 'a' on field 'Foo.foo' changed from 'String' to 'Int'",
+      );
+      // Nullable to non-nullable
+      expect(change.b.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.b.type).toEqual('FIELD_ARGUMENT_TYPE_CHANGED');
+      expect(change.b.message).toEqual(
+        "Type for argument 'b' on field 'Foo.foo' changed from 'String' to 'String!'",
+      );
+      // Non-nullable to nullable
+      expect(change.c.criticality.level).toEqual(CriticalityLevel.NonBreaking);
+      expect(change.c.type).toEqual('FIELD_ARGUMENT_TYPE_CHANGED');
+      expect(change.c.message).toEqual(
+        "Type for argument 'c' on field 'Foo.foo' changed from 'String!' to 'String'",
+      );
+    });
+
+    test('added', () => {
+      const a = buildSchema(/* GraphQL */ `
+        type Foo {
+          foo(a: String): String
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        type Foo {
+          foo(a: String, b: String!, c: String): String
+        }
+      `);
+
+      const changes = diff(a, b);
+      const change = {
+        b: findFirstChangeByPath(changes, 'Foo.foo.b'),
+        c: findFirstChangeByPath(changes, 'Foo.foo.c'),
+      };
+
+      // Added non-nullable
+      expect(change.b.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.b.type).toEqual('FIELD_ARGUMENT_ADDED');
+      expect(change.b.message).toEqual(
+        "Argument 'b: String!' added to field 'Foo.foo'",
+      );
+      // Added nullable
+      expect(change.c.criticality.level).toEqual(CriticalityLevel.Dangerous);
+      expect(change.c.type).toEqual('FIELD_ARGUMENT_ADDED');
+      expect(change.c.message).toEqual(
+        "Argument 'c: String' added to field 'Foo.foo'",
+      );
+    });
+
+    test('removed', () => {
+      const a = buildSchema(/* GraphQL */ `
+        type Foo {
+          foo(a: String, b: String!, c: String): String
+        }
+      `);
+      const b = buildSchema(/* GraphQL */ `
+        type Foo {
+          foo(a: String): String
+        }
+      `);
+
+      const changes = diff(a, b);
+      const change = {
+        b: findFirstChangeByPath(changes, 'Foo.foo.b'),
+        c: findFirstChangeByPath(changes, 'Foo.foo.c'),
+      };
+
+      // Removed non-nullable
+      expect(change.b.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.b.type).toEqual('FIELD_ARGUMENT_REMOVED');
+      expect(change.b.message).toEqual(
+        "Argument 'b: String!' was removed from field 'Foo.foo'",
+      );
+      // Removed nullable
+      expect(change.c.criticality.level).toEqual(CriticalityLevel.Breaking);
+      expect(change.c.type).toEqual('FIELD_ARGUMENT_REMOVED');
+      expect(change.c.message).toEqual(
+        "Argument 'c: String' was removed from field 'Foo.foo'",
+      );
+    });
+  });
+});
diff --git a/packages/core/__tests__/diff/schema.ts b/packages/core/__tests__/diff/schema.ts
index b68b4a2d..31f21ce4 100644
--- a/packages/core/__tests__/diff/schema.ts
+++ b/packages/core/__tests__/diff/schema.ts
@@ -1,17 +1,11 @@
-import {
-  buildASTSchema,
-  buildSchema,
-  introspectionFromSchema,
-  buildClientSchema,
-} from 'graphql';
-import gql from 'graphql-tag';
+import {buildSchema, introspectionFromSchema, buildClientSchema} from 'graphql';
 
 import {diff} from '../../src/index';
 import {CriticalityLevel, Change} from '../../src/diff/changes/change';
 import {findBestMatch} from '../../src/utils/string';
 
 test('same schema', () => {
-  const schemaA = buildASTSchema(gql`
+  const schemaA = buildSchema(/* GraphQL */ `
     type Post {
       id: ID
     }
@@ -21,7 +15,7 @@ test('same schema', () => {
     }
   `);
 
-  const schemaB = buildASTSchema(gql`
+  const schemaB = buildSchema(/* GraphQL */ `
     type Post {
       id: ID
     }
@@ -37,13 +31,13 @@ test('same schema', () => {
 });
 
 test('renamed query', () => {
-  const schemaA = buildASTSchema(gql`
+  const schemaA = buildSchema(/* GraphQL */ `
     type Query {
       fieldA: String!
     }
   `);
 
-  const schemaB = buildASTSchema(gql`
+  const schemaB = buildSchema(/* GraphQL */ `
     type RootQuery {
       fieldA: String!
     }
@@ -86,13 +80,13 @@ test('renamed query', () => {
 });
 
 test('new field and field changed', () => {
-  const schemaA = buildASTSchema(gql`
+  const schemaA = buildSchema(/* GraphQL */ `
     type Query {
       fieldA: String!
     }
   `);
 
-  const schemaB = buildASTSchema(gql`
+  const schemaB = buildSchema(/* GraphQL */ `
     type Query {
       fieldA: Int
       fieldB: String
@@ -131,7 +125,7 @@ test('schema from an introspection result should be the same', () => {
 });
 
 test('huge test', () => {
-  const schemaA = buildASTSchema(gql`
+  const schemaA = buildSchema(/* GraphQL */ `
     schema {
       query: Query
     }
@@ -204,7 +198,7 @@ test('huge test', () => {
     directive @willBeRemoved on FIELD
   `);
 
-  const schemaB = buildASTSchema(gql`
+  const schemaB = buildSchema(/* GraphQL */ `
     schema {
       query: Query
     }
@@ -307,8 +301,8 @@ test('huge test', () => {
     `Default value '10' was added to argument 'arg' on field 'CType.d'`,
     `Member 'BType' was removed from Union type 'MyUnion'`,
     `Member 'DType' was added to Union type 'MyUnion'`,
-    `Field 'anotherInterfaceField' was removed from object type 'AnotherInterface'`,
-    `Field 'b' was added to object type 'AnotherInterface'`,
+    `Field 'anotherInterfaceField' was removed from interface 'AnotherInterface'`,
+    `Field 'b' was added to interface 'AnotherInterface'`,
     `'WithInterfaces' object type no longer implements 'AnotherInterface' interface`,
     `Description for argument 'a' on field 'WithArguments.a' changed from 'Meh' to 'Description for a'`,
     `Type for argument 'b' on field 'WithArguments.a' changed from 'String' to 'String!'`,
@@ -404,13 +398,13 @@ test('huge test', () => {
 });
 
 test('array as default value in argument (same)', () => {
-  const schemaA = buildASTSchema(gql`
+  const schemaA = buildSchema(/* GraphQL */ `
     interface MyInterface {
       a(b: [String] = ["Hello"]): String!
     }
   `);
 
-  const schemaB = buildASTSchema(gql`
+  const schemaB = buildSchema(/* GraphQL */ `
     interface MyInterface {
       a(b: [String] = ["Hello"]): String!
     }
@@ -422,13 +416,13 @@ test('array as default value in argument (same)', () => {
 });
 
 test('array as default value in argument (different)', () => {
-  const schemaA = buildASTSchema(gql`
+  const schemaA = buildSchema(/* GraphQL */ `
     interface MyInterface {
       a(b: [String] = ["Hello"]): String!
     }
   `);
 
-  const schemaB = buildASTSchema(gql`
+  const schemaB = buildSchema(/* GraphQL */ `
     interface MyInterface {
       a(b: [String] = ["Goodbye"]): String!
     }
@@ -446,7 +440,7 @@ test('array as default value in argument (different)', () => {
 });
 
 test('input as default value (same)', () => {
-  const schemaA = buildASTSchema(gql`
+  const schemaA = buildSchema(/* GraphQL */ `
     enum SortOrder {
       ASC
     }
@@ -461,7 +455,7 @@ test('input as default value (same)', () => {
     }
   `);
 
-  const schemaB = buildASTSchema(gql`
+  const schemaB = buildSchema(/* GraphQL */ `
     enum SortOrder {
       ASC
     }
@@ -482,7 +476,7 @@ test('input as default value (same)', () => {
 });
 
 test('array as default value in input (same)', () => {
-  const schemaA = buildASTSchema(gql`
+  const schemaA = buildSchema(/* GraphQL */ `
     enum SortOrder {
       ASC
     }
@@ -493,7 +487,7 @@ test('array as default value in input (same)', () => {
     }
   `);
 
-  const schemaB = buildASTSchema(gql`
+  const schemaB = buildSchema(/* GraphQL */ `
     enum SortOrder {
       ASC
     }
@@ -510,7 +504,7 @@ test('array as default value in input (same)', () => {
 });
 
 test('array as default value in input (different)', () => {
-  const schemaA = buildASTSchema(gql`
+  const schemaA = buildSchema(/* GraphQL */ `
     enum SortOrder {
       ASC
       DEC
@@ -522,7 +516,7 @@ test('array as default value in input (different)', () => {
     }
   `);
 
-  const schemaB = buildASTSchema(gql`
+  const schemaB = buildSchema(/* GraphQL */ `
     enum SortOrder {
       ASC
       DEC
@@ -546,7 +540,7 @@ test('array as default value in input (different)', () => {
 });
 
 test('Input fields becoming nullable is a non-breaking change', () => {
-  const schemaA = buildASTSchema(gql`
+  const schemaA = buildSchema(/* GraphQL */ `
     scalar CustomScalar
 
     input CommentQuery {
@@ -561,7 +555,7 @@ test('Input fields becoming nullable is a non-breaking change', () => {
     }
   `);
 
-  const schemaB = buildASTSchema(gql`
+  const schemaB = buildSchema(/* GraphQL */ `
     scalar CustomScalar
 
     input CommentQuery {
@@ -606,7 +600,7 @@ test('Input fields becoming nullable is a non-breaking change', () => {
 });
 
 test('Input fields becoming non-nullable is a breaking change', () => {
-  const schemaA = buildASTSchema(gql`
+  const schemaA = buildSchema(/* GraphQL */ `
     scalar CustomScalar
 
     input CommentQuery {
@@ -621,7 +615,7 @@ test('Input fields becoming non-nullable is a breaking change', () => {
     }
   `);
 
-  const schemaB = buildASTSchema(gql`
+  const schemaB = buildSchema(/* GraphQL */ `
     scalar CustomScalar
 
     input CommentQuery {
@@ -666,7 +660,7 @@ test('Input fields becoming non-nullable is a breaking change', () => {
 });
 
 test('Query fields becoming non-nullable is a non-breaking change', () => {
-  const schemaA = buildASTSchema(gql`
+  const schemaA = buildSchema(/* GraphQL */ `
     scalar CustomScalar
 
     type Comment {
@@ -681,7 +675,7 @@ test('Query fields becoming non-nullable is a non-breaking change', () => {
     }
   `);
 
-  const schemaB = buildASTSchema(gql`
+  const schemaB = buildSchema(/* GraphQL */ `
     scalar CustomScalar
 
     type Comment {
@@ -726,7 +720,7 @@ test('Query fields becoming non-nullable is a non-breaking change', () => {
 });
 
 test('Query fields becoming nullable is a breaking change', () => {
-  const schemaA = buildASTSchema(gql`
+  const schemaA = buildSchema(/* GraphQL */ `
     scalar CustomScalar
 
     type Comment {
@@ -741,7 +735,7 @@ test('Query fields becoming nullable is a breaking change', () => {
     }
   `);
 
-  const schemaB = buildASTSchema(gql`
+  const schemaB = buildSchema(/* GraphQL */ `
     scalar CustomScalar
 
     type Comment {
diff --git a/packages/core/__tests__/diff/union.ts b/packages/core/__tests__/diff/union.ts
new file mode 100644
index 00000000..0d8a693d
--- /dev/null
+++ b/packages/core/__tests__/diff/union.ts
@@ -0,0 +1,111 @@
+import {buildSchema} from 'graphql';
+
+import {diff} from '../../src/index';
+import {CriticalityLevel} from '../../src/diff/changes/change';
+import {findChangesByPath, findFirstChangeByPath} from '../../utils/testing';
+
+describe('union', () => {
+  test('member added', () => {
+    const a = buildSchema(/* GraphQL */ `
+      type A {
+        a: String!
+      }
+
+      type B {
+        b: String!
+      }
+
+      union Foo = A | B
+    `);
+    const b = buildSchema(/* GraphQL */ `
+      type A {
+        a: String!
+      }
+
+      type B {
+        b: String!
+      }
+
+      type C {
+        C: String!
+      }
+
+      union Foo = A | B | C
+    `);
+
+    const changes = diff(a, b);
+
+    const change = findFirstChangeByPath(changes, 'Foo');
+
+    expect(change.criticality.level).toEqual(CriticalityLevel.Breaking);
+    expect(change.type).toEqual('UNION_MEMBER_ADDED');
+    expect(change.message).toEqual("Member 'C' was added to Union type 'Foo'");
+  });
+
+  test('member removed', () => {
+    const a = buildSchema(/* GraphQL */ `
+      type A {
+        a: String!
+      }
+
+      type B {
+        b: String!
+      }
+
+      type C {
+        C: String!
+      }
+
+      union Foo = A | B | C
+    `);
+    const b = buildSchema(/* GraphQL */ `
+      type A {
+        a: String!
+      }
+
+      type B {
+        b: String!
+      }
+
+      union Foo = A | B
+    `);
+
+    const changes = diff(a, b);
+    const change = findFirstChangeByPath(changes, 'Foo');
+
+    expect(change.criticality.level).toEqual(CriticalityLevel.Breaking);
+    expect(change.type).toEqual('UNION_MEMBER_REMOVED');
+    expect(change.message).toEqual(
+      "Member 'C' was removed from Union type 'Foo'",
+    );
+  });
+
+  test('same members but different order', () => {
+    const a = buildSchema(/* GraphQL */ `
+      type A {
+        a: String!
+      }
+
+      type B {
+        b: String!
+      }
+
+      union Foo = A | B
+    `);
+    const b = buildSchema(/* GraphQL */ `
+      type A {
+        a: String!
+      }
+
+      type B {
+        b: String!
+      }
+
+      union Foo = B | A
+    `);
+
+    const changes = findChangesByPath(diff(a, b), 'Foo');
+
+    expect(changes).toHaveLength(0);
+  });
+});
diff --git a/packages/core/__tests__/validate.ts b/packages/core/__tests__/validate.ts
index 53d37bed..539905da 100644
--- a/packages/core/__tests__/validate.ts
+++ b/packages/core/__tests__/validate.ts
@@ -1,188 +1,189 @@
-import {buildASTSchema, Source, print} from 'graphql';
-import gql from 'graphql-tag';
+import {buildSchema, Source, print, parse} from 'graphql';
 
 import {validate} from '../src/index';
 
-test('validate', () => {
-  const schema = buildASTSchema(gql`
-    type Post {
-      id: ID
-      title: String @deprecated(reason: "BECAUSE")
-      author: String
-      createdAt: Int
-    }
-
-    type Query {
-      post: Post
-    }
-
-    schema {
-      query: Query
-    }
-  `);
-
-  const doc = gql`
-    query getPost {
-      post {
-        id
-        title
+describe('validate', () => {
+  test('basic', () => {
+    const schema = buildSchema(/* GraphQL */ `
+      type Post {
+        id: ID
+        title: String @deprecated(reason: "BECAUSE")
+        author: String
+        createdAt: Int
       }
-    }
-  `;
 
-  const results = validate(schema, [new Source(print(doc))]);
-
-  expect(results.length).toEqual(1);
-  expect(results[0].errors.length).toEqual(0);
-  expect(results[0].deprecated.length).toEqual(1);
+      type Query {
+        post: Post
+      }
 
-  const deprecated = results[0].deprecated[0];
-  expect(deprecated.message).toMatch(
-    `The field 'Post.title' is deprecated. BECAUSE`,
-  );
-});
+      schema {
+        query: Query
+      }
+    `);
 
-test('multiple fragments across multiple files with nested fragments (#36)', async () => {
-  const schema = buildASTSchema(gql`
-    type Post {
-      id: ID
-      title: String @deprecated(reason: "BECAUSE")
-      author: User
-      createdAt: Int
-    }
-
-    type User {
-      id: ID
-      name: String
-    }
-
-    type Query {
-      post: Post
-    }
-
-    schema {
-      query: Query
-    }
-  `);
-
-  const docs = [
-    gql`
+    const doc = parse(/* GraphQL */ `
       query getPost {
         post {
-          ...PostInfo
+          id
+          title
         }
       }
-    `,
-    gql`
-      fragment PostInfo on Post {
-        id
-        author {
-          ...AuthorInfo
-        }
-      }
-    `,
-    gql`
-      fragment AuthorInfo on User {
-        id
-        name
+    `);
+
+    const results = validate(schema, [new Source(print(doc))]);
+
+    expect(results.length).toEqual(1);
+    expect(results[0].errors.length).toEqual(0);
+    expect(results[0].deprecated.length).toEqual(1);
+
+    const deprecated = results[0].deprecated[0];
+    expect(deprecated.message).toMatch(
+      `The field 'Post.title' is deprecated. BECAUSE`,
+    );
+  });
+
+  test('multiple fragments across multiple files with nested fragments (#36)', async () => {
+    const schema = buildSchema(/* GraphQL */ `
+      type Post {
+        id: ID
+        title: String @deprecated(reason: "BECAUSE")
+        author: User
+        createdAt: Int
       }
-    `,
-  ];
 
-  const results = validate(schema, docs.map(doc => new Source(print(doc))));
+      type User {
+        id: ID
+        name: String
+      }
 
-  expect(results.length).toEqual(0);
-});
+      type Query {
+        post: Post
+      }
 
-test('fail on non unique fragment names', async () => {
-  const schema = buildASTSchema(gql`
-    type Post {
-      id: ID
-      title: String
-      createdAt: Int
-    }
-
-    type Query {
-      post: Post
-    }
-
-    schema {
-      query: Query
-    }
-  `);
-
-  const docs = [
-    gql`
-      query getPost {
-        post {
-          ...PostInfo
+      schema {
+        query: Query
+      }
+    `);
+
+    const docs = [
+      parse(/* GraphQL */ `
+        query getPost {
+          post {
+            ...PostInfo
+          }
         }
+      `),
+      parse(/* GraphQL */ `
+        fragment PostInfo on Post {
+          id
+          author {
+            ...AuthorInfo
+          }
+        }
+      `),
+      parse(/* GraphQL */ `
+        fragment AuthorInfo on User {
+          id
+          name
+        }
+      `),
+    ];
+
+    const results = validate(schema, docs.map(doc => new Source(print(doc))));
+
+    expect(results.length).toEqual(0);
+  });
+
+  test('fail on non unique fragment names', async () => {
+    const schema = buildSchema(/* GraphQL */ `
+      type Post {
+        id: ID
+        title: String
+        createdAt: Int
       }
-    `,
-    gql`
-      fragment PostInfo on Post {
-        id
-        title
+
+      type Query {
+        post: Post
       }
-    `,
-    gql`
-      fragment PostInfo on Post {
-        id
-        title
+
+      schema {
+        query: Query
       }
-    `,
-  ];
+    `);
+
+    const docs = [
+      parse(/* GraphQL */ `
+        query getPost {
+          post {
+            ...PostInfo
+          }
+        }
+      `),
+      parse(/* GraphQL */ `
+        fragment PostInfo on Post {
+          id
+          title
+        }
+      `),
+      parse(/* GraphQL */ `
+        fragment PostInfo on Post {
+          id
+          title
+        }
+      `),
+    ];
 
-  const results = validate(schema, docs.map(doc => new Source(print(doc))));
+    const results = validate(schema, docs.map(doc => new Source(print(doc))));
 
-  expect(results.length).toEqual(1);
-  expect(results[0].errors.length).toEqual(1);
-  expect(results[0].deprecated.length).toEqual(0);
+    expect(results.length).toEqual(1);
+    expect(results[0].errors.length).toEqual(1);
+    expect(results[0].deprecated.length).toEqual(0);
 
-  const error = results[0].errors[0];
+    const error = results[0].errors[0];
 
-  expect(error.message).toMatch(`Name of 'PostInfo' fragment is not unique`);
-});
+    expect(error.message).toMatch(`Name of 'PostInfo' fragment is not unique`);
+  });
 
-test('omit unused fragment from a document', async () => {
-  const schema = buildASTSchema(gql`
-    type Post {
-      id: ID
-      title: String
-      createdAt: Int
-    }
-
-    type Query {
-      post: Post
-    }
-
-    schema {
-      query: Query
-    }
-  `);
-
-  const docs = [
-    gql`
-      query getPost {
-        post {
-          ...PostInfo
-        }
+  test('omit unused fragment from a document', async () => {
+    const schema = buildSchema(/* GraphQL */ `
+      type Post {
+        id: ID
+        title: String
+        createdAt: Int
       }
 
-      fragment UnusedPost on Post {
-        id
-        createdAt
+      type Query {
+        post: Post
       }
-    `,
-    gql`
-      fragment PostInfo on Post {
-        id
-        title
+
+      schema {
+        query: Query
       }
-    `,
-  ];
+    `);
+
+    const docs = [
+      parse(/* GraphQL */ `
+        query getPost {
+          post {
+            ...PostInfo
+          }
+        }
+
+        fragment UnusedPost on Post {
+          id
+          createdAt
+        }
+      `),
+      parse(/* GraphQL */ `
+        fragment PostInfo on Post {
+          id
+          title
+        }
+      `),
+    ];
 
-  const results = validate(schema, docs.map(doc => new Source(print(doc))));
+    const results = validate(schema, docs.map(doc => new Source(print(doc))));
 
-  expect(results.length).toEqual(0);
+    expect(results.length).toEqual(0);
+  });
 });
diff --git a/packages/core/utils/testing.ts b/packages/core/utils/testing.ts
new file mode 100644
index 00000000..d94eb74e
--- /dev/null
+++ b/packages/core/utils/testing.ts
@@ -0,0 +1,11 @@
+// import { findDangerousChanges } from 'graphql';
+
+import {Change} from '../src/diff/changes/change';
+
+export function findChangesByPath(changes: Change[], path: string) {
+  return changes.filter(c => c.path === path);
+}
+
+export function findFirstChangeByPath(changes: Change[], path: string) {
+  return findChangesByPath(changes, path)[0];
+}