Skip to content

[code-infra] Move the shared spy helpers off Sinon - #23466

Merged
JCQuintas merged 3 commits into
mui:masterfrom
JCQuintas:code-infra/replace-sinon-spy-helpers
Sep 1, 2026
Merged

[code-infra] Move the shared spy helpers off Sinon#23466
JCQuintas merged 3 commits into
mui:masterfrom
JCQuintas:code-infra/replace-sinon-spy-helpers

Conversation

@JCQuintas

@JCQuintas JCQuintas commented Aug 31, 2026

Copy link
Copy Markdown
Member

Fourth step away from Sinon, following #23443, #23460 and #23464.

The previous steps took the spies each test file owns. This one takes the two shared helpers, and every call site that reads what they return. Those had to move together: a helper cannot change its return type without every consumer changing its accessors in the same commit.

Sinon goes from 37 files to 29. It stays a dependency, and test/setupVitest.ts keeps calling sinon.restore().

The two helpers

spyApi (test/utils/helperFn.ts) wraps a Grid API method with a recording function and swaps it onto the api object. It never used the spy-on-object form, so it maps straight to vi.fn.

StoreSpy (test/utils/scheduler/StoreSpy.tsx) is the first real spy-on-object in this series:

-const sp = sinonSpy(store as any, method as any);
+const sp = vi.spyOn(store as any, method as any);
 ...
-return () => spyRef.current?.restore?.();
+return () => spyRef.current?.mockRestore?.();

Worth noting for the vi.spyOn step that follows: this helper has always restored its own spy in its effect cleanup, so it needs nothing from the shared teardown. No vi.restoreAllMocks() is required here.

Their spies are typed accordingly, Mock for spyApi and MockInstance for StoreSpy.

calledBefore

The one accessor with no equivalent. Sinon compares call objects; Vitest exposes a global ordering counter instead:

-expect(spiedSetEditCellValue.firstCall.calledBefore(onValueChange.firstCall)).to.equal(true);
+expect(spiedSetEditCellValue.mock.invocationCallOrder[0]).to.be.lessThan(
+  onValueChange.mock.invocationCallOrder[0],
+);

Types that needed a real signature

vi.fn(() => new Promise(() => {})) declares no parameters, so mock.lastCall types as an empty tuple even though the Grid calls it with a row. Two processRowUpdate mocks now declare the parameter their assertions read, the same fix #23464 needed for a getter mock.

Still on Sinon here

One file keeps its Sinon import for a reason outside this step: cellSelection.DataGridPremium uses stub for requestAnimationFrame. It now reads its helper-derived spies through the Vitest API while that waits for a later step.

Status

typescript and eslint are clean, and the browser suite is fully green: 761 files passed, 4 skipped, 0 failures.

The jsdom suite shows the usual intermittent failures in x-telemetry/src/postinstall/get-project-id.test.ts on my machine. That file is untouched here, it passes when its package runs alone, and test_unit has been green on it in CI, so it is local environment noise.

Fourth step away from Sinon, following mui#23443, mui#23460 and mui#23464. Those
took the spies each test owns. This one takes the two shared helpers and
every call site that reads what they return, which had to move together.

`spyApi` in `test/utils/helperFn.ts` wraps a Grid API method, so it maps
straight to `vi.fn`. `StoreSpy` is the first real spy-on-object in this
series: `spy(store, method)` becomes `vi.spyOn(store, method)`, and the
`restore()` in its cleanup becomes `mockRestore()`. The helper has always
restored its own spy, so the shared teardown still needs no
`vi.restoreAllMocks()`.

`calledBefore` has no direct equivalent and becomes a comparison of
`mock.invocationCallOrder`.

Two files keep their Sinon import for reasons outside this step:
`cellSelection.DataGridPremium` still uses `stub`, and
`rowEditing.DataGridPro` still uses the Sinon fake timers.

Sinon is now down to 29 files.
@JCQuintas JCQuintas added test type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. labels Aug 31, 2026
@JCQuintas JCQuintas self-assigned this Aug 31, 2026
@code-infra-dashboard

