saintest
Version:
Lightweight testing framework
562 lines (485 loc) • 14.2 kB
JavaScript
/*!
* saintest v0.2.0 | MIT License
* Copyright (c) 2025-present NOuSantx
*/
const style = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
italic: '\x1b[3m',
underline: '\x1b[4m',
fg: (n) => `\x1b[38;5;${n}m`,
bg: (n) => `\x1b[48;5;${n}m`
};
const colors = {
success: 35,
error: 203,
text: 245,
highlight: 7,
warning: 214,
info: 39
};
function formatDiff(actual, expected) {
return `\n ${style.fg(colors.success)}+ Expected: ${JSON.stringify(expected)}${style.reset}
${style.fg(colors.error)}- Received: ${JSON.stringify(actual)}${style.reset}`
}
function expect(actual) {
return {
toBe(expected) {
if (actual !== expected) {
throw new Error(
`Expected ${JSON.stringify(actual)} to be ${JSON.stringify(expected)}${formatDiff(
actual,
expected
)}`
)
}
},
toEqual(expected) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`Expected deep equality${formatDiff(actual, expected)}`)
}
},
toThrow(expectedError) {
let threw = false;
let thrownError = null;
try {
actual();
} catch (error) {
threw = true;
thrownError = error;
}
if (!threw) {
throw new Error('Expected function to throw an error')
}
if (expectedError && thrownError.message !== expectedError) {
throw new Error(
`Expected error message "${expectedError}" but got "${thrownError.message}"`
)
}
},
toBeGreaterThan(expected) {
if (!(actual > expected)) {
throw new Error(`Expected ${actual} to be greater than ${expected}`)
}
},
toBeLessThan(expected) {
if (!(actual < expected)) {
throw new Error(`Expected ${actual} to be less than ${expected}`)
}
},
toContain(item) {
if (!actual.includes(item)) {
throw new Error(`Expected ${JSON.stringify(actual)} to contain ${JSON.stringify(item)}`)
}
},
toHaveLength(length) {
if (actual.length !== length) {
throw new Error(`Expected length of ${actual.length} to be ${length}`)
}
},
toBeInstanceOf(constructor) {
if (!(actual instanceof constructor)) {
throw new Error(`Expected ${actual} to be instance of ${constructor.name}`)
}
},
toBeTruthy() {
if (!actual) {
throw new Error(`Expected ${actual} to be truthy`)
}
},
toBeFalsy() {
if (actual) {
throw new Error(`Expected ${actual} to be falsy`)
}
},
toBeNull() {
if (actual !== null) {
throw new Error(`Expected ${actual} to be null`)
}
},
toBeUndefined() {
if (actual !== undefined) {
throw new Error(`Expected ${actual} to be undefined`)
}
},
toBeDefined() {
if (actual === undefined) {
throw new Error('Expected value to be defined')
}
},
toBeNaN() {
if (!Number.isNaN(actual)) {
throw new Error(`Expected ${actual} to be NaN`)
}
},
toMatch(regex) {
if (!regex.test(actual)) {
throw new Error(`Expected ${actual} to match ${regex}`)
}
},
not: {
toBe(expected) {
if (actual === expected) {
throw new Error(
`Expected ${JSON.stringify(actual)} not to be ${JSON.stringify(expected)}`
)
}
},
toEqual(expected) {
if (JSON.stringify(actual) === JSON.stringify(expected)) {
throw new Error(`Expected values not to be deeply equal${formatDiff(actual, expected)}`)
}
},
toBeInstanceOf(constructor) {
if (actual instanceof constructor) {
throw new Error(`Expected ${actual} not to be instance of ${constructor.name}`)
}
},
toMatch(regex) {
if (regex.test(actual)) {
throw new Error(`Expected ${actual} not to match ${regex}`)
}
},
toContain(item) {
if (actual.includes(item)) {
throw new Error(
`Expected ${JSON.stringify(actual)} not to contain ${JSON.stringify(item)}`
)
}
},
toBeTruthy() {
if (actual) {
throw new Error(`Expected ${actual} not to be truthy`)
}
},
toBeFalsy() {
if (!actual) {
throw new Error(`Expected ${actual} not to be falsy`)
}
},
toBeNull() {
if (actual === null) {
throw new Error('Expected value not to be null')
}
},
toBeUndefined() {
if (actual === undefined) {
throw new Error('Expected value not to be undefined')
}
},
toBeDefined() {
if (actual !== undefined) {
throw new Error('Expected value to be undefined')
}
},
toBeNaN() {
if (Number.isNaN(actual)) {
throw new Error('Expected value not to be NaN')
}
},
toHaveLength(length) {
if (actual.length === length) {
throw new Error(`Expected length not to be ${length}`)
}
},
toBeGreaterThan(expected) {
if (actual > expected) {
throw new Error(`Expected ${actual} not to be greater than ${expected}`)
}
},
toBeLessThan(expected) {
if (actual < expected) {
throw new Error(`Expected ${actual} not to be less than ${expected}`)
}
},
toHaveProperty(propertyPath, value) {
const properties = propertyPath.split('.');
let currentObject = actual;
try {
for (const property of properties) {
currentObject = currentObject[property];
}
if (value === undefined) {
throw new Error(`Expected object not to have property "${propertyPath}"`)
}
if (currentObject === value) {
throw new Error(
`Expected property "${propertyPath}" not to have value ${JSON.stringify(value)}`
)
}
} catch (e) {
// Property path doesn't exist, which is what we want for negative case
return
}
},
toThrow(expectedError) {
try {
actual();
// If we get here, the function didn't throw, which is what we want
} catch (error) {
if (!expectedError) {
throw new Error('Expected function not to throw an error')
}
if (error.message === expectedError) {
throw new Error(`Expected function not to throw error "${expectedError}"`)
}
}
},
toBeCloseTo(expected, precision = 2) {
const multiplier = Math.pow(10, precision);
const roundedActual = Math.round(actual * multiplier);
const roundedExpected = Math.round(expected * multiplier);
if (roundedActual === roundedExpected) {
throw new Error(
`Expected ${actual} not to be close to ${expected} with precision of ${precision} decimal points`
)
}
}
},
toHaveProperty(propertyPath, value) {
const properties = propertyPath.split('.');
let currentObject = actual;
for (const property of properties) {
if (!(property in currentObject)) {
throw new Error(`Expected object to have property "${propertyPath}"`)
}
currentObject = currentObject[property];
}
if (value !== undefined && currentObject !== value) {
throw new Error(
`Expected property "${propertyPath}" to have value ${JSON.stringify(
value
)}, got ${JSON.stringify(currentObject)}`
)
}
},
toBeCloseTo(expected, precision = 2) {
const multiplier = Math.pow(10, precision);
const roundedActual = Math.round(actual * multiplier);
const roundedExpected = Math.round(expected * multiplier);
if (roundedActual !== roundedExpected) {
throw new Error(
`Expected ${actual} to be close to ${expected} with precision of ${precision} decimal points`
)
}
}
}
}
const testSuites = [];
const defaultSuite = {
name: 'Standalone Tests',
tests: [],
beforeEach: null,
afterEach: null,
beforeAll: null,
afterAll: null
};
let currentSuite = null;
let passedTests = 0;
let failedTests = 0;
let skippedTests = 0;
let startTime = 0;
const getExecutionTime = () => {
const endTime = performance.now();
return ((endTime - startTime) / 1000).toFixed(3)
};
function describe(name, fn) {
const suite = {
name,
tests: [],
beforeEach: null,
afterEach: null,
beforeAll: null,
afterAll: null
};
testSuites.push(suite);
const previousSuite = currentSuite;
currentSuite = suite;
fn();
currentSuite = previousSuite;
}
function beforeEach(fn) {
if (currentSuite) {
currentSuite.beforeEach = fn;
} else {
defaultSuite.beforeEach = fn;
}
}
function afterEach(fn) {
if (currentSuite) {
currentSuite.afterEach = fn;
} else {
defaultSuite.afterEach = fn;
}
}
function beforeAll(fn) {
if (currentSuite) {
currentSuite.beforeAll = fn;
} else {
defaultSuite.beforeAll = fn;
}
}
function afterAll(fn) {
if (currentSuite) {
currentSuite.afterAll = fn;
} else {
defaultSuite.afterAll = fn;
}
}
function it(name, fn) {
const test = {
name,
fn,
skip: false,
only: false,
timeout: 100
};
if (currentSuite) {
currentSuite.tests.push(test);
} else {
defaultSuite.tests.push(test);
}
return {
skip: () => {
test.skip = true;
skippedTests++;
},
only: () => {
test.only = true;
},
timeout: (ms) => {
test.timeout = ms;
}
}
}
function test(name, fn) {
return it(name, fn)
}
async function runTest(test, suite) {
if (test.skip) {
console.log(
` ${style.fg(colors.warning)}○${style.reset} ` +
`${style.fg(colors.text)}${test.name} ${style.italic}(skipped)${style.reset}`
);
return { status: 'skipped' }
}
try {
if (suite.beforeEach) await suite.beforeEach();
const testPromise = Promise.race([
Promise.resolve(test.fn()),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Test timed out after ${test.timeout}ms`)), test.timeout)
)
]);
await testPromise;
if (suite.afterEach) await suite.afterEach();
console.log(
` ${style.fg(colors.success)}✓${style.reset} ` +
`${style.fg(colors.highlight)}${test.name}${style.reset}`
);
return { status: 'passed' }
} catch (error) {
console.log(
` ${style.fg(colors.error)}✗${style.reset} ` +
`${style.fg(colors.text)}${test.name}${style.reset}`
);
console.log(`${style.fg(colors.text)} ${error.message}${style.reset}`);
return { status: 'failed' }
}
}
async function runSuite(suite) {
if (suite.tests.length === 0) return
let suitePassed = 0;
let suiteFailed = 0;
let suiteSkipped = 0;
console.log(
`\n${style.fg(colors.success)}${suite.name}${style.fg(colors.text)} ` +
`[${suite.tests.length} tests]${style.reset}\n`
);
try {
if (suite.beforeAll) await suite.beforeAll();
const onlyTests = suite.tests.filter((t) => t.only);
const testsToRun = onlyTests.length > 0 ? onlyTests : suite.tests;
for (const test of testsToRun) {
const result = await runTest(test, suite);
switch (result.status) {
case 'passed':
suitePassed++;
passedTests++;
break
case 'failed':
suiteFailed++;
failedTests++;
break
case 'skipped':
suiteSkipped++;
break
}
}
if (suite.afterAll) await suite.afterAll();
const suiteTotal = suitePassed + suiteFailed + suiteSkipped;
const suitePassedPercentage = ((suitePassed / (suiteTotal - suiteSkipped)) * 100).toFixed(2);
console.log(`\n Suite Summary:`);
console.log(
` ${style.fg(colors.success)}${suitePassedPercentage}%${style.reset} ` +
`${style.fg(colors.text)}passing${style.reset}`
);
console.log(
` ${style.fg(colors.success)}${suitePassed} passed${style.reset}` +
` · ` +
`${style.fg(colors.error)}${suiteFailed} failed${style.reset}` +
` · ` +
`${style.fg(colors.warning)}${suiteSkipped} skipped${style.reset}` +
` · ` +
`${style.fg(colors.text)}${suiteTotal} total${style.reset}`
);
} catch (error) {
console.error(`${style.fg(colors.error)}Suite Error: ${error.message}${style.reset}`);
}
}
async function run() {
startTime = performance.now();
if (defaultSuite.tests.length > 0) {
await runSuite(defaultSuite);
}
for (const suite of testSuites) {
await runSuite(suite);
}
const executionTime = getExecutionTime();
const totalTestCount = passedTests + failedTests + skippedTests;
const passedPercentage = ((passedTests / (totalTestCount - skippedTests)) * 100).toFixed(2);
if (testSuites.length > 0 || defaultSuite.tests.length > 0) {
console.log('\nFinal Test Results:');
console.log(
`${style.fg(colors.success)}${passedPercentage}%${style.reset} ` +
`${style.fg(colors.text)}of all tests passing${style.reset}`
);
console.log(
`${style.fg(colors.success)}${passedTests} passed${style.reset}` +
` · ` +
`${style.fg(colors.error)}${failedTests} failed${style.reset}` +
` · ` +
`${style.fg(colors.warning)}${skippedTests} skipped${style.reset}` +
` · ` +
`${style.fg(colors.text)}${totalTestCount} total${style.reset}`
);
console.log(`${style.fg(colors.text)}Total Time: ${executionTime}s${style.reset}\n`);
}
}
var index = {
expect,
it,
test,
describe,
beforeEach,
afterEach,
beforeAll,
afterAll,
run,
testSuites,
defaultSuite,
currentSuite
};
export { afterAll, afterEach, beforeAll, beforeEach, currentSuite, index as default, defaultSuite, describe, expect, it, run, test, testSuites };
//# sourceMappingURL=index.esm.js.map