From 6a9e9ee3fc1a0403b41faf394a69e5e764052407 Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Sat, 15 Apr 2023 16:30:05 -0400 Subject: [PATCH] feat: toHaveLength matcher (#25) * feat: toHaveLength matcher * refactor: standardize errors thrown on failed expectation * test: toHaveLength coverage * fix: resolve type discrepancy * ci: add typecheck step --- .github/workflows/nodejs.yml | 20 ++++++++++++++ package.json | 1 + src/testComponents/expect.ts | 4 +-- src/testComponents/matchers.ts | 35 +++++++++++++++++++++++-- src/types.ts | 5 ++++ tests/expect.test.ts | 48 ++++++++++++++++++++++++++++++++++ 6 files changed, 109 insertions(+), 4 deletions(-) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 24519ea..f9f8e2c 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -45,6 +45,26 @@ jobs: - if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }} run: corepack enable && yarn + typecheck: + name: Typecheck + runs-on: ubuntu-latest + needs: dependencies + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - uses: actions/cache@v3 + id: dependencies-cache + env: + cache-name: dependencies-cache + with: + path: .yarn + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }}-node-18 + - run: | + corepack enable && yarn + yarn types:check + integration: runs-on: ubuntu-latest needs: dependencies diff --git a/package.json b/package.json index fb355e3..c7ad0a6 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "prebuild": "rm -rf dist", "lint": "rome format src tests && rome check src tests", "lint:fix": "rome format src tests --write && rome check src tests --apply", + "types:check": "yarn tsc --project . --noEmit", "test": "$SHELL ./script/test-suite", "test:integration": "$SHELL ./script/integration-tests", "build:ts": "tsc --project .", diff --git a/src/testComponents/expect.ts b/src/testComponents/expect.ts index e932ab1..b21fba4 100644 --- a/src/testComponents/expect.ts +++ b/src/testComponents/expect.ts @@ -63,7 +63,7 @@ class Expect { const out = (matcher as RawNoArgMatcher)(this.value, negated) if (!out.pass) { - throw new TestAssertionFailed(out.message) + assert.fail(out.message) } }) as NoArgMatcher } else if (matcher.length === 2) { @@ -71,7 +71,7 @@ class Expect { const out = (matcher as RawComparisonMatcher)(this.value, other, negated) if (!out.pass) { - throw new TestAssertionFailed(out.message) + assert.fail(out.message) } }) as ComparisonMatcher } diff --git a/src/testComponents/matchers.ts b/src/testComponents/matchers.ts index 9f060dd..3141d98 100644 --- a/src/testComponents/matchers.ts +++ b/src/testComponents/matchers.ts @@ -6,7 +6,7 @@ */ import assert from 'assert' -import { type MatcherReport } from '../types' +import { type MatcherReport, type WithLength } from '../types' /* * Asserts whether value and other are strictly equal. @@ -97,4 +97,35 @@ function toNotThrow(func: () => unknown, negated: boolean = false): MatcherRepor return out } -export default [toEqual, toBe, toThrow, toNotEqual, toNotBe, toNotThrow] +/* + * Validates that the `value` has a length of `length`. The value provided to `value` should + * have a defined length (i.e. it can be a string or some sort of iterable). + */ +function toHaveLength(value: unknown, length: unknown, negated: boolean = false): MatcherReport { + let valueLength = 0 + + const typedLength = length as number + const typedValue = value as WithLength + + if (typeof typedValue === 'string' || typeof typedValue.length === 'number') valueLength = typedValue.length as number + else if (typeof typedValue.length === 'function') valueLength = typedValue.length() + else if (typeof typedValue.size === 'number') valueLength = typedValue.size + else if (typeof typedValue.size === 'function') valueLength = typedValue.size() + else assert.fail(`${value} does not have a known length.`) + + const pass = (valueLength === typedLength && !negated) || (valueLength !== typedLength && negated) + + if (!negated) { + return { + pass, + message: pass ? '' : `${value} has length ${valueLength}, not ${typedLength}.`, + } + } + + return { + pass, + message: pass ? '' : `${value} has length ${typedLength}.`, + } +} + +export default [toEqual, toBe, toThrow, toNotEqual, toNotBe, toNotThrow, toHaveLength] diff --git a/src/types.ts b/src/types.ts index d7ce86c..3593f4d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -61,3 +61,8 @@ export interface WorkerReport { export interface CollectorReport { totalCases: number } + +export interface WithLength { + length?: number | (() => number) + size?: number | (() => number) +} diff --git a/tests/expect.test.ts b/tests/expect.test.ts index 7e0f11b..587f075 100644 --- a/tests/expect.test.ts +++ b/tests/expect.test.ts @@ -57,3 +57,51 @@ test('Identity negation', () => { test('Identity negation (fail)', () => { assert.throws(() => expect('yes').not.toBe('yes')) }) + +describe('toHaveLength', () => { + test.each([ + 'word', + [1, 2, 3, 4], + new Set([1, 2, 3, 4]), + new Map([ + [1, 1], + [2, 2], + [3, 3], + [4, 4], + ]), + ])('Asserts length correctly (value=%s)', (value: unknown) => { + assert.doesNotThrow(() => expect(value).toHaveLength(4)) + }) + + test.each([ + 'word', + [1, 2, 3, 4], + new Set([1, 2, 3, 4]), + new Map([ + [1, 1], + [2, 2], + [3, 3], + [4, 4], + ]), + ])('Asserts length mismatch correctly when negated (value=%s)', (value: unknown) => { + assert.doesNotThrow(() => expect(value).not.toHaveLength(5)) + }) + + test('Fails if the value has no length or size', () => { + assert.throws( + () => { + expect(123).toHaveLength(1) + }, + { name: 'AssertionError', message: '123 does not have a known length.' }, + ) + }) + + test('Fails if the provided value is not accurate', () => { + assert.throws( + () => { + expect('word').toHaveLength(1) + }, + { name: 'AssertionError', message: 'word has length 4, not 1.' }, + ) + }) +})