PLAINFINCH learn by building

Testing objects and arrays

You will compare lists and objects by what they contain and catch code that rearranges the list you gave it without telling you.

Why does toBe fail on two identical arrays?

toBe() uses Object.is, the same check as === for objects. It asks whether two values are the very same thing in memory. The array returned by speciesList() is built fresh inside the function, and the array written in the test is built fresh in the test. They hold the same two strings, but they are two separate arrays, so toBe() says they differ.

The practice runner adds a note to the failure when this happens, pointing you to toEqual(). Vitest shows a similar hint about serialising to the same string. Either way, the fix is the same: when you care about what an object or array contains, compare contents.

FigureTwo arrays both holding 'Grey heron' and 'Kingfisher', one from speciesList(log) and one written in the test. toBe fails because they are separate arrays; toEqual passes because the items match in order.Two arrays both holding 'Grey heron' and 'Kingfisher', one from speciesList(log) and one written in the test. toBe fails because they are separate arrays; toEqual passes because the items match in order.
speciesList(log) and the test each build their own array. toBe() asks whether they are the same array and fails; toEqual() compares the items in order and passes.

What does toEqual compare?

toEqual() walks through both values and compares them piece by piece. Two arrays are equal when they have the same length and each pair of items is equal, in order. Two objects are equal when they have the same keys and each value is equal. It goes all the way down, so a list of sighting objects is compared field by field.

Order counts for arrays. ['Kingfisher', 'Grey heron'] does not equal ['Grey heron', 'Kingfisher'], which is exactly what you want when the order is part of the promise, such as an A to Z species list. It does not count for object keys: { species, count } equals { count, species }.

expect(speciesList(log)).toEqual(['Grey heron', 'Kingfisher']);

const dates = newestFirst(log).map((s) => s.date);
expect(dates).toEqual(['2026-03-12', '2026-03-09', '2026-03-02']);

When should you use toStrictEqual?

toEqual() is forgiving in two ways. It ignores properties whose value is undefined, so { species: 'Otter', note: undefined } equals { species: 'Otter' }. And it does not check what kind of object something is, so an instance of a class equals a plain object with the same fields.

toStrictEqual() checks both. Use it when the difference counts, for example when the code sends an object to another service that treats a key set to undefined differently from a missing key, or when you need a real Date or class instance back. For most everyday data, toEqual() is the simpler choice. Vitest's strict version also notices sparse arrays, which the practice runner does not check.

How do you check only some fields?

Sometimes an object carries fields you cannot or should not pin down. addSighting() stamps the new entry with today's date, and the test cares about species and count. toMatchObject() passes when the object has at least the fields you list with those values, and ignores everything else.

Inside a larger comparison, expect.objectContaining({ ... }) does the same job for one part, and expect.arrayContaining([...]) checks that certain items are present in an array in any order. Asymmetric matchers like these can sit anywhere inside the expected value of toEqual(), so you can say a list has exactly three entries while being relaxed about one field of one entry.

const updated = addSighting(freshLog(), 'Otter', 2);
expect(updated).toHaveLength(4);
expect(updated.at(-1)).toMatchObject({ species: 'Otter', count: 2 });

When is a loose match too loose?

A loose matcher says what it does not care about. That is a decision, and the Break It challenge shows what happens when it is the wrong one. toMatchObject({ role, joined }) passes for a public profile that also holds a phone number, because extra fields are ignored. For a profile shown to strangers, the absence of private fields is the whole point of the function, so the test must compare the full object or assert the field is missing with not.toHaveProperty('phone').

The same goes for arrayContaining(). It is right for a set of tags where order means nothing, and wrong for the stops on a walk, where the order is the route. Before choosing a loose matcher, ask which bug you would miss, and whether you would mind.

How do you test that the input is left alone?

Some array methods change the array they are called on. .sort(), .reverse(), .splice() and .push() all do. When newestFirst() calls sightings.sort(), the caller's own log is rearranged as a side effect. The returned list is right, so a test that only checks the result passes, and the bug shows up later somewhere else, when the club's page lists sightings in a mysterious order.

To test for it, keep a reference to the input, call the function, then check the input against a fresh copy of what it was. A small helper like freshLog() gives each test its own data and a clean copy to compare with. The fix is to sort a copy, [...sightings].sort(...), or to use toSorted(), which returns a new array and never touches the original.

test('leaves the original log in its order', () => {
  const log = freshLog();
  newestFirst(log);
  expect(log).toEqual(freshLog());
});

Why give each test fresh data?

If every test shared one log array declared at the top of the file, a test that mutated it would change the data for the tests that run after it. Results would then depend on the order the tests run in, and a single bug could make unrelated tests fail. Building the data in a function that returns a new array each time keeps every test independent.

The same idea scales up in the setup and teardown lesson, with beforeEach() building fresh data for every test. For small files, a plain function like freshLog() is enough and easy to read.

What do AI tools get wrong with objects and arrays?

Generated tests often use toBe() on arrays and objects, then fail, and a follow-up suggestion switches to toMatchObject() or arrayContaining() to make the failure go away. The test turns green and checks much less than it did. Read the matcher in every object comparison and ask whether it would catch an extra field, a missing field or a wrong order.

Another common gap is mutation. AI-written code reaches for .sort() and .reverse() on the input, and AI-written tests rarely check the input afterwards. If a function takes an array, add one test that the array is unchanged after the call.

Field notes

  • toBe on arrays checks for the same object, so use toEqual to compare contents.
  • toEqual cares about array order and ignores the order of object keys.
  • toMatchObject checks only the fields you list, such as species and count.
  • .sort() changes the original array, so sort a copy with [...list].sort().

Easy to get wrong

toBe() checks that two values are the same object. A freshly built array never is. toEqual() compares the items.

toMatchObject() ignores extra fields, so it cannot catch private data leaking into the result. Compare the whole object when extra fields would be a bug.

.sort() rearranges the array it is called on, so the caller's data changes too. Sort a copy, and add a test that the input is untouched.

Questions people ask

Does toEqual care about the order of object keys?

No. Objects are compared key by key, whatever order the keys were written in. Arrays are compared position by position, so their order does matter.

What is the difference between toContain and toContainEqual?

toContain() uses === for each item, which works for strings and numbers. toContainEqual() compares contents, so use it to check that an array holds an object such as { species: 'Otter', count: 2 }.

Can I use toEqual on a Map or a Set?

Yes. Vitest, Jest and the practice runner compare Maps entry by entry and Sets item by item. A Set's order does not count.

Is structuredClone a good way to copy test data?

It makes a deep copy, which is handy for nested data. A small function that returns fresh literals, like freshLog(), is often easier to follow because the data is visible in the test file.

Keep going