├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── deno.json ├── mod.test.ts ├── mod.ts └── shims ├── bun.ts ├── deno.ts └── node.ts /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to jsr 2 | on: 3 | release: 4 | types: [released] 5 | 6 | workflow_dispatch: 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: read 14 | id-token: write 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Setup Deno 20 | uses: denoland/setup-deno@v1 21 | with: 22 | deno-version: v2.x 23 | 24 | - name: Publish package 25 | run: deno publish 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main, dev] 4 | pull_request: 5 | branches: [main, dev] 6 | 7 | jobs: 8 | deno_ci: 9 | uses: cross-org/workflows/.github/workflows/deno-ci.yml@main 10 | with: 11 | entrypoint: mod.ts 12 | lint_docs: false 13 | bun_ci: 14 | uses: cross-org/workflows/.github/workflows/bun-ci.yml@main 15 | with: 16 | jsr_dependencies: "@std/assert @std/async @cross/runtime" 17 | npm_dependencies: "sinon" 18 | node_ci: 19 | uses: cross-org/workflows/.github/workflows/node-ci.yml@main 20 | with: 21 | test_target: "*.test.ts" 22 | jsr_dependencies: "@std/assert @std/async @cross/runtime" 23 | npm_dependencies: "sinon" 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | .npmrc 133 | deno.lock 134 | bun.lockb 135 | package.json 136 | bunfig.toml 137 | package-lock.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Hexagon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Cross-runtime Testing for Deno, Bun, and Node.js 2 | 3 | [![JSR Version](https://jsr.io/badges/@cross/test?v=bust)](https://jsr.io/@cross/test) [![JSR Score](https://jsr.io/badges/@cross/test/score?v=bust)](https://jsr.io/@cross/test/score) 4 | 5 | @cross/test is a minimal testing framework designed for seamless use across Deno, Bun, and Node.js. Works great with @std/assert and @std/expect for assertions, and sinon for spying. Part of the 6 | @cross suite - check out our growing collection of cross-runtime tools at [github.com/cross-org](https://github.com/cross-org). 7 | 8 | ### Installation 9 | 10 | Install `@cross/test` along with the assertion library of your choice. We recommend using `@std/assert` for consistency across runtimes: 11 | 12 | ```bash 13 | # Pick your runtime and package manager: 14 | npx jsr add @cross/test @std/assert # Node.js 15 | deno add jsr:@cross/test jsr:@std/assert # Deno 16 | bunx jsr add @cross/test @std/assert # Bun 17 | ``` 18 | 19 | ### Examples 20 | 21 | #### Assertions using @std/assert 22 | 23 | ```javascript 24 | import { test } from "@cross/test"; 25 | import { assertEquals, assertNotEquals } from "@std/assert"; 26 | 27 | test("Multiplication", () => { 28 | assertEquals(5 * 4, 20); 29 | }); 30 | 31 | test("Multiplication with timeout", () => { 32 | assertEquals(5 * 4, 20); 33 | }, { timeout: 1000 }); 34 | 35 | test("Async test", (_context, done) => { 36 | setTimeout(() => { 37 | assertNotEquals(5, 4); 38 | done(); 39 | }, 500); 40 | }, { waitForCallback: true }); 41 | ``` 42 | 43 | #### Spying, mocking and stubbing using sinon 44 | 45 | ```js 46 | import { test } from "@cross/test"; 47 | import { assertEquals } from "@std/assert"; 48 | import sinon from "sinon"; 49 | 50 | // Prepare the "environment" 51 | function bar() {/*...*/} 52 | export const funcs = { 53 | bar, 54 | }; 55 | export function foo() { 56 | funcs.bar(); 57 | } 58 | 59 | test("calls bar during execution of foo", () => { 60 | const spy = sinon.spy(funcs, "bar"); 61 | 62 | foo(); 63 | 64 | assertEquals(spy.called, true); 65 | assertEquals(spy.getCalls().length, 1); 66 | }); 67 | ``` 68 | 69 | ### Running the tests 70 | 71 | - **Node.js:** `node --test` 72 | - **Node.js (TS):** `npx tsx --test` _Remember `{ "type": "module" }` in package.json_ 73 | - **Deno:** `deno test` 74 | - **Bun:** `bun test` 75 | 76 | ### Configuring CI 77 | 78 | - **Bun (GitHub Actions):** 79 | 80 | ```yaml 81 | name: Bun CI 82 | 83 | on: [push, pull_request] 84 | 85 | jobs: 86 | test: 87 | runs-on: ubuntu-latest 88 | steps: 89 | - uses: actions/checkout@v3 90 | - uses: antongolub/action-setup-bun@v1.12.8 91 | with: 92 | bun-version: v1.x # Uses latest bun 1 93 | - run: bun x jsr add @cross/test @std/assert # Installs dependencies 94 | - run: bun test # Runs the tests 95 | ``` 96 | 97 | - **Deno (GitHub Actions):** 98 | 99 | ```yaml 100 | name: Deno CI 101 | 102 | on: [push, pull_request] 103 | 104 | jobs: 105 | test: 106 | runs-on: ubuntu-latest 107 | steps: 108 | - uses: actions/checkout@v4 109 | - uses: denoland/setup-deno@v1 110 | with: 111 | deno-version: v1.x # Uses latest deno version 1 112 | - run: deno add @cross/test @std/assert # Installs dependencies from jsr.io 113 | - run: deno test # Runs tests 114 | ``` 115 | 116 | - **Node (GitHub actions):** 117 | 118 | ```yaml 119 | name: Node.js CI 120 | 121 | on: 122 | push: 123 | branches: [main] 124 | pull_request: 125 | branches: [main] 126 | 127 | jobs: 128 | build: 129 | runs-on: ubuntu-latest 130 | 131 | strategy: 132 | matrix: 133 | node-version: [18.x, 21.x] 134 | 135 | steps: 136 | - uses: actions/checkout@v3 137 | - run: npx jsr add @cross/test @std/assert 138 | - run: "echo '{ \"type\": \"module\" }' > package.json" # Needed for tsx to work 139 | - run: npx --yes tsx --test *.test.ts 140 | ``` 141 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cross/test", 3 | "version": "0.0.10", 4 | "exports": "./mod.ts", 5 | "fmt": { 6 | "lineWidth": 200 7 | }, 8 | "imports": { 9 | "@cross/runtime": "jsr:@cross/runtime@~1.0.0", 10 | "@std/assert": "jsr:@std/assert@~1.0.8", 11 | "@std/async": "jsr:@std/async@~1.0.9", 12 | "sinon": "npm:sinon@~19.0.2" 13 | }, 14 | "publish": { 15 | "exclude": [".github", "*.test.ts"] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /mod.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "./mod.ts"; 2 | import { assertEquals, assertNotEquals } from "@std/assert"; 3 | import { delay } from "@std/async"; 4 | 5 | // Simple test 6 | test("Multiplication", () => { 7 | assertEquals(5 * 4, 20); 8 | }); 9 | 10 | // Simple test with timeout 11 | test("Multiplication with timeout", () => { 12 | assertEquals(5 * 4, 20); 13 | }, { timeout: 1000 }); 14 | 15 | // Failing async test with done callback 16 | test("Long async test", (_context, done) => { 17 | setTimeout(() => { 18 | assertNotEquals(5, 4); 19 | done(); // Signal test completion 20 | }, 500); 21 | }, { waitForCallback: true }); 22 | 23 | // Test with done callback (useful for async operations) 24 | test("Async test", (_context, done) => { 25 | setTimeout(() => { 26 | assertNotEquals(5, 4); 27 | done(); // Signal test completion 28 | }, 4500); 29 | }, { waitForCallback: true, timeout: 5500 }); 30 | 31 | // Test async 32 | test("async hello world", async () => { 33 | const x = 1 + 2; 34 | 35 | // await some async task 36 | await delay(100); 37 | 38 | if (x !== 3) { 39 | throw Error("x should be equal to 3"); 40 | } 41 | }); 42 | 43 | /* Test sinon */ 44 | import sinon from "sinon"; 45 | 46 | function bar() {/*...*/} 47 | 48 | export const funcs = { 49 | bar, 50 | }; 51 | 52 | // 'foo' no longer takes a parameter, but calls 'bar' from an object 53 | export function foo() { 54 | funcs.bar(); 55 | } 56 | 57 | test("calls bar during execution of foo", () => { 58 | // create a test spy that wraps 'bar' on the 'funcs' object 59 | const spy = sinon.spy(funcs, "bar"); 60 | 61 | // call function 'foo' without an argument 62 | foo(); 63 | 64 | assertEquals(spy.called, true); 65 | assertEquals(spy.getCalls().length, 1); 66 | }); 67 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | import { CurrentRuntime, Runtime } from "@cross/runtime"; 2 | 3 | /** 4 | * Test subject 5 | */ 6 | export type TestSubject = (context: unknown | undefined, done: (value?: unknown) => void) => void | Promise; 7 | 8 | /** 9 | * Runtime independent test function 10 | */ 11 | export interface WrappedTest { 12 | (name: string, testFn: TestSubject, options?: WrappedTestOptions): Promise; 13 | } 14 | 15 | /** 16 | * Runtime independent test options 17 | */ 18 | export interface WrappedTestOptions { 19 | timeout?: number; // Timeout duration in milliseconds (optional) 20 | skip?: boolean; // Whether to skip the test (optional) 21 | waitForCallback?: boolean; // Whether to wait for the done-callback to be called 22 | } 23 | 24 | let wrappedTestToUse: WrappedTest; 25 | if (CurrentRuntime == Runtime.Deno) { 26 | const { wrappedTest } = await import("./shims/deno.ts"); 27 | // @ts-ignore js 28 | wrappedTestToUse = wrappedTest; 29 | } else if (CurrentRuntime == Runtime.Node) { 30 | const { wrappedTest } = await import("./shims/node.ts"); 31 | // @ts-ignore js 32 | wrappedTestToUse = wrappedTest; 33 | } else if (CurrentRuntime == Runtime.Bun) { 34 | const { wrappedTest } = await import("./shims/bun.ts"); 35 | // @ts-ignore js 36 | wrappedTestToUse = wrappedTest; 37 | } else { 38 | throw new Error("Unsupported runtime"); 39 | } 40 | /** 41 | * Defines and executes a single test. 42 | * @param name - The name of the test. 43 | * @param options? - Options for the test (structure depends on your shim) 44 | * @param testFn - The function containing the test logic. 45 | */ 46 | export async function test(name: string, testFn: TestSubject, options: WrappedTestOptions = {}) { 47 | await wrappedTestToUse(name, testFn, options); 48 | } 49 | -------------------------------------------------------------------------------- /shims/bun.ts: -------------------------------------------------------------------------------- 1 | import { test } from "bun:test"; 2 | import type { TestSubject, WrappedTestOptions } from "../mod.ts"; 3 | 4 | export async function wrappedTest( 5 | name: string, 6 | testFn: TestSubject, 7 | options: WrappedTestOptions, 8 | ) { 9 | return await test(name, async () => { 10 | // Adapt the context here 11 | let testFnPromise = undefined; 12 | const callbackPromise = new Promise((resolve, reject) => { 13 | testFnPromise = testFn(undefined, (e) => { 14 | if (e) reject(e); 15 | else resolve(0); 16 | }); 17 | }); 18 | let timeoutId: number = -1; // Store the timeout ID 19 | try { 20 | if (options.timeout) { 21 | const timeoutPromise = new Promise((_, reject) => { 22 | timeoutId = setTimeout(() => { 23 | reject(new Error("Test timed out")); 24 | }, options.timeout); 25 | }); 26 | await Promise.race([options.waitForCallback ? callbackPromise : testFnPromise, timeoutPromise]); 27 | } else { 28 | // No timeout, just await testFn 29 | await options.waitForCallback ? callbackPromise : testFnPromise; 30 | } 31 | } catch (error) { 32 | throw error; 33 | } finally { 34 | if (timeoutId) clearTimeout(timeoutId); 35 | // Make sure testFnPromise has completed 36 | await testFnPromise; 37 | if (options.waitForCallback) await callbackPromise; 38 | } 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /shims/deno.ts: -------------------------------------------------------------------------------- 1 | import type { TestSubject, WrappedTestOptions } from "../mod.ts"; // Assuming cross runtime types are here 2 | 3 | export function wrappedTest(name: string, testFn: TestSubject, options: WrappedTestOptions) { 4 | // @ts-ignore The Deno namespace isn't available in Node or Bun 5 | Deno.test({ 6 | name, 7 | ignore: options?.skip || false, 8 | async fn(context) { 9 | // Adapt the context here 10 | let testFnPromise = undefined; 11 | const callbackPromise = new Promise((resolve, reject) => { 12 | testFnPromise = testFn(context, (e) => { 13 | if (e) reject(e); 14 | else resolve(0); 15 | }); 16 | }); 17 | let timeoutId: number = -1; // Store the timeout ID 18 | try { 19 | if (options.timeout) { 20 | const timeoutPromise = new Promise((_, reject) => { 21 | timeoutId = setTimeout(() => { 22 | reject(new Error("Test timed out")); 23 | }, options.timeout); 24 | }); 25 | await Promise.race([options.waitForCallback ? callbackPromise : testFnPromise, timeoutPromise]); 26 | } else { 27 | await options.waitForCallback ? callbackPromise : testFnPromise; 28 | } 29 | } catch (error) { 30 | throw error; 31 | } finally { 32 | if (timeoutId) clearTimeout(timeoutId); 33 | await testFnPromise; 34 | if (options.waitForCallback) await callbackPromise; 35 | } 36 | }, 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /shims/node.ts: -------------------------------------------------------------------------------- 1 | import { test } from "node:test"; // For type safety 2 | import type { WrappedTestOptions } from "../mod.ts"; // Shared options 3 | import type { TestSubject } from "../mod.ts"; 4 | 5 | function transformOptions(options?: WrappedTestOptions) { 6 | return { 7 | skip: options?.skip || false, 8 | timeout: options?.timeout, 9 | }; 10 | } 11 | 12 | export function wrappedTest( 13 | name: string, 14 | testFn: TestSubject, 15 | options: WrappedTestOptions, 16 | ) { 17 | // deno-lint-ignore no-explicit-any 18 | test(name, transformOptions(options), async (context: any) => { 19 | // Adapt the context here 20 | let testFnPromise = undefined; 21 | const callbackPromise = new Promise((resolve, reject) => { 22 | testFnPromise = testFn(context, (e) => { 23 | if (e) reject(e); 24 | else resolve(0); 25 | }); 26 | }); 27 | if (options.waitForCallback) await callbackPromise; 28 | await testFnPromise; 29 | }); 30 | } 31 | --------------------------------------------------------------------------------