Repository: unexpectedjs/unexpected
Pull request #671 (2019-12-09 17:54:44)

Support an "only" flag in the "to have properties" assertion.


Patch:
diff --git a/.nvmrc b/.nvmrc
index c78ac614..354ff615 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1,1 +1,1 @@
-v8.7.0
+v8.16.2
diff --git a/.travis.yml b/.travis.yml
index 177ee689..a841ffe8 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -5,35 +5,35 @@ env:
 
 node_js:
   - '6'
-  - '8.7.0'
+  - '8.16.2'
   - '9'
   - '10'
 
 matrix:
   include:
-    - name: Lint (8.7.0)
-      node_js: '8.7.0'
+    - name: Lint (8.16.2)
+      node_js: '8.16.2'
       env: TARGET=lint
-    - name: Jest (8.7.0)
-      node_js: '8.7.0'
+    - name: Jest (8.16.2)
+      node_js: '8.16.2'
       env: TARGET=test-jest
-    - name: Jasmine (8.7.0)
-      node_js: '8.7.0'
+    - name: Jasmine (8.16.2)
+      node_js: '8.16.2'
       env: TARGET=test-jasmine
-    - name: Coverage (8.7.0)
-      node_js: '8.7.0'
+    - name: Coverage (8.16.2)
+      node_js: '8.16.2'
       env: TARGET=travis-coverage
-    - name: Plugins (8.7.0)
-      node_js: '8.7.0'
+    - name: Plugins (8.16.2)
+      node_js: '8.16.2'
       env: TARGET=test-plugins
     - name: BrowserStack IE11
-      node_js: '8.7.0'
+      node_js: '8.16.2'
       env: TARGET=test-browserstack-ie11
     - name: Chrome headless
-      node_js: '8.7.0'
+      node_js: '8.16.2'
       env: TARGET=test-chrome-headless
     - name: Deno
-      node_js: '8.7.0'
+      node_js: '8.16.2'
       env: TARGET=test-deno
 
 addons:
diff --git a/documentation/assertions/object/to-have-properties.md b/documentation/assertions/object/to-have-properties.md
index aa8c0430..5c89ccb0 100644
--- a/documentation/assertions/object/to-have-properties.md
+++ b/documentation/assertions/object/to-have-properties.md
@@ -12,6 +12,22 @@ expect(['a', { c: 'c' }, 'd'], 'to have properties', {
 });
 ```
 
+When validating the object against an array of properties, the `only` flag can
+be used to assert that only the specified properties are present:
+
+```js
+expect({ foo: 123, bar: 456 }, 'to only have properties', ['foo']);
+```
+
+```output
+expected { foo: 123, bar: 456 } to only have properties [ 'foo' ]
+
+{
+  foo: 123,
+  bar: 456 // should be removed
+}
+```
+
 Using the `own` flag, you can assert presence of an own properties.
 
 ```js
diff --git a/lib/assertions.js b/lib/assertions.js
index c4bc2099..4f4b973f 100644
--- a/lib/assertions.js
+++ b/lib/assertions.js
@@ -319,7 +319,7 @@ module.exports = expect => {
   );
 
   expect.addAssertion(
-    '<object> [not] to have [own] properties <array>',
+    '<object> [not] to [only] have [own] properties <array>',
     (expect, subject, propertyNames) => {
       const unsupportedPropertyNames = [];
       propertyNames.forEach(propertyName => {
@@ -344,9 +344,109 @@ module.exports = expect => {
           this.outdentLines();
         });
       }
-      propertyNames.forEach(propertyName => {
-        expect(subject, '[not] to have [own] property', String(propertyName));
-      });
+
+      if (expect.flags.only) {
+        if (expect.flags.not) {
+          expect.errorMode = 'bubble';
+          expect.fail(
+            'The "not" flag cannot be used together with "to only have properties".'
+          );
+        }
+        if (expect.flags.own) {
+          expect.errorMode = 'bubble';
+          expect.fail(
+            'The "own" flag cannot be used together with "to only have properties".'
+          );
+        }
+        const subjectType = expect.subjectType;
+        const subjectKeys = subjectType.getKeys(subject).filter(
+          key =>
+            // include only those keys whose value is not undefined
+            typeof subjectType.valueForKey(subject, key) !== 'undefined'
+        );
+
+        expect.withError(
+          () => {
+            expect(subjectKeys.length === propertyNames.length, 'to be true');
+            // now catch differing property names
+            const keyInValue = {};
+            propertyNames.forEach(key => {
+              keyInValue[key] = true;
+            });
+            subjectKeys.forEach(key =>
+              expect(
+                Object.prototype.hasOwnProperty.call(keyInValue, key),
+                'to be true'
+              )
+            );
+          },
+          () => {
+            expect.fail({
+              diff: (output, diff, inspect, equal) => {
+                output.inline = true;
+
+                const keyInValue = {};
+                propertyNames.forEach(key => {
+                  keyInValue[key] = true;
+                });
+
+                subjectType.prefix(output, subject);
+                output.nl().indentLines();
+
+                subjectKeys.forEach((key, index) => {
+                  const propertyOutput = subjectType.property(
+                    output.clone(),
+                    key,
+                    inspect(subjectType.valueForKey(subject, key))
+                  );
+                  const delimiterOutput = subjectType.delimiter(
+                    output.clone(),
+                    index,
+                    subjectKeys.length
+                  );
+
+                  output
+                    .i()
+                    .block(function() {
+                      this.append(propertyOutput).amend(delimiterOutput);
+                      if (
+                        !Object.prototype.hasOwnProperty.call(keyInValue, key)
+                      ) {
+                        this.sp().annotationBlock(function() {
+                          this.error('should be removed');
+                        });
+                      } else {
+                        delete keyInValue[key];
+                      }
+                    })
+                    .nl();
+                });
+
+                // list any remaining value properties as missing
+                Object.keys(keyInValue).forEach(valueKey => {
+                  output
+                    .i()
+                    .annotationBlock(function() {
+                      this.error('missing')
+                        .sp()
+                        .append(inspect(valueKey));
+                    })
+                    .nl();
+                });
+
+                output.outdentLines();
+                subjectType.suffix(output, subject);
+
+                return output;
+              }
+            });
+          }
+        );
+      } else {
+        propertyNames.forEach(propertyName => {
+          expect(subject, '[not] to have [own] property', String(propertyName));
+        });
+      }
     }
   );
 


