jest-extended icon indicating copy to clipboard operation
jest-extended copied to clipboard

feature: toContainEqual+toMatchObject

Open jadbox opened this issue 7 years ago • 13 comments

Based on issue: https://github.com/facebook/jest/issues/6588

🚀 Feature Proposal

Find an object in an array that matches like an object.

Motivation

There may be times where you need to match a partial object against something in a returned list.

Example

I am writing tests for an ORM that saves an object, but it adds extra metadata to the objects when they are later retrieved. The order of the list is not deterministic. Therefor I want to match the fetched object list from the DB and test if the origin object I saved matches any of the objects in the list returned from the query.

const list = [ 
  { foo: "bar" , color: "blue"},
  { foo: "bazz" , color: "red"},
]

expect(list).toContainMatch( { color: "blue" } ); // true
expect(list).toContainMatch( { color: "red" } ); // true
expect(list).toContainMatch( { foo: "bar" , color: "blue"} ); // true
expect(list).toContainMatch( { color: "green"} ); // false

jadbox avatar Jul 07 '18 01:07 jadbox

I like this, i can see a use case for it,

i've wrote this for context, as I feel the jest matchers names arn't really that clear, and your suggestion would simplify the logic for checking if some element appears in an array at any point. however is toContainMatch clear language? would something like expect(Array).toMatchAtleastOnce(expected) that can take any input for the expected?

describe('test examples', () => {
  it('toContain', () => {
    const example = [
      'some',
      'thing'
    ]
    expect(example).toContain('thing')
  })

  it('toContainEqual', () => {
    const example = [
      {
        some: 'thing'
      }
    ]
    expect(example).toContainEqual({some: 'thing'})
  })

  it('toMatch', () => {
    const example = 'some grapefruit'
    expect(example).toMatch(/grapefruit/)
    expect(example).toMatch(new RegExp('grapefruit'))
  })

  it('toMatchObject', () => {
    const example = {
      some: 'thing',
      addtional: 'thing'
    }
    const expected = {
      some: 'thing'
    }
    expect(example).toMatchObject(expected)
  })

  it('toContainMathch', () => {
    const example = [
      {
        some: 'thing',
        addition: 'otherThing'
      },
      {
        foo: 'bar',
        bar: 'foo'
      }
    ]
    expect(example).toEqual(
      expect.arrayContaining([expect.objectContaining({foo: 'bar'})])
    )
  })
})

benjaminkay93 avatar Jul 08 '18 12:07 benjaminkay93

So for example, these are the behavious i would expect from such a matcher:

describe('test use case toMatchAtleastOnce', () => {
  it('toMatchAtleastOnce Strings', () => {
    const example = [
      'some',
      'thing'
    ]
    expect(example).toMatchAtleastOnce('thing')
  })

  it('toMatchAtleastOnce Objects', () => {
    const example = [
      {
        addivional: 'value',
        some: 'thing'
      },
      {
        addivional: 'value'
      }
    ]
    expect(example).toMatchAtleastOnce({some: 'thing'})
  })

  it('toMatchAtleastOnce Arrays', () => {
    const example = [
      [
        'some',
        'thing'
      ],
      [
        'additional',
        'entry'
      ]
    ]
    expect(example).toMatchAtleastOnce('thing')
  })

  it('toMatchAtleastOnce Numbers', () => {
    const example = [
      1,
      2,
      3
    ]
    expect(example).toMatchAtleastOnce(2)
  })
})

benjaminkay93 avatar Jul 08 '18 13:07 benjaminkay93

I don't know how we would deal with this use case though, objects in nested arrays,

  it('toMatchAtleastOnce Objects', () => {
    const example = [
      [
        {
          addivional: 'value',
          some: 'thing'
        },
      ],
      [
        {
          addivional: 'value'
        }
      ]
    ]
    expect(example).toMatchAtleastOnce({some: 'thing'})
  })

benjaminkay93 avatar Jul 08 '18 13:07 benjaminkay93

from .toMatchAtleastOnce() i can see a case being drawn for a whole raft of matchers:

  • toMatchAtleastOnce()
  • toMatchExactlyOnce()
  • toMatchAtleastTimes()
  • toMatchExactlyTimes() / Uses strict equality
  • toMatchAtPosition(val, position)
  • toMatchFirstElement(val) --> first element of array
  • toMatchLastElement(val) --> last elemtent of array / Partially matchs array elements, such as having {some: 'thing'} in a larger object
  • toPartiallyMatchAtPosition(val, position)
  • toPartiallyMatchFirstElement(val)
  • toPartiallyMatchLastElement(val)

I dont know how this would be recieved as i'm not sure if these things exist or not yet.

benjaminkay93 avatar Jul 08 '18 13:07 benjaminkay93

Perhaps I'm biased here, but I see lists as fundamental primites worthy of having robust testing functions as you mentioned above. I'm using Jest correctly to write tests for a new DB I'm developing, and as you can expect, many of the APIs return lists that need to be verified using a variety of ways, like what @benjaminkay93 mentioned above.

jadbox avatar Jul 11 '18 18:07 jadbox

Thats the same use case for me, a step beyond this would be schema testing, maybe we could wrap a an ajv schema testing into a matcher aswell, i would love to be able to do say:

expect(someObject).toMatchSchemaDraft7(someSchema);

benjaminkay93 avatar Jul 12 '18 17:07 benjaminkay93

Hey guys sorry to take so long to chime in on this!

I'm open to adding a partial matching assertion for lists 👍

