diff --git a/rome.json b/rome.json index 142ad0e..9bb1acd 100644 --- a/rome.json +++ b/rome.json @@ -1,12 +1,4 @@ { - "linter": { - "enabled": true, - "rules": { - "suspicious": { - "noExplicitAny": "warn" - } - } - }, "formatter": { "enabled": true, "lineWidth": 120 diff --git a/src/expect.ts b/src/expect.ts index 71ec84b..43ad143 100644 --- a/src/expect.ts +++ b/src/expect.ts @@ -1,6 +1,16 @@ import assert from 'assert' -import { type ExpectBase, type Expect } from './types' +import { + type Matcher, + type NoArgMatcher, + type ComparisonMatcher, + type RawMatcher, + type RawNoArgMatcher, + type RawComparisonMatcher, + type RawMatchersMap, + type MatcherName, +} from './types' + import matchers from './matchers' class TestAssertionFailed extends Error { @@ -10,46 +20,106 @@ class TestAssertionFailed extends Error { } } -function expect(value: ValueType): Expect { - const expectation: ExpectBase = { - value, - negated: false, - not: {}, - addMatcher: function (this: any, matcher: any) { - return (other: unknown) => { - const out = matcher(this.value, other) +class Expect { + static #rawMatchers: RawMatchersMap = { + comparisonMatchers: [], + noArgMatchers: [], + } + + /* + * Value the expectation is run against. + */ + value: ValueType + + /* + * Collection of inverted matchers. Any matchers registered + * is also available negated under .not. + */ + not: { [key: MatcherName]: Matcher } = {} + + /* + * Registers matchers with Expect. At this point, Expect knows of them, but + * still needs to prepare them on instantiation so they can be used. + */ + static addMatcher(matcher: RawMatcher) { + if (matcher.length === 1) Expect.#rawMatchers.noArgMatchers.push(matcher as RawNoArgMatcher) + else Expect.#rawMatchers.comparisonMatchers.push(matcher as RawComparisonMatcher) + } + + /* + * Returns all registered matchers. + */ + static #getRawMatchers(): Array { + return [...Expect.#rawMatchers.comparisonMatchers, ...Expect.#rawMatchers.noArgMatchers] + } + + /* + * Prepares a raw matchers for the current + * Expect instance. + */ + #prepareMatcher(matcher: RawMatcher, negated: boolean = false): Matcher { + if (matcher.length === 1) { + return (() => { + const out = (matcher as RawNoArgMatcher)(this.value, negated) if (!out.pass) { throw new TestAssertionFailed(out.message) } - } - }, - } - Object.entries(matchers.matchers).forEach(([label, matcher]) => { - Object.defineProperty(expectation, label, { - value: expectation.addMatcher(matcher), - enumerable: true, - }) + }) as NoArgMatcher + } else if (matcher.length === 2) { + return ((other: unknown) => { + const out = (matcher as RawComparisonMatcher)(this.value, other, negated) - if (label in matchers.matchersToInverseMap) { - const reverseMatcherName = matchers.matchersToInverseMap[ - label as keyof typeof matchers.matchersToInverseMap - ] as keyof typeof matchers.inverseMatchers - Object.defineProperty(expectation.not, label, { - value: expectation.addMatcher(matchers.inverseMatchers[reverseMatcherName]), - enumerable: true, - }) + if (!out.pass) { + throw new TestAssertionFailed(out.message) + } + }) as ComparisonMatcher } - }) - Object.entries(matchers.inverseMatchers).forEach(([label, matcher]) => { - Object.defineProperty(expectation, label, { - value: expectation.addMatcher(matcher), + throw Error('Unknown matcher layout') + } + + /* + * Adds a matcher to the current Expect instance. + */ + #extendWithMatcher(matcher: RawMatcher) { + Object.defineProperty(this, matcher.name, { + value: this.#prepareMatcher(matcher), enumerable: true, }) - }) - return expectation as Expect + Object.defineProperty(this.not, matcher.name, { + value: this.#prepareMatcher(matcher, true), + enumerable: true, + }) + } + + constructor(value: ValueType) { + this.value = value + + Expect.#getRawMatchers().forEach((matcher) => { + this.#extendWithMatcher(matcher) + }) + } } -export default expect +type ExpectWithMatchers = Expect & { + [key: MatcherName]: Matcher +} + +/* + * The `expect` function returned is the main access point + * to create Expect objects. On import, all the built-in matchers + * are registered, but more can be registered ad-hoc via `addMatcher`. + */ +export default (() => { + matchers.forEach((matcher) => { + Expect.addMatcher(matcher) + }) + + function expect(value: ValueType): ExpectWithMatchers { + return new Expect(value) as ExpectWithMatchers + } + + return expect +})() diff --git a/src/matchers.ts b/src/matchers.ts index a970854..6b9f300 100644 --- a/src/matchers.ts +++ b/src/matchers.ts @@ -11,7 +11,8 @@ import { type MatcherReport } from './types' /* * Asserts whether value and other are strictly equal. */ -function toEqual(value: unknown, other: unknown): MatcherReport { +function toEqual(value: unknown, other: unknown, negated: boolean = false): MatcherReport { + if (negated) return toNotEqual(value, other) const output = { pass: false, message: '' } try { @@ -27,7 +28,9 @@ function toEqual(value: unknown, other: unknown): MatcherReport { /* * Inverse of toEqual. */ -function toNotEqual(value: unknown, other: unknown): MatcherReport { +function toNotEqual(value: unknown, other: unknown, negated: boolean = false): MatcherReport { + if (negated) return toEqual(value, other) + const out = toEqual(value, other) out.pass = !out.pass @@ -39,7 +42,9 @@ function toNotEqual(value: unknown, other: unknown): MatcherReport { /* * Asserts whether value and other are the same entity. */ -function toBe(value: unknown, other: unknown): MatcherReport { +function toBe(value: unknown, other: unknown, negated: boolean = false): MatcherReport { + if (negated) return toNotBe(value, other) + const isSame = Object.is(value, other) return { pass: isSame, message: `${value} is not ${other}` } } @@ -47,7 +52,8 @@ function toBe(value: unknown, other: unknown): MatcherReport { /* * Inverse ot toBe. */ -function toNotBe(value: unknown, other: unknown): MatcherReport { +function toNotBe(value: unknown, other: unknown, negated: boolean = false): MatcherReport { + if (negated) return toBe(value, other) const out = toBe(value, other) out.pass = !out.pass @@ -59,7 +65,9 @@ function toNotBe(value: unknown, other: unknown): MatcherReport { /* * Asserts whether the provided function throws the provided error. */ -function toThrow(func: () => unknown, error: Error): MatcherReport { +function toThrow(func: () => unknown, negated: boolean = false): MatcherReport { + if (negated) return toNotThrow(func) + const report = { pass: false, message: '' } try { @@ -78,8 +86,10 @@ function toThrow(func: () => unknown, error: Error): MatcherReport { /* * Inverse of toThrow. */ -function toNotThrow(func: () => unknown, error: Error): MatcherReport { - const out = toThrow(func, error) +function toNotThrow(func: () => unknown, negated: boolean = false): MatcherReport { + if (negated) return toThrow(func) + + const out = toThrow(func) out.pass = !out.pass out.message = out.pass ? '' : 'Function threw exception' @@ -87,7 +97,4 @@ function toNotThrow(func: () => unknown, error: Error): MatcherReport { return out } -const matchers = { toEqual, toBe, toThrow } -const inverseMatchers = { toNotEqual, toNotBe, toNotThrow } -const matchersToInverseMap = { toEqual: 'toNotEqual', toBe: 'toNotBe', toThrow: 'toNotThrow' } -export default { matchers, inverseMatchers, matchersToInverseMap } +export default [toEqual, toBe, toThrow, toNotEqual, toNotBe, toNotThrow] diff --git a/src/runner.ts b/src/runner.ts index eabbeb3..e3cdfde 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -99,9 +99,11 @@ function parseArgs(args: Array): Args { argsWithoutFlags, shortFlags, longFlags, - }: { argsWithoutFlags: Array; longFlags: Array; shortFlags: Array } = ( - userArgs as Array - ).reduce( + }: { + argsWithoutFlags: Array + longFlags: Array + shortFlags: Array + } = (userArgs as Array).reduce( (acc, arg: string) => { if (arg.startsWith('--')) acc.longFlags.push(arg) else if (arg.startsWith('-')) acc.shortFlags.push(arg) diff --git a/src/types.ts b/src/types.ts index f7e120a..6e200a3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,13 +29,19 @@ export interface MatcherReport { message: string } -export interface ExpectBase { - value?: ValueType - negated?: boolean - not: ExpectBase & any - addMatcher: (this: ExpectBase & any, matcher: any) => void +export type MatcherName = string + +export type ComparisonMatcher = (value: unknown) => void +export type NoArgMatcher = () => void + +export type RawComparisonFuncMatcher = (value: () => unknown, other: unknown) => MatcherReport +export type RawComparisonMatcher = (value: unknown, other: unknown, negated?: boolean) => MatcherReport +export type RawNoArgMatcher = (value: unknown | (() => unknown), negated?: boolean) => MatcherReport +export type RawNoArgFuncMatcher = (value: () => unknown, negated?: boolean) => MatcherReport +export type Matcher = (...rest: Array) => void +export type RawMatcher = RawComparisonMatcher | RawNoArgMatcher | RawNoArgFuncMatcher + +export interface RawMatchersMap { + comparisonMatchers: Array + noArgMatchers: Array } - -export type ComparisonMatcher = (value: unknown) => boolean - -export type Expect = ExpectBase & { [key: string]: ComparisonMatcher } diff --git a/tests/expect.test.ts b/tests/expect.test.ts index d6f876b..a6745bb 100644 --- a/tests/expect.test.ts +++ b/tests/expect.test.ts @@ -62,7 +62,7 @@ describe('Exception expectation', () => { const err = new Error('err') expect(() => { throw err - }).toThrow(err) + }).toThrow() }) test('Expects no error', () => {