code-infra-dashboard Bot commented Aug 31, 2026

Copy link
Copy Markdown

Deploy preview

https://deploy-preview-23466--material-ui-x.netlify.app/
QR code for https://deploy-preview-23466--material-ui-x.netlify.app/

Bundle size

Bundle Parsed size Gzip size
@mui/x-data-grid 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-pro 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-premium 0B(0.00%) 0B(0.00%)
@mui/x-charts 0B(0.00%) 0B(0.00%)
@mui/x-charts-pro 0B(0.00%) 0B(0.00%)
@mui/x-charts-premium 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers-pro 0B(0.00%) 0B(0.00%)
@mui/x-tree-view 0B(0.00%) 0B(0.00%)
@mui/x-tree-view-pro 0B(0.00%) 0B(0.00%)
@mui/x-scheduler 0B(0.00%) 0B(0.00%)
@mui/x-scheduler-premium 0B(0.00%) 0B(0.00%)
@mui/x-chat 0B(0.00%) 0B(0.00%)
@mui/x-license 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@JCQuintas

Copy link
Copy Markdown
Member Author

@claude review

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

PR review

Nothing here is merge-blocking. This is a mechanical Sinon→Vitest migration of the two shared spy helpers (spyApi, StoreSpy) plus every consumer, and the translations are faithful: callCountmock.calls.length, lastCall.args[n]mock.lastCall?.[n], firstArg[0], resetHistorymockClear, returnValuesmock.results.map(r => r.value), and calledBeforemock.invocationCallOrder comparison (valid — Vitest's invocation counter is global across all mocks). I verified every spyApi and StoreSpy consumer in the repo is inside this diff, that no Sinon accessor (callCount, lastCall.args, resetHistory, calledOnce, …) survives in the touched files, and that useGridApiInitialization's spying/target re-binding still works with a vi.fn (arbitrary properties assign fine). The remaining findings are a weak assertion and some dead defensive code the rewrite introduced. Note: ./pr-head is data-only here, so the 180+ assertion rewrites were verified by reading, not by running the suites.

Tests (1)

1. 🟡 Double-negative assertion no longer matches the test name

Location: packages/x-data-grid-premium/src/tests/exportExcel.DataGridPremium.test.tsx:713

it('should not call getDataAsExcel', async () => {
  render(<TestCaseExcelExport />);
  const getDataAsExcelSpy = spyApi(apiRef.current!, 'getDataAsExcel');
  await act(() => apiRef.current?.exportDataAsExcel({ worker: () => workerMock as any }));
  expect(getDataAsExcelSpy.mock.calls.length).not.to.equal(1);
});

The old expect(spy.calledOnce).to.equal(false) was already weak, and the literal translation keeps that weakness in a form that now reads as an obvious mismatch with the test name: the assertion passes if getDataAsExcel is called twice, three times, or any number other than exactly one. Since the whole point of the worker path is that serialization moves off the main API, the assertion should pin zero calls. The rewrite touched this exact line, which is the cheapest moment to fix it.

Failure scenario: A regression that makes the worker path call getDataAsExcel twice (for example, a duplicated export kick-off) leaves this test green, so the guarantee the test name advertises is never actually checked.

Fix: expect(getDataAsExcelSpy.mock.calls.length).to.equal(0);

Simplifications (4)

1. 🟡 ?? {} fallbacks are unnecessary and degrade the failure message

Location: packages/x-scheduler-premium/src/event-calendar-premium/tests/EventDialog.test.tsx:3360

const { changes } = updateEventSpy?.mock.lastCall?.[0] ?? {};
expect(changes.customField).to.equal('edited');

and

// exportExcel.DataGridPremium.test.tsx:776
const { serializedRows } = workerMock.postMessage.mock.lastCall?.[0] ?? {};

Neither ?? {} is needed for type-checking. updateEventSpy is declared as bare let updateEventSpy; (implicit any), and workerMock.postMessage is Mock whose default Procedure signature makes mock.lastCall?.[0] resolve to any — destructuring any is legal. What the fallback does buy is a worse diagnostic: the base version's updateEventSpy!.lastCall.firstArg failed at the accessor naming the spy, whereas now the empty object flows one line further and dies as Cannot read properties of undefined (reading 'customField'). It also makes the file internally inconsistent — 30 lines earlier the same spy is read as updateEventSpy!.mock.lastCall?.[0].

Failure scenario: If the scope-dialog wiring stops calling updateRecurringEvent, whoever triages the failure gets a customField TypeError pointing at the assertion rather than a message identifying the un-called spy, and has to re-derive which spy was empty.

Fix: Drop ?? {} at both sites and use the non-null form already used elsewhere in the same file: const { changes } = updateEventSpy!.mock.lastCall![0];.

2. 🟡 Two now-identical assertions in the same test

Location: packages/x-scheduler-premium/src/event-calendar-premium/tests/EventDialog.test.tsx:1900

expect(updateRecurringEventSpy?.mock.calls.length).to.equal(1);
expect(selectRecurringEventScopeSpy?.mock.calls.length).to.be.greaterThan(0);
expect(selectRecurringEventScopeSpy?.mock.lastCall?.[0]).to.equal(null);
expect(updateRecurringEventSpy?.mock.calls.length).to.equal(1);

The base version had calledOnce === true on the first line and callCount === 1 on the last — redundant, but not visibly so. After the rewrite they are character-for-character identical, with no user action between them, so line 1903 cannot observe anything line 1900 did not.

Failure scenario: A maintainer reading this test has to reason about why the same expectation appears twice and whether the ordering is load-bearing; it is not.

Fix: Delete line 1903. While there, expect(...).to.be.greaterThan(0) on line 1901 is the translation of .called === true.to.not.equal(0) reads closer to the original intent and produces a clearer message when the spy is undefined.

3. ℹ️ spyApi still returns any, so the new .mock.* accessors are unchecked

Location: test/utils/helperFn.ts:60

export function spyApi(api: GridApiCommon, methodName: string) {
  ...
  const spyFn = vi.fn((...args: any[]) => {
    return spyFn.target(...args);
  }) as any;
  ...
  return spyFn;
}

The migration moved every call site from a one-level accessor (lastCall.args[0]) to a three-level one (mock.lastCall?.[0]), which is exactly the shape a typo silently survives — .mock.lastCalls?.[0] or .mock.call[0] type-checks as any, evaluates to undefined, and only shows up as a confusing assertion diff. Annotating the return type costs nothing and makes all six consumer files type-checked.

Failure scenario: A future test writes spiedSetEditCellValue.mock.calls.length as .mock.callCount (muscle memory from Sinon); TypeScript stays silent and the assertion compares undefined, so the test fails for a reason unrelated to the behavior under test.

Fix: Give spyApi an explicit return type, e.g. : Mock<(...args: any[]) => any> (the internal as any on spyFn can stay, since the spying/target properties are not part of Mock). The one existing cast in editComponents.DataGridPro.test.tsx may need as unknown as Mock<…>.

4. ℹ️ Optional call on mockRestore is dead defensiveness

Location: test/utils/scheduler/StoreSpy.tsx:30

return () => spyRef.current?.mockRestore?.();

The restore?.() in the base version existed because Sinon's anonymous spies have no restore method, so the guard was meaningful there. A MockInstance from vi.spyOn always has mockRestore, and spyRef is typed MockInstance | null, so the second ?. can never be the branch that fires.

Failure scenario: No runtime cost; it just leaves a reader wondering under what condition a MockInstance lacks mockRestore, and a later refactor may propagate the pattern.

Fix: return () => spyRef.current?.mockRestore();

Docs (1)

1. ℹ️ PR description's "Still on Sinon here" section is wrong about rowEditing

Location: packages/x-data-grid-pro/src/tests/rowEditing.DataGridPro.test.tsx:16

The description states that rowEditing.DataGridPro "keeps its Sinon import" because it "uses the Sinon fake timers". Neither half holds: the file used vi.useFakeTimers() before this PR (base line 411) and still does (line 410), and this diff removes its only Sinon import (import { spy } from 'sinon') with no replacement — the file is now fully Sinon-free. Only cellSelection.DataGridPremium (which keeps stub for requestAnimationFrame) belongs in that section.

The countable claim checks out: 29 files import from 'sinon' at this head, matching the stated 37→29.

Failure scenario: Whoever picks up the next step in this series budgets work for migrating Sinon fake timers in rowEditing that does not exist, and may skip the file believing it still has Sinon usage to unwind.

Fix: Reword the "Still on Sinon here" section to name only cellSelection.DataGridPremium and its stub usage.

Verdict

Approve after nits — the helper rewrites and all ~180 accessor translations are semantically equivalent and every consumer of both shared helpers is covered, leaving only one weak assertion and some dead defensive code introduced by the rewrite.


🤖 Review generated with Claude Code · Opus 5 (High) · medium review depth · 54 turns · 8m50s · $3.95 · run

- `should not call getDataAsExcel` asserted `not.to.equal(1)`, the
  literal translation of the old `calledOnce === false`, which passes on
  two calls as well. Pin it to zero.
- Give `spyApi` a real return type and drop the `as any` on the spy. The
  wrapper it returns is `Mock` plus the `spying`/`target` fields the grid
  reads back, so consumers now get their `.mock.*` reads type-checked.
  Only the two writes onto the API keep an escape hatch: indexing with
  `keyof GridApiCommon` collapses to an intersection of every method
  signature.
- Assert the call before destructuring `mock.lastCall`, so an un-called
  spy fails by name instead of surfacing later as a property TypeError.
- Drop a duplicated assertion and a dead optional call on `mockRestore`.

Kept `to.be.greaterThan(0)` over the suggested `to.not.equal(0)` for the
call-count checks. These spies are read through `?.`, so a spy that was
never created reads as `undefined`, and `expect(undefined).to.not.equal(0)`
passes while `to.be.greaterThan(0)` fails.
@JCQuintas

Copy link
Copy Markdown
Member Author

Applied everything except one sub-suggestion, which I want to flag because it would weaken the assertion.

In Simplifications 2 the suggestion was to prefer expect(...).to.not.equal(0) over expect(...).to.be.greaterThan(0) as the translation of Sinon's .called === true.

These spies come from StoreSpy and are read through optional chaining, so a spy that was never created reads as undefined rather than as a number:

expect(selectRecurringEventScopeSpy?.mock.calls.length).to.be.greaterThan(0);

I checked both matchers against undefined rather than reasoning about it:

PROBE not.to.equal(0): true | greaterThan(0): false

expect(undefined).to.not.equal(0) passes, because undefined !== 0. That is precisely the case the assertion exists to catch, so the suggested form would let a never-created spy through silently. to.be.greaterThan(0) fails on undefined, so I kept it.

Everything else is in:

  • should not call getDataAsExcel now pins zero calls instead of not.to.equal(1).
  • spyApi has a real return type and no longer returns any, so consumers get their .mock.* reads type checked. The as any on the spy itself is gone; only the two writes onto the API keep an escape hatch, since indexing with keyof GridApiCommon collapses to an intersection of every method signature.
  • Duplicated assertion removed, and the dead ?. on mockRestore dropped.
  • For the ?? {} fallbacks, I kept the fallback but added a call-count assertion before each destructure. That fixes the diagnostic problem the finding describes, an un-called spy now fails by name instead of surfacing as a property TypeError one line later, without a non-null assertion.
  • The description was wrong about rowEditing.DataGridPro. It uses Vitest's fake timers and is fully Sinon free after this PR. Corrected to name only cellSelection.DataGridPremium and its stub.

@JCQuintas

Copy link
Copy Markdown
Member Author

@claude review

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

PR review

Nothing merge-blocking. This is a mechanical Sinon→Vitest swap of the two shared test helpers plus every call site, and the accessor mappings check out: lastCall.args[n]mock.lastCall?.[n], firstCall.firstArgmock.calls[0][0], callCount/calledOnce/calledmock.calls.length, returnValuesmock.results.map(r => r.value), resetHistory()mockClear() (5 for 5), and calledBeforeinvocationCallOrder comparison (Vitest's counter is global and assigned at call entry, so the ordering semantics match). All six spyApi consumers and all three StoreSpy consumers are in the diff, so no untouched file is left reading a Sinon accessor off a Vitest mock.

Three assertions are strengthened rather than mapped one-to-one, and are worth a reviewer's eye since they are the only non-mechanical parts of a 750-line diff: exportExcel.DataGridPremium.test.tsx:713 goes from calledOnce === false (which also passed on 2+ calls) to calls.length === 0, and exportExcel.DataGridPremium.test.tsx:776 and EventDialog.test.tsx:3359 add new call-count assertions. All three match their test names, so they look intentional.

Tests (2)

1. ℹ️ StoreSpy cleanup now wipes call history, not just the wrapper

Location: test/utils/scheduler/StoreSpy.tsx:30

const sp = vi.spyOn(store as any, method as any);
spyRef.current = sp;
onSpyReady(sp);

return () => spyRef.current?.mockRestore();

Sinon's restore() unwraps the method but leaves the spy's recorded calls readable. Vitest's mockRestore() is mockReset() + restore, so it also clears mock.calls. The PR description frames this cleanup as a like-for-like swap ("this helper has always restored its own spy in its effect cleanup"), which is true of the unwrapping but not of the history.

This is safe as written: every StoreSpy in the three consumer files is a direct child of the provider, so it stays mounted through the assertions, and on a re-render both libraries hand the test a fresh zero-call spy via onSpyReady. It only bites the day someone mounts a StoreSpy inside the dialog content and asserts after the dialog closes.

Failure scenario: A future test renders <StoreSpy method="createEvent" /> inside the dialog subtree, clicks Save (closing the dialog), then asserts createEventSpy.mock.calls.length === 1 — it reads 0, because unmount cleanup reset the mock. Under Sinon the same test passed.

Fix: Add a one-line comment on the cleanup noting that mockRestore also clears recorded calls, so the spy must stay mounted for the duration of any assertion that reads it.

2. ℹ️ One optional-chained assertion is vacuous on its own

Location: packages/x-data-grid-pro/src/tests/rowEditing.DataGridPro.test.tsx:273

const args2 = preProcessEditCellProps2.mock.lastCall?.[0];
expect(args2?.id).to.equal(0);
expect(args2?.row).to.deep.equal(defaultData.rows[0]);
expect(args2?.hasChanged).to.equal(false);

args2?.hasChanged is undefined when the mock was never called, and expect(undefined).to.equal(false) passes. Every other ?. site in the diff compares against a truthy value or dereferences further, so it still fails loudly; this is the one line where the optional chain converts "never called" into a pass. It is currently covered by the neighbouring args2?.id assertion failing first, so no regression can actually slip through — it is only a robustness/intent nit.

Failure scenario: If the surrounding assertions were ever reordered or removed, a preProcessEditCellProps2 that stops being called entirely would leave this line green.

Fix: Drop the optional chain on the extracted args (const args2 = preProcessEditCellProps2.mock.lastCall![0]) so the intent — "it was called, and here is what with" — is expressed once.

Simplifications (1)

1. 🟡 Object.assign used as a type escape hatch where a cast reads better

Location: test/utils/helperFn.ts:78

// Indexing with `keyof GridApiCommon` collapses to an intersection of every method
// signature, so the write itself needs the escape hatch, not the spy.
Object.assign(api, { [methodKey]: spyFn });
Object.assign(privateApi, { [methodKey]: spyFn });

The comment correctly identifies the problem (the indexed write, not the spy), but Object.assign is an indirect way to express it: it allocates a throwaway object literal per call and hides a plain property write behind a method that readers have to decode. The direct form says the same thing in fewer moving parts and matches what the comment describes as "the escape hatch".

Failure scenario: A reader of this shared helper has to work out that Object.assign(api, { [methodKey]: spyFn }) is a single property write dodging a TypeScript intersection, rather than a merge with some other purpose — and the pattern gets copied into the remaining Sinon migrations as if it were meaningful.

Fix:

(api as any)[methodKey] = spyFn;
(privateApi as any)[methodKey] = spyFn;

Verdict

Approve after nits — the accessor mapping and helper typings are correct across all nine call-site files; only a shared-helper readability nit and two test-infra notes remain.


🤖 Review generated with Claude Code · Opus 5 (High) · medium review depth · 51 turns · 8m35s · $3.92 · run

@JCQuintas

Copy link
Copy Markdown
Member Author

Took one of the three, and I checked the other two rather than reasoning about them.

Tests 1, mockRestore also clears history: correct, documented. Verified it:

PROBE calls before restore: 1
PROBE calls after restore: 0

Sinon's restore leaves the recorded calls readable, Vitest's mockRestore does not. Added a comment on the cleanup, since a shared helper is exactly where that difference will bite someone later.

Tests 2, the "vacuous" assertion: this one does not hold. The claim is that expect(undefined).to.equal(false) passes, so expect(args2?.hasChanged).to.equal(false) would go green when the mock was never called. Chai's equal is strict, so undefined === false is false:

PROBE expect(undefined).to.equal(false): FAILS
PROBE expect(undefined).to.equal(0): FAILS

The assertion already fails loudly on a never-called mock, so there is nothing to strengthen and no reason to reach for a non-null assertion. Worth noting the general shape, since it came up in the previous review too: on an optional-chained read, to.equal(<concrete value>) is safe, and it is only to.equal(undefined) and to.not.equal(...) that turn "never called" into a pass.

Simplifications 1, Object.assign vs a direct cast: keeping Object.assign. The suggested (api as any)[methodKey] = spyFn reads better, but it puts any back into this helper, which is what the previous round removed. The no-any direct form does not come out cleaner:

(api as unknown as Record<string, unknown>)[methodKey] = spyFn;

GridApiCommon does not overlap Record<string, unknown>, so it needs the double cast. Between an indirect one-liner and a double cast, I would rather keep the property write typed and let the comment carry the explanation. Happy to switch if you would rather have the as any back.

@JCQuintas
JCQuintas requested a review from LukasTy August 31, 2026 16:35

@LukasTy LukasTy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Opus findings

  1. mock.lastCall?.[0] makes the four to.equal(undefined) assertions vacuous. On a never-called mock the optional chain yields undefined, so the assertion passes where the Sinon original threw. I probed it rather than reasoning about it: expect(neverCalled.mock.lastCall?.[0].foo).to.equal(undefined) PASSES, while mock.lastCall![0].foo THROWS. The sites are editComponents.DataGridPro.test.tsx:258 and :436 (.debounceMs), and cellEditing.DataGridPro.test.tsx:247 and rowEditing.DataGridPro.test.tsx:289 (.foo). All four are covered today by a neighbouring assertion that fails first, so there is no live gap. But mock.lastCall![0] is the faithful translation, and this is the rule worth carrying into the remaining 29 files: on an optional-chained read, to.equal(<concrete value>) is safe and to.equal(undefined) is not.

  2. The ?? {} fallbacks are now dead. With expect(...mock.calls.length).to.equal(1) in front of each destructure, lastCall cannot be undefined at exportExcel.DataGridPremium.test.tsx:777 or EventDialog.test.tsx:3360. Both expressions are already any, so nothing needs the fallback to type-check either. The call-count assertion fixed the diagnostic; the fallback can go with it.

@JCQuintas
JCQuintas marked this pull request as ready for review September 1, 2026 08:12
@JCQuintas
JCQuintas merged commit 8a72a34 into mui:master Sep 1, 2026
22 checks passed
@JCQuintas
JCQuintas deleted the code-infra/replace-sinon-spy-helpers branch September 1, 2026 08:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants