From 0f08dda2c619b228fc0d2f9a0b4432f4b4e04f54 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 23 Mar 2026 19:25:41 +0900 Subject: [PATCH] fix(expect): fix sinon-chai style API (#9943) --- docs/api/expect.md | 12 +- docs/guide/migration.md | 3 +- packages/expect/src/chai-style-assertions.ts | 10 +- packages/expect/src/types.ts | 8 +- pnpm-lock.yaml | 61 ++++++ pnpm-workspace.yaml | 2 + test/core/package.json | 2 + .../test/chai-style-assertions-sinon.test.ts | 181 ++++++++++++++++++ test/core/test/chai-style-assertions.test.ts | 4 +- 9 files changed, 264 insertions(+), 19 deletions(-) create mode 100644 test/core/test/chai-style-assertions-sinon.test.ts diff --git a/docs/api/expect.md b/docs/api/expect.md index 3ae4f95a7..c5e022046 100644 --- a/docs/api/expect.md +++ b/docs/api/expect.md @@ -1559,23 +1559,19 @@ test('spy nth called with', () => { ## returned 4.1.0 {#returned} -- **Type:** `Assertion` (property, not a method) - -Chai-style assertion that checks if a spy returned successfully at least once. This is equivalent to `toHaveReturned()`. +- **Type:** `(value: any) => void` -::: tip -This is a property assertion following sinon-chai conventions. Access it without parentheses: `expect(spy).to.have.returned` -::: +Chai-style assertion that checks if a spy returned a specific value at least once. This is equivalent to `toHaveReturnedWith(value)`. ```ts import { expect, test, vi } from 'vitest' test('spy returned', () => { - const spy = vi.fn(() => 'result') + const spy = vi.fn(() => 'value') spy() - expect(spy).to.have.returned + expect(spy).to.have.returned('value') }) ``` diff --git a/docs/guide/migration.md b/docs/guide/migration.md index 9e88e89e8..e5742cc8b 100644 --- a/docs/guide/migration.md +++ b/docs/guide/migration.md @@ -742,8 +742,7 @@ Vitest supports all common sinon-chai assertions: | `spy.callCount(n)` | `callCount(n)` | Spy was called n times | | `spy.calledWith(...)` | `calledWith(...)` | Spy was called with specific args | | `spy.calledOnceWith(...)` | `calledOnceWith(...)` | Spy was called once with specific args | -| `spy.returned` | `returned` | Spy returned successfully | -| `spy.returnedWith(value)` | `returnedWith(value)` | Spy returned specific value | +| `spy.returned(value)` | `returned` | Spy returned specific value | See the [Chai-Style Spy Assertions](/api/expect#chai-style-spy-assertions) documentation for the complete list. diff --git a/packages/expect/src/chai-style-assertions.ts b/packages/expect/src/chai-style-assertions.ts index 85a1749a4..e9fbe39c4 100644 --- a/packages/expect/src/chai-style-assertions.ts +++ b/packages/expect/src/chai-style-assertions.ts @@ -44,7 +44,7 @@ export const ChaiStyleAssertions: ChaiPlugin = (chai, utils) => { name: keyof Assertion, delegateTo: keyof Assertion, ) { - utils.addChainableMethod( + utils.addMethod( chai.Assertion.prototype, name, function (this: Chai.AssertionStatic & Assertion, ...args: any[]) { @@ -56,13 +56,13 @@ export const ChaiStyleAssertions: ChaiPlugin = (chai, utils) => { } return jestMethod.call(this, ...args) }, - () => {}, ) } + // API to (somewhat) mirror sinon-chai + // https://github.com/chaijs/sinon-chai defProperty('called', 'toHaveBeenCalled') defProperty('calledOnce', 'toHaveBeenCalledOnce') - defProperty('returned', 'toHaveReturned') defPropertyWithArgs('calledTwice', 'toHaveBeenCalledTimes', 2) defPropertyWithArgs('calledThrice', 'toHaveBeenCalledTimes', 3) @@ -71,10 +71,14 @@ export const ChaiStyleAssertions: ChaiPlugin = (chai, utils) => { defMethod('calledOnceWith', 'toHaveBeenCalledExactlyOnceWith') defMethod('lastCalledWith', 'toHaveBeenLastCalledWith') defMethod('nthCalledWith', 'toHaveBeenNthCalledWith') + defMethod('returned', 'toHaveReturned') defMethod('returnedWith', 'toHaveReturnedWith') defMethod('returnedTimes', 'toHaveReturnedTimes') defMethod('lastReturnedWith', 'toHaveLastReturnedWith') defMethod('nthReturnedWith', 'toHaveNthReturnedWith') defMethod('calledBefore', 'toHaveBeenCalledBefore') defMethod('calledAfter', 'toHaveBeenCalledAfter') + + // TODO: implement + // defMethod('thrown', 'toHaveThrown') } diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 7f84cbe69..b5a7864a9 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -836,13 +836,13 @@ export interface ChaiMockAssertion { nthCalledWith: (n: number, ...args: E) => void /** - * Checks that a spy returned successfully at least once. - * Chai-style equivalent of `toHaveReturned`. + * Checks that a spy returned a specific value at least once. + * Chai-style equivalent of `toHaveReturnedWith`. * * @example - * expect(spy).to.have.returned + * expect(spy).to.have.returned('value') */ - readonly returned: Assertion + returned: (value: E) => void /** * Checks that a spy returned a specific value at least once. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd29eac78..2fbfbbbd5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,12 @@ catalogs: playwright: specifier: ^1.58.2 version: 1.58.2 + sinon: + specifier: ^21.0.3 + version: 21.0.3 + sinon-chai: + specifier: ^4.0.1 + version: 4.0.1 sirv: specifier: ^3.0.2 version: 3.0.2 @@ -1512,6 +1518,12 @@ importers: react-18: specifier: npm:react@18.3.1 version: react@18.3.1 + sinon: + specifier: 'catalog:' + version: 21.0.3 + sinon-chai: + specifier: 'catalog:' + version: 4.0.1(chai@6.2.2)(sinon@21.0.3) temporal-polyfill: specifier: ~0.3.0 version: 0.3.0 @@ -4971,6 +4983,12 @@ packages: '@sinonjs/fake-timers@15.0.0': resolution: {integrity: sha512-dlUB2oL+hDIYkIq/OWFBDhQAuU6kDey3eeMiYpVb7UXHhkMq/r1HloKXAbJwJZpYWkFWsydLjMqDpueMUEOjXQ==} + '@sinonjs/fake-timers@15.1.1': + resolution: {integrity: sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==} + + '@sinonjs/samsam@9.0.3': + resolution: {integrity: sha512-ZgYY7Dc2RW+OUdnZ1DEHg00lhRt+9BjymPKHog4PRFzr1U3MbK57+djmscWyKxzO1qfunHqs4N45WWyKIFKpiQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -6639,6 +6657,10 @@ packages: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + engines: {node: '>=0.3.1'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -9502,6 +9524,15 @@ packages: simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sinon-chai@4.0.1: + resolution: {integrity: sha512-xMKEEV3cYHC1G+boyr7QEqi80gHznYsxVdC9CdjP5JnCWz/jPGuXQzJz3PtBcb0CcHAxar15Y5sjLBoAs6a0yA==} + peerDependencies: + chai: ^5.0.0 || ^6.0.0 + sinon: '>=4.0.0' + + sinon@21.0.3: + resolution: {integrity: sha512-0x8TQFr8EjADhSME01u1ZK31yv2+bd6Z5NrBCHVM+n4qL1wFqbxftmeyi3bwlr49FbbzRfrqSFOpyHCOh/YmYA==} + sirv@3.0.2: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} @@ -9952,6 +9983,10 @@ packages: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + type-fest@0.16.0: resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} engines: {node: '>=10'} @@ -13416,6 +13451,15 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers@15.1.1': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@sinonjs/samsam@9.0.3': + dependencies: + '@sinonjs/commons': 3.0.1 + type-detect: 4.1.0 + '@standard-schema/spec@1.1.0': {} '@stylistic/eslint-plugin@5.9.0(eslint@10.0.3(jiti@2.6.1))': @@ -15276,6 +15320,8 @@ snapshots: diff-sequences@29.6.3: {} + diff@8.0.3: {} + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -18839,6 +18885,19 @@ snapshots: dependencies: is-arrayish: 0.3.2 + sinon-chai@4.0.1(chai@6.2.2)(sinon@21.0.3): + dependencies: + chai: 6.2.2 + sinon: 21.0.3 + + sinon@21.0.3: + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers': 15.1.1 + '@sinonjs/samsam': 9.0.3 + diff: 8.0.3 + supports-color: 7.2.0 + sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.24 @@ -19317,6 +19376,8 @@ snapshots: type-detect@4.0.8: {} + type-detect@4.1.0: {} + type-fest@0.16.0: {} type-fest@2.13.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2264f9ae9..5f317b55a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -75,6 +75,8 @@ catalog: obug: ^2.1.1 pathe: ^2.0.3 playwright: ^1.58.2 + sinon: ^21.0.3 + sinon-chai: ^4.0.1 sirv: ^3.0.2 std-env: ^4.0.0-rc.1 strip-literal: ^3.1.0 diff --git a/test/core/package.json b/test/core/package.json index ff850bb52..75d99e282 100644 --- a/test/core/package.json +++ b/test/core/package.json @@ -38,6 +38,8 @@ "obug": "^2.1.1", "react": "^19.2.4", "react-18": "npm:react@18.3.1", + "sinon": "catalog:", + "sinon-chai": "catalog:", "temporal-polyfill": "~0.3.0", "tinyrainbow": "catalog:", "tinyspy": "^4.0.4", diff --git a/test/core/test/chai-style-assertions-sinon.test.ts b/test/core/test/chai-style-assertions-sinon.test.ts new file mode 100644 index 000000000..966a7f000 --- /dev/null +++ b/test/core/test/chai-style-assertions-sinon.test.ts @@ -0,0 +1,181 @@ +/* eslint-disable ts/no-unused-expressions */ +// @ts-expect-error no type override otherwise vitest ones broken +import sinon from 'sinon' +// @ts-expect-error same +import sinonChai from 'sinon-chai' +import { chai, describe, expect, it } from 'vitest' + +chai.use(sinonChai) + +describe('sinon-chai assertions', () => { + describe('called', () => { + it('passes when spy was called', () => { + const spy = sinon.spy() + spy() + expect(spy).to.have.been.called + }) + + it('negated: passes when spy was not called', () => { + const spy = sinon.spy() + expect(spy).to.not.have.been.called + }) + }) + + describe('callCount', () => { + it('passes when spy was called exact number of times', () => { + const spy = sinon.spy() + spy() + spy() + spy() + expect(spy).to.have.callCount(3) + }) + + it('negated: passes when spy was called different number of times', () => { + const spy = sinon.spy() + spy() + expect(spy).to.not.have.callCount(3) + }) + }) + + describe('calledWith', () => { + it('passes when spy was called with specific arguments', () => { + const spy = sinon.spy() + spy('arg1', 'arg2') + expect(spy).to.have.been.calledWith('arg1', 'arg2') + }) + + it('passes when spy was called with arguments among other calls', () => { + const spy = sinon.spy() + spy('other') + spy('arg1', 'arg2') + spy('another') + expect(spy).to.have.been.calledWith('arg1', 'arg2') + }) + + it('negated: passes when spy was not called with specific arguments', () => { + const spy = sinon.spy() + spy('other', 'args') + expect(spy).to.not.have.been.calledWith('arg1', 'arg2') + }) + }) + + describe('calledOnce', () => { + it('passes when spy was called exactly once', () => { + const spy = sinon.spy() + spy() + expect(spy).to.have.been.calledOnce + }) + + it('negated: passes when spy was not called once', () => { + const spy = sinon.spy() + spy() + spy() + expect(spy).to.not.have.been.calledOnce + }) + }) + + describe('calledOnceWith', () => { + it('passes when spy was called exactly once with specific arguments', () => { + const spy = sinon.spy() + spy('arg1', 'arg2') + expect(spy).to.have.been.calledOnceWith('arg1', 'arg2') + }) + + it('negated: passes when spy was not called once with specific arguments', () => { + const spy = sinon.spy() + spy('arg1', 'arg2') + spy('arg1', 'arg2') + expect(spy).to.not.have.been.calledOnceWith('arg1', 'arg2') + }) + }) + + describe('calledTwice', () => { + it('passes when spy was called exactly twice', () => { + const spy = sinon.spy() + spy() + spy() + expect(spy).to.have.been.calledTwice + }) + + it('negated: passes when spy was not called twice', () => { + const spy = sinon.spy() + spy() + expect(spy).to.not.have.been.calledTwice + }) + }) + + describe('calledThrice', () => { + it('passes when spy was called exactly three times', () => { + const spy = sinon.spy() + spy() + spy() + spy() + expect(spy).to.have.been.calledThrice + }) + + it('negated: passes when spy was not called three times', () => { + const spy = sinon.spy() + spy() + spy() + expect(spy).to.not.have.been.calledThrice + }) + }) + + describe('calledBefore / calledAfter', () => { + it('passes when spy was called before another spy', () => { + const spy1 = sinon.spy() + const spy2 = sinon.spy() + spy1() + spy2() + expect(spy1).to.have.been.calledBefore(spy2) + }) + + it('passes when spy was called after another spy', () => { + const spy1 = sinon.spy() + const spy2 = sinon.spy() + spy1() + spy2() + expect(spy2).to.have.been.calledAfter(spy1) + }) + }) + + describe('returned', () => { + it('passes when spy returned specific value', () => { + const spy = sinon.spy(() => 'value') + spy() + expect(spy).to.have.returned('value') + }) + + it('passes with no arguments (checks for undefined)', () => { + const spy = sinon.spy(() => {}) + spy() + expect(spy).to.have.returned(undefined) + }) + + it('negated: passes when spy did not return specific value', () => { + const spy = sinon.spy(() => 'other') + spy() + expect(spy).to.not.have.returned('value') + }) + }) + + // TODO: implement `thrown` + // describe('thrown', () => { + // it('passes when spy threw', () => { + // const spy = sinon.spy(() => { + // throw new Error('test error') + // }) + // try { + // spy() + // } + // catch {} + // expect(spy).to.have.thrown() + // }) + + // it('negated: passes when spy did not throw', () => { + // const spy = sinon.spy(() => 'value') + // spy() + // expect(spy).to.not.have.thrown() + // }) + // }) +}) diff --git a/test/core/test/chai-style-assertions.test.ts b/test/core/test/chai-style-assertions.test.ts index aecdb9ad3..241a838af 100644 --- a/test/core/test/chai-style-assertions.test.ts +++ b/test/core/test/chai-style-assertions.test.ts @@ -206,7 +206,7 @@ describe('Chai-style assertions', () => { it('passes when spy returned successfully', () => { const spy = vi.fn(() => 'value') spy() - expect(spy).to.have.returned + expect(spy).to.have.returned('value') }) it('fails when spy threw an error', () => { @@ -218,7 +218,7 @@ describe('Chai-style assertions', () => { } catch {} expect(() => { - expect(spy).to.have.returned + expect(spy).to.have.returned('value') }).toThrow(/expected "testSpy" to be successfully called at least once/) }) -- 2.51.2