Repository: unexpectedjs/unexpected
Pull request #755 (2020-09-08 09:50:47)

Add only flag for to contain assertion

papandreou: `to only contain` might be easier to grok for users?
gustavnikolaj: Fair point! :-) It is much more descriptive and simple, and it's already a well established name for a flag in other assertions.
sunesimonsen: > Great idea! Looks like we need a more advanced implementation to handle duplicate items, though.

@papandreou nice catch, that if a bit hairy 😓 
papandreou: > @papandreou nice catch, that if a bit hairy 😓

People's test suites are hostile environments :)
papandreou: This diff is slightly less helpful, but OK I think:

```
expected [ 1, 2 ] to only contain 3

[
  1, // should equal 3
  2 // should be removed
]
```
gustavnikolaj: Merging to master :-)

Patch:
diff --git a/documentation/assertions/array-like/to-contain.md b/documentation/assertions/array-like/to-contain.md
index 5caba77a..719929ad 100644
--- a/documentation/assertions/array-like/to-contain.md
+++ b/documentation/assertions/array-like/to-contain.md
@@ -46,3 +46,34 @@ not to contain { name: 'Jane Doe' }
   { name: 'Jane Doe' } // should be removed
 ]
 ```
+
+You can use the `only` flag to indicate that you want no other items to
+be in the subject.
+
+```js
+expect(
+  [{ name: 'John Doe' }, { name: 'Jane Doe' }],
+  'to only contain',
+  { name: 'Jane Doe' },
+  { name: 'John Doe' }
+);
+```
+
+In case there are more items than that in your subject, you will get the
+following output:
+
+```js
+expect([{ name: 'Jane Doe' }, { name: 'John Doe' }], 'to only contain', {
+  name: 'Jane Doe',
+});
+```
+
+```output
+expected [ { name: 'Jane Doe' }, { name: 'John Doe' } ]
+to only contain { name: 'Jane Doe' }
+
+[
+  { name: 'Jane Doe' },
+  { name: 'John Doe' } // should be removed
+]
+```
diff --git a/lib/assertions.js b/lib/assertions.js
index cf95050e..8a9b0dd5 100644
--- a/lib/assertions.js
+++ b/lib/assertions.js
@@ -766,39 +766,65 @@ module.exports = (expect) => {
     );
   });
 
-  expect.addAssertion('<array-like> [not] to contain <any+>', function (
-    expect,
-    subject
-  ) {
-    const args = Array.prototype.slice.call(arguments, 2);
-    expect.withError(
-      () => {
-        args.forEach((arg) => {
-          expect(
-            subject &&
+  expect.addAssertion(
+    [
+      '<array-like> to [only] contain <any+>',
+      '<array-like> [not] to contain <any+>',
+    ],
+    function (expect, subject) {
+      const args = Array.prototype.slice.call(arguments, 2);
+
+      expect.withError(
+        () => {
+          args.forEach((arg) => {
+            expect(
               Array.prototype.some.call(subject, (item) =>
                 expect.equal(item, arg)
               ),
-            '[not] to be truthy'
-          );
-        });
-      },
-      (e) => {
-        expect.fail({
-          diff:
-            expect.flags.not &&
-            ((output, diff, inspect, equal) =>
-              diff(
-                subject,
-                Array.prototype.filter.call(
-                  subject,
-                  (item) => !args.some((arg) => equal(item, arg))
+              '[not] to be truthy'
+            );
+          });
+          if (expect.flags.only) {
+            expect(
+              Array.prototype.every.call(subject, (item) =>
+                Array.prototype.some.call(args, (arg) =>
+                  expect.equal(item, arg)
                 )
-              )),
-        });
-      }
-    );
-  });
+              ),
+              'to be truthy'
+            );
+          }
+        },
+        (e) => {
+          expect.fail({
+            diff(output, diff, inspect, equal) {
+              if (expect.flags.not) {
+                return diff(
+                  subject,
+                  Array.prototype.filter.call(
+                    subject,
+                    (item) => !args.some((arg) => equal(item, arg))
+                  )
+                );
+              } else if (expect.flags.only) {
+                var excessItems = Array.prototype.filter.call(subject, (item) =>
+                  args.some((arg) => equal(item, arg))
+                );
+                var missingItems = args.filter(
+                  (arg) =>
+                    !Array.prototype.some.call(subject, (item) =>
+                      equal(arg, item)
+                    )
+                );
+
+                return diff(subject, [...excessItems, ...missingItems]);
+              }
+            },
+          });
+        }
+      );
+    }
+  );
 
   expect.addAssertion(
     [


Test patch:
diff --git a/test/assertions/to-contain.spec.js b/test/assertions/to-contain.spec.js
index 00cf7808..25cf179b 100644
--- a/test/assertions/to-contain.spec.js
+++ b/test/assertions/to-contain.spec.js
@@ -81,6 +81,7 @@ describe('to contain assertion', () => {
         '    <number> to contain <number>\n' +
         '  did you mean:\n' +
         '    <array-like> [not] to contain <any+>\n' +
+        '    <array-like> to [only] contain <any+>\n' +
         '    <string> [not] to contain <string+>'
     );
   });
@@ -166,6 +167,77 @@ describe('to contain assertion', () => {
     });
   });
 
+  describe('with the only flag', () => {
+    it('should not throw when all items are included in the subject', () => {
+      expect(function () {
+        expect(
+          [{ bar: 456 }, { foo: 123 }],
+          'to only contain',
+          { foo: 123 },
+          { bar: 456 }
+        );
+      }, 'not to throw');
+    });
+
+    it('should not throw when all items are included in the subject and some multiple times', () => {
+      expect(function () {
+        expect(
+          [{ bar: 456 }, { foo: 123 }, { bar: 456 }],
+          'to only contain',
+          { foo: 123 },
+          { bar: 456 }
+        );
+      }, 'not to throw');
+    });
+
+    it('should throw when all items are not included in the subject', () => {
+      expect(
+        function () {
+          expect([{ bar: 456 }], 'to only contain', { foo: 123 }, { bar: 456 });
+        },
+        'to throw exception',
+        'expected [ { bar: 456 } ] to only contain { foo: 123 }, { bar: 456 }\n' +
+          '\n' +
+          '[\n' +
+          '  { bar: 456 }\n' +
+          '  // missing { foo: 123 }\n' +
+          ']'
+      );
+    });
+
+    it('should throw when all items are included in the subject but subject has other items as well', () => {
+      expect(
+        function () {
+          expect(
+            [{ bar: 456 }, { foo: 123 }, { baz: 789 }],
+            'to only contain',
+            { foo: 123 },
+            { bar: 456 }
+          );
+        },
+        'to throw exception',
+        'expected [ { bar: 456 }, { foo: 123 }, { baz: 789 } ]\n' +
+          'to only contain { foo: 123 }, { bar: 456 }\n' +
+          '\n' +
+          '[\n' +
+          '  { bar: 456 },\n' +
+          '  { foo: 123 },\n' +
+          '  { baz: 789 } // should be removed\n' +
+          ']'
+      );
+    });
+
+    it('should throw when combining the not and only flags', () => {
+      expect(
+        function () {
+          expect([{ foo: 123 }], 'not to only contain', { foo: 123 });
+        },
+        'to throw exception',
+        "Unknown assertion 'not to only contain', did you mean: 'to only contain'"
+      );
+    });
+  });
+
   it('should not highlight overlapping partial matches', () => {
     expect(
       function () {