From 60e678d0cfc271e4f8af793865b2454bd2cd569a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Lemaire?= Date: Sun, 5 Sep 2021 19:55:39 +0200 Subject: [PATCH] Extract from devtools project --- .editorconfig | 9 + .gitignore | 3 + README.md | 34 ++ TODO.md | 3 + deps.ts | 0 doc/about.md | 4 + doc/credits.md | 4 + doc/import.md | 7 + doc/index | 4 + doc/use.md | 12 + mod.ts | 5 + run | 19 + src/assertions.test.ts | 20 + src/assertions.ts | 8 + src/blocks.ts | 24 ++ src/expect/LICENSE | 15 + src/expect/expect.ts | 120 ++++++ src/expect/expect_test.ts | 742 ++++++++++++++++++++++++++++++++++++ src/expect/matchers.ts | 613 +++++++++++++++++++++++++++++ src/expect/matchers_test.ts | 728 +++++++++++++++++++++++++++++++++++ src/expect/mock.ts | 57 +++ src/expect/mock_test.ts | 72 ++++ src/mock.test.ts | 206 ++++++++++ src/mock.ts | 86 +++++ tsconfig.json | 10 + 25 files changed, 2805 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 README.md create mode 100644 TODO.md create mode 100644 deps.ts create mode 100644 doc/about.md create mode 100644 doc/credits.md create mode 100644 doc/import.md create mode 100644 doc/index create mode 100644 doc/use.md create mode 100644 mod.ts create mode 100755 run create mode 100644 src/assertions.test.ts create mode 100644 src/assertions.ts create mode 100644 src/blocks.ts create mode 100644 src/expect/LICENSE create mode 100644 src/expect/expect.ts create mode 100644 src/expect/expect_test.ts create mode 100644 src/expect/matchers.ts create mode 100644 src/expect/matchers_test.ts create mode 100644 src/expect/mock.ts create mode 100644 src/expect/mock_test.ts create mode 100644 src/mock.test.ts create mode 100644 src/mock.ts create mode 100644 tsconfig.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..83c1115 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*.{ts,json}] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d69a4c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +deno.d.ts +.vscode +.local diff --git a/README.md b/README.md new file mode 100644 index 0000000..174228b --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# typescript/testing + +[![Build Status](https://thunderk.visualstudio.com/typescript/_apis/build/status/testing?branchName=master)](https://dev.azure.com/thunderk/typescript/_build?pipelineNameFilter=testing) + +## About + +Testing utilities for Deno, trying to stick to jasmine/jest syntax for easy +porting. + +## Import + +In deno: + +```typescript +import { describe, expect, it } from "https://js.thunderk.net/testing/mod.ts"; +``` + +## Use + +Syntax is similar to [Jest](https://jestjs.io), and tries to be mostly +compatible. + +```typescript +describe("a module", () => { + it("does something", () => { + expect(5).toBeGreatherThan(4); + }); +}; +``` + +## Credits + +- Embeds a slighly adapted version of [expect](https://github.com/allain/expect) + library from Allain Lalonde diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..dbdb43e --- /dev/null +++ b/TODO.md @@ -0,0 +1,3 @@ +# TODO + +- Nested describe blocks diff --git a/deps.ts b/deps.ts new file mode 100644 index 0000000..e69de29 diff --git a/doc/about.md b/doc/about.md new file mode 100644 index 0000000..2b54971 --- /dev/null +++ b/doc/about.md @@ -0,0 +1,4 @@ +## About + +Testing utilities for Deno, trying to stick to jasmine/jest syntax for easy +porting. diff --git a/doc/credits.md b/doc/credits.md new file mode 100644 index 0000000..9b82b7c --- /dev/null +++ b/doc/credits.md @@ -0,0 +1,4 @@ +## Credits + +- Embeds a slighly adapted version of [expect](https://github.com/allain/expect) + library from Allain Lalonde diff --git a/doc/import.md b/doc/import.md new file mode 100644 index 0000000..03b96d0 --- /dev/null +++ b/doc/import.md @@ -0,0 +1,7 @@ +## Import + +In deno: + +```typescript +import { describe, expect, it } from "https://js.thunderk.net/testing/mod.ts"; +``` diff --git a/doc/index b/doc/index new file mode 100644 index 0000000..6a1b371 --- /dev/null +++ b/doc/index @@ -0,0 +1,4 @@ +about +import +use +credits \ No newline at end of file diff --git a/doc/use.md b/doc/use.md new file mode 100644 index 0000000..9ac8223 --- /dev/null +++ b/doc/use.md @@ -0,0 +1,12 @@ +## Use + +Syntax is similar to [Jest](https://jestjs.io), and tries to be mostly +compatible. + +```typescript +describe("a module", () => { + it("does something", () => { + expect(5).toBeGreatherThan(4); + }); +}; +``` diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..09ad5fc --- /dev/null +++ b/mod.ts @@ -0,0 +1,5 @@ +// Testing tools, trying to stick to jasmine/jest syntax for easy porting. + +export { describe, it } from "./src/blocks.ts"; +export { expect } from "./src/assertions.ts"; +export { mock, mockfn, patch } from "./src/mock.ts"; diff --git a/run b/run new file mode 100755 index 0000000..74d1c6d --- /dev/null +++ b/run @@ -0,0 +1,19 @@ +#!/bin/sh +# Simplified run tool for deno commands + +if test $# -eq 0 +then + echo "Usage: $0 [file or command]" + exit 1 +elif echo $1 | grep -q '.*.ts' +then + denocmd=run + denoargs=$1 + shift +else + denocmd=$1 + shift +fi + +denoargs="$(cat config/$denocmd.flags 2> /dev/null) $denoargs $@" +exec deno $denocmd $denoargs diff --git a/src/assertions.test.ts b/src/assertions.test.ts new file mode 100644 index 0000000..9023876 --- /dev/null +++ b/src/assertions.test.ts @@ -0,0 +1,20 @@ +import { describe, it } from "./blocks.ts"; +import { expect } from "./assertions.ts"; + +describe("expect", () => { + it("checks correctly for set equality", () => { + expect(() => { + expect(new Set([1, 2])).toEqual(new Set([2, 1])); + }).not.toThrow(); + + expect(() => { + expect(new Set([1])).toEqual([1]); + }).toThrow(); + expect(() => { + expect(new Set([1])).toEqual(new Set([2])); + }).toThrow(); + expect(() => { + expect(new Set([1])).toEqual({}); + }).toThrow(); + }); +}); diff --git a/src/assertions.ts b/src/assertions.ts new file mode 100644 index 0000000..cfb6271 --- /dev/null +++ b/src/assertions.ts @@ -0,0 +1,8 @@ +import { expect as _expect } from "./expect/expect.ts"; + +export function expect(value: any): ReturnType { + if (value && value.hasOwnProperty("_toExpected")) { + value = value._toExpected(); + } + return _expect(value); +} diff --git a/src/blocks.ts b/src/blocks.ts new file mode 100644 index 0000000..79d4600 --- /dev/null +++ b/src/blocks.ts @@ -0,0 +1,24 @@ +var _last_title = ""; + +// Test block, containing one or more it() calls. +export function describe( + title: string | { new (...args: any[]): any } | Function, + body: Function, +) { + switch (typeof title) { + case "string": { + _last_title = title; + break; + } + case "function": { + _last_title = title.name; + break; + } + } + body(); +} + +// Single test. +export function it(title: string, body: () => void | Promise) { + Deno.test(_last_title ? `${_last_title} ${title}` : title, body); +} diff --git a/src/expect/LICENSE b/src/expect/LICENSE new file mode 100644 index 0000000..960d050 --- /dev/null +++ b/src/expect/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2019, Allain Lalonde + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. \ No newline at end of file diff --git a/src/expect/expect.ts b/src/expect/expect.ts new file mode 100644 index 0000000..dcb75dd --- /dev/null +++ b/src/expect/expect.ts @@ -0,0 +1,120 @@ +import * as builtInMatchers from "./matchers.ts"; +import type { Matcher, Matchers } from "./matchers.ts"; + +import { AssertionError } from "https://deno.land/std@0.106.0/testing/asserts.ts"; + +interface Expected { + toBe(candidate: any): void; + toEqual(candidate: any): void; + toBeTruthy(): void; + toBeFalsy(): void; + toBeDefined(): void; + toBeInstanceOf(clazz: any): void; + toBeUndefined(): void; + toBeNull(): void; + toBeNaN(): void; + toMatch(pattern: RegExp | string): void; + toHaveProperty(propName: string): void; + toHaveLength(length: number): void; + toContain(item: any): void; + toThrow(error?: RegExp | string): void; + toBeGreaterThan(number: number): void; + toBeGreaterThanOrEqual(number: number): void; + toBeLessThan(number: number): void; + toBeLessThanOrEqual(number: number): void; + toHaveBeenCalled(): void; + toHaveBeenCalledTimes(number: number): void; + toHaveBeenCalledWith(...args: any[]): void; + toHaveBeenLastCalledWith(...args: any[]): void; + toHaveBeenNthCalledWith(nthCall: number, ...args: any[]): void; + toHaveReturned(): void; + toHaveReturnedTimes(number: number): void; + toHaveReturnedWith(value: any): void; + toHaveLastReturnedWith(value: any): void; + toHaveNthReturnedWith(nthCall: number, value: any): void; + + not: Expected; + resolves: Expected; + rejects: Expected; +} + +const matchers: Record = { + ...builtInMatchers, +}; + +export function expect(value: any): Expected { + let isNot = false; + let isPromised = false; + const self: any = new Proxy( + {}, + { + get(_, name) { + if (name === "not") { + isNot = !isNot; + return self; + } + + if (name === "resolves") { + if (!(value instanceof Promise)) { + throw new AssertionError("expected value must be a Promise"); + } + + isPromised = true; + return self; + } + + if (name === "rejects") { + if (!(value instanceof Promise)) { + throw new AssertionError("expected value must be a Promise"); + } + + value = value.then( + (value) => { + throw new AssertionError( + `Promise did not reject. resolved to ${value}`, + ); + }, + (err) => err, + ); + isPromised = true; + return self; + } + + const matcher: Matcher = matchers[name as any]; + if (!matcher) { + throw new TypeError( + typeof name === "string" + ? `matcher not found: ${name}` + : "matcher not found", + ); + } + + return (...args: any[]) => { + function applyMatcher(value: any, args: any[]) { + if (isNot) { + let result = matcher(value, ...args); + if (result.pass) { + throw new AssertionError("should not " + result.message); + } + } else { + let result = matcher(value, ...args); + if (!result.pass) { + throw new AssertionError(result.message || "Unknown error"); + } + } + } + + return isPromised + ? value.then((value: any) => applyMatcher(value, args)) + : applyMatcher(value, args); + }; + }, + }, + ); + + return self; +} + +export function addMatchers(newMatchers: Matchers): void { + Object.assign(matchers, newMatchers); +} diff --git a/src/expect/expect_test.ts b/src/expect/expect_test.ts new file mode 100644 index 0000000..11a8b55 --- /dev/null +++ b/src/expect/expect_test.ts @@ -0,0 +1,742 @@ +import { + assertEquals, + AssertionError, + assertThrows, +} from "https://deno.land/std@0.106.0/testing/asserts.ts"; + +import { addMatchers, expect } from "./expect.ts"; +import * as mock from "./mock.ts"; + +async function assertPass(fn: Function) { + try { + assertEquals(await fn(), undefined); + } catch (err) { + throw new AssertionError( + `expected ${fn.toString()} to pass but it failed with ${err}`, + ); + } +} + +async function assertAllPass(...fns: Function[]) { + for (let fn of fns) { + await assertPass(fn); + } +} + +async function assertFail(fn: Function) { + let thrown = true; + try { + let resolution = await fn(); + thrown = false; + throw new AssertionError( + `expected ${fn.toString()} to throw but it resolved with ${resolution}`, + ); + } catch (err) { + // expected + if (!thrown) throw err; + } +} + +async function assertAllFail(...fns: Function[]) { + for (let fn of fns) { + await assertFail(fn); + } +} + +Deno.test({ + name: "exportsFunction", + fn: () => { + assertEquals(typeof expect, "function"); + }, +}); + +Deno.test({ + name: "throwsWhenNoMatcherFound", + fn: () => { + assertThrows( + //@ts-ignore + () => expect(true).toBeFancy(), + TypeError, + "matcher not found: toBeFancy", + ); + }, +}); + +Deno.test({ + name: "allowsExtendingMatchers", + fn: () => { + addMatchers({ + toBeFancy(value: any) { + if (value === "fancy") { + return { pass: true }; + } else { + return { pass: false, message: "was not fancy" }; + } + }, + }); + + // @ts-ignore + assertPass(() => expect("fancy").toBeFancy()); + }, +}); + +Deno.test({ + name: "toBe", + fn: async () => { + const obj = {}; + assertEquals(typeof expect(obj).toBe, "function"); + await assertAllPass( + () => expect(obj).toBe(obj), + () => expect(obj).not.toBe({}), + () => expect(Promise.resolve(1)).resolves.toBe(1), + () => expect(Promise.reject(1)).rejects.toBe(1), + ); + + await assertFail(() => expect(obj).toBe({})); + await assertFail(() => expect(obj).not.toBe(obj)); + }, +}); + +Deno.test({ + name: "toEqual", + fn: async () => { + const obj = {}; + + await assertAllPass( + () => expect(1).toEqual(1), + () => expect(obj).toEqual({}), + () => expect(obj).toEqual(obj), + () => expect({ a: 1 }).toEqual({ a: 1 }), + () => expect([1]).toEqual([1]), + () => expect(Promise.resolve(1)).resolves.toEqual(1), + () => expect(Promise.reject(1)).rejects.toEqual(1), + ); + + await assertAllFail( + () => expect(1).toEqual(2), + () => expect(1).toEqual(true), + () => expect({}).toEqual(true), + () => expect(1).not.toEqual(1), + () => expect(true).not.toEqual(true), + ); + }, +}); + +Deno.test({ + name: "resolves", + fn: async () => { + const resolves = expect(Promise.resolve(true)).resolves; + for (let method of ["toEqual", "toBe", "toBeTruthy", "toBeFalsy"]) { + assertEquals( + typeof (resolves as any)[method], + "function", + `missing ${method}`, + ); + } + }, +}); + +Deno.test({ + name: "rejects", + fn: async () => { + const rejects = expect(Promise.reject(true)).rejects; + for ( + let method of ["toEqual", "toBe", "toBeTruthy", "toBeFalsy"] + ) { + assertEquals(typeof (rejects as any)[method], "function"); + } + }, +}); + +Deno.test({ + name: "toBeDefined", + fn: async () => { + await assertAllPass( + () => expect(true).toBeDefined(), + () => expect({}).toBeDefined(), + () => expect([]).toBeDefined(), + () => expect(undefined).not.toBeDefined(), + () => expect(Promise.resolve({})).resolves.toBeDefined(), + () => expect(Promise.reject({})).rejects.toBeDefined(), + ); + + await assertAllFail( + () => expect(undefined).toBeDefined(), + () => expect(true).not.toBeDefined(), + ); + }, +}); + +Deno.test({ + name: "toBeUndefined", + fn: async () => { + await assertAllPass( + () => expect(undefined).toBeUndefined(), + () => expect(null).not.toBeUndefined(), + () => expect(Promise.resolve(undefined)).resolves.toBeUndefined(), + () => expect(Promise.reject(undefined)).rejects.toBeUndefined(), + ); + + await assertAllFail( + () => expect(null).toBeUndefined(), + () => expect(undefined).not.toBeUndefined(), + () => expect(false).toBeUndefined(), + ); + }, +}); + +Deno.test({ + name: "toBeTruthy", + fn: async () => { + await assertAllPass( + () => expect(true).toBeTruthy(), + () => expect(false).not.toBeTruthy(), + () => expect(Promise.resolve(true)).resolves.toBeTruthy(), + () => expect(Promise.reject(true)).rejects.toBeTruthy(), + ); + + await assertAllFail( + () => expect(false).toBeTruthy(), + () => expect(true).not.toBeTruthy(), + ); + }, +}); + +Deno.test({ + name: "toBeFalsy", + fn: async () => { + await assertAllPass( + () => expect(false).toBeFalsy(), + () => expect(true).not.toBeFalsy(), + () => expect(Promise.resolve(false)).resolves.toBeFalsy(), + () => expect(Promise.reject(false)).rejects.toBeFalsy(), + ); + await assertAllFail( + () => expect(true).toBeFalsy(), + () => expect(false).not.toBeFalsy(), + ); + }, +}); + +Deno.test({ + name: "toBeGreaterThan", + fn: async () => { + await assertAllPass( + () => expect(2).toBeGreaterThan(1), + () => expect(1).not.toBeGreaterThan(2), + () => expect(Promise.resolve(2)).resolves.toBeGreaterThan(1), + () => expect(Promise.reject(2)).rejects.toBeGreaterThan(1), + ); + + await assertAllFail( + () => expect(1).toBeGreaterThan(1), + () => expect(1).toBeGreaterThan(2), + () => expect(2).not.toBeGreaterThan(1), + ); + }, +}); + +Deno.test({ + name: "toBeLessThan", + fn: async () => { + await assertAllPass( + () => expect(1).toBeLessThan(2), + () => expect(2).not.toBeLessThan(1), + () => expect(Promise.resolve(1)).resolves.toBeLessThan(2), + () => expect(Promise.reject(1)).rejects.toBeLessThan(2), + ); + + await assertAllFail( + () => expect(1).toBeLessThan(1), + () => expect(2).toBeLessThan(1), + () => expect(1).not.toBeLessThan(2), + ); + }, +}); + +Deno.test({ + name: "toBeGreaterThanOrEqual", + fn: async () => { + await assertAllPass( + () => expect(2).toBeGreaterThanOrEqual(1), + () => expect(1).toBeGreaterThanOrEqual(1), + () => expect(1).not.toBeGreaterThanOrEqual(2), + () => expect(Promise.resolve(2)).resolves.toBeGreaterThanOrEqual(2), + () => expect(Promise.reject(2)).rejects.toBeGreaterThanOrEqual(2), + ); + + await assertAllFail( + () => expect(1).toBeGreaterThanOrEqual(2), + () => expect(2).not.toBeGreaterThanOrEqual(1), + ); + }, +}); + +Deno.test({ + name: "toBeLessThanOrEqual", + fn: async () => { + await assertAllPass( + () => expect(1).toBeLessThanOrEqual(2), + () => expect(1).toBeLessThanOrEqual(1), + () => expect(2).not.toBeLessThanOrEqual(1), + () => expect(Promise.resolve(1)).resolves.toBeLessThanOrEqual(2), + () => expect(Promise.reject(1)).rejects.toBeLessThanOrEqual(2), + ); + await assertAllFail( + () => expect(2).toBeLessThanOrEqual(1), + () => expect(1).not.toBeLessThanOrEqual(1), + () => expect(1).not.toBeLessThanOrEqual(2), + ); + }, +}); + +Deno.test({ + name: "toBeNull", + fn: async () => { + await assertAllPass( + () => expect(null).toBeNull(), + () => expect(undefined).not.toBeNull(), + () => expect(false).not.toBeNull(), + () => expect(Promise.resolve(null)).resolves.toBeNull(), + () => expect(Promise.reject(null)).rejects.toBeNull(), + ); + + await assertAllFail( + () => expect({}).toBeNull(), + () => expect(null).not.toBeNull(), + () => expect(undefined).toBeNull(), + ); + }, +}); + +Deno.test({ + name: "toBeInstanceOf", + fn: async () => { + class A {} + class B {} + + await assertAllPass( + () => expect(new A()).toBeInstanceOf(A), + () => expect(new A()).not.toBeInstanceOf(B), + ); + + await assertAllFail( + () => expect({}).toBeInstanceOf(A), + () => expect(null).toBeInstanceOf(A), + ); + }, +}); + +Deno.test({ + name: "toBeNaN", + fn: async () => { + await assertAllPass( + () => expect(NaN).toBeNaN(), + () => expect(10).not.toBeNaN(), + () => expect(Promise.resolve(NaN)).resolves.toBeNaN(), + ); + + await assertAllFail(() => expect(10).toBeNaN(), () => expect(10).toBeNaN()); + }, +}); + +Deno.test({ + name: "toBeMatch", + fn: async () => { + await assertAllPass( + () => expect("hello").toMatch(/^hell/), + () => expect("hello").toMatch("hello"), + () => expect("hello").toMatch("hell"), + ); + + await assertAllFail(() => expect("yo").toMatch(/^hell/)); + }, +}); + +Deno.test({ + name: "toHaveProperty", + fn: async () => { + await assertAllPass(() => expect({ a: "10" }).toHaveProperty("a")); + + await assertAllFail(() => expect({ a: 1 }).toHaveProperty("b")); + }, +}); + +Deno.test({ + name: "toHaveLength", + fn: async () => { + await assertAllPass( + () => expect([1, 2]).toHaveLength(2), + () => expect({ length: 10 }).toHaveLength(10), + ); + + await assertAllFail(() => expect([]).toHaveLength(10)); + }, +}); + +Deno.test({ + name: "toContain", + fn: async () => { + await assertAllPass( + () => expect([1, 2, 3]).toContain(2), + () => expect([]).not.toContain(2), + ); + + await assertAllFail( + () => expect([1, 2, 3]).toContain(4), + () => expect([]).toContain(4), + ); + }, +}); + +Deno.test({ + name: "toThrow", + fn: async () => { + await assertAllPass( + () => + expect(() => { + throw new Error("TEST"); + }).toThrow("TEST"), + () => expect(Promise.reject(new Error("TEST"))).rejects.toThrow("TEST"), + ); + + await assertAllFail(() => expect(() => true).toThrow()); + }, +}); + +Deno.test({ + name: "toHaveBeenCalled", + fn: async () => { + await assertAllPass( + () => { + const m = mock.fn(); + m(10); + m(20); + expect(m).toHaveBeenCalled(); + }, + () => { + const m = mock.fn(); + expect(m).not.toHaveBeenCalled(); + }, + ); + + await assertAllFail(() => { + const m = mock.fn(); + expect(m).toHaveBeenCalled(); + }); + }, +}); + +Deno.test({ + name: "toHaveBeenCalledTimes", + fn: async () => { + await assertAllPass( + () => { + const m = mock.fn(); + expect(m).toHaveBeenCalledTimes(0); + }, + () => { + const m = mock.fn(); + m(); + m(); + expect(m).toHaveBeenCalledTimes(2); + }, + ); + + await assertAllFail( + () => { + const m = mock.fn(); + expect(m).toHaveBeenCalledTimes(1); + }, + () => { + const m = mock.fn(); + m(); + m(); + expect(m).toHaveBeenCalledTimes(3); + }, + ); + }, +}); + +Deno.test({ + name: "toHaveBeenCalledWith", + fn: async () => { + await assertAllPass( + () => { + const m = mock.fn(); + m(1, 2, 3); + expect(m).toHaveBeenCalledWith(1, 2, 3); + }, + () => { + const m = mock.fn(); + m(1, 2, 3); + m(2, 3, 4); + expect(m).toHaveBeenCalledWith(1, 2, 3); + expect(m).toHaveBeenCalledWith(2, 3, 4); + }, + ); + + await assertAllFail( + () => { + const m = mock.fn(); + expect(m).toHaveBeenCalledWith(1); + }, + () => { + const m = mock.fn(); + m(2); + expect(m).toHaveBeenCalledWith(1); + }, + ); + }, +}); + +Deno.test({ + name: "toHaveBeenLastCalledWith", + fn: async () => { + await assertAllPass( + () => { + const m = mock.fn(); + m(1, 2, 3); + expect(m).toHaveBeenLastCalledWith(1, 2, 3); + }, + () => { + const m = mock.fn(); + m(1, 2, 3); + m(2, 3, 4); + expect(m).not.toHaveBeenLastCalledWith(1, 2, 3); + expect(m).toHaveBeenLastCalledWith(2, 3, 4); + }, + ); + + await assertAllFail( + () => { + const m = mock.fn(); + expect(m).toHaveBeenLastCalledWith(1); + }, + () => { + const m = mock.fn(); + m(2); + expect(m).toHaveBeenLastCalledWith(1); + }, + ); + }, +}); + +Deno.test({ + name: "toHaveBeenNthCalledWith", + fn: async () => { + await assertAllPass( + () => { + const m = mock.fn(); + m(1, 2, 3); + expect(m).toHaveBeenNthCalledWith(1, 1, 2, 3); + }, + () => { + const m = mock.fn(); + m(1, 2, 3); + m(2, 3, 4); + expect(m).not.toHaveBeenNthCalledWith(2, 1, 2, 3); + expect(m).toHaveBeenNthCalledWith(2, 2, 3, 4); + }, + ); + + await assertAllFail( + () => { + const m = mock.fn(); + expect(m).toHaveBeenNthCalledWith(1, 1, 2); + }, + () => { + const m = mock.fn(); + m(2); + expect(m).toHaveBeenNthCalledWith(1, 1); + }, + ); + }, +}); + +Deno.test({ + name: "toHaveReturnedWith", + fn: async () => { + await assertAllPass( + () => { + const m = mock.fn(); + m(); + expect(m).toHaveReturnedWith(undefined); + }, + () => { + const m = mock.fn(() => true); + m(); + expect(m).not.toHaveReturnedWith(false); + expect(m).toHaveReturnedWith(true); + }, + () => { + const m = mock.fn(() => { + throw new Error("TEST"); + }); + try { + m(); + } catch (err) {} + expect(m).not.toHaveReturnedWith(10); + }, + ); + + await assertAllFail( + () => { + const m = mock.fn(); + expect(m).toHaveReturnedWith(1); + }, + () => { + const m = mock.fn(); + m(2); + expect(m).toHaveReturnedWith(1); + }, + ); + }, +}); + +Deno.test({ + name: "toHaveReturnedTimes", + fn: async () => { + await assertAllPass( + () => { + const m = mock.fn(); + m(); + expect(m).toHaveReturnedTimes(1); + }, + () => { + const m = mock.fn(() => true); + expect(m).toHaveReturnedTimes(0); + }, + () => { + const m = mock.fn(() => { + throw new Error("TEST"); + }); + try { + m(); + } catch (err) {} + expect(m).toHaveReturnedTimes(0); + }, + ); + + await assertAllFail( + () => { + const m = mock.fn(); + expect(m).toHaveReturnedTimes(1); + }, + () => { + const m = mock.fn(); + m(2); + expect(m).not.toHaveReturnedTimes(1); + }, + ); + }, +}); + +Deno.test({ + name: "toHaveReturned", + fn: async () => { + await assertAllPass( + () => { + const m = mock.fn(); + m(); + expect(m).toHaveReturned(); + }, + () => { + const m = mock.fn(); + expect(m).not.toHaveReturned(); + }, + () => { + const m = mock.fn(() => { + throw new Error("TEST"); + }); + try { + m(); + } catch (err) {} + expect(m).not.toHaveReturned(); + }, + ); + + await assertAllFail( + () => { + const m = mock.fn(); + expect(m).toHaveReturned(); + }, + () => { + const m = mock.fn(); + m(); + expect(m).not.toHaveReturned(); + }, + () => { + const m = mock.fn(() => { + throw new Error("TEST"); + }); + m(); + expect(m).not.toHaveReturned(); + }, + ); + }, +}); + +Deno.test({ + name: "toHaveLastReturnedWith", + fn: async () => { + await assertAllPass( + () => { + const m = mock.fn((x: number) => x); + m(1); + m(2); + expect(m).toHaveLastReturnedWith(2); + }, + () => { + const m = mock.fn((x: number) => x); + m(1); + m(2); + expect(m).toHaveLastReturnedWith(2); + }, + ); + + await assertAllFail( + () => { + const m = mock.fn((x: number) => x); + expect(m).toHaveLastReturnedWith(1); + }, + () => { + const m = mock.fn((x: number) => x); + m(2); + expect(m).toHaveLastReturnedWith(1); + }, + ); + }, +}); + +Deno.test({ + name: "toHaveNthReturnedWith", + fn: async () => { + await assertAllPass( + () => { + const m = mock.fn((x: number) => x); + m(1, 2, 3); + expect(m).toHaveNthReturnedWith(1, 1); + }, + () => { + const m = mock.fn((x: number) => x); + m(1, 2, 3); + m(2, 3, 4); + expect(m).not.toHaveNthReturnedWith(2, 1); + expect(m).toHaveNthReturnedWith(2, 2); + }, + ); + + await assertAllFail( + () => { + const m = mock.fn(); + expect(m).toHaveNthReturnedWith(1, 1); + }, + () => { + const m = mock.fn(); + m(2); + expect(m).toHaveNthReturnedWith(1, 1); + }, + ); + }, +}); diff --git a/src/expect/matchers.ts b/src/expect/matchers.ts new file mode 100644 index 0000000..af2ca0b --- /dev/null +++ b/src/expect/matchers.ts @@ -0,0 +1,613 @@ +import { + AssertionError, + equal, +} from "https://deno.land/std@0.106.0/testing/asserts.ts"; + +import { + diff, + DiffResult, + DiffType, +} from "https://deno.land/std@0.106.0/testing/_diff.ts"; + +import { + bold, + gray, + green, + red, + white, +} from "https://deno.land/std@0.106.0/fmt/colors.ts"; + +import * as mock from "./mock.ts"; + +type MatcherState = { + isNot: boolean; +}; + +export type Matcher = (value: any, ...args: any[]) => MatchResult; + +export type Matchers = { + [key: string]: Matcher; +}; +export type MatchResult = { + pass: boolean; + message?: string; +}; + +const ACTUAL = red(bold("actual")); +const EXPECTED = green(bold("expected")); + +const CAN_NOT_DISPLAY = "[Cannot display]"; + +function createStr(v: unknown): string { + try { + return Deno.inspect(v); + } catch (e) { + return red(CAN_NOT_DISPLAY); + } +} + +function createColor(diffType: DiffType): (s: string) => string { + switch (diffType) { + case DiffType.added: + return (s: string) => green(bold(s)); + case DiffType.removed: + return (s: string) => red(bold(s)); + default: + return white; + } +} + +function createSign(diffType: DiffType): string { + switch (diffType) { + case DiffType.added: + return "+ "; + case DiffType.removed: + return "- "; + default: + return " "; + } +} + +function buildMessage(diffResult: ReadonlyArray>): string { + return diffResult + .map((result: DiffResult) => { + const c = createColor(result.type); + return c(`${createSign(result.type)}${result.value}`); + }) + .join("\n"); +} + +function buildDiffMessage(actual: unknown, expected: unknown) { + const actualString = createStr(actual); + const expectedString = createStr(expected); + + let message; + try { + const diffResult = diff( + actualString.split("\n"), + expectedString.split("\n"), + ); + + return buildMessage(diffResult); + } catch (e) { + return `\n${red(CAN_NOT_DISPLAY)} + \n\n`; + } +} + +function buildFail(message: string) { + return { + pass: false, + message, + }; +} + +export function toBe(actual: any, expected: any): MatchResult { + if (actual === expected) return { pass: true }; + + return buildFail( + `expect(${ACTUAL}).toBe(${EXPECTED})\n\n${ + buildDiffMessage( + actual, + expected, + ) + }`, + ); +} + +export function toEqual(actual: any, expected: any): MatchResult { + if (equal(actual, expected)) return { pass: true }; + + return buildFail( + `expect(${ACTUAL}).toEqual(${EXPECTED})\n\n${ + buildDiffMessage( + actual, + expected, + ) + }`, + ); +} + +export function toBeGreaterThan(actual: any, comparison: number): MatchResult { + if (actual > comparison) return { pass: true }; + + const actualString = createStr(actual); + const comparisonString = createStr(comparison); + + return buildFail( + `expect(${ACTUAL}).toBeGreaterThan(${EXPECTED})\n\n ${ + red( + actualString, + ) + } is not greater than ${green(comparisonString)}`, + ); +} + +export function toBeLessThan(actual: any, comparison: number): MatchResult { + if (actual < comparison) return { pass: true }; + + const actualString = createStr(actual); + const comparisonString = createStr(comparison); + + return buildFail( + `expect(${ACTUAL}).toBeLessThan(${EXPECTED})\n\n ${ + red( + actualString, + ) + } is not less than ${green(comparisonString)}`, + ); +} + +export function toBeGreaterThanOrEqual( + actual: any, + comparison: number, +): MatchResult { + if (actual >= comparison) return { pass: true }; + + const actualString = createStr(actual); + const comparisonString = createStr(comparison); + + return buildFail( + `expect(${ACTUAL}).toBeGreaterThanOrEqual(${EXPECTED})\n\n ${ + red( + actualString, + ) + } is not greater than or equal to ${green(comparisonString)}`, + ); +} + +export function toBeLessThanOrEqual( + actual: any, + comparison: number, +): MatchResult { + if (actual <= comparison) return { pass: true }; + + const actualString = createStr(actual); + const comparisonString = createStr(comparison); + + return buildFail( + `expect(${ACTUAL}).toBeLessThanOrEqual(${EXPECTED})\n\n ${ + red( + actualString, + ) + } is not less than or equal to ${green(comparisonString)}`, + ); +} + +export function toBeTruthy(value: any): MatchResult { + if (value) return { pass: true }; + + const actualString = createStr(value); + + return buildFail(`expect(${ACTUAL}).toBeTruthy() + + ${red(actualString)} is not truthy`); +} + +export function toBeFalsy(value: any): MatchResult { + if (!value) return { pass: true }; + + const actualString = createStr(value); + + return buildFail( + `expect(${ACTUAL}).toBeFalsy()\n\n ${red(actualString)} is not falsy`, + ); +} + +export function toBeDefined(value: unknown): MatchResult { + if (typeof value !== "undefined") return { pass: true }; + + const actualString = createStr(value); + + return buildFail( + `expect(${ACTUAL}).toBeDefined()\n\n ${ + red(actualString) + } is not defined`, + ); +} + +export function toBeUndefined(value: unknown): MatchResult { + if (typeof value === "undefined") return { pass: true }; + + const actualString = createStr(value); + + return buildFail( + `expect(${ACTUAL}).toBeUndefined()\n\n ${ + red( + actualString, + ) + } is defined but should be undefined`, + ); +} + +export function toBeNull(value: unknown): MatchResult { + if (value === null) return { pass: true }; + + const actualString = createStr(value); + + return buildFail( + `expect(${ACTUAL}).toBeNull()\n\n ${red(actualString)} should be null`, + ); +} + +export function toBeNaN(value: unknown): MatchResult { + if (typeof value === "number" && isNaN(value)) return { pass: true }; + + const actualString = createStr(value); + + return buildFail( + `expect(${ACTUAL}).toBeNaN()\n\n ${red(actualString)} should be NaN`, + ); +} + +export function toBeInstanceOf(value: any, expected: Function): MatchResult { + if (value instanceof expected) return { pass: true }; + + const actualString = createStr(value); + const expectedString = createStr(expected); + + return buildFail( + `expect(${ACTUAL}).toBeInstanceOf(${EXPECTED})\n\n expected ${ + green( + expected.name, + ) + } but received ${red(actualString)}`, + ); +} + +export function toMatch(value: any, pattern: RegExp | string): MatchResult { + const valueStr = value.toString(); + if (typeof pattern === "string") { + if (valueStr.indexOf(pattern) !== -1) return { pass: true }; + + const actualString = createStr(value); + const patternString = createStr(pattern); + + return buildFail( + `expect(${ACTUAL}).toMatch(${EXPECTED})\n\n expected ${ + red( + actualString, + ) + } to contain ${green(patternString)}`, + ); + } else if (pattern instanceof RegExp) { + if (pattern.exec(valueStr)) return { pass: true }; + + const actualString = createStr(value); + const patternString = createStr(pattern); + + return buildFail( + `expect(${ACTUAL}).toMatch(${EXPECTED})\n\n ${ + red( + actualString, + ) + } did not match regex ${green(patternString)}`, + ); + } else { + return buildFail("Invalid internal state"); + } +} + +export function toHaveProperty(value: any, propName: string): MatchResult { + if (typeof value === "object" && typeof value[propName] !== "undefined") { + return { pass: true }; + } + + const actualString = createStr(value); + const propNameString = createStr(propName); + + return buildFail( + `expect(${ACTUAL}).toHaveProperty(${EXPECTED})\n\n ${ + red( + actualString, + ) + } did not contain property ${green(propNameString)}`, + ); +} +export function toHaveLength(value: any, length: number): MatchResult { + if (value?.length === length) return { pass: true }; + + const actualString = createStr(value.length); + const lengthString = createStr(length); + + return buildFail( + `expect(${ACTUAL}).toHaveLength(${EXPECTED})\n\n expected array to have length ${ + green( + lengthString, + ) + } but was ${red(actualString)}`, + ); +} + +export function toContain(value: any, item: any): MatchResult { + if (value && typeof value.includes === "function" && value.includes(item)) { + return { pass: true }; + } + + const actualString = createStr(value); + const itemString = createStr(item); + + if (value && typeof value.includes === "function") { + return buildFail( + `expect(${ACTUAL}).toContain(${EXPECTED})\n\n ${ + red( + actualString, + ) + } did not contain ${green(itemString)}`, + ); + } else { + return buildFail( + `expect(${ACTUAL}).toContain(${EXPECTED})\n\n expected ${ + red( + actualString, + ) + } to have an includes method but it is ${green(itemString)}`, + ); + } +} + +export function toThrow(value: any, error?: RegExp | string): MatchResult { + let fn; + if (typeof value === "function") { + fn = value; + try { + value = value(); + } catch (err) { + value = err; + } + } + + const actualString = createStr(fn); + const errorString = createStr(error); + + if (value instanceof Error) { + if (typeof error === "string") { + if (!value.message.includes(error)) { + return buildFail( + `expect(${ACTUAL}).toThrow(${EXPECTED})\n\nexpected ${ + red( + actualString, + ) + } to throw error matching ${green(errorString)} but it threw ${ + red( + value.toString(), + ) + }`, + ); + } + } else if (error instanceof RegExp) { + if (!value.message.match(error)) { + return buildFail( + `expect(${ACTUAL}).toThrow(${EXPECTED})\n\nexpected ${ + red( + actualString, + ) + } to throw error matching ${green(errorString)} but it threw ${ + red( + value.toString(), + ) + }`, + ); + } + } + + return { pass: true }; + } else { + return buildFail( + `expect(${ACTUAL}).toThrow(${EXPECTED})\n\nexpected ${ + red( + actualString, + ) + } to throw but it did not`, + ); + } +} + +function extractMockCalls( + value: any, + name: string, +): { error?: string; calls: mock.MockCall[] | null } { + if (typeof value !== "function") { + return { + calls: null, + error: `${name} only works on mock functions. received: ${value}`, + }; + } + const calls = mock.calls(value); + if (calls === null) { + return { calls: null, error: `${name} only works on mock functions` }; + } + + return { calls }; +} + +export function toHaveBeenCalled(value: any): MatchResult { + const { calls, error } = extractMockCalls(value, "toHaveBeenCalled"); + if (error) return buildFail(error); + + const actualString = createStr(value); + + if (calls && calls.length !== 0) return { pass: true }; + + return buildFail( + `expect(${ACTUAL}).toHaveBeenCalled()\n\n ${ + red( + actualString, + ) + } was not called`, + ); +} + +export function toHaveBeenCalledTimes(value: any, times: number): MatchResult { + const { calls, error } = extractMockCalls(value, "toHaveBeenCalledTimes"); + if (error) return buildFail(error); + if (!calls) return buildFail("Invalid internal state"); + + if (calls && calls.length === times) return { pass: true }; + + return buildFail( + `expect(${ACTUAL}).toHaveBeenCalledTimes(${EXPECTED})\n\n expected ${times} calls but was called: ${calls.length}`, + ); +} + +export function toHaveBeenCalledWith(value: any, ...args: any[]): MatchResult { + const { calls, error } = extractMockCalls(value, "toHaveBeenCalledWith"); + if (error) return buildFail(error); + + const wasCalledWith = calls && calls.some((c) => equal(c.args, args)); + if (wasCalledWith) return { pass: true }; + + const argsString = createStr(args); + + return buildFail( + `expect(${ACTUAL}).toHaveBeenCalledWith(${EXPECTED})\n\n function was not called with: ${ + green( + argsString, + ) + }`, + ); +} + +export function toHaveBeenLastCalledWith( + value: any, + ...args: any[] +): MatchResult { + const { calls, error } = extractMockCalls(value, "toHaveBeenLastCalledWith"); + if (error) return buildFail(error); + + if (!calls || !calls.length) { + return buildFail( + `expect(${ACTUAL}).toHaveBeenLastCalledWith(...${EXPECTED})\n\n expect last call args to be ${args} but was not called`, + ); + } + + const lastCall = calls[calls.length - 1]; + if (equal(lastCall.args, args)) return { pass: true }; + + return buildFail( + `expect(${ACTUAL}).toHaveBeenLastCalledWith(...${EXPECTED})\n\n expect last call args to be ${args} but was: ${lastCall.args}`, + ); +} + +export function toHaveBeenNthCalledWith( + value: any, + nth: number, + ...args: any[] +): MatchResult { + const { calls, error } = extractMockCalls(value, "toHaveBeenNthCalledWith"); + if (error) return buildFail(error); + + const nthCall = calls && calls[nth - 1]; + if (nthCall) { + if (equal(nthCall.args, args)) return { pass: true }; + return buildFail( + `expect(${ACTUAL}).toHaveBeenNthCalledWith(${EXPECTED})\n\n expect ${nth}th call args to be ${args} but was: ${nthCall.args}`, + ); + } else { + return buildFail( + `expect(${ACTUAL}).toHaveBeenNthCalledWith(${EXPECTED})\n\n ${nth}th call was not made.`, + ); + } +} + +export function toHaveReturnedWith(value: any, result: any): MatchResult { + const { calls, error } = extractMockCalls(value, "toHaveReturnedWith"); + if (error) return buildFail(error); + + const wasReturnedWith = calls && + calls.some((c) => c.returns && equal(c.returned, result)); + if (wasReturnedWith) return { pass: true }; + + return buildFail( + `expect(${ACTUAL}).toHaveReturnedWith(${EXPECTED})\n\n function did not return: ${result}`, + ); +} + +export function toHaveReturned(value: any): MatchResult { + const { calls, error } = extractMockCalls(value, "toHaveReturned"); + if (error) return buildFail(error); + + if (calls && calls.some((c) => c.returns)) return { pass: true }; + + // TODO(allain): better messages + return buildFail(`expected function to return but it never did`); +} + +// TODO(allain): better messages +export function toHaveLastReturnedWith(value: any, expected: any): MatchResult { + const { calls, error } = extractMockCalls(value, "toHaveLastReturnedWith"); + if (error) return buildFail(error); + + const lastCall = calls && calls[calls.length - 1]; + if (!lastCall) { + return buildFail("no calls made to function"); + } + if (lastCall.throws) { + return buildFail(`last call to function threw: ${lastCall.thrown}`); + } + + if (equal(lastCall.returned, expected)) return { pass: true }; + + return buildFail( + `expected last call to return ${expected} but returned: ${lastCall.returned}`, + ); +} + +export function toHaveReturnedTimes(value: any, times: number): MatchResult { + const { calls, error } = extractMockCalls(value, "toHaveReturnedTimes"); + if (error) return buildFail(error); + + const returnCount = calls && calls.filter((c) => c.returns).length; + if (returnCount !== times) { + return buildFail( + `expected ${times} returned times but returned ${returnCount} times`, + ); + } + + return { pass: true }; +} + +export function toHaveNthReturnedWith( + value: any, + nth: number, + expected: any, +): MatchResult { + const { calls, error } = extractMockCalls(value, "toHaveNthReturnedWith"); + if (error) return buildFail(error); + + const nthCall = calls && calls[nth - 1]; + if (!nthCall) { + return buildFail(`${nth} calls were now made`); + } + + if (nthCall.throws) { + return buildFail(`${nth}th call to function threw: ${nthCall.thrown}`); + } + + if (!equal(nthCall.returned, expected)) { + return buildFail( + `expected ${nth}th call to return ${expected} but returned: ${nthCall.returned}`, + ); + } + + return { pass: true }; +} diff --git a/src/expect/matchers_test.ts b/src/expect/matchers_test.ts new file mode 100644 index 0000000..0007449 --- /dev/null +++ b/src/expect/matchers_test.ts @@ -0,0 +1,728 @@ +import { + assert, + assertEquals, +} from "https://deno.land/std@0.106.0/testing/asserts.ts"; +import * as mock from "./mock.ts"; + +import { + MatchResult, + toBe, + toBeDefined, + toBeFalsy, + toBeGreaterThan, + toBeInstanceOf, + toBeLessThan, + toBeLessThanOrEqual, + toBeNaN, + toBeNull, + toBeTruthy, + toBeUndefined, + toContain, + toEqual, + toHaveBeenCalled, + toHaveBeenCalledTimes, + toHaveBeenCalledWith, + toHaveBeenLastCalledWith, + toHaveBeenNthCalledWith, + toHaveLastReturnedWith, + toHaveLength, + toHaveNthReturnedWith, + toHaveProperty, + toHaveReturned, + toHaveReturnedTimes, + toHaveReturnedWith, + toMatch, + toThrow, +} from "./matchers.ts"; + +function assertResult(actual: MatchResult, expected: MatchResult) { + assertEquals( + actual.pass, + expected.pass, + `expected to be ${ + expected.pass ? `pass but received: ${actual.message}` : "fail" + }`, + ); + if (typeof expected.message !== "undefined") { + assert(!!actual.message, "no message given"); + const colourless = actual.message.replace( + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, + "", + ); + const trim = (x: string) => x.trim().replace(/\s*\n\s+/g, "\n"); + + assertEquals(trim(colourless), trim(expected.message)); + } +} + +function assertResultPass(result: any) { + assertResult(result, { pass: true }); +} + +Deno.test({ + name: "toBePass", + fn: () => { + assertResultPass(toBe(10, 10)); + }, +}); + +Deno.test({ + name: "toBeFail", + fn: () => { + assertResult(toBe(10, 20), { + pass: false, + message: `expect(actual).toBe(expected) + - 10 + + 20`, + }); + + assertResult(toBe({}, {}), { + pass: false, + message: `expect(actual).toBe(expected) + + {}`, + }); + }, +}); + +Deno.test({ + name: "toEqualPass", + fn: () => { + assertResultPass(toEqual({ a: 1 }, { a: 1 })); + assertResultPass(toEqual(1, 1)); + assertResultPass(toEqual([1], [1])); + }, +}); + +Deno.test({ + name: "toEqualFail", + fn: () => { + assertResult(toEqual(10, 20), { + pass: false, + message: `expect(actual).toEqual(expected)\n\n- 10\n+ 20`, + }); + + assertResult(toEqual({ a: 1 }, { a: 2 }), { + pass: false, + message: `expect(actual).toEqual(expected) + - { a: 1 } + + { a: 2 }`, + }); + }, +}); + +Deno.test({ + name: "toBeGreaterThanPass", + fn: () => { + assertResultPass(toBeGreaterThan(2, 1)); + }, +}); + +Deno.test({ + name: "toBeGreaterThanFail", + fn: () => { + assertResult(toBeGreaterThan(1, 2), { + pass: false, + message: `expect(actual).toBeGreaterThan(expected) + + 1 is not greater than 2`, + }); + }, +}); + +Deno.test({ + name: "toBeLessThanPass", + fn: () => { + assertResultPass(toBeLessThan(1, 2)); + }, +}); + +Deno.test({ + name: "toBeLessThanFail", + fn: () => { + assertResult(toBeLessThan(2, 1), { + pass: false, + message: `expect(actual).toBeLessThan(expected) + + 2 is not less than 1`, + }); + }, +}); + +Deno.test({ + name: "toBeLessThanOrEqualPass", + fn: () => { + assertResultPass(toBeLessThanOrEqual(1, 2)); + }, +}); + +Deno.test({ + name: "toBeLessThanOrEqualFail", + fn: () => { + assertResult(toBeLessThanOrEqual(2, 1), { + pass: false, + message: `expect(actual).toBeLessThanOrEqual(expected) + + 2 is not less than or equal to 1`, + }); + }, +}); + +Deno.test({ + name: "toBeTruthyPass", + fn: () => { + assertResultPass(toBeTruthy(1)); + assertResultPass(toBeTruthy(true)); + assertResultPass(toBeTruthy([])); + }, +}); + +Deno.test({ + name: "toBeTruthyFail", + fn: () => { + assertResult(toBeTruthy(false), { + pass: false, + message: `expect(actual).toBeTruthy() + + false is not truthy`, + }); + }, +}); + +Deno.test({ + name: "toBeFalsyPass", + fn: () => { + assertResultPass(toBeFalsy(0)); + assertResultPass(toBeFalsy(false)); + assertResultPass(toBeFalsy(null)); + }, +}); + +Deno.test({ + name: "toBeFalsyFail", + fn: () => { + assertResult(toBeFalsy(true), { + pass: false, + message: `expect(actual).toBeFalsy() + + true is not falsy`, + }); + }, +}); + +Deno.test({ + name: "toBeDefinedPass", + fn: () => { + assertResultPass(toBeDefined(1)); + assertResultPass(toBeDefined({})); + }, +}); + +Deno.test({ + name: "toBeDefinedFail", + fn: () => { + assertResult(toBeDefined(undefined), { + pass: false, + message: `expect(actual).toBeDefined() + + undefined is not defined`, + }); + }, +}); + +Deno.test({ + name: "toBeUndefinedPass", + fn: () => { + assertResultPass(toBeUndefined(undefined)); + }, +}); + +Deno.test({ + name: "toBeUndefinedFail", + fn: () => { + assertResult(toBeUndefined(null), { + pass: false, + message: `expect(actual).toBeUndefined() + + null is defined but should be undefined`, + }); + }, +}); + +Deno.test({ + name: "toBeNullPass", + fn: () => { + assertResultPass(toBeNull(null)); + }, +}); + +Deno.test({ + name: "toBeNullFail", + fn: () => { + assertResult(toBeNull(10), { + pass: false, + message: `expect(actual).toBeNull() + + 10 should be null`, + }); + }, +}); + +Deno.test({ + name: "toBeNaNPass", + fn: () => { + assertResultPass(toBeNaN(NaN)); + }, +}); + +Deno.test({ + name: "toBeNaNFail", + fn: () => { + assertResult(toBeNaN(10), { + pass: false, + message: `expect(actual).toBeNaN() + + 10 should be NaN`, + }); + }, +}); + +Deno.test({ + name: "toBeInstanceOfPass", + fn: () => { + class A {} + const a = new A(); + assertResultPass(toBeInstanceOf(a, A)); + }, +}); + +Deno.test({ + name: "toBeInstanceOfFail", + fn: () => { + class A {} + class B {} + + const a = new A(); + + assertResult(toBeInstanceOf(a, B), { + pass: false, + message: `expect(actual).toBeInstanceOf(expected) + + expected B but received A {}`, + }); + }, +}); + +Deno.test({ + name: "toBeMatchPass", + fn: () => { + assertResultPass(toMatch("hello", "hell")); + assertResultPass(toMatch("hello", /^hell/)); + }, +}); + +Deno.test({ + name: "toBeMatchFail", + fn: () => { + assertResult(toMatch("yo", "hell"), { + pass: false, + message: `expect(actual).toMatch(expected) + + expected "yo" to contain "hell"`, + }); + + assertResult(toMatch("yo", /^hell/), { + pass: false, + message: `expect(actual).toMatch(expected) + + "yo" did not match regex /^hell/`, + }); + }, +}); + +Deno.test({ + name: "toBeHavePropertyPass", + fn: () => { + assertResultPass(toHaveProperty({ a: 1 }, "a")); + }, +}); + +Deno.test({ + name: "toBeHavePropertyFail", + fn: () => { + assertResult(toHaveProperty({ a: 1 }, "b"), { + pass: false, + message: `expect(actual).toHaveProperty(expected) + + { a: 1 } did not contain property "b"`, + }); + }, +}); + +Deno.test({ + name: "toHaveLengthPass", + fn: () => { + assertResultPass(toHaveLength([], 0)); + assertResultPass(toHaveLength([1, 2], 2)); + assertResultPass(toHaveLength({ length: 2 }, 2)); + }, +}); + +Deno.test({ + name: "toBeHaveLengthFail", + fn: () => { + assertResult(toHaveLength([], 1), { + pass: false, + message: `expect(actual).toHaveLength(expected) + + expected array to have length 1 but was 0`, + }); + }, +}); + +Deno.test({ + name: "toContainPass", + fn: () => { + assertResultPass(toContain([1, 2], 2)); + assertResultPass(toContain("Hello", "Hell")); + }, +}); + +Deno.test({ + name: "toContainFail", + fn: () => { + assertResult(toContain([2, 3], 1), { + pass: false, + message: `expect(actual).toContain(expected) + + [ 2, 3 ] did not contain 1`, + }); + assertResult(toContain("Hello", "Good"), { + pass: false, + message: `expect(actual).toContain(expected) + + "Hello" did not contain "Good"`, + }); + assertResult(toContain(false, 1), { + pass: false, + message: `expect(actual).toContain(expected) + + expected false to have an includes method but it is 1`, + }); + }, +}); + +Deno.test({ + name: "toThrowPass", + fn: () => { + assertResultPass( + toThrow(() => { + throw new Error("TEST"); + }, "TEST"), + ); + assertResultPass( + toThrow(() => { + throw new Error("TEST"); + }, /^TEST/), + ); + }, +}); + +Deno.test({ + name: "toThrowFail", + fn: () => { + assertResult( + toThrow(() => {}, "TEST"), + { + pass: false, + message: `expect(actual).toThrow(expected) + + expected [Function] to throw but it did not`, + }, + ); + + assertResult( + toThrow(() => { + throw new Error("BLAH"); + }, "TEST"), + { + pass: false, + message: `expect(actual).toThrow(expected) + + expected [Function] to throw error matching "TEST" but it threw Error: BLAH`, + }, + ); + + assertResult( + toThrow(() => { + throw new Error("BLAH"); + }, /^TEST/), + { + pass: false, + message: `expect(actual).toThrow(expected) + + expected [Function] to throw error matching /^TEST/ but it threw Error: BLAH`, + }, + ); + }, +}); + +Deno.test({ + name: "toHaveBeenCalledPass", + fn: () => { + const m = mock.fn(); + m(10); + assertResultPass(toHaveBeenCalled(m)); + }, +}); + +Deno.test({ + name: "toHaveBeenCalledFail", + fn: () => { + const m = mock.fn(); + assertResult(toHaveBeenCalled(m), { + pass: false, + message: `expect(actual).toHaveBeenCalled() + + [Function: f] was not called`, + }); + }, +}); + +Deno.test({ + name: "toHaveBeenCalledTimesPass", + fn: () => { + const m = mock.fn(); + m(10); + m(12); + assertResultPass(toHaveBeenCalledTimes(m, 2)); + }, +}); + +Deno.test({ + name: "toHaveBeenCalledTimesFail", + fn: () => { + const m = mock.fn(); + m(10); + assertResult(toHaveBeenCalledTimes(m, 2), { + pass: false, + message: `expect(actual).toHaveBeenCalledTimes(expected) + + expected 2 calls but was called: 1`, + }); + }, +}); + +Deno.test({ + name: "toHaveBeenCalledWithPass", + fn: () => { + const m = mock.fn(); + m(1, "a"); + assertResultPass(toHaveBeenCalledWith(m, 1, "a")); + }, +}); + +Deno.test({ + name: "toHaveBeenCalledWithFail", + fn: () => { + const m = mock.fn(); + m(1, "a"); + assertResult(toHaveBeenCalledWith(m, 2, "b"), { + pass: false, + message: `expect(actual).toHaveBeenCalledWith(expected) + + function was not called with: [ 2, "b" ]`, + }); + }, +}); + +Deno.test({ + name: "toHaveBeenLastCalledWithPass", + fn: () => { + const m = mock.fn(); + m(1, "a"); + m(2, "b"); + m(3, "c"); + assertResultPass(toHaveBeenLastCalledWith(m, 3, "c")); + }, +}); + +Deno.test({ + name: "toHaveBeenLastCalledWithPass", + fn: () => { + const m = mock.fn(); + assertResult(toHaveBeenLastCalledWith(m, 2, "b"), { + pass: false, + message: `expect(actual).toHaveBeenLastCalledWith(...expected) + + expect last call args to be 2,b but was not called`, + }); + m(1, "a"); + m(2, "b"); + m(3, "c"); + assertResult(toHaveBeenLastCalledWith(m, 2, "b"), { + pass: false, + message: `expect(actual).toHaveBeenLastCalledWith(...expected) + + expect last call args to be 2,b but was: 3,c`, + }); + }, +}); + +Deno.test({ + name: "toHaveBeenNthCalledWithPass", + fn: () => { + const m = mock.fn(); + m(1, "a"); + m(2, "b"); + m(3, "c"); + const nthCall = 2; + assertResultPass(toHaveBeenNthCalledWith(m, nthCall, 2, "b")); + }, +}); + +Deno.test({ + name: "toHaveBeenNthCalledWithFail", + fn: () => { + const m = mock.fn(); + const nthCall = 3; + assertResult(toHaveBeenNthCalledWith(m, nthCall, 2, "b"), { + pass: false, + message: `expect(actual).toHaveBeenNthCalledWith(expected) + + 3th call was not made.`, + }); + m(1, "a"); + m(2, "b"); + m(3, "c"); + assertResult(toHaveBeenNthCalledWith(m, nthCall, 2, "b"), { + pass: false, + message: `expect(actual).toHaveBeenNthCalledWith(expected) + + expect 3th call args to be 2,b but was: 3,c`, + }); + }, +}); + +Deno.test({ + name: "toHaveReturnedWithPass", + fn: () => { + const m = mock.fn(() => true); + m(); + assertResultPass(toHaveReturnedWith(m, true)); + }, +}); + +Deno.test({ + name: "toHaveReturnedWithFail", + fn: () => { + const m = mock.fn(() => true); + m(); + assertResult(toHaveReturnedWith(m, false), { + pass: false, + message: `expect(actual).toHaveReturnedWith(expected) + + function did not return: false`, + }); + }, +}); + +Deno.test({ + name: "toHaveReturnedPass", + fn: () => { + const m = mock.fn(() => true); + m(); + assertResultPass(toHaveReturned(m)); + }, +}); + +Deno.test({ + name: "toHaveReturnedFail", + fn: () => { + const m = mock.fn(() => true); + assertResult(toHaveReturned(m), { + pass: false, + message: `expected function to return but it never did`, + }); + }, +}); + +Deno.test({ + name: "toHaveLastReturnedWithPass", + fn: () => { + const m = mock.fn((arg: boolean) => arg); + m(false); + m(true); + assertResultPass(toHaveLastReturnedWith(m, true)); + }, +}); + +Deno.test({ + name: "toHaveLastReturnedWithFail", + fn: () => { + const m = mock.fn((arg: boolean) => arg); + assertResult(toHaveLastReturnedWith(m, true), { + pass: false, + message: `no calls made to function`, + }); + m(true); + m(false); + assertResult(toHaveLastReturnedWith(m, true), { + pass: false, + message: `expected last call to return true but returned: false`, + }); + }, +}); + +Deno.test({ + name: "toHaveReturnedTimesPass", + fn: () => { + const m = mock.fn(() => true); + m(); + m(); + m(); + assertResultPass(toHaveReturnedTimes(m, 3)); + }, +}); + +Deno.test({ + name: "toHaveReturnedTimesFail", + fn: () => { + const m = mock.fn(() => true); + m(); + assertResult(toHaveReturnedTimes(m, 3), { + pass: false, + message: `expected 3 returned times but returned 1 times`, + }); + }, +}); + +Deno.test({ + name: "toHaveNthReturnedWithPass", + fn: () => { + const m = mock.fn((n: number) => n); + m(1); + m(2); + m(3); + const nthCall = 2; + assertResultPass(toHaveNthReturnedWith(m, nthCall, 2)); + }, +}); + +Deno.test({ + name: "toHaveNthReturnedWithFail", + fn: () => { + const m = mock.fn((n: number) => n); + m(1); + m(2); + m(3); + assertResult(toHaveNthReturnedWith(m, 2, 1), { + pass: false, + message: `expected 2th call to return 1 but returned: 2`, + }); + assertResult(toHaveNthReturnedWith(m, 9, 1), { + pass: false, + message: `9 calls were now made`, + }); + }, +}); diff --git a/src/expect/mock.ts b/src/expect/mock.ts new file mode 100644 index 0000000..0f59a58 --- /dev/null +++ b/src/expect/mock.ts @@ -0,0 +1,57 @@ +const MOCK_SYMBOL = Symbol.for("@MOCK"); + +export type MockCall = { + args: any[]; + returned?: any; + thrown?: any; + timestamp: number; + returns: boolean; + throws: boolean; +}; + +export function fn(...stubs: Function[]) { + const calls: MockCall[] = []; + + const f = (...args: any[]) => { + const stub = stubs.length === 1 + ? // keep reusing the first + stubs[0] + : // pick the exact mock for the current call + stubs[calls.length]; + + try { + const returned = stub ? stub(...args) : undefined; + calls.push({ + args, + returned, + timestamp: Date.now(), + returns: true, + throws: false, + }); + return returned; + } catch (err) { + calls.push({ + args, + timestamp: Date.now(), + returns: false, + thrown: err, + throws: true, + }); + throw err; + } + }; + + Object.defineProperty(f, MOCK_SYMBOL, { + value: { calls }, + writable: false, + }); + + return f; +} + +export function calls(f: Function): MockCall[] { + const mockInfo = (f as any)[MOCK_SYMBOL]; + if (!mockInfo) throw new Error("callCount only available on mock functions"); + + return [...mockInfo.calls]; +} diff --git a/src/expect/mock_test.ts b/src/expect/mock_test.ts new file mode 100644 index 0000000..74173b3 --- /dev/null +++ b/src/expect/mock_test.ts @@ -0,0 +1,72 @@ +import { + assert, + assertEquals, +} from "https://deno.land/std@0.106.0/testing/asserts.ts"; + +import * as mock from "./mock.ts"; + +Deno.test({ + name: "canMockFunctions", + fn: () => { + assertEquals(typeof mock.fn(), "function"); + const f = mock.fn(); + f(10); + f(20); + + const calls = mock.calls(f); + assert(Array.isArray(calls)); + assertEquals(calls.length, 2); + assertEquals(calls.map((c: any) => c.args), [[10], [20]]); + assertEquals(calls.map((c: any) => c.returned), [undefined, undefined]); + + assert( + calls.map((c: any) => typeof c.timestamp).every((t: string) => + t === "number" + ), + ); + }, +}); + +Deno.test({ + name: "mockFunctionTracksReturns", + fn: () => { + const f = mock.fn( + () => 1, + () => { + throw new Error("TEST"); + }, + ); + try { + f(); + f(); + } catch {} + const calls = mock.calls(f); + assert(calls[0].returns); + assert(!calls[0].throws); + assert(!calls[1].returns); + assert(calls[1].throws); + }, +}); + +Deno.test({ + name: "mockFunctionCanHaveImplementations", + fn: () => { + const f = mock.fn( + (n: number) => n, + (n: number) => n * 2, + (n: number) => n * 3, + ); + f(1); + f(1); + f(1); + f(1); + f(1); + + const calls = mock.calls(f); + assertEquals(calls.length, 5); + assertEquals( + calls.map((c: mock.MockCall) => c.returned), + [1, 2, 3, undefined, undefined], + ); + }, +}); diff --git a/src/mock.test.ts b/src/mock.test.ts new file mode 100644 index 0000000..c8852e0 --- /dev/null +++ b/src/mock.test.ts @@ -0,0 +1,206 @@ +import { describe, it } from "./blocks.ts"; +import { expect } from "./assertions.ts"; +import { mock, mockfn, patch } from "./mock.ts"; + +class Obj { + called = 0; + func(x: number) { + this.called += 1; + return x * 2; + } +} + +function checkRestored(o: Obj) { + const called = o.called; + const result = o.func(4); + expect(o.called).toEqual(called + 1); + expect(result).toEqual(8); +} + +describe("patch", () => { + it("calls through by default", () => { + const o = new Obj(); + + patch(o, "func", (mock) => { + const result = o.func(3); + expect(o.called).toEqual(1); + expect(result).toEqual(6); + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenCalledWith(3); + }); + + patch(o, "func", (mock) => { + const result = o.func(1); + expect(o.called).toEqual(2); + expect(result).toEqual(2); + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenCalledWith(1); + }); + + checkRestored(o); + }); + + it("calls sequential stubs before passing through", () => { + const o = new Obj(); + + patch(o, "func", (mock) => { + mock.stub((a) => a * 3); + mock.stub((a) => a * 4); + + let result = o.func(2); + expect(result).toEqual(6); + + result = o.func(2); + expect(result).toEqual(8); + + expect(o.called).toEqual(0); + + result = o.func(2); + expect(result).toEqual(4); + + expect(o.called).toEqual(1); + expect(mock).toHaveBeenCalledTimes(3); + }); + + checkRestored(o); + }); + + it("calls stubs in loop", () => { + const o = new Obj(); + + patch(o, "func", (mock) => { + mock.stub((a) => a * 3, true); + let result = o.func(2); + expect(result).toEqual(6); + + result = o.func(2); + expect(result).toEqual(6); + + result = o.func(2); + expect(result).toEqual(6); + + expect(o.called).toEqual(0); + expect(mock).toHaveBeenCalledTimes(3); + }); + + checkRestored(o); + }); + + it("can reset the mock", () => { + const o = new Obj(); + + patch(o, "func", (mock) => { + mock.stub((a) => a + 1, true); + let result = o.func(3); + expect(o.called).toEqual(0); + expect(result).toEqual(4); + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenCalledWith(3); + + result = o.func(2); + expect(o.called).toEqual(0); + expect(result).toEqual(3); + expect(mock).toHaveBeenCalledTimes(2); + expect(mock).toHaveBeenCalledWith(2); + + mock.reset(); + mock.stub((a) => a + 10, true); + + result = o.func(4); + expect(o.called).toEqual(0); + expect(result).toEqual(14); + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenCalledWith(4); + + mock.reset(); + + result = o.func(4); + expect(o.called).toEqual(1); + expect(result).toEqual(8); + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenCalledWith(4); + }); + + checkRestored(o); + }); +}); + +describe("mock", () => { + it("puts a no-op stub by default", () => { + const o = new Obj(); + + mock(o, "func", undefined, (mock) => { + let result = o.func(2); + expect(result).toBeUndefined(); + + expect(o.called).toEqual(0); + expect(mock).toHaveBeenCalledTimes(1); + }); + + checkRestored(o); + + const f = mock(o, "func", undefined, () => o.func(2)); + expect(f).toHaveBeenCalledTimes(1); + + checkRestored(o); + }); + + it("stubs with a fixed value", () => { + const o = new Obj(); + + mock(o, "func", 17, (mock) => { + let result = o.func(2); + expect(result).toBe(17); + + result = o.func(2); + expect(result).toBe(17); + + expect(o.called).toEqual(0); + expect(mock).toHaveBeenCalledTimes(2); + }); + + checkRestored(o); + }); + + it("stubs with a function result", () => { + const o = new Obj(); + + let i = 9; + mock(o, "func", () => ++i, (mock) => { + let result = o.func(2); + expect(result).toBe(10); + + result = o.func(2); + expect(result).toBe(11); + + expect(o.called).toEqual(0); + expect(mock).toHaveBeenCalledTimes(2); + }); + + checkRestored(o); + }); +}); + +describe("mockfn", () => { + it("constructs a simple mock function", () => { + let mock = mockfn(); + expect(mock).toHaveBeenCalledTimes(0); + mock(); + expect(mock).toHaveBeenCalledTimes(1); + + mock = mockfn(() => 2); + expect(mock).toHaveBeenCalledTimes(0); + let result = mock(); + expect(result).toEqual(2); + expect(mock).toHaveBeenCalledTimes(1); + result = mock(); + expect(result).toEqual(2); + expect(mock).toHaveBeenCalledTimes(2); + + mock = mockfn((x: number) => x + 1); + result = mock(5); + expect(result).toEqual(6); + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenCalledWith(5); + }); +}); diff --git a/src/mock.ts b/src/mock.ts new file mode 100644 index 0000000..d628128 --- /dev/null +++ b/src/mock.ts @@ -0,0 +1,86 @@ +import { expect } from "./assertions.ts"; +import * as _mock from "./expect/mock.ts"; + +export const mockfn = _mock.fn; + +/** + * Patch an object's method + */ +export function patch< + O, + K extends keyof O, + F extends Function & O[K], +>( + obj: O, + method: K, + lifetime: (mock: MockManipulator) => void, +): F { + const orig = obj[method] as F; + let stubs: { impl: F; loop: boolean }[] = []; + const createMock = () => + mockfn((...args: any[]) => { + if (stubs.length) { + const result = stubs[0].impl(...args); + if (!stubs[0].loop) { + stubs.shift(); + } + return result; + } else { + return orig.call(obj, ...args); + } + }); + let mock = createMock(); + + Object.assign(obj, { [method]: mock }); + try { + lifetime({ + _toExpected() { + return mock; + }, + stub(impl?: F, loop = false) { + if (impl) { + stubs.push({ impl, loop }); + } else { + stubs.push({ impl: function () {} as any, loop: true }); + } + }, + reset() { + mock = createMock(); + Object.assign(obj, { [method]: mock }); + stubs = []; + }, + }); + } finally { + Object.assign(obj, { [method]: orig }); + } + + return mock as unknown as F; +} + +/** + * Similar to *patch*, but with stub as enforced default + */ +export function mock< + O, + K extends keyof O, + F extends (...args: any) => any & O[K], +>( + obj: O, + method: K, + stub: F | ReturnType | undefined, + lifetime: (mock: MockManipulator) => void, +): F { + return patch(obj, method, (mock) => { + mock.stub( + typeof stub == "function" ? stub as any : (() => stub) as F, + true, + ); + lifetime(mock as any); + }) as unknown as F; +} + +type MockManipulator = { + _toExpected: () => Parameters[0]; + reset: () => void; + stub: (impl?: F, loop?: boolean) => void; +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e28737f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "esnext", + "target": "ESNext", + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "preserveConstEnums": true + } +}