Repository: unexpectedjs/unexpected
Pull request #423 (2017-10-15 13:24:28)

Additional object type overrides
Hey,

The changes in this commit are a sort of minimal set of overrides allowing the Map type to share inspect and diff code with the base object plugin. If you want to see how they are used, I split out the Map code itself into a separate repo https://github.com/alexjeffburke/unexpected-map.

My hope is what's here strikes a decent balance between re-use while avoiding invasive changes to other areas that might require other approaches (i.e. "to satisfy"). I'm more than willing to iterate on this and the main reason for sharing it now is to get agreement on the approach and early feedback.

Look forward to hearing your thoughts!

alexjeffburke: @sunesimonsen @papandreou I've not converted "to satisfy" via the use has a `hasKey()`. In actual fact I'd already written that code at the weekend when I added tests of <object> `similar()` :)
papandreou: Remember the `array-like` type and `<array-like> to satisfy <array-like>` :)
alexjeffburke: @papandreou haha yep, takin it one step at a time :p
alexjeffburke: @papandreou Think I just got the <array-like> support working :)

Assuming CI comes back clean I think it is now safe to say array-like now uses `valueKey()`.
papandreou: Talked to @albertfdp about using these hooks to support the https://facebook.github.io/immutable-js/ types without converting the entire structures being compared to JavaScript objects first. Instead the Immutable types could just inherit from the Unexpected ones and use `getKeys`, `valueForKey` overrides to make them work with the existing assertion/diffing code.

In other words, it would be really great to get this landed :)
sunesimonsen: I opened https://github.com/unexpectedjs/unexpected/pull/438 and is trying to rebase and see if we can get the branch in a mergeable state.

Patch:
diff --git a/.travis.yml b/.travis.yml
index c63b0222..fe3029eb 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -7,5 +7,4 @@ node_js:
    - "6"
    - "7"
    - "8.4.0"
-   - "9"
 script: "make travis"
