diff --git a/package-lock.json b/package-lock.json index 82ce324..1e5b83b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@athenna/test", - "version": "4.4.0", + "version": "4.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@athenna/test", - "version": "4.4.0", + "version": "4.6.0", "license": "MIT", "dependencies": { "@japa/assert": "^1.4.1", diff --git a/package.json b/package.json index 9b56506..5f503f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@athenna/test", - "version": "4.5.0", + "version": "4.6.0", "description": "The Athenna test runner. Built on top of Japa.", "license": "MIT", "author": "João Lenon ", diff --git a/src/globals/Assert.ts b/src/globals/Assert.ts index 00994c0..1fd691a 100644 --- a/src/globals/Assert.ts +++ b/src/globals/Assert.ts @@ -7,6 +7,85 @@ * file that was distributed with this source code. */ +import { Assert } from '@japa/assert' +import type { SinonSpy } from 'sinon' + +Assert.macro('called', function (mock: SinonSpy) { + return this.isTrue(mock.called) +}) + +Assert.macro('calledOnce', function (mock: SinonSpy) { + return this.deepEqual(mock.callCount, 1) +}) + +Assert.macro('calledTimes', function (mock: SinonSpy, times: number) { + return this.deepEqual(mock.callCount, times) +}) + +Assert.macro('calledWith', function (mock: SinonSpy, ...args: any[]) { + const hasCalledWith = mock.calledWith(...args) + + if (!hasCalledWith) { + return this.deepEqual(mock.args[0], args) + } + + return this.isTrue(hasCalledWith) +}) + +Assert.macro('calledOnceWith', function (mock: SinonSpy, ...args: any[]) { + return this.isTrue(mock.calledOnceWith(...args)) +}) + +Assert.macro( + 'calledTimesWith', + function (mock: SinonSpy, times: number, ...args: any[]) { + this.calledTimes(mock, times) + this.calledWith(mock, ...args) + } +) + +Assert.macro('calledBefore', function (mock: SinonSpy, beforeMock: SinonSpy) { + return this.isTrue(mock.calledBefore(beforeMock)) +}) + +Assert.macro('calledAfter', function (mock: SinonSpy, afterMock: SinonSpy) { + return this.isTrue(mock.calledAfter(afterMock)) +}) + +Assert.macro('returned', function (mock: SinonSpy, value: any) { + const hasReturned = mock.returned(value) + + if (!hasReturned) { + return this.deepEqual(mock.returnValues[0], value) + } + + return this.isTrue(hasReturned) +}) + +Assert.macro('threw', function (mock: SinonSpy, value?: any) { + const hasThrew = mock.threw(value) + + if (!hasThrew) { + return this.deepEqual(mock.returnValues[0], value) + } + + return this.isTrue(hasThrew) +}) + +Assert.macro('resolved', async function (mock: SinonSpy, value?: any) { + const hasResolved = mock.returned(value) + + if (!hasResolved) { + return this.deepEqual(await mock.returnValues[0], value) + } + + return this.isTrue(hasResolved) +}) + +Assert.macro('rejected', async function (mock: SinonSpy, value?: any) { + return this.rejects(() => mock.returnValues[0], value) +}) + export {} declare module '@japa/assert' { @@ -23,5 +102,64 @@ declare module '@japa/assert' { errType: any, message?: string ): Promise + hey(): void + /** + * Assert that the given mock was called. + */ + called(mock: SinonSpy): void + /** + * Assert that the given mock was called only once. + */ + calledOnce(mock: SinonSpy): void + /** + * Assert that the given mock was called the + * determined number of times. + */ + calledTimes(mock: SinonSpy, times: number): void + /** + * Assert that the given mock was called with the + * determined arguments. + */ + calledWith(mock: SinonSpy, ...args: any[]): void + /** + * Assert that the given mock was called only once + * with the determined arguments. + */ + calledOnceWith(mock: SinonSpy, ...args: any[]): void + /** + * Assert that the given mock was called the + * determined number of times with always the determined + * arguments. + */ + calledTimesWith(mock: SinonSpy, times: number, ...args: any[]): void + /** + * Assert that the given mock was called before + * an other determined mock. + */ + calledBefore(mock: SinonSpy, beforeMock: SinonSpy): void + /** + * Assert that the given mock was called after + * an other determined mock. + */ + calledAfter(mock: SinonSpy, afterMock: SinonSpy): void + /** + * Assert that the given mock returned a determined value. + */ + returned(mock: SinonSpy, value: any): void + /** + * Assert that the given threw. Could also + * assert the value that has been thrown. + */ + threw(mock: SinonSpy, value?: any): void + /** + * Assert that the given mock resolved. Could also + * assert the value that has been returned. + */ + resolved(mock: SinonSpy, value?: any): Promise + /** + * Assert that the given mock rejected. Could also + * assert the value that has been returned. + */ + rejected(mock: SinonSpy, value?: any): Promise } } diff --git a/src/index.ts b/src/index.ts index ae8b355..611d962 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,8 @@ export * from '#src/globals/Context' export * from '#src/helpers/Runner' export * from '#src/helpers/Importer' export * from '#src/helpers/ExitFaker' +export * from '#src/mocks/Mock' +export * from '#src/mocks/MockBuilder' export * from '#src/annotations/AfterAll' export * from '#src/annotations/AfterEach' export * from '#src/annotations/BeforeAll' diff --git a/src/mocks/Mock.ts b/src/mocks/Mock.ts new file mode 100644 index 0000000..9493ed1 --- /dev/null +++ b/src/mocks/Mock.ts @@ -0,0 +1,70 @@ +/** + * @athenna/test + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { createSandbox } from 'sinon' +import { MockBuilder } from '#src/mocks/MockBuilder' +import type { StubMethod } from '#src/types/StubMethod' +import type { SpyMethod, SpyInstance } from '#src/types' +import type { StubInstance } from '#src/types/StubInstance' + +export class Mock { + /** + * Sinon sandbox instance. + */ + public static sandbox = createSandbox() + + /** + * Create a mock builder instance for the given object + * and a method of the object. + */ + public static when(object: T, method: keyof T): MockBuilder { + return new MockBuilder(object, method, Mock.sandbox) + } + + /** + * Create a spy function for a given object. + */ + public static spy(object: T): SpyInstance + + /** + * Create a spy function for a given object method. + */ + public static spy(object: T, method: keyof T): SpyMethod + + public static spy(object: T, method?: keyof T) { + return Mock.sandbox.spy(object, method) + } + + /** + * Create a stub function for a given object. + */ + public static stub(object: T): StubInstance + + /** + * Create a stub function for a given object method. + */ + public static stub( + object: T, + method: keyof T + ): StubMethod + + public static stub(object: T, method?: keyof T) { + return Mock.sandbox.stub(object, method) + } + + /** + * Restore all mocks to default. + */ + public static restoreAll(): void { + Mock.sandbox.restore() + Mock.sandbox.resetHistory() + Mock.sandbox.resetBehavior() + Mock.sandbox.reset() + } +} diff --git a/src/mocks/MockBuilder.ts b/src/mocks/MockBuilder.ts new file mode 100644 index 0000000..e6f7a75 --- /dev/null +++ b/src/mocks/MockBuilder.ts @@ -0,0 +1,100 @@ +/** + * @athenna/test + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Stub } from '#src/types' +import type { SinonSandbox } from 'sinon' +import type { Exception } from '@athenna/common' + +export class MockBuilder { + public constructor( + private object: any, + private method: any, + private sandbox: SinonSandbox + ) {} + + /** + * Mock the method to return the given value. + * + * @example + * ```ts + * import { Mock } from '@athenna/test' + * + * const mock = Mock.when(console, 'log').return('Hello World') + * + * mock.called // false + * ``` + */ + public return(value: T): Stub { + const stub = this.sandbox.stub(this.object, this.method) + + stub.returns(value) + + return stub + } + + /** + * Mock the method to throw the given value. + * + * @example + * ```ts + * import { Mock } from '@athenna/test' + * + * const mock = Mock.when(console, 'log').throw('Hello World') + * + * mock.called // false + * ``` + */ + public throw(value: string | Error | Exception): Stub { + const stub = this.sandbox.stub(this.object, this.method) + + stub.throws(value) + + return stub + } + + /** + * Mock the method to resolve returning the given value. + * + * @example + * ```ts + * import { Mock } from '@athenna/test' + * + * const mock = Mock.when(console, 'log').resolve('Hello World') + * + * mock.called // false + * ``` + */ + public resolve(value: T): Stub { + const stub = this.sandbox.stub(this.object, this.method) + + stub.resolves(value) + + return stub + } + + /** + * Mock the method to reject returning the given value. + * + * @example + * ```ts + * import { Mock } from '@athenna/test' + * + * const mock = Mock.when(console, 'log').reject('Hello World') + * + * mock.called // false + * ``` + */ + public reject(value: T): Stub { + const stub = this.sandbox.stub(this.object, this.method) + + stub.rejects(value) + + return stub + } +} diff --git a/src/types/Spy.ts b/src/types/Spy.ts new file mode 100644 index 0000000..4972878 --- /dev/null +++ b/src/types/Spy.ts @@ -0,0 +1,15 @@ +/** + * @athenna/test + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { SinonSpy } from 'sinon' + +export type Spy< + TArgs extends readonly any[] = any[], + TReturnValue = any +> = SinonSpy diff --git a/src/types/SpyInstance.ts b/src/types/SpyInstance.ts new file mode 100644 index 0000000..fc33870 --- /dev/null +++ b/src/types/SpyInstance.ts @@ -0,0 +1,14 @@ +/** + * @athenna/test + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { SpyMethod } from '#src/types/SpyMethod' + +export type SpyInstance = { + [P in keyof T]: SpyMethod +} diff --git a/src/types/SpyMethod.ts b/src/types/SpyMethod.ts new file mode 100644 index 0000000..42b4b15 --- /dev/null +++ b/src/types/SpyMethod.ts @@ -0,0 +1,12 @@ +/** + * @athenna/test + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { SinonSpiedMember } from 'sinon' + +export type SpyMethod = SinonSpiedMember diff --git a/src/types/Stub.ts b/src/types/Stub.ts new file mode 100644 index 0000000..102b389 --- /dev/null +++ b/src/types/Stub.ts @@ -0,0 +1,15 @@ +/** + * @athenna/test + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { SinonStub } from 'sinon' + +export type Stub< + TArgs extends readonly any[] = any[], + TReturnValue = any +> = SinonStub diff --git a/src/types/StubInstance.ts b/src/types/StubInstance.ts new file mode 100644 index 0000000..df7cdde --- /dev/null +++ b/src/types/StubInstance.ts @@ -0,0 +1,14 @@ +/** + * @athenna/test + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { StubMethod } from '#src/types/StubMethod' + +export type StubInstance = { + [P in keyof T]: StubMethod +} diff --git a/src/types/StubMethod.ts b/src/types/StubMethod.ts new file mode 100644 index 0000000..3ea6fd0 --- /dev/null +++ b/src/types/StubMethod.ts @@ -0,0 +1,12 @@ +/** + * @athenna/test + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { SinonStubbedMember } from 'sinon' + +export type StubMethod = SinonStubbedMember diff --git a/src/types/index.ts b/src/types/index.ts index 30c3646..cd2bd6e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,6 +9,12 @@ export type { Config, PluginFn } from '@japa/runner' +export * from '#src/types/Spy' +export * from '#src/types/SpyMethod' +export * from '#src/types/SpyInstance' +export * from '#src/types/Stub' +export * from '#src/types/StubMethod' +export * from '#src/types/StubInstance' export * from '#src/types/Context' export * from '#src/types/TestOptions' export * from '#src/types/SetupHandler' diff --git a/tests/fixtures/UserService.ts b/tests/fixtures/UserService.ts new file mode 100644 index 0000000..9c4d363 --- /dev/null +++ b/tests/fixtures/UserService.ts @@ -0,0 +1,44 @@ +/** + * @athenna/test + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export class UserService { + public async find() { + return [ + { + id: 1, + name: 'João Lenon', + email: 'lenon@athenna.io' + } + ] + } + + public async findById(id: number) { + const users = await this.find() + + return users.find(user => user.id === id) + } + + public findSync() { + return [ + { + id: 1, + name: 'João Lenon', + email: 'lenon@athenna.io' + } + ] + } + + public throw() { + throw new Error('User not found') + } + + public async reject() { + throw new Error('User not found') + } +} diff --git a/tests/unit/mocks/AssertMockTest.ts b/tests/unit/mocks/AssertMockTest.ts new file mode 100644 index 0000000..506962f --- /dev/null +++ b/tests/unit/mocks/AssertMockTest.ts @@ -0,0 +1,258 @@ +/** + * @athenna/test + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Test, Mock, type Context, Fails } from '#src' +import { UserService } from '#tests/fixtures/UserService' + +export default class AssertMockTest { + @Test() + public async shouldBeAbleToAssertSpyWasCalled({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService, 'findById') + + await userService.findById(1) + + assert.called(spy) + } + + @Test() + public async shouldBeAbleToAssertSpyWasCalledOnce({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService, 'findById') + + await userService.findById(1) + + assert.calledOnce(spy) + assert.equal(spy.callCount, 1) + } + + @Test() + public async shouldBeAbleToAssertSpyWasCalledTimes({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService, 'findById') + + await userService.findById(1) + + assert.calledTimes(spy, 1) + assert.equal(spy.callCount, 1) + } + + @Test() + public async shouldBeAbleToAssertSpyWasCalledWithArgs({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService, 'findById') + + await userService.findById(1) + + assert.calledWith(spy, 1) + } + + @Test() + @Fails() + public async shouldFailWhenAssertingThatSpyWasNotCalledWithArgs({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService, 'findById') + + await userService.findById(1) + + assert.calledWith(spy, 2) + } + + @Test() + public async shouldBeAbleToAssertSpyWasCalledOnceWithArgs({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService, 'findById') + + await userService.findById(1) + + assert.calledOnceWith(spy, 1) + } + + @Test() + @Fails() + public async shouldFailIfSpyWasCalledMoreThemOnceWithArgs({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService, 'findById') + + // call twice + await userService.findById(1) + await userService.findById(1) + + assert.calledOnceWith(spy, 1) + } + + @Test() + public async shouldBeAbleToAssertSpyWasCalledTimesWithArgs({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService, 'findById') + + await userService.findById(1) + + assert.calledTimesWith(spy, 1, 1) + } + + @Test() + @Fails() + public async shouldFailIfSpyWasCalledMoreThemTimesWithArgs({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService, 'findById') + + // call twice + await userService.findById(1) + await userService.findById(1) + + assert.calledTimesWith(spy, 1, 1) + } + + @Test() + public async shouldBeAbleToAssertOtherSpyWasCalledBefore({ assert }: Context) { + const userService = new UserService() + + const spyFind = Mock.spy(userService, 'find') + const spyFindById = Mock.spy(userService, 'findById') + + await userService.find() + await userService.findById(1) + + assert.calledBefore(spyFind, spyFindById) + } + + @Test() + public async shouldBeAbleToAssertOtherSpyWasCalledAfter({ assert }: Context) { + const userService = new UserService() + + const spyFind = Mock.spy(userService, 'find') + const spyFindById = Mock.spy(userService, 'findById') + + await userService.findById(1) + + assert.calledAfter(spyFind, spyFindById) + } + + @Test() + public async shouldBeAbleToAssertThatSpyHasReturnedAValue({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService, 'findSync') + + userService.findSync() + + assert.returned(spy, [ + { + id: 1, + name: 'João Lenon', + email: 'lenon@athenna.io' + } + ]) + } + + @Test() + @Fails() + public async shouldFailWhenSpyHasNotReturnedTheGivenValue({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService, 'findSync') + + userService.findSync() + + assert.returned(spy, [ + { + id: 2, + name: 'João Lenon', + email: 'lenon@athenna.io' + } + ]) + } + + @Test() + public async shouldBeAbleToAssertThatSpyHasResolvedAValue({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService, 'find') + + await userService.find() + + await assert.resolved(spy, [ + { + id: 1, + name: 'João Lenon', + email: 'lenon@athenna.io' + } + ]) + } + + @Test() + @Fails() + public async shouldFailWhenSpyHasNotResolvedTheGivenValue({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService, 'findSync') + + userService.findSync() + + await assert.resolved(spy, [ + { + id: 2, + name: 'João Lenon', + email: 'lenon@athenna.io' + } + ]) + } + + @Test() + public async shouldBeAbleToAssertThatSpyHasThrewAValue({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService, 'throw') + + assert.throws(() => userService.throw(), 'User not found') + assert.threw(spy, 'Error') + } + + @Test() + @Fails() + public async shouldFailWhenSpyHasNotThrewTheGivenValue({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService, 'throw') + + assert.throws(() => userService.throw(), 'User not found') + assert.threw(spy, 'OtherError') + } + + @Test() + public async shouldBeAbleToAssertThatSpyHasRejectedAValue({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService, 'reject') + + await assert.rejects(() => userService.reject(), 'User not found') + await assert.rejected(spy, 'User not found') + } + + @Test() + @Fails() + public async shouldFailWhenSpyHasNotRejectedTheGivenValue({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService, 'reject') + + await assert.rejects(() => userService.reject(), 'User not found') + await assert.rejected(spy, 'OtherError') + } +} diff --git a/tests/unit/mocks/MockTest.ts b/tests/unit/mocks/MockTest.ts new file mode 100644 index 0000000..d72beed --- /dev/null +++ b/tests/unit/mocks/MockTest.ts @@ -0,0 +1,131 @@ +/** + * @athenna/test + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Test, Mock, type Context } from '#src' +import { UserService } from '#tests/fixtures/UserService' + +export default class MockTest { + @Test() + public async shouldBeAbleToSpyObjectToMakeAssertionsOnIt({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService) + + await userService.findById(1) + + assert.calledWith(spy.findById, 1) + await assert.resolved(spy.findById, { + id: 1, + name: 'João Lenon', + email: 'lenon@athenna.io' + }) + } + + @Test() + public async shouldBeAbleToCreateAStubInstanceThatCouldReplaceTheOriginalObjectToMakeAssertionsOnIt({ + assert + }: Context) { + const userService = new UserService() + + const stub = Mock.stub(userService) + + stub.findById.resolves({ + id: 1, + name: 'Victor Tesoura', + email: 'txsoura@athenna.io' + }) + + await userService.findById(1) + + assert.calledWith(stub.findById, 1) + await assert.resolved(stub.findById, { + id: 1, + name: 'Victor Tesoura', + email: 'txsoura@athenna.io' + }) + } + + @Test() + public async shouldBeAbleToSpyObjectMethodsToMakeAssertionsOnIt({ assert }: Context) { + const userService = new UserService() + + const spy = Mock.spy(userService, 'findById') + + await userService.findById(1) + + assert.calledWith(spy, 1) + await assert.resolved(spy, { + id: 1, + name: 'João Lenon', + email: 'lenon@athenna.io' + }) + } + + @Test() + public async shouldBeAbleToMockObjectMethodsToReturnValue({ assert }: Context) { + const userService = new UserService() + + const mock = Mock.when(userService, 'findById').return({ id: 2 }) + + userService.findById(1) + + assert.calledWith(mock, 1) + assert.returned(mock, { id: 2 }) + } + + @Test() + public async shouldBeAbleToMockObjectMethodsToResolveAReturnValue({ assert }: Context) { + const userService = new UserService() + + const mock = Mock.when(userService, 'findById').resolve({ id: 2 }) + + await userService.findById(1) + + assert.calledWith(mock, 1) + await assert.resolved(mock, { id: 2 }) + } + + @Test() + public async shouldBeAbleToMockObjectMethodsToThrowValue({ assert }: Context) { + const userService = new UserService() + + const mock = Mock.when(userService, 'findById').throw(new Error('ERROR_MOCK')) + + assert.throws(() => userService.findById(1), Error) + + assert.threw(mock) + assert.threw(mock, 'Error') + } + + @Test() + public async shouldBeAbleToMockObjectMethodsToRejectAValue({ assert }: Context) { + const userService = new UserService() + + const mock = Mock.when(userService, 'findById').reject(new Error('ERROR_MOCK')) + + await assert.rejects(() => userService.findById(1), Error) + await assert.rejected(mock, Error) + } + + @Test() + public async shouldBeAbleToRestoreAllMocks({ assert }: Context) { + const userService = new UserService() + + Mock.when(userService, 'find').reject(new Error('ERROR_MOCK')) + Mock.when(userService, 'findById').reject(new Error('ERROR_MOCK')) + + await assert.rejects(() => userService.find(), Error) + await assert.rejects(() => userService.findById(1), Error) + + Mock.restoreAll() + + await assert.doesNotRejects(() => userService.find()) + await assert.doesNotRejects(() => userService.findById(1)) + } +}