
Ever wondered how testing frameworks like Jest or Mocha work under the hood? In this tutorial, you’ll build your own JavaScript testing framework from scratch to find out.
Creating a custom testing framework is the absolute best way to understand what’s happening behind the scenes when you run tests. And honestly? It’s way simpler than you think. By the end of this guide, you’ll have a fully functional test runner that executes in your browser with beautiful green/red visuals showing exactly which tests pass and which fail. Zero dependencies. Pure vanilla JavaScript, HTML, and CSS.
Why Build Your Own Test Framework?
Look, I know what you’re thinking—why reinvent the wheel when Jest and Mocha exist? Here’s the thing: building your own framework teaches you exactly how test runners work internally. You’ll understand assertions, test suites, matchers, and the entire execution flow. This knowledge makes you a significantly better developer, whether you’re debugging test failures or contributing to testing tools.
Plus, for small sandbox projects or learning environments where you want to avoid build pipelines and npm packages, this lightweight approach is perfect. It runs directly in the browser with immediate visual feedback.
What’s in This Minimalistic JavaScript Test Framework
Here’s everything we’re building today:
- Simple test helpers like
expect,describe, andtestthat mirror popular frameworks - Powerful matchers including
toBe,toContain, andtoMatchfor assertions - Easy browser execution with zero configuration—just open an HTML file
- Visual test results with color-coded pass/fail indicators
- No dependencies whatsoever—no npm, no webpack, no babel, nothing
The framework is intentionally minimal, but it’s also genuinely functional and demonstrates all the core concepts that power production testing tools.
Step 1: Define the Core Helper Functions
1.1 The expect Function
The expect function is the absolute backbone of our test framework. This is where the magic happens—it allows us to make assertions about values and provides matcher methods that determine whether tests pass or fail.
function expect(actual) {
return {
toBe(expected) {
if (actual !== expected) {
throw new Error(`Expected ${expected}, but received ${actual}`);
}
},
toContain(item) {
if (!actual.includes(item)) {
throw new Error(`Expected array to contain ${item}, but it did not.`);
}
},
toMatch(regex) {
if (!regex.test(actual)) {
throw new Error(`Expected string to match ${regex}, but it did not.`);
}
}
};
}JavaScriptHow it works: The expect function returns an object containing matcher functions. Each matcher compares the actual value against an expected value and throws an error if the assertion fails. That error is what makes the test fail—simple but effective.
The toBe matcher uses strict equality (!==) just like Jest does. The toContain matcher works for arrays and strings by checking if the item exists. And, the toMatch matcher tests strings against regular expressions, which is incredibly useful for pattern validation.
1.2 The test Function
The test javascript function defines individual test cases and is the heart of our framework. Think of it as registering each unit test with the framework so we can execute them all later.
const tests = [];
function test(description, callback) {
tests.push({ description, callback });
}JavaScriptThat’s it. Seriously. We’re just pushing test objects into an array. Each object contains a description (what the test does) and a callback function (the actual test code). The framework will iterate through this array when it’s time to run tests.
This pattern is exactly how Jest and Mocha track tests internally—they maintain registries of test suites and cases.
1.3 The describe Function
The describe function groups related tests together. While this is optional, it dramatically improves readability and helps organize test suites, especially as your codebase grows.
function describe(suiteName, callback) {
console.group(suiteName);
callback();
console.groupEnd();
}JavaScriptWe’re using console.group for visual organization in the console. The describe block doesn’t affect test execution—it’s purely for developer experience. Inside the callback, you’ll define multiple test cases that logically belong together.
Save all these functions in a file called testFramework.js. This is our core testing library.
Step 2: Running Tests on an HTML Page
Now that we’ve built the test framework itself, we need a way to execute tests and display results. This is where things get visual.
2.1 HTML Structure
Create an index.html file with this structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JavaScript Test Framework - Live Results</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
max-width: 900px;
margin: 40px auto;
padding: 0 20px;
background: #f5f5f5;
}
h1 {
color: #333;
border-bottom: 3px solid #4CAF50;
padding-bottom: 10px;
}
.test-result {
margin: 10px 0;
padding: 12px 16px;
border-radius: 6px;
font-size: 14px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.pass {
background-color: #d4edda;
color: #155724;
border-left: 4px solid #28a745;
}
.fail {
background-color: #f8d7da;
color: #721c24;
border-left: 4px solid #dc3545;
}
.summary {
margin-top: 30px;
padding: 20px;
background: white;
border-radius: 8px;
font-weight: bold;
font-size: 16px;
}
</style>
</head>
<body>
<h1>Test Results</h1>
<div id="results"></div>
<script src="testFramework.js"></script>
<script src="tests.js"></script>
</body>
</html>HTMLCritical note: Make sure the charset is defined properly (UTF-8). Otherwise, the emoji in our test results won’t render correctly, and nobody wants broken checkmarks and X marks.
The CSS creates clean, readable test output with green boxes for passing tests and red boxes for failures. The border-left accent adds a nice visual touch that makes scanning results faster.
2.2 Running Tests Dynamically
Add this script to the bottom of your testFramework.js file to execute tests and dynamically display results:
document.addEventListener('DOMContentLoaded', () => {
const resultsDiv = document.getElementById('results');
let passed = 0;
let failed = 0;
tests.forEach(({ description, callback }) => {
try {
callback();
const resultDiv = document.createElement('div');
resultDiv.className = 'test-result pass';
resultDiv.textContent = `✅ PASS: ${description}`;
resultsDiv.appendChild(resultDiv);
passed++;
} catch (error) {
const resultDiv = document.createElement('div');
resultDiv.className = 'test-result fail';
resultDiv.textContent = `❌ FAIL: ${description} - ${error.message}`;
resultsDiv.appendChild(resultDiv);
failed++;
}
});
// Display summar
const summaryDiv = document.createElement('div');
summaryDiv.className = 'summary';
summaryDiv.textContent = `Summary: ${passed} Passed, ${failed} Failed`;
summaryDiv.style.color = failed > 0 ? '#dc3545' : '#28a745';
resultsDiv.appendChild(summaryDiv);
});JavaScriptHere’s what happens: When the DOM loads, we iterate through all registered tests. We execute each test’s callback function inside a try-catch block. If the test throws an error (from a failed assertion), we catch it and mark the test as failed. If it completes without errors, it’s a pass.
The framework creates a div for each test result, applies the appropriate CSS class, and injects it into the page. At the end, we display a summary showing total passes and failures.
This approach gives you immediate visual feedback—no need to dig through console logs.
Step 3: Writing Sample Test Cases
Our framework is ready. Now it’s time to write actual tests and watch them run!
Example Test Cases
Create a file called tests.js with these sample tests:
describe('String Operations', () => {
test('should concatenate strings correctly', () => {
const result = 'Hello, ' + 'World!';
expect(result).toBe('Hello, World!');
});
test('should find substring within string', () => {
const str = 'Hello, World!';
expect(str).toContain('World');
});
test('should validate email format with regex', () => {
const email = '[email protected]';
expect(email).toMatch(/^[^@]+@[^@]+\.[^@]+$/);
});
});
describe('Array Operations', () => {
test('should find item in array', () => {
const arr = [1, 2, 3, 4, 5];
expect(arr).toContain(3);
});
test('should identify missing item in array', () => {
const arr = ['apple', 'banana', 'cherry'];
expect(arr).toContain('banana');
});
});
describe('Math Operations', () => {
test('should add numbers correctly', () => {
const sum = 2 + 2;
expect(sum).toBe(4);
});
test('should multiply numbers correctly', () => {
const product = 5 * 6;
expect(product).toBe(30);
});
});JavaScriptThese test cases cover string manipulation, array operations, and basic math. Each describe block groups related tests together, making the output organized and easy to scan.
Notice how readable these tests are—even someone unfamiliar with testing frameworks can understand what’s being tested. That’s the beauty of the describe-test-expect pattern that Jest popularized.
Step 4: Visualizing Test Results
Open index.html in your browser. You’ll see your tests execute automatically and display beautiful visual results. Passed tests appear in green with checkmarks, while any failing tests show up in red with detailed error messages.
Alt Text for Screenshot: Screenshot of custom JavaScript test framework showing test results with green pass and red fail indicators
The visual representation makes debugging incredibly fast. You can immediately identify which tests failed and see the exact error message explaining why.
And that’s it—you’ve built a fully functional testing framework! All test results are running and displaying in a clean visual interface without depending on any external libraries or build tools. 🎉

Step 5: Enhancements & Next Steps
Our framework handles basic testing, but real-world frameworks like Jest include advanced features. Here’s how you could extend this foundation:
Adding Setup and Teardown Hooks
Setup and teardown functions (beforeEach and afterEach) run code before and after each test. This is crucial for resetting state or cleaning up resources:
let beforeEachCallback = null;
let afterEachCallback = null;
function beforeEach(callback) {
beforeEachCallback = callback;
}
function afterEach(callback) {
afterEachCallback = callback;
}
// Then modify the test runner
tests.forEach(({ description, callback }) => {
try {
if (beforeEachCallback) beforeEachCallback();
callback();
if (afterEachCallback) afterEachCallback();
// ... rest of pass logic
} catch (error) {
// ... fail logic
}
});JavaScriptThis pattern ensures consistent test isolation, which prevents one test from affecting another.
Handling Asynchronous Tests
Our current framework doesn’t support async operations. To add async test support, you’d need to handle promises and use async/await:
function test(description, callback) {
tests.push({
description,
callback,
isAsync: callback.constructor.name === 'AsyncFunction'
});
}
// In the runner, check if test is async and await it
if (test.isAsync) {
await callback();
} else {
callback();
}JavaScriptThis allows testing API calls, setTimeout operations, and other asynchronous code.
Additional Matchers
You could add more matchers to make assertions more expressive:
toEqual()for deep object comparisontoBeTruthy()andtoBeFalsy()for boolean checkstoThrow()for testing error conditionstoBeGreaterThan()andtoBeLessThan()for numerical comparisons
Each matcher follows the same pattern—compare values and throw errors on failure.
Common Pitfalls & Debugging
When building custom test frameworks, watch out for these issues:
Forgetting try-catch blocks: Without proper error handling, a single failed test will crash your entire test suite. Always wrap test execution in try-catch.
Scope issues: Make sure your test variables don’t leak between tests. This is why beforeEach and afterEach hooks are so important.
Async timing: If you add async support, remember that tests might complete in a different order than they started. You’ll need to handle this with promises or async/await properly.
Browser console errors: Check the browser console if tests don’t display. Missing charset declarations or JavaScript errors can break the visual output.
FAQ: Common Questions About Building Javascript Test Framework
No, the basic implementation doesn’t support async operations. The framework runs tests synchronously, so promises or async/await won’t work out of the box. However, you can extend it by checking if the test callback is an async function and using await in the test runner loop. This requires making the runner function itself async and handling promise rejections properly.
Implement global variables to store setup and teardown callbacks, then execute them before and after each test in the runner loop. Create beforeEach() and afterEach() functions that save the callbacks, similar to how test() registers tests. In the test execution loop, call the beforeEach callback before running the test and afterEach callback after, even if the test fails (use a finally block for cleanup).
You absolutely should use established frameworks like Jest, Mocha, or Jasmine for real projects. They handle edge cases, provide better error messages, support advanced features like mocking and code coverage, and have massive community support. Building your own framework is purely educational—it teaches you how test runners work internally, which makes you better at using and debugging professional testing tools. This DIY approach is perfect for learning environments or tiny sandbox projects where avoiding dependencies makes sense.
In our framework, toBe uses strict equality (===), meaning it checks if two values are exactly the same reference. For primitive values like numbers and strings, this works fine. Professional frameworks like Jest also have toEqual, which performs deep equality checks—it recursively compares object properties and array elements. If you need to compare objects or arrays in our framework, you’d have to implement a custom matcher with deep comparison logic.
Conclusion & Further Reading on Testing
By building this minimalistic JavaScript test framework from scratch, you’ve gained deep insight into how testing tools operate behind the scenes. You now understand test runners, assertion functions, matchers, and visual test reporting—all the core concepts that power frameworks like Jest and Mocha.
This framework is lightweight, dependency-free, and provides immediate visual feedback. However, I strongly recommend against using it in production settings. Stick with established testing frameworks that have robust features, community support, and years of refinement. Use this DIY framework for learning, teaching, or special scenarios where you genuinely need to avoid third-party dependencies but still want test coverage.
Ready to dive deeper into JavaScript testing? Check out the Jest documentation for advanced testing patterns, or explore Mocha for a flexible test framework with extensive plugin support. For comprehensive testing best practices, the MDN Testing Guide is an excellent resource.
Also here’s a codepen demo link for the code used in this guide so that you can try it out right now! Happy coding 🧑💻!
Discover more from CodeSamplez.com
Subscribe to get the latest posts sent to your email.

Leave a Reply