diff --git a/lib/assertions.js b/lib/assertions.js
index 21e50d79..f5992088 100644
--- a/lib/assertions.js
+++ b/lib/assertions.js
@@ -286,8 +286,12 @@ module.exports = expect => {
   expect.addAssertion(
     '<object|function> [not] to have property <string>',
     (expect, subject, key) => {
-      expect(subject[key], '[!not] to be undefined');
-      return subject[key];
+      const subjectType = expect.findTypeOf(subject);
+      const subjectKey = subjectType.is('function')
+        ? subject[key]
+        : subjectType.valueForKey(subject, key);
+      expect(subjectKey, '[!not] to be undefined');
+      return subjectKey;
     }
   );
 
@@ -421,7 +425,8 @@ module.exports = expect => {
     '<object> to [not] [only] have keys <array>',
     (expect, subject, keys) => {
       const keysInSubject = {};
-      const subjectKeys = expect.findTypeOf(subject).getKeys(subject);
+      const subjectType = expect.findTypeOf(subject);
+      const subjectKeys = subjectType.getKeys(subject);
       subjectKeys.forEach(key => {
         keysInSubject[key] = true;
       });
@@ -448,22 +453,28 @@ module.exports = expect => {
                   keys.forEach(key => {
                     keyInValue[key] = true;
                   });
-                  const subjectType = expect.findTypeOf(subject);
                   const subjectIsArrayLike = subjectType.is('array-like');
 
                   subjectType.prefix(output, subject);
                   output.nl().indentLines();
 
                   subjectKeys.forEach((key, index) => {
+                    const propertyOutput = subjectType.property(
+                      output.clone(),
+                      key,
+                      inspect(subjectType.valueForKey(subject, key)),
+                      subjectIsArrayLike
+                    );
+                    const delimiterOutput = subjectType.delimiter(
+                      output.clone(),
+                      index,
+                      subjectKeys.length
+                    );
+
                     output
                       .i()
                       .block(function() {
-                        this.property(
-                          key,
-                          inspect(subject[key]),
-                          subjectIsArrayLike
-                        );
-                        subjectType.delimiter(this, index, subjectKeys.length);
+                        this.amend(propertyOutput).amend(delimiterOutput);
                         if (!keyInValue[key]) {
                           this.sp().annotationBlock(function() {
                             this.error('should be removed');
@@ -1133,7 +1144,8 @@ module.exports = expect => {
       expect(subject, 'not to be empty');
       expect.errorMode = 'bubble';
 
-      const keys = expect.subjectType.getKeys(subject);
+      const subjectType = expect.findTypeOf(subject);
+      const keys = subjectType.getKeys(subject);
       return expect.promise
         .any(
           keys.map((key, index) => {
@@ -1146,7 +1158,11 @@ module.exports = expect => {
               expected = nextArg;
             }
             return expect.promise(() =>
-              expect(subject[key], 'to [exhaustively] satisfy', expected)
+              expect(
+                subjectType.valueForKey(subject, key),
+                'to [exhaustively] satisfy',
+                expected
+              )
             );
           })
         )
@@ -1409,29 +1425,28 @@ module.exports = expect => {
     '<array-like> to [exhaustively] satisfy <array-like>',
     (expect, subject, value) => {
       expect.errorMode = 'bubble';
-      let i;
       const subjectType = expect.subjectType;
+      const subjectKeys = subjectType.getKeys(subject);
       const valueType = expect.argTypes[0];
       const valueKeys = valueType.getKeys(value).filter(
         key =>
           utils.numericalRegExp.test(key) ||
           typeof key === 'symbol' ||
           // include keys whose value is not undefined on either LHS or RHS
-          typeof value[key] !== 'undefined' ||
-          typeof subject[key] !== 'undefined'
+          typeof valueType.valueForKey(value, key) !== 'undefined' ||
+          typeof subjectType.valueForKey(subject, key) !== 'undefined'
       );
       const keyPromises = {};
-      valueKeys.forEach(valueKey => {
-        keyPromises[valueKey] = expect.promise(() => {
-          const valueKeyType = expect.findTypeOf(value[valueKey]);
+      valueKeys.forEach(function(keyInValue) {
+        keyPromises[keyInValue] = expect.promise(function() {
+          const subjectKey = subjectType.valueForKey(subject, keyInValue);
+          const valueKey = valueType.valueForKey(value, keyInValue);
+          const valueKeyType = expect.findTypeOf(valueKey);
+
           if (valueKeyType.is('function')) {
-            return value[valueKey](subject[valueKey]);
+            return valueKey(subjectKey);
           } else {
-            return expect(
-              subject[valueKey],
-              'to [exhaustively] satisfy',
-              value[valueKey]
-            );
+            return expect(subjectKey, 'to [exhaustively] satisfy', valueKey);
           }
         });
       });
@@ -1456,7 +1471,7 @@ module.exports = expect => {
               key =>
                 utils.numericalRegExp.test(key) ||
                 typeof key === 'symbol' ||
-                typeof subject[key] !== 'undefined' ||
+                typeof subjectType.valueForKey(subject, key) !== 'undefined' ||
                 remainingKeysInSubject[key] === 2
             );
             // key checking succeeds with no outstanding keys
@@ -1464,8 +1479,9 @@ module.exports = expect => {
           }),
           expect.promise.all(keyPromises)
         ])
-        .caught(() =>
-          expect.promise.settle(keyPromises).then(() => {
+        .caught(() => {
+          let i = 0;
+          return expect.promise.settle(keyPromises).then(() => {
             const toSatisfyMatrix = new Array(subject.length);
             for (i = 0; i < subject.length; i += 1) {
               toSatisfyMatrix[i] = new Array(value.length);
@@ -1507,16 +1523,21 @@ module.exports = expect => {
             }
 
             let isAsync = false;
+            const subjectElements = utils.duplicateArrayLikeUsingType(
+              subject,
+              subjectType
+            );
+            const valueElements = utils.duplicateArrayLikeUsingType(
+              value,
+              valueType
+            );
             const nonNumericalKeysAndSymbols =
               !subjectType.numericalPropertiesOnly &&
-              utils.uniqueNonNumericalStringsAndSymbols(
-                subjectType.getKeys(subject),
-                valueType.getKeys(value)
-              );
+              utils.uniqueNonNumericalStringsAndSymbols(subjectKeys, valueKeys);
 
             const changes = arrayChanges(
-              subject,
-              value,
+              subjectElements,
+              valueElements,
               function equal(a, b, aIndex, bIndex) {
                 toSatisfyMatrix[aIndex] = toSatisfyMatrix[aIndex] || [];
                 const existingResult = toSatisfyMatrix[aIndex][bIndex];
@@ -1619,103 +1640,102 @@ module.exports = expect => {
                       } else {
                         return output.clone().block(function() {
                           if (type === 'moveSource') {
-                            this.property(
+                            const propertyOutput = subjectType.property(
+                              output.clone(),
                               diffItem.actualIndex,
                               inspect(diffItem.value),
                               true
-                            )
-                              .amend(delimiterOutput.sp())
+                            );
+
+                            this.append(propertyOutput)
+                              .append(delimiterOutput)
+                              .sp()
                               .error('// should be moved');
                           } else if (type === 'insert') {
                             this.annotationBlock(function() {
-                              const index =
-                                typeof diffItem.actualIndex !== 'undefined'
-                                  ? diffItem.actualIndex
-                                  : diffItem.expectedIndex;
                               if (
                                 expect.findTypeOf(diffItem.value).is('function')
                               ) {
-                                this.error('missing: ').property(
-                                  index,
-                                  output.clone().block(function() {
-                                    this.omitSubject = undefined;
-                                    const promise =
-                                      keyPromises[diffItem.expectedIndex];
-                                    if (promise.isRejected()) {
-                                      this.appendErrorMessage(promise.reason());
-                                    } else {
-                                      this.appendInspected(diffItem.value);
-                                    }
-                                  }),
-                                  true
-                                );
+                                this.error('missing: ').block(function() {
+                                  this.omitSubject = undefined;
+                                  const promise =
+                                    keyPromises[diffItem.expectedIndex];
+                                  if (promise.isRejected()) {
+                                    this.appendErrorMessage(promise.reason());
+                                  } else {
+                                    this.appendInspected(diffItem.value);
+                                  }
+                                });
                               } else {
-                                this.error('missing ').property(
+                                const index =
+                                  typeof diffItem.actualIndex !== 'undefined'
+                                    ? diffItem.actualIndex
+                                    : diffItem.expectedIndex;
+                                const propertyOutput = subjectType.property(
+                                  output.clone(),
                                   index,
                                   inspect(diffItem.value),
                                   true
                                 );
+                                this.error('missing ').append(propertyOutput);
                               }
                             });
                           } else {
-                            this.property(
+                            const propertyOutput = subjectType.property(
+                              output.clone(),
                               diffItem.actualIndex,
-                              output.clone().block(function() {
-                                if (type === 'remove') {
-                                  this.append(
-                                    inspect(diffItem.value)
-                                      .amend(delimiterOutput.sp())
-                                      .error('// should be removed')
-                                  );
-                                } else if (type === 'equal') {
-                                  this.append(
-                                    inspect(diffItem.value).amend(
-                                      delimiterOutput
-                                    )
+                              inspect(diffItem.value),
+                              true
+                            );
+
+                            this.block(function() {
+                              if (type === 'remove') {
+                                this.append(propertyOutput)
+                                  .append(delimiterOutput)
+                                  .sp()
+                                  .error('// should be removed');
+                              } else if (type === 'equal') {
+                                this.append(propertyOutput).append(
+                                  delimiterOutput
+                                );
+                              } else {
+                                const toSatisfyResult =
+                                  toSatisfyMatrix[diffItem.actualIndex][
+                                    diffItem.expectedIndex
+                                  ];
+                                const valueDiff =
+                                  toSatisfyResult &&
+                                  toSatisfyResult !== true &&
+                                  toSatisfyResult.getDiff({
+                                    output: output.clone()
+                                  });
+                                if (valueDiff && valueDiff.inline) {
+                                  this.append(valueDiff).append(
+                                    delimiterOutput
                                   );
                                 } else {
-                                  const toSatisfyResult =
-                                    toSatisfyMatrix[diffItem.actualIndex][
-                                      diffItem.expectedIndex
-                                    ];
-                                  const valueDiff =
-                                    toSatisfyResult &&
-                                    toSatisfyResult !== true &&
-                                    toSatisfyResult.getDiff({
-                                      output: output.clone()
-                                    });
-                                  if (valueDiff && valueDiff.inline) {
-                                    this.append(
-                                      valueDiff.amend(delimiterOutput)
-                                    );
-                                  } else {
-                                    this.append(
-                                      inspect(diffItem.value).amend(
-                                        delimiterOutput
-                                      )
-                                    )
-                                      .sp()
-                                      .annotationBlock(function() {
-                                        this.omitSubject = diffItem.value;
-                                        const label = toSatisfyResult.getLabel();
-                                        if (label) {
-                                          this.error(label)
-                                            .sp()
-                                            .block(inspect(diffItem.expected));
-                                          if (valueDiff) {
-                                            this.nl(2).append(valueDiff);
-                                          }
-                                        } else {
-                                          this.appendErrorMessage(
-                                            toSatisfyResult
-                                          );
+                                  this.append(propertyOutput)
+                                    .append(delimiterOutput)
+                                    .sp()
+                                    .annotationBlock(function() {
+                                      this.omitSubject = diffItem.value;
+                                      const label = toSatisfyResult.getLabel();
+                                      if (label) {
+                                        this.error(label)
+                                          .sp()
+                                          .block(inspect(diffItem.expected));
+                                        if (valueDiff) {
+                                          this.nl(2).append(valueDiff);
                                         }
-                                      });
-                                  }
+                                      } else {
+                                        this.appendErrorMessage(
+                                          toSatisfyResult
+                                        );
+                                      }
+                                    });
                                 }
-                              }),
-                              true
-                            );
+                              }
+                            });
                           }
                         });
                       }
@@ -1737,8 +1757,8 @@ module.exports = expect => {
                 }
               });
             }
-          })
-        );
+          });
+        });
     }
   );
 
@@ -1772,18 +1792,16 @@ module.exports = expect => {
 
       keys.forEach((key, index) => {
         promiseByKey[key] = expect.promise(() => {
-          const valueKeyType = expect.findTypeOf(value[key]);
+          const subjectKey = subjectType.valueForKey(subject, key);
+          const valueKey = valueType.valueForKey(value, key);
+          const valueKeyType = expect.findTypeOf(valueKey);
           if (valueKeyType.is('expect.it')) {
             expect.context.thisObject = subject;
-            return value[key](subject[key], expect.context);
+            return valueKey(subjectKey, expect.context);
           } else if (valueKeyType.is('function')) {
-            return value[key](subject[key]);
+            return valueKey(subjectKey);
           } else {
-            return expect(
-              subject[key],
-              'to [exhaustively] satisfy',
-              value[key]
-            );
+            return expect(subjectKey, 'to [exhaustively] satisfy', valueKey);
           }
         });
       });
@@ -1795,13 +1813,14 @@ module.exports = expect => {
               const nonOwnKeysWithDefinedValues = keys.filter(
                 key =>
                   !Object.prototype.hasOwnProperty.call(subject, key) &&
-                  typeof subject[key] !== 'undefined'
+                  typeof subjectType.valueForKey(subject, key) !== 'undefined'
               );
               const valueKeysWithDefinedValues = keys.filter(
-                key => typeof value[key] !== 'undefined'
+                key => typeof valueType.valueForKey(value, key) !== 'undefined'
               );
               const subjectKeysWithDefinedValues = subjectKeys.filter(
-                key => typeof subject[key] !== 'undefined'
+                key =>
+                  typeof subjectType.valueForKey(subject, key) !== 'undefined'
               );
               expect(
                 valueKeysWithDefinedValues.length -
@@ -1819,16 +1838,16 @@ module.exports = expect => {
               diff(output, diff, inspect, equal) {
                 output.inline = true;
                 const subjectIsArrayLike = subjectType.is('array-like');
-                const keys = utils
-                  .uniqueStringsAndSymbols(
-                    subjectKeys,
-                    valueType.getKeys(value)
-                  )
-                  .filter(
-                    (
-                      key // Skip missing keys expected to be missing so they don't get rendered in the diff
-                    ) => key in subject || typeof value[key] !== 'undefined'
-                  );
+                const valueKeys = valueType.getKeys(value);
+                const keys = subjectType
+                  .uniqueKeys(subjectKeys, valueKeys)
+                  .filter(key => {
+                    // Skip missing keys expected to be missing so they don't get rendered in the diff
+                    return (
+                      subjectType.hasKey(subject, key) ||
+                      typeof valueType.valueForKey(value, key) !== 'undefined'
+                    );
+                  });
                 const prefixOutput = subjectType.prefix(
                   output.clone(),
                   subject
@@ -1839,6 +1858,8 @@ module.exports = expect => {
                   output.indentLines();
                 }
                 keys.forEach((key, index) => {
+                  const subjectKey = subjectType.valueForKey(subject, key);
+                  const valueKey = valueType.valueForKey(value, key);
                   output
                     .nl(index > 0 ? 1 : 0)
                     .i()
@@ -1858,19 +1879,20 @@ module.exports = expect => {
                       }
 
                       const missingArrayIndex =
-                        subjectType.is('array-like') && !(key in subject);
+                        subjectType.is('array-like') &&
+                        !subjectType.hasKey(subject, key);
 
                       let isInlineDiff = true;
 
-                      output.omitSubject = subject[key];
-                      if (!(key in value)) {
+                      output.omitSubject = subjectKey;
+                      if (!valueType.hasKey(value, key)) {
                         if (expect.flags.exhaustively) {
                           annotation.error('should be removed');
                         } else {
                           conflicting = null;
                         }
-                      } else if (!(key in subject)) {
-                        if (expect.findTypeOf(value[key]).is('function')) {
+                      } else if (!subjectType.hasKey(subject, key)) {
+                        if (expect.findTypeOf(valueKey).is('function')) {
                           if (promiseByKey[key].isRejected()) {
                             output.error('// missing:').sp();
                             valueOutput = output
@@ -1886,7 +1908,7 @@ module.exports = expect => {
                           }
                         } else {
                           output.error('// missing').sp();
-                          valueOutput = inspect(value[key]);
+                          valueOutput = inspect(valueKey);
                         }
                       } else if (conflicting || missingArrayIndex) {
                         const keyDiff =
@@ -1897,7 +1919,7 @@ module.exports = expect => {
                         }
                         if (keyDiff && keyDiff.inline) {
                           valueOutput = keyDiff;
-                        } else if (typeof value[key] === 'function') {
+                        } else if (typeof valueKey === 'function') {
                           isInlineDiff = false;
                           annotation.appendErrorMessage(conflicting);
                         } else if (!keyDiff || (keyDiff && !keyDiff.inline)) {
@@ -1907,7 +1929,7 @@ module.exports = expect => {
                                 'should satisfy'
                             )
                             .sp()
-                            .block(inspect(value[key]));
+                            .block(inspect(valueKey));
 
                           if (keyDiff) {
                             annotation.nl(2).append(keyDiff);
@@ -1918,10 +1940,13 @@ module.exports = expect => {
                       }
 
                       if (!valueOutput) {
-                        if (missingArrayIndex || !(key in subject)) {
+                        if (
+                          missingArrayIndex ||
+                          !subjectType.hasKey(subject, key)
+                        ) {
                           valueOutput = output.clone();
                         } else {
-                          valueOutput = inspect(subject[key]);
+                          valueOutput = inspect(subjectKey);
                         }
                       }
 
@@ -1929,13 +1954,12 @@ module.exports = expect => {
                         missingArrayIndex || index >= subjectKeys.length - 1;
 
                       if (!omitDelimiter) {
-                        valueOutput.amend(
-                          subjectType.delimiter(
-                            output.clone(),
-                            index,
-                            keys.length
-                          )
+                        const delimiterOutput = subjectType.delimiter(
+                          output.clone(),
+                          index,
+                          keys.length
                         );
+                        valueOutput.amend(delimiterOutput);
                       }
 
                       const annotationOnNextLine =
@@ -1963,7 +1987,14 @@ module.exports = expect => {
                         valueOutput = output.clone().block(valueOutput);
                       }
 
-                      this.property(key, valueOutput, subjectIsArrayLike);
+                      const propertyOutput = subjectType.property(
+                        output.clone(),
+                        key,
+                        valueOutput,
+                        subjectIsArrayLike
+                      );
+
+                      this.append(propertyOutput);
                     });
                 });
 
diff --git a/lib/styles.js b/lib/styles.js
index 8ba347f0..32efe916 100644
--- a/lib/styles.js
+++ b/lib/styles.js
@@ -122,7 +122,11 @@ module.exports = expect => {
       .jsString("'");
   });
 
-  expect.addStyle('property', function(key, inspectedValue, isArrayLike) {
+  expect.addStyle('propertyForObject', function(
+    key,
+    inspectedValue,
+    isArrayLike
+  ) {
     let keyOmitted = false;
     let isSymbol;
     isSymbol = typeof key === 'symbol';
diff --git a/lib/types.js b/lib/types.js
index 298d959f..c4ce3c8e 100644
--- a/lib/types.js
+++ b/lib/types.js
@@ -71,38 +71,6 @@ module.exports = function(expect) {
     });
   }
 
-  // If Symbol support is not detected, default to passing undefined to
-  // Array.prototype.sort, which means "natural" (asciibetical) sort.
-  let keyComparator;
-  if (typeof Symbol === 'function') {
-    // Comparator that puts symbols last:
-    keyComparator = (a, b) => {
-      let aIsSymbol, bIsSymbol;
-      let aString = a;
-      let bString = b;
-      aIsSymbol = typeof a === 'symbol';
-      bIsSymbol = typeof b === 'symbol';
-      if (aIsSymbol) {
-        if (bIsSymbol) {
-          aString = a.toString();
-          bString = b.toString();
-        } else {
-          return 1;
-        }
-      } else if (bIsSymbol) {
-        return -1;
-      }
-
-      if (aString < bString) {
-        return -1;
-      } else if (aString > bString) {
-        return 1;
-      }
-
-      return 0;
-    };
-  }
-
   expect.addType({
     name: 'object',
     indent: true,
@@ -122,6 +90,9 @@ module.exports = function(expect) {
       }
       return output.text('{');
     },
+    property(output, key, inspectedValue, isArrayLike) {
+      return output.propertyForObject(key, inspectedValue, isArrayLike);
+    },
     suffix(output, obj) {
       output.text('}');
       const constructor = obj.constructor;
@@ -152,6 +123,36 @@ module.exports = function(expect) {
           }
         }
       : Object.keys,
+    // If Symbol support is not detected default to undefined which, when
+    // passed to Array.prototype.sort, means "natural" (asciibetical) sort.
+    keyComparator:
+      typeof Symbol === 'function'
+        ? (a, b) => {
+            let aIsSymbol, bIsSymbol;
+            let aString = a;
+            let bString = b;
+            aIsSymbol = typeof a === 'symbol';
+            bIsSymbol = typeof b === 'symbol';
+            if (aIsSymbol) {
+              if (bIsSymbol) {
+                aString = a.toString();
+                bString = b.toString();
+              } else {
+                return 1;
+              }
+            } else if (bIsSymbol) {
+              return -1;
+            }
+
+            if (aString < bString) {
+              return -1;
+            } else if (aString > bString) {
+              return 1;
+            }
+
+            return 0;
+          }
+        : undefined,
     equal(a, b, equal) {
       if (a === b) {
         return true;
@@ -162,10 +163,10 @@ module.exports = function(expect) {
       }
 
       const actualKeys = this.getKeys(a).filter(
-        key => typeof a[key] !== 'undefined'
+        key => typeof this.valueForKey(a, key) !== 'undefined'
       );
       const expectedKeys = this.getKeys(b).filter(
-        key => typeof b[key] !== 'undefined'
+        key => typeof this.valueForKey(b, key) !== 'undefined'
       );
 
       // having the same number of owned properties (keys incorporates hasOwnProperty)
@@ -173,8 +174,8 @@ module.exports = function(expect) {
         return false;
       }
       //the same set of keys (although not necessarily the same order),
-      actualKeys.sort(keyComparator);
-      expectedKeys.sort(keyComparator);
+      actualKeys.sort(this.keyComparator);
+      expectedKeys.sort(this.keyComparator);
       // cheap key test
       for (let i = 0; i < actualKeys.length; i += 1) {
         if (actualKeys[i] !== expectedKeys[i]) {
@@ -186,12 +187,15 @@ module.exports = function(expect) {
       // possibly expensive deep test
       for (let j = 0; j < actualKeys.length; j += 1) {
         const key = actualKeys[j];
-        if (!equal(a[key], b[key])) {
+        if (!equal(this.valueForKey(a, key), this.valueForKey(b, key))) {
           return false;
         }
       }
       return true;
     },
+    hasKey(obj, key) {
+      return key in obj;
+    },
     inspect(obj, depth, output, inspect) {
       const keys = this.getKeys(obj);
       if (keys.length === 0) {
@@ -211,13 +215,18 @@ module.exports = function(expect) {
           propertyOutput.text('set').sp();
         }
         // Inspect the setter function if there's no getter:
-        const value = hasSetter && !hasGetter ? hasSetter : obj[key];
+        let value;
+        if (hasSetter && !hasGetter) {
+          value = hasSetter;
+        } else {
+          value = type.valueForKey(obj, key);
+        }
 
         let inspectedValue = inspect(value);
         if (value && value._expectIt) {
           inspectedValue = output.clone().block(inspectedValue);
         }
-        propertyOutput.property(key, inspectedValue);
+        type.property(propertyOutput, key, inspectedValue);
 
         propertyOutput.amend(
           type.delimiter(output.clone(), index, keys.length)
@@ -318,10 +327,8 @@ module.exports = function(expect) {
 
       output.inline = true;
       const actualKeys = this.getKeys(actual);
-      const keys = utils.uniqueStringsAndSymbols(
-        actualKeys,
-        this.getKeys(expected)
-      );
+      const expectedKeys = this.getKeys(expected);
+      const keys = this.uniqueKeys(actualKeys, expectedKeys);
       const prefixOutput = this.prefix(output.clone(), actual);
       output.append(prefixOutput).nl(prefixOutput.isEmpty() ? 0 : 1);
 
@@ -334,22 +341,24 @@ module.exports = function(expect) {
           .nl(index > 0 ? 1 : 0)
           .i()
           .block(function() {
-            let valueOutput;
+            const valueActual = type.valueForKey(actual, key);
+            const valueExpected = type.valueForKey(expected, key);
             const annotation = output.clone();
-            const conflicting = !equal(actual[key], expected[key]);
+            const conflicting = !equal(valueActual, valueExpected);
             let isInlineDiff = false;
+            let valueOutput;
             if (conflicting) {
               if (!(key in expected)) {
                 annotation.error('should be removed');
                 isInlineDiff = true;
               } else if (!(key in actual)) {
                 this.error('// missing').sp();
-                valueOutput = output.clone().appendInspected(expected[key]);
+                valueOutput = output.clone().appendInspected(valueExpected);
                 isInlineDiff = true;
               } else {
-                const keyDiff = diff(actual[key], expected[key]);
+                const keyDiff = diff(valueActual, valueExpected);
                 if (!keyDiff || (keyDiff && !keyDiff.inline)) {
-                  annotation.shouldEqualError(expected[key]);
+                  annotation.shouldEqualError(valueExpected);
                   if (keyDiff) {
                     annotation.nl(2).append(keyDiff);
                   }
@@ -363,7 +372,7 @@ module.exports = function(expect) {
             }
 
             if (!valueOutput) {
-              valueOutput = inspect(actual[key], conflicting ? Infinity : null);
+              valueOutput = inspect(valueActual, conflicting ? Infinity : null);
             }
 
             valueOutput.amend(
@@ -372,7 +381,7 @@ module.exports = function(expect) {
             if (!isInlineDiff) {
               valueOutput = output.clone().block(valueOutput);
             }
-            this.property(key, valueOutput);
+            type.property(this, key, valueOutput);
             if (!annotation.isEmpty()) {
               this.sp().annotationBlock(annotation);
             }
@@ -385,7 +394,6 @@ module.exports = function(expect) {
       const suffixOutput = this.suffix(output.clone(), actual);
       return output.nl(suffixOutput.isEmpty() ? 0 : 1).append(suffixOutput);
     },
-
     similar(a, b) {
       if (a === null || b === null) {
         return false;
@@ -417,11 +425,15 @@ module.exports = function(expect) {
         Math.max(aKeys.length, bKeys.length) / 2
       );
       return aKeys.concat(bKeys).some(key => {
-        if (key in a && key in b) {
+        if (this.hasKey(a, key) && this.hasKey(b, key)) {
           numberOfSimilarKeys += 1;
         }
         return numberOfSimilarKeys >= requiredSimilarKeys;
       });
+    },
+    uniqueKeys: utils.uniqueStringsAndSymbols,
+    valueForKey(obj, key) {
+      return obj[key];
     }
   });
 
@@ -482,30 +494,36 @@ module.exports = function(expect) {
 
         // compare numerically indexed elements
         for (i = 0; i < a.length; i += 1) {
-          if (!equal(a[i], b[i])) {
+          if (!equal(this.valueForKey(a, i), this.valueForKey(b, i))) {
             return false;
           }
         }
 
         // compare non-numerical keys if enabled for the type
         if (!this.numericalPropertiesOnly) {
-          const aKeys = this.getKeysNonNumerical(a).filter(
-            (
-              key // include keys whose value is not undefined
-            ) => typeof a[key] !== 'undefined'
-          );
-          const bKeys = this.getKeysNonNumerical(b).filter(
-            (
-              key // include keys whose value is not undefined on either LHS or RHS
-            ) => typeof b[key] !== 'undefined' || typeof a[key] !== 'undefined'
-          );
+          const aKeys = this.getKeysNonNumerical(a).filter(key => {
+            // include keys whose value is not undefined
+            return typeof this.valueForKey(a, key) !== 'undefined';
+          });
+          const bKeys = this.getKeysNonNumerical(b).filter(key => {
+            // include keys whose value is not undefined on either LHS or RHS
+            return (
+              typeof this.valueForKey(b, key) !== 'undefined' ||
+              typeof this.valueForKey(a, key) !== 'undefined'
+            );
+          });
 
           if (aKeys.length !== bKeys.length) {
             return false;
           }
 
           for (i = 0; i < aKeys.length; i += 1) {
-            if (!equal(a[aKeys[i]], b[aKeys[i]])) {
+            if (
+              !equal(
+                this.valueForKey(a, aKeys[i]),
+                this.valueForKey(b, bKeys[i])
+              )
+            ) {
               return false;
             }
           }
@@ -539,8 +557,8 @@ module.exports = function(expect) {
 
       const inspectedItems = keys.map(key => {
         let inspectedValue;
-        if (key in arr) {
-          inspectedValue = inspect(arr[key]);
+        if (this.hasKey(arr, key)) {
+          inspectedValue = inspect(this.valueForKey(arr, key));
         } else if (utils.numericalRegExp.test(key)) {
           // Sparse array entry
           inspectedValue = output.clone();
@@ -548,7 +566,7 @@ module.exports = function(expect) {
           // Not present non-numerical property returned by getKeys
           inspectedValue = inspect(undefined);
         }
-        return output.clone().property(key, inspectedValue, true);
+        return this.property(output.clone(), key, inspectedValue, true);
       });
 
       const currentDepth = defaultDepth - Math.min(defaultDepth, depth);
@@ -566,9 +584,8 @@ module.exports = function(expect) {
           width += size.width;
           return width > maxLineLength;
         });
-      const type = this;
       inspectedItems.forEach((inspectedItem, index) => {
-        inspectedItem.amend(type.delimiter(output.clone(), index, keys.length));
+        inspectedItem.amend(this.delimiter(output.clone(), index, keys.length));
       });
       if (multipleLines) {
         output.append(prefixOutput);
@@ -626,17 +643,18 @@ module.exports = function(expect) {
         output.indentLines();
       }
 
-      const nonNumericalKeysAndSymbols =
+      var actualElements = utils.duplicateArrayLikeUsingType(actual, this);
+      var actualKeys = this.getKeys(actual);
+      var expectedElements = utils.duplicateArrayLikeUsingType(expected, this);
+      var expectedKeys = this.getKeys(expected);
+      var nonNumericalKeysAndSymbols =
         !this.numericalPropertiesOnly &&
-        utils.uniqueNonNumericalStringsAndSymbols(
-          this.getKeys(actual),
-          this.getKeys(expected)
-        );
+        utils.uniqueNonNumericalStringsAndSymbols(actualKeys, expectedKeys);
 
       const type = this;
       const changes = arrayChanges(
-        actual,
-        expected,
+        actualElements,
+        expectedElements,
         equal,
         (a, b) => type.similar(a, b),
         {
@@ -662,12 +680,15 @@ module.exports = function(expect) {
           } else {
             return output.clone().block(function() {
               if (diffItem.type === 'moveSource') {
-                this.property(
+                const propertyOutput = type.property(
+                  output.clone(),
                   diffItem.actualIndex,
                   inspect(diffItem.value),
                   true
-                )
-                  .amend(delimiterOutput.sp())
+                );
+                this.amend(propertyOutput)
+                  .amend(delimiterOutput)
+                  .sp()
                   .error('// should be moved');
               } else if (diffItem.type === 'insert') {
                 this.annotationBlock(function() {
@@ -676,53 +697,60 @@ module.exports = function(expect) {
                       typeof diffItem.actualIndex !== 'undefined'
                         ? diffItem.actualIndex
                         : diffItem.expectedIndex;
-                    this.property(index, inspect(diffItem.value), true);
+                    const propertyOutput = type.property(
+                      output.clone(),
+                      index,
+                      inspect(diffItem.value),
+                      true
+                    );
+                    this.amend(propertyOutput);
                   });
                 });
               } else if (diffItem.type === 'remove') {
                 this.block(function() {
-                  this.property(
+                  const propertyOutput = type.property(
+                    output.clone(),
                     diffItem.actualIndex,
                     inspect(diffItem.value),
                     true
-                  )
-                    .amend(delimiterOutput.sp())
+                  );
+                  this.amend(propertyOutput)
+                    .amend(delimiterOutput)
+                    .sp()
                     .error('// should be removed');
                 });
               } else if (diffItem.type === 'equal') {
                 this.block(function() {
-                  this.property(
+                  const propertyOutput = type.property(
+                    output.clone(),
                     diffItem.actualIndex,
                     inspect(diffItem.value),
                     true
-                  ).amend(delimiterOutput);
+                  );
+                  this.amend(propertyOutput).amend(delimiterOutput);
                 });
               } else {
                 this.block(function() {
                   const valueDiff = diff(diffItem.value, diffItem.expected);
-                  this.property(
-                    diffItem.actualIndex,
-                    output.clone().block(function() {
-                      if (valueDiff && valueDiff.inline) {
-                        this.append(valueDiff.amend(delimiterOutput));
-                      } else if (valueDiff) {
-                        this.append(
-                          inspect(diffItem.value).amend(delimiterOutput.sp())
-                        ).annotationBlock(function() {
-                          this.shouldEqualError(diffItem.expected, inspect)
-                            .nl(2)
-                            .append(valueDiff);
-                        });
-                      } else {
-                        this.append(
-                          inspect(diffItem.value).amend(delimiterOutput.sp())
-                        ).annotationBlock(function() {
-                          this.shouldEqualError(diffItem.expected, inspect);
-                        });
-                      }
-                    }),
-                    true
-                  );
+                  if (valueDiff && valueDiff.inline) {
+                    this.append(valueDiff).append(delimiterOutput);
+                  } else {
+                    const propertyOutput = type.property(
+                      output.clone(),
+                      diffItem.actualIndex,
+                      inspect(diffItem.value),
+                      true
+                    );
+                    this.append(propertyOutput)
+                      .append(delimiterOutput)
+                      .sp()
+                      .annotationBlock(function() {
+                        this.shouldEqualError(diffItem.expected, inspect);
+                        if (valueDiff) {
+                          this.nl(2).append(valueDiff);
+                        }
+                      });
+                  }
                 });
               }
             });
diff --git a/lib/utils.js b/lib/utils.js
index de8b8542..6b8217f8 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -27,6 +27,40 @@ const utils = (module.exports = {
       return a === b;
     }),
 
+  duplicateArrayLikeUsingType(obj, type) {
+    const keys = type.getKeys(obj);
+
+    let numericalKeyLength = keys.length;
+    if (!type.numericalPropertiesOnly) {
+      let nonNumericalKeyLength = 0;
+      // find non-numerical keys in reverse order to keep iteration minimal
+      for (let i = keys.length - 1; i > -1; i -= 1) {
+        const key = keys[i];
+        if (typeof key === 'symbol' || !utils.numericalRegExp.test(key)) {
+          nonNumericalKeyLength += 1;
+        } else {
+          break;
+        }
+      }
+      // remove non-numerical keys to ensure the copy is sized correctly
+      numericalKeyLength -= nonNumericalKeyLength;
+    }
+
+    const arr = new Array(numericalKeyLength);
+
+    keys.forEach(function(key, index) {
+      const isNonNumericKey = index >= numericalKeyLength;
+      if (isNonNumericKey && !type.hasKey(obj, key)) {
+        // do not add non-numerical keys that are not actually attached
+        // to the array-like to ensure they will be treated as "missing"
+        return;
+      }
+      arr[key] = type.hasKey(obj, key) ? type.valueForKey(obj, key) : undefined;
+    });
+
+    return arr;
+  },
+
   isArray(ar) {
     return Object.prototype.toString.call(ar) === '[object Array]';
   },


Test patch:
diff --git a/test/assertions/to-have-a-value-satisfying.spec.js b/test/assertions/to-have-a-value-satisfying.spec.js
index eeb0782e..8d39b165 100644
--- a/test/assertions/to-have-a-value-satisfying.spec.js
+++ b/test/assertions/to-have-a-value-satisfying.spec.js
@@ -157,4 +157,33 @@ describe('to have a value satisfying assertion', function() {
       );
     });
   });
+
+  describe('with a subtype that overrides valueForKey()', function() {
+    var clonedExpect = expect.clone();
+
+    clonedExpect.addType({
+      name: 'oneFooObject',
+      base: 'object',
+      identify: function(obj) {
+        return obj && typeof 'object' && obj.foo === '';
+      },
+      valueForKey: function(obj, key) {
+        if (key === 'foo') {
+          return 1;
+        }
+        return obj[key];
+      }
+    });
+
+    it('should process the value in "to have a value satisfying"', function() {
+      expect(
+        clonedExpect(
+          { foo: '' },
+          'to have a value satisfying',
+          expect.it('to be a number')
+        ),
+        'to be fulfilled'
+      );
+    });
+  });
 });
diff --git a/test/assertions/to-have-keys.spec.js b/test/assertions/to-have-keys.spec.js
index ea1daf8a..d3113a73 100644
--- a/test/assertions/to-have-keys.spec.js
+++ b/test/assertions/to-have-keys.spec.js
@@ -51,4 +51,39 @@ describe('to have keys assertion', function() {
   it('should work with non-enumerable keys returned by the getKeys function of the subject type', function() {
     expect(new Error('foo'), 'to only have key', 'message');
   });
+
+  describe('with a subtype that overrides valueForKey()', function() {
+    var clonedExpect = expect.clone();
+
+    clonedExpect.addType({
+      name: 'upperFooObject',
+      base: 'object',
+      identify: function(obj) {
+        return obj && typeof 'object' && obj.foo === '';
+      },
+      valueForKey: function(obj, key) {
+        if (key === 'foo') {
+          return 'FOO';
+        }
+        return obj[key];
+      }
+    });
+
+    it('should process the value in "to only have keys"', function() {
+      expect(
+        function() {
+          clonedExpect({ oof: undefined, foo: '' }, 'to only have keys', [
+            'oof'
+          ]);
+        },
+        'to throw',
+        "expected { oof: undefined, foo: 'FOO' } to only have keys [ 'oof' ]\n" +
+          '\n' +
+          '{\n' +
+          '  oof: undefined,\n' +
+          "  foo: 'FOO' // should be removed\n" +
+          '}'
+      );
+    });
+  });
 });
diff --git a/test/assertions/to-have-property.spec.js b/test/assertions/to-have-property.spec.js
index 26b553ce..6c255fc4 100644
--- a/test/assertions/to-have-property.spec.js
+++ b/test/assertions/to-have-property.spec.js
@@ -157,4 +157,30 @@ describe('to have property assertion', function() {
         '    <object|function> [not] to have own property <string>'
     );
   });
+
+  describe('with a subtype that overrides valueForKey()', function() {
+    var clonedExpect = expect.clone();
+
+    clonedExpect.addType({
+      name: 'upperCaseObject',
+      base: 'object',
+      identify: function(obj) {
+        return obj && typeof 'object';
+      },
+      valueForKey: function(obj, key) {
+        if (typeof obj[key] === 'string') {
+          return obj[key].toUpperCase();
+        }
+        return obj[key];
+      }
+    });
+
+    it('should process the value in "to have property"', function() {
+      expect(
+        clonedExpect({ foo: 'bAr' }, 'to have property', 'foo'),
+        'to be fulfilled with',
+        'BAR'
+      );
+    });
+  });
 });
diff --git a/test/assertions/to-satisfy.spec.js b/test/assertions/to-satisfy.spec.js
index bb613708..458b29d1 100644
--- a/test/assertions/to-satisfy.spec.js
+++ b/test/assertions/to-satisfy.spec.js
@@ -93,7 +93,7 @@ describe('to satisfy assertion', function() {
   });
 
   describe('with an array satisfied against an array', function() {
-    it('should render missing items nicely', function() {
+    it('should render missing number items nicely', function() {
       expect(
         function() {
           expect([], 'to satisfy', [1, 2]);
@@ -108,6 +108,63 @@ describe('to satisfy assertion', function() {
       );
     });
 
+    it('should render missing object items nicely', function() {
+      expect(
+        function() {
+          expect([], 'to satisfy', [{ foo: true }, { baz: false }]);
+        },
+        'to throw',
+        'expected [] to satisfy [ { foo: true }, { baz: false } ]\n' +
+          '\n' +
+          '[\n' +
+          '  // missing { foo: true }\n' +
+          '  // missing { baz: false }\n' +
+          ']'
+      );
+    });
+
+    it('should render missing custom items nicely', function() {
+      var clonedExpect = expect.clone();
+
+      clonedExpect.addStyle('xuuqProperty', function(key, inspectedValue) {
+        this.text('<')
+          .appendInspected(key)
+          .text('> --> ')
+          .append(inspectedValue);
+      });
+
+      clonedExpect.addType({
+        name: 'xuuq',
+        base: 'object',
+        identify: function(obj) {
+          return obj && typeof 'object' && obj.quux === 'xuuq';
+        },
+        property: function(output, key, inspectedValue) {
+          return output.xuuqProperty(key, inspectedValue);
+        }
+      });
+
+      expect(
+        function() {
+          clonedExpect([], 'to satisfy', [
+            { quux: 'xuuq', foo: true },
+            { quux: 'xuuq', baz: false }
+          ]);
+        },
+        'to throw',
+        'expected [] to satisfy\n' +
+          '[\n' +
+          "  { <'quux'> --> 'xuuq', <'foo'> --> true },\n" +
+          "  { <'quux'> --> 'xuuq', <'baz'> --> false }\n" +
+          ']\n' +
+          '\n' +
+          '[\n' +
+          "  // missing { <'quux'> --> 'xuuq', <'foo'> --> true }\n" +
+          "  // missing { <'quux'> --> 'xuuq', <'baz'> --> false }\n" +
+          ']'
+      );
+    });
+
     it('should fall back to comparing index-by-index if one of the arrays has more than 10 entries', function() {
       expect(
         function() {
diff --git a/test/types/array-like-type.spec.js b/test/types/array-like-type.spec.js
index 653657e8..3605f353 100644
--- a/test/types/array-like-type.spec.js
+++ b/test/types/array-like-type.spec.js
@@ -372,6 +372,179 @@ describe('array-like type', function() {
           ']'
       );
     });
+
+    it('should honour the precise list of keys returned by getKeys in "to satisfy"', () => {
+      var clonedExpect = expect.clone();
+
+      clonedExpect.addType({
+        name: 'foo',
+        base: 'array-like',
+        identify: function(obj) {
+          return obj && obj._isFoo;
+        },
+        numericalPropertiesOnly: false,
+        getKeys: function(obj) {
+          var keys = this.baseType.getKeys(obj);
+          var fooIndex = keys.indexOf('_isFoo');
+          if (fooIndex > -1) {
+            keys = keys.splice(fooIndex, 1);
+          }
+          keys.push('bar');
+          return keys;
+        }
+      });
+
+      var foo1 = ['hey', 'there'];
+      foo1._isFoo = true;
+      Object.defineProperty(foo1, 'bar', {
+        value: 123,
+        enumerable: false
+      });
+      var foo2 = ['hey', 'there'];
+      foo2._isFoo = true;
+      Object.defineProperty(foo2, 'bar', {
+        value: 456,
+        enumerable: false
+      });
+
+      expect(
+        function() {
+          clonedExpect(foo1, 'to satisfy', foo2);
+        },
+        'to throw',
+        "expected [ 'hey', 'there', bar: 123 ] to satisfy [ 'hey', 'there', bar: 456 ]\n" +
+          '\n' +
+          '[\n' +
+          "  'hey',\n" +
+          "  'there',\n" +
+          '  bar: 123 // should equal 456\n' +
+          ']'
+      );
+    });
+  });
+
+  describe('with a custom subtype that comes with its own hasKey', function() {
+    it('should honour the presence of a key within inspection', function() {
+      var clonedExpect = expect.clone().addType({
+        name: 'allExceptFoo',
+        base: 'array-like',
+        identify: Array.isArray,
+        numericalPropertiesOnly: false,
+        hasKey: function(obj, key) {
+          if (String(key).indexOf('foo') === 0) {
+            return false;
+          }
+          return obj[key];
+        }
+      });
+
+      var arr = ['a'];
+      arr.fooAndBar = true;
+
+      clonedExpect(arr, 'to inspect as', "[ 'a', fooAndBar: undefined ]");
+    });
+  });
+
+  describe('with a subtype that overrides property()', function() {
+    it('should render correctly in both inspection and diff in "to equal"', function() {
+      var clonedExpect = expect.clone();
+
+      clonedExpect.addStyle('xuuqProperty', function(key, inspectedValue) {
+        this.text('<')
+          .appendInspected(key)
+          .text('> --> ')
+          .append(inspectedValue);
+      });
+
+      clonedExpect.addType({
+        name: 'xuuq',
+        base: 'array-like',
+        numericalPropertiesOnly: false,
+        identify: function(obj) {
+          return obj && typeof 'object' && obj.quux === 'xuuq';
+        },
+        property: function(output, key, inspectedValue, isSubjectArrayLike) {
+          if (isSubjectArrayLike && !isNaN(Number(key))) {
+            return this.baseType.property(
+              output,
+              key,
+              inspectedValue,
+              isSubjectArrayLike
+            );
+          }
+          return output.xuuqProperty(key, inspectedValue);
+        }
+      });
+
+      const lhs = [1, 2, 3];
+      lhs.quux = 'xuuq';
+      lhs.foobar = 'faz';
+      lhs.missing = true;
+      const rhs = [1, 2, 4];
+      rhs.quux = 'xuuq';
+      rhs.foobar = 'baz';
+
+      expect(
+        function() {
+          clonedExpect(lhs, 'to equal', rhs);
+        },
+        'to throw',
+        'expected\n' +
+          '[\n' +
+          '  1,\n' +
+          '  2,\n' +
+          '  3,\n' +
+          "  <'quux'> --> 'xuuq',\n" +
+          "  <'foobar'> --> 'faz',\n" +
+          "  <'missing'> --> true\n" +
+          ']\n' +
+          "to equal [ 1, 2, 4, <'quux'> --> 'xuuq', <'foobar'> --> 'baz' ]\n" +
+          '\n' +
+          '[\n' +
+          '  1,\n' +
+          '  2,\n' +
+          '  3, // should equal 4\n' +
+          "  <'quux'> --> 'xuuq',\n" +
+          "  <'foobar'> --> 'faz', // should equal 'baz'\n" +
+          '                        //\n' +
+          '                        // -faz\n' +
+          '                        // +baz\n' +
+          "  <'missing'> --> true // should be removed\n" +
+          ']'
+      );
+    });
+  });
+
+  describe('with a custom subtype that comes with its own valueForKeys', function() {
+    it('should process the elements in both inspection and diff in "to equal"', function() {
+      var clonedExpect = expect.clone().addType({
+        name: 'firstElemUpper',
+        base: 'array-like',
+        identify: Array.isArray,
+        valueForKey: function(arr, key) {
+          var value = arr[key];
+          if (key === 0) {
+            return value.toUpperCase();
+          }
+          return value;
+        }
+      });
+      expect(
+        function() {
+          clonedExpect(['foobar', 'barbar'], 'to equal', ['foobar', 'barbaz']);
+        },
+        'to throw',
+        "expected [ 'FOOBAR', 'barbar' ] to equal [ 'FOOBAR', 'barbaz' ]\n" +
+          '\n' +
+          '[\n' +
+          "  'FOOBAR',\n" +
+          "  'barbar' // should equal 'barbaz'\n" +
+          '           //\n' +
+          '           // -barbar\n' +
+          '           // +barbaz\n' +
+          ']'
+      );
+    });
   });
 
   it('should inspect as [...] at depth 2+', function() {
diff --git a/test/types/object-type.spec.js b/test/types/object-type.spec.js
index 31a91af4..06f9d9c2 100644
--- a/test/types/object-type.spec.js
+++ b/test/types/object-type.spec.js
@@ -42,6 +42,132 @@ describe('object type', function() {
     });
   });
 
+  describe('#equal', function() {
+    it('should ignore undefined properties on the LHS', function() {
+      expect(function() {
+        expect({ lhs: undefined }, 'to equal', {});
+      }, 'not to throw');
+    });
+
+    it('should ignore undefined properties on the RHS', function() {
+      expect(function() {
+        expect({}, 'to equal', { rhs: undefined });
+      }, 'not to throw');
+    });
+
+    describe('with a subtype that overrides valueForKey()', function() {
+      var clonedExpect = expect.clone();
+
+      clonedExpect.addType({
+        name: 'undefinerObject',
+        base: 'object',
+        identify: function(obj) {
+          return obj && typeof 'object' && obj.xuuq;
+        },
+        valueForKey: function(obj, key) {
+          if (key !== 'xuuq') {
+            return undefined;
+          }
+          return obj[key];
+        }
+      });
+
+      it('should ignore undefined properties on the LHS', function() {
+        expect(function() {
+          expect({ xuuq: true, lhs: undefined }, 'to equal', { xuuq: true });
+        }, 'not to throw');
+      });
+
+      it('should ignore undefined properties on the RHS', function() {
+        expect(function() {
+          expect({ xuuq: true }, 'to equal', { xuuq: true, rhs: undefined });
+        }, 'not to throw');
+      });
+    });
+  });
+
+  describe('#getKeys', function() {
+    var clonedExpect = expect.clone();
+
+    clonedExpect.addType({
+      name: 'fooObject',
+      base: 'object',
+      identify: function(obj) {
+        return obj && typeof 'object' && obj.foo;
+      },
+      getKeys: function(obj) {
+        return Object.keys(obj).filter(function(key) {
+          return key[0] !== '_';
+        });
+      }
+    });
+
+    it('should restrict the compared properties', function() {
+      expect(function() {
+        clonedExpect({ foo: true, _bar: true }, 'to equal', {
+          foo: true,
+          _bar: false
+        });
+      }, 'not to throw');
+    });
+  });
+
+  describe('#similar', function() {
+    var clonedExpect = expect.clone();
+
+    clonedExpect.addType({
+      name: 'ignoreUnderscoresObject',
+      base: 'object',
+      identify(obj) {
+        return obj && typeof 'object' && obj.xuuq;
+      },
+      valueForKey(obj, key) {
+        if (key[0] === '_') {
+          return undefined;
+        }
+        return obj[key];
+      }
+    });
+
+    it('should pass with values overriding valueForKey()', function() {
+      expect(function() {
+        clonedExpect(
+          [{ xuuq: true, quux: 'foo', _bob: true }, 'foobar'],
+          'to equal',
+          [{ xuuq: true, quux: 'foo', _bob: false }, 'foobar']
+        );
+      }, 'not to throw');
+    });
+
+    it('should fail with values overriding valueForKey()', function() {
+      expect(
+        function() {
+          clonedExpect(
+            [{ xuuq: true, quux: 'bar', _bob: true }, 'foobar'],
+            'to equal',
+            ['foobar', { xuuq: true, quux: 'baz', _bob: false }]
+          );
+        },
+        'to throw',
+        "expected [ { xuuq: true, quux: 'bar', _bob: undefined }, 'foobar' ]\n" +
+          "to equal [ 'foobar', { xuuq: true, quux: 'baz', _bob: undefined } ]\n" +
+          '\n' +
+          '[\n' +
+          '┌─▷\n' +
+          '│   {\n' +
+          '│     xuuq: true,\n' +
+          "│     quux: 'bar', // should equal 'baz'\n" +
+          '│                  //\n' +
+          '│                  // -bar\n' +
+          '│                  // +baz\n' +
+          '│     _bob: undefined\n' +
+          '│   },\n' +
+          "└── 'foobar' // should be moved\n" +
+          ']'
+      );
+    });
+  });
+
   describe('with a subtype that disables indentation', function() {
     var clonedExpect = expect.clone();
 
@@ -195,4 +321,145 @@ describe('object type', function() {
       );
     });
   });
+
+  describe('with a subtype that overrides property()', function() {
+    it('should render correctly in both inspection and diff', function() {
+      var clonedExpect = expect.clone();
+
+      clonedExpect.addStyle('xuuqProperty', function(key, inspectedValue) {
+        this.text('<')
+          .appendInspected(key)
+          .text('> --> ')
+          .append(inspectedValue);
+      });
+
+      clonedExpect.addType({
+        name: 'xuuq',
+        base: 'object',
+        identify: function(obj) {
+          return obj && typeof 'object' && obj.quux === 'xuuq';
+        },
+        property: function(output, key, inspectedValue) {
+          return output.xuuqProperty(key, inspectedValue);
+        }
+      });
+
+      expect(
+        function() {
+          clonedExpect({ quux: 'xuuq', foobar: 'faz' }, 'to equal', {
+            quux: 'xuuq',
+            foobar: 'baz'
+          });
+        },
+        'to throw',
+        "expected { <'quux'> --> 'xuuq', <'foobar'> --> 'faz' }\n" +
+          "to equal { <'quux'> --> 'xuuq', <'foobar'> --> 'baz' }\n" +
+          '\n' +
+          '{\n' +
+          "  <'quux'> --> 'xuuq',\n" +
+          "  <'foobar'> --> 'faz' // should equal 'baz'\n" +
+          '                       //\n' +
+          '                       // -faz\n' +
+          '                       // +baz\n' +
+          '}'
+      );
+    });
+  });
+
+  describe('with a subtype that overrides valueForKey()', function() {
+    var clonedExpect = expect.clone();
+
+    clonedExpect.addType({
+      name: 'nineObject',
+      base: 'object',
+      identify: function(obj) {
+        return obj && typeof 'object' && obj.nine === 9;
+      },
+      valueForKey: function(obj, key) {
+        if (key === 'oof') {
+          return;
+        }
+        if (typeof obj[key] === 'string') {
+          return obj[key].toUpperCase();
+        }
+        return obj[key];
+      }
+    });
+
+    it('should process propeties in both inspection and diff in "to equal"', function() {
+      expect(
+        function() {
+          clonedExpect({ nine: 9, zero: 1, foo: 'bAr' }, 'to equal', {
+            nine: 9,
+            zero: 0,
+            foo: 'BaR'
+          });
+        },
+        'to throw',
+        "expected { nine: 9, zero: 1, foo: 'BAR' } to equal { nine: 9, zero: 0, foo: 'BAR' }\n" +
+          '\n' +
+          '{\n' +
+          '  nine: 9,\n' +
+          '  zero: 1, // should equal 0\n' +
+          "  foo: 'BAR'\n" +
+          '}'
+      );
+    });
+
+    it('should process propeties in both inspection and diff in "to satsify"', function() {
+      expect(
+        function() {
+          clonedExpect(
+            { nine: 9, zero: 1, foo: 'bAr', baz: undefined },
+            'to satisfy',
+            { nine: 9, zero: 0, foo: 'BaR', baz: expect.it('to be undefined') }
+          );
+        },
+        'to throw',
+        "expected { nine: 9, zero: 1, foo: 'BAR', baz: undefined }\n" +
+          "to satisfy { nine: 9, zero: 0, foo: 'BAR', baz: expect.it('to be undefined') }\n" +
+          '\n' +
+          '{\n' +
+          '  nine: 9,\n' +
+          '  zero: 1, // should equal 0\n' +
+          "  foo: 'BAR',\n" +
+          '  baz: undefined\n' +
+          '}'
+      );
+    });
+
+    (Object.setPrototypeOf ? it : it.skip)(
+      'should process keys from the prototype chain in "to exhaustively satisfy"',
+      function() {
+        var fooObject = {
+          foo: 'bAr',
+          oof: 'should not appear'
+        };
+        var chainedObject = { nine: 9, baz: undefined };
+        Object.setPrototypeOf(chainedObject, fooObject);
+
+        expect(
+          function() {
+            clonedExpect(chainedObject, 'to exhaustively satisfy', {
+              nine: 9,
+              foo: 'BaZ',
+              baz: expect.it('to be undefined')
+            });
+          },
+          'to throw',
+          'expected { nine: 9, baz: undefined }\n' +
+            "to exhaustively satisfy { nine: 9, foo: 'BAZ', baz: expect.it('to be undefined') }\n" +
+            '\n' +
+            '{\n' +
+            '  nine: 9,\n' +
+            '  baz: undefined\n' +
+            "  foo: 'BAR' // should equal 'BAZ'\n" +
+            '             //\n' +
+            '             // -BAR\n' +
+            '             // +BAZ\n' +
+            '}'
+        );
+      }
+    );
+  });
 });