Around the naming conventions, I would say that it would be better to be closer to the current naming style for Array's with .toInclude as the prefix.

Off the top of my head I think I would suggest something like:

.toIncludePartial or .toIncludePartialMatch

The way I see it is you can only perform a partial match on:

  • Object
  • Array
  • Set
  • Map
  • String

and not on: boolean, number, Date, Symbol, null, undefined

test('matches partially object', () => {
  const listObjs = [ 
    { foo: "bar" , color: "blue"},
    { foo: "bazz" , color: "red"},
  ]
  expect(listObjs).toIncludePartial({ color: "blue" }); // true
})

test('matches partially string', () => {
  const listStrings = ["blue", "red"]
  expect(listStrings).toIncludePartial("bl"); // true
})

test('matches partially array', () => {
  const listArrays = [ 
    ['bar', 'blue'],
    ['bazz', 'red']
  ]
   expect(listArrays).toIncludePartial(["blue"]); // true
})

test('matches partially map', () => {
  const listMaps = [ 
    new Map([['foo', 'bar'], ['color', 'blue']]),
    new Map([['foo', 'bazz'], ['color', 'red']])
  ]
  expect(listMaps).toIncludePartial(new Map([['color', 'blue']]); // true
})

test('matches partially set', () => {
  const listSet = [ 
    new Set(['bar', 'blue']),
    new Set(['bazz', 'red'])
  ]
  expect(listSet).toIncludePartial(new Set('blue')); // true
})

My question is how deep does the partial match go and how do you specify the structure?

What if you had something like: List<List<Map<String, Set<Object>>>

const list = [
  [
    new Map([
      [
        'key1',
        new Set([
          { foo: 'bar', color: 'blue' },
          { foo: 'bazz', color: 'red' }
        ])
      ],
      ['key2', new Set()]
    ])
  ]
]

// should this work? i.e. traversing multiple data types
expect(list).toIncludePartial({ color: 'blue'}) 

// or do you have to specify the types?
expect(list).toIncludePartial([new Map([['key1', new Set([{ color: 'blue'}]) ]])]) 

mattphillips avatar Jul 31 '18 10:07 mattphillips

@mattphillips For how deep the matches go for mixed collection types, I don't have a strong opinion on. I'd probably opt for expect(list).toIncludePartial({ color: 'blue'}) since we can assume I'm not trying to validate against types but rather my intent it to just check "hey, is this value matching something in this collection?". As @benjaminkay93 mentioned, it would be useful to be also able to specify how many times the value gets matched. Does this value match to something in the collection 'only once', 'at least once, 'twice', etc?

expect(list).toIncludePartial({ color: 'blue'}, [1]) // match exactly once
expect(list).toIncludePartial({ color: 'blue'}, [0,1]) // match exactly zero or one
expect(list).toIncludePartial({ color: 'blue'}, [1,-1]) // match one or more times (default)
expect(list).toIncludePartial({ color: 'blue'})  // same as above

jadbox avatar Jul 31 '18 20:07 jadbox

maybe we give the option to include a predicate, then people can write their own thing to check these things with a passed in function?

benjaminkay93 avatar Aug 01 '18 08:08 benjaminkay93

@benjaminkay93 we already have .toSatisfyAll which takes a predicate :smile:

@jadbox I think .toIncludePartial(value) and .toIncludePartialTimes(value, times) is probably sufficient.

mattphillips avatar Aug 02 '18 12:08 mattphillips

@mattphillips actually, ya... .toIncludePartialTimes(value, times) would be sufficient.

jadbox avatar Aug 05 '18 23:08 jadbox

@mattphillips any update on toIncludePartial and toIncludePartialTimes? Looking at the docs today (it's been some time), I don't see any additions to aid this use-case. toIncludeAnyMembers isn't quite close enough as it cannot match partial objects within the collection.

jadbox avatar Jul 18 '19 17:07 jadbox

I have been facing the same issue and have come up with the following solution:

import matchers from 'expect/build/matchers'; // The matchers used internally by Jest

expect.extend({
  /**
   * Deep-match an array of objects against another object. (= Basically, run
   * expect().toMatchObject() in a loop and declare the test to have passed if
   * any object matches the expected one.)
   */
  toContainObjectMatching(received, expected: Record<string, unknown>) {
    let matchFound = false;
    if (Array.isArray(received)) {
      matchFound = 
        received.map((obj) => {
          const result = matchers.toMatchObject.call(this, obj, expected);
          // For some reason TSC doesn't fully recognize result as object of type
          // ExpectationResult
          return (result as any).pass;
        }).reduce((a, b) => a || b, false);
    }

    const prettyPrinted = {
      received: this.utils.printReceived(received),
      expected: this.utils.printExpected(expected),
    };
    return {
      message: () =>
        this.isNot
          ? `expected ${prettyPrinted.received} to be list containing no object matching ${prettyPrinted.expected}`
          : `expected ${prettyPrinted.received} to be list containing at least one object matching ${prettyPrinted.expected}`,
      pass: matchFound,
    };
  },
});

declare global {
  namespace jest {
    interface Matchers<R> {
      toContainObjectMatching(expected: Record<string, unknown>): R;
    }
  }
}

Usage: Add the above file to the setupFilesAfterEnv setting in jest.config.ts. Then in your tests do

expect([
  { foo: 'foo' },
  { bar: 'bar' },
]).toContainObjectMatching({
  foo: 'foo'
});

codethief avatar Aug 11 '22 17:08 codethief