Test patch:
diff --git a/test/assertions/to-have-properties.spec.js b/test/assertions/to-have-properties.spec.js
index 23fd89e7..2ede6871 100644
--- a/test/assertions/to-have-properties.spec.js
+++ b/test/assertions/to-have-properties.spec.js
@@ -195,7 +195,7 @@ describe('to have properties assertion', () => {
         '  The assertion does not have a matching signature for:\n' +
         '    <object> to have properties <string> <string>\n' +
         '  did you mean:\n' +
-        '    <object> [not] to have [own] properties <array>\n' +
+        '    <object> [not] to [only] have [own] properties <array>\n' +
         '    <object> to have [own] properties <object>'
     );
 
@@ -211,7 +211,7 @@ describe('to have properties assertion', () => {
         '  The assertion does not have a matching signature for:\n' +
         '    <object> not to have properties <object>\n' +
         '  did you mean:\n' +
-        '    <object> [not] to have [own] properties <array>'
+        '    <object> [not] to [only] have [own] properties <array>'
     );
   });
 
@@ -289,4 +289,107 @@ describe('to have properties assertion', () => {
       );
     });
   });
+
+  describe('with the "only" flag', () => {
+    it('should pass', () => {
+      expect(function() {
+        expect({ foo: 123, bar: 456 }, 'to only have properties', [
+          'foo',
+          'bar'
+        ]);
+      }, 'not to throw');
+    });
+
+    it('should pass regardless of ordering', () => {
+      expect(function() {
+        expect({ foo: 123, bar: 456, baz: 768 }, 'to only have properties', [
+          'baz',
+          'foo',
+          'bar'
+        ]);
+      }, 'not to throw');
+    });
+
+    it('should fail with a diff and mark properties to be removed', () => {
+      expect(
+        function() {
+          expect({ foo: 123, bar: 456 }, 'to only have properties', ['foo']);
+        },
+        'to error',
+        "expected { foo: 123, bar: 456 } to only have properties [ 'foo' ]\n" +
+          '\n' +
+          '{\n' +
+          '  foo: 123,\n' +
+          '  bar: 456 // should be removed\n' +
+          '}'
+      );
+    });
+
+    it('should fail with a diff and mark properties that are missing', () => {
+      expect(
+        function() {
+          expect({ foo: 123, bar: 456 }, 'to only have properties', [
+            'foo',
+            'baz'
+          ]);
+        },
+        'to error',
+        "expected { foo: 123, bar: 456 } to only have properties [ 'foo', 'baz' ]\n" +
+          '\n' +
+          '{\n' +
+          '  foo: 123,\n' +
+          '  bar: 456 // should be removed\n' +
+          "  // missing 'baz'\n" +
+          '}'
+      );
+    });
+
+    it('should fail with a diff while avoiding the prototype chain', () => {
+      expect(
+        function() {
+          expect({ toString: 'foobar' }, 'to only have properties', []);
+        },
+        'to error',
+        "expected { toString: 'foobar' } to only have properties []\n" +
+          '\n' +
+          '{\n' +
+          "  toString: 'foobar' // should be removed\n" +
+          '}'
+      );
+    });
+
+    it('should ignore undefined properties (pass)', () => {
+      expect(function() {
+        expect({ foo: 123, bar: undefined }, 'to only have properties', [
+          'foo'
+        ]);
+      }, 'not to throw');
+    });
+
+    it('should ignore undefined properties (fail)', () => {
+      expect(function() {
+        expect({ 123: undefined }, 'to only have properties', ['123']);
+      }, 'to throw');
+    });
+
+    it('should error if used with the not flag', () => {
+      expect(
+        function() {
+          expect({ foo: 123 }, 'not to only have properties', ['foo']);
+        },
+        'to throw',
+        'The "not" flag cannot be used together with "to only have properties".'
+      );
+    });
+
+    it('should error if used with the own flag', () => {
+      expect(
+        function() {
+          expect({ foo: 123 }, 'to only have own properties', ['foo']);
+        },
+        'to throw',
+        'The "own" flag cannot be used together with "to only have properties".'
+      );
+    });
+  });
 });