@kalxjs/core
Version:
A modern JavaScript framework for building user interfaces with reactive state, composition API, and built-in performance optimizations
816 lines (707 loc) • 23.2 kB
JavaScript
// @kalxjs/core - Built-in testing framework
/**
* Test suite types
*/
export const TEST_TYPES = {
UNIT: 'unit',
COMPONENT: 'component',
E2E: 'e2e',
INTEGRATION: 'integration',
PERFORMANCE: 'performance'
};
// Export enhanced testing utilities
export * from './component-testing.js';
export * from './mocks.js';
export * from './user-events.js';
export * from './async-utilities.js';
export * from './snapshot.js';
export * from './test-presets.js';
/**
* Creates a test suite
* @param {string} name - Test suite name
* @param {Function} fn - Test suite function
* @returns {Object} Test suite
*/
export function describe(name, fn) {
const suite = {
name,
tests: [],
beforeEach: null,
afterEach: null,
beforeAll: null,
afterAll: null
};
const context = {
test: (testName, testFn) => {
suite.tests.push({
name: testName,
fn: testFn,
skip: false,
only: false
});
return context;
},
it: (testName, testFn) => context.test(testName, testFn),
beforeEach: (fn) => {
suite.beforeEach = fn;
return context;
},
afterEach: (fn) => {
suite.afterEach = fn;
return context;
},
beforeAll: (fn) => {
suite.beforeAll = fn;
return context;
},
afterAll: (fn) => {
suite.afterAll = fn;
return context;
},
skip: (testName, testFn) => {
suite.tests.push({
name: testName,
fn: testFn,
skip: true,
only: false
});
return context;
},
only: (testName, testFn) => {
suite.tests.push({
name: testName,
fn: testFn,
skip: false,
only: true
});
return context;
}
};
// Aliases
context.it.skip = context.skip;
context.it.only = context.only;
// Execute the suite function
fn(context);
return suite;
}
/**
* Creates a test
* @param {string} name - Test name
* @param {Function} fn - Test function
* @returns {Object} Test
*/
export function test(name, fn) {
return {
name,
fn,
skip: false,
only: false
};
}
/**
* Alias for test
*/
export const it = test;
/**
* Creates a skipped test
* @param {string} name - Test name
* @param {Function} fn - Test function
* @returns {Object} Test
*/
export function skip(name, fn) {
return {
name,
fn,
skip: true,
only: false
};
}
/**
* Creates a test that will be run exclusively
* @param {string} name - Test name
* @param {Function} fn - Test function
* @returns {Object} Test
*/
export function only(name, fn) {
return {
name,
fn,
skip: false,
only: true
};
}
// Add skip and only to test and it
test.skip = skip;
test.only = only;
it.skip = skip;
it.only = only;
/**
* Creates a test runner
* @param {Object} options - Test runner options
* @returns {Object} Test runner
*/
export function createTestRunner(options = {}) {
const {
reporter = 'console',
timeout = 5000,
bail = false,
grep = null,
verbose = false
} = options;
// Test suites
const suites = [];
// Test results
const results = {
passed: 0,
failed: 0,
skipped: 0,
total: 0,
duration: 0,
suites: []
};
/**
* Adds a test suite
* @param {Object} suite - Test suite
*/
const addSuite = (suite) => {
suites.push(suite);
};
/**
* Runs all test suites
* @returns {Promise} Test results
*/
const runAll = async () => {
const startTime = Date.now();
// Check if any tests are marked as "only"
const hasOnly = suites.some(suite =>
suite.tests.some(test => test.only)
);
// Run each suite
for (const suite of suites) {
const suiteResult = {
name: suite.name,
passed: 0,
failed: 0,
skipped: 0,
total: suite.tests.length,
duration: 0,
tests: []
};
// Run beforeAll hook
if (suite.beforeAll) {
try {
await suite.beforeAll();
} catch (error) {
console.error(`Error in beforeAll hook for suite "${suite.name}":`, error);
// Mark all tests as failed
for (const test of suite.tests) {
suiteResult.tests.push({
name: test.name,
status: 'failed',
error: new Error(`beforeAll hook failed: ${error.message}`),
duration: 0
});
suiteResult.failed++;
}
results.suites.push(suiteResult);
continue;
}
}
// Run each test
for (const test of suite.tests) {
// Skip tests if not matching grep pattern
if (grep && !test.name.match(grep)) {
suiteResult.tests.push({
name: test.name,
status: 'skipped',
duration: 0
});
suiteResult.skipped++;
continue;
}
// Skip tests if marked as skip or if other tests are marked as only
if (test.skip || (hasOnly && !test.only)) {
suiteResult.tests.push({
name: test.name,
status: 'skipped',
duration: 0
});
suiteResult.skipped++;
continue;
}
// Run the test
const testResult = {
name: test.name,
status: 'passed',
duration: 0
};
const testStartTime = Date.now();
try {
// Run beforeEach hook
if (suite.beforeEach) {
await suite.beforeEach();
}
// Run the test with timeout
await Promise.race([
test.fn(),
new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`Test timed out after ${timeout}ms`));
}, timeout);
})
]);
// Run afterEach hook
if (suite.afterEach) {
await suite.afterEach();
}
testResult.status = 'passed';
suiteResult.passed++;
} catch (error) {
testResult.status = 'failed';
testResult.error = error;
suiteResult.failed++;
// Bail if configured
if (bail) {
break;
}
}
testResult.duration = Date.now() - testStartTime;
suiteResult.tests.push(testResult);
}
// Run afterAll hook
if (suite.afterAll) {
try {
await suite.afterAll();
} catch (error) {
console.error(`Error in afterAll hook for suite "${suite.name}":`, error);
}
}
suiteResult.duration = Date.now() - startTime;
results.suites.push(suiteResult);
}
// Update overall results
results.passed = results.suites.reduce((sum, suite) => sum + suite.passed, 0);
results.failed = results.suites.reduce((sum, suite) => sum + suite.failed, 0);
results.skipped = results.suites.reduce((sum, suite) => sum + suite.skipped, 0);
results.total = results.passed + results.failed + results.skipped;
results.duration = Date.now() - startTime;
// Report results
reportResults(results);
return results;
};
/**
* Reports test results
* @param {Object} results - Test results
*/
const reportResults = (results) => {
switch (reporter) {
case 'console':
reportToConsole(results);
break;
case 'json':
reportToJSON(results);
break;
default:
if (typeof reporter === 'function') {
reporter(results);
} else {
reportToConsole(results);
}
}
};
/**
* Reports test results to the console
* @param {Object} results - Test results
*/
const reportToConsole = (results) => {
console.log('\n=== Test Results ===');
console.log(`Total: ${results.total}, Passed: ${results.passed}, Failed: ${results.failed}, Skipped: ${results.skipped}`);
console.log(`Duration: ${results.duration}ms`);
if (verbose || results.failed > 0) {
console.log('\n=== Test Details ===');
for (const suite of results.suites) {
console.log(`\nSuite: ${suite.name}`);
console.log(` Passed: ${suite.passed}, Failed: ${suite.failed}, Skipped: ${suite.skipped}`);
for (const test of suite.tests) {
const icon = test.status === 'passed' ? '✓' : test.status === 'failed' ? '✗' : '-';
console.log(` ${icon} ${test.name} (${test.duration}ms)`);
if (test.status === 'failed' && test.error) {
console.error(` Error: ${test.error.message}`);
if (test.error.stack) {
console.error(` Stack: ${test.error.stack.split('\n').slice(1).join('\n ')}`);
}
}
}
}
}
console.log('\n=== End of Test Results ===\n');
};
/**
* Reports test results as JSON
* @param {Object} results - Test results
*/
const reportToJSON = (results) => {
console.log(JSON.stringify(results, null, 2));
};
return {
describe: (name, fn) => {
const suite = describe(name, fn);
addSuite(suite);
return suite;
},
test,
it,
skip,
only,
run: runAll
};
}
/**
* Creates a component test utility
* @param {Object} options - Component test options
* @returns {Object} Component test utility
*/
export function createComponentTest(options = {}) {
const {
plugins = [],
global = {},
mocks = {}
} = options;
/**
* Mounts a component for testing
* @param {Object} component - Component to mount
* @param {Object} options - Mount options
* @returns {Object} Mounted component
*/
const mount = (component, options = {}) => {
const {
props = {},
slots = {},
attrs = {},
listeners = {},
provide = {},
shallow = false
} = options;
// Create a test app
const app = createApp({
render() {
return h(component, {
...props,
...attrs,
...Object.entries(listeners).reduce((acc, [event, handler]) => {
acc[`on${event.charAt(0).toUpperCase() + event.slice(1)}`] = handler;
return acc;
}, {})
}, slots);
}
});
// Apply plugins
plugins.forEach(plugin => {
if (Array.isArray(plugin)) {
app.use(plugin[0], plugin[1]);
} else {
app.use(plugin);
}
});
// Apply global mocks
Object.entries(global).forEach(([key, value]) => {
app.config.globalProperties[key] = value;
});
// Apply provide values
Object.entries(provide).forEach(([key, value]) => {
app.provide(key, value);
});
// Create a container
const container = document.createElement('div');
document.body.appendChild(container);
// Mount the app
app.mount(container);
// Create wrapper with testing utilities
const wrapper = {
app,
container,
component,
// Find elements
find: (selector) => container.querySelector(selector),
findAll: (selector) => container.querySelectorAll(selector),
// Get text content
text: () => container.textContent,
// Get HTML content
html: () => container.innerHTML,
// Check if element exists
exists: (selector) => !!container.querySelector(selector),
// Trigger events
trigger: async (selector, event, options = {}) => {
const element = typeof selector === 'string' ? container.querySelector(selector) : selector;
if (!element) {
throw new Error(`Element not found: ${selector}`);
}
const eventObj = new Event(event, {
bubbles: true,
cancelable: true,
...options
});
element.dispatchEvent(eventObj);
// Wait for the next tick
await nextTick();
return wrapper;
},
// Set input value
setValue: async (selector, value) => {
const element = typeof selector === 'string' ? container.querySelector(selector) : selector;
if (!element) {
throw new Error(`Element not found: ${selector}`);
}
if (element.tagName === 'SELECT') {
element.value = value;
await wrapper.trigger(element, 'change');
} else if (element.tagName === 'INPUT') {
if (element.type === 'checkbox') {
element.checked = value;
await wrapper.trigger(element, 'change');
} else if (element.type === 'radio') {
element.checked = value;
await wrapper.trigger(element, 'change');
} else {
element.value = value;
await wrapper.trigger(element, 'input');
}
} else if (element.tagName === 'TEXTAREA') {
element.value = value;
await wrapper.trigger(element, 'input');
}
return wrapper;
},
// Unmount the component
unmount: () => {
app.unmount();
container.remove();
}
};
return wrapper;
};
/**
* Shallowly mounts a component for testing
* @param {Object} component - Component to mount
* @param {Object} options - Mount options
* @returns {Object} Mounted component
*/
const shallowMount = (component, options = {}) => {
return mount(component, { ...options, shallow: true });
};
return {
mount,
shallowMount
};
}
/**
* Creates assertions for testing
* @returns {Object} Assertions
*/
export function createAssertions() {
/**
* Asserts that a condition is true
* @param {boolean} condition - Condition to check
* @param {string} message - Error message
*/
const assert = (condition, message = 'Assertion failed') => {
if (!condition) {
throw new Error(message);
}
};
/**
* Asserts that two values are equal
* @param {any} actual - Actual value
* @param {any} expected - Expected value
* @param {string} message - Error message
*/
const assertEqual = (actual, expected, message = 'Values are not equal') => {
if (actual !== expected) {
throw new Error(`${message}: ${actual} !== ${expected}`);
}
};
/**
* Asserts that two values are not equal
* @param {any} actual - Actual value
* @param {any} expected - Expected value
* @param {string} message - Error message
*/
const assertNotEqual = (actual, expected, message = 'Values are equal') => {
if (actual === expected) {
throw new Error(`${message}: ${actual} === ${expected}`);
}
};
/**
* Asserts that a value is truthy
* @param {any} value - Value to check
* @param {string} message - Error message
*/
const assertTruthy = (value, message = 'Value is not truthy') => {
if (!value) {
throw new Error(message);
}
};
/**
* Asserts that a value is falsy
* @param {any} value - Value to check
* @param {string} message - Error message
*/
const assertFalsy = (value, message = 'Value is not falsy') => {
if (value) {
throw new Error(message);
}
};
/**
* Asserts that a value is null
* @param {any} value - Value to check
* @param {string} message - Error message
*/
const assertNull = (value, message = 'Value is not null') => {
if (value !== null) {
throw new Error(message);
}
};
/**
* Asserts that a value is not null
* @param {any} value - Value to check
* @param {string} message - Error message
*/
const assertNotNull = (value, message = 'Value is null') => {
if (value === null) {
throw new Error(message);
}
};
/**
* Asserts that a value is undefined
* @param {any} value - Value to check
* @param {string} message - Error message
*/
const assertUndefined = (value, message = 'Value is not undefined') => {
if (value !== undefined) {
throw new Error(message);
}
};
/**
* Asserts that a value is not undefined
* @param {any} value - Value to check
* @param {string} message - Error message
*/
const assertDefined = (value, message = 'Value is undefined') => {
if (value === undefined) {
throw new Error(message);
}
};
/**
* Asserts that a value is an instance of a class
* @param {any} value - Value to check
* @param {Function} constructor - Constructor to check against
* @param {string} message - Error message
*/
const assertInstanceOf = (value, constructor, message = 'Value is not an instance of the expected constructor') => {
if (!(value instanceof constructor)) {
throw new Error(message);
}
};
/**
* Asserts that a function throws an error
* @param {Function} fn - Function to check
* @param {RegExp|string|Function} expected - Expected error
* @param {string} message - Error message
*/
const assertThrows = (fn, expected, message = 'Function did not throw an error') => {
try {
fn();
throw new Error(message);
} catch (error) {
if (expected instanceof RegExp) {
if (!expected.test(error.message)) {
throw new Error(`${message}: ${error.message} does not match ${expected}`);
}
} else if (typeof expected === 'function') {
if (!(error instanceof expected)) {
throw new Error(`${message}: ${error.constructor.name} is not an instance of ${expected.name}`);
}
} else if (typeof expected === 'string') {
if (error.message !== expected) {
throw new Error(`${message}: ${error.message} !== ${expected}`);
}
}
}
};
/**
* Asserts that a function does not throw an error
* @param {Function} fn - Function to check
* @param {string} message - Error message
*/
const assertDoesNotThrow = (fn, message = 'Function threw an error') => {
try {
fn();
} catch (error) {
throw new Error(`${message}: ${error.message}`);
}
};
/**
* Asserts that a value is close to another value
* @param {number} actual - Actual value
* @param {number} expected - Expected value
* @param {number} delta - Maximum difference
* @param {string} message - Error message
*/
const assertCloseTo = (actual, expected, delta = 0.001, message = 'Values are not close') => {
if (Math.abs(actual - expected) > delta) {
throw new Error(`${message}: ${actual} is not close to ${expected} (delta: ${delta})`);
}
};
return {
assert,
assertEqual,
assertNotEqual,
assertTruthy,
assertFalsy,
assertNull,
assertNotNull,
assertUndefined,
assertDefined,
assertInstanceOf,
assertThrows,
assertDoesNotThrow,
assertCloseTo
};
}
/**
* Creates a testing plugin for KalxJS
* @param {Object} options - Testing plugin options
* @returns {Object} Testing plugin
*/
export function createTestingPlugin(options = {}) {
return {
name: 'testing',
install(app) {
// Create test runner
const testRunner = createTestRunner(options);
// Create component test utility
const componentTest = createComponentTest({
plugins: options.plugins || []
});
// Create assertions
const assertions = createAssertions();
// Add testing utilities to the app
app.config = app.config || {};
app.config.globalProperties = app.config.globalProperties || {};
app.config.globalProperties.$testing = {
testRunner,
componentTest,
assertions
};
// Add testing utilities to the window
if (typeof window !== 'undefined') {
window.$kalxjs = window.$kalxjs || {};
window.$kalxjs.testing = {
testRunner,
componentTest,
assertions
};
}
}
};
}