Vanilla Testing: A Comprehensive Guide to Testing Without Frameworks
Testing is an essential part of software development, ensuring the quality and reliability of your code. While numerous testing frameworks exist, sometimes the best approach is to go back to basics and perform “vanilla testing” – testing without relying on external libraries or frameworks. This article provides a comprehensive guide to vanilla testing, covering its benefits, how to set it up, and detailed steps to implement various testing techniques.
## What is Vanilla Testing?
Vanilla testing refers to testing your JavaScript code using only the built-in features of the JavaScript language and the browser environment. This means avoiding popular testing frameworks like Jest, Mocha, Jasmine, or Chai. The term “vanilla” emphasizes the absence of external dependencies, allowing you to understand the core principles of testing and how to implement them manually.
## Why Choose Vanilla Testing?
While testing frameworks offer convenience and a structured approach, vanilla testing can be beneficial in several situations:
* **Deep Understanding:** It forces you to understand the fundamentals of testing and how assertions and test structures work under the hood.
* **Dependency-Free:** It eliminates the need for external dependencies, reducing the risk of version conflicts and simplifying your project setup.
* **Lightweight:** It results in a smaller codebase, especially for small projects or when a full-fledged testing framework is overkill.
* **Educational:** It’s an excellent way to learn how testing frameworks are built and gain a deeper appreciation for their abstractions.
* **Debugging:** Helps better understand how tests function and debug errors more effectively by directly manipulating the test environment.
## Setting Up Your Vanilla Testing Environment
Creating a vanilla testing environment involves setting up the necessary HTML, CSS, and JavaScript files. Here’s a step-by-step guide:
1. **Create Project Directory:**
* Create a new directory for your project.
* Inside the directory, create the following files:
* `index.html`: The main HTML file to load your JavaScript code and display test results.
* `script.js`: Your JavaScript code that you want to test.
* `test.js`: The JavaScript file containing your test functions.
* `style.css`: A stylesheet for basic styling.
2. **HTML (`index.html`) Setup:**
* Open `index.html` and add the following basic structure:
html
Vanilla Testing
* This HTML file includes:
* A title for the page.
* A link to the `style.css` file.
* A `div` element with the ID `test-results`, which will be used to display the test results.
* Links to both `script.js` (your code) and `test.js` (your tests).
3. **CSS (`style.css`) Setup (Optional):**
* Create `style.css` and add some basic styling to make the test results more readable:
css
body {
font-family: Arial, sans-serif;
margin: 20px;
}
#test-results {
margin-top: 20px;
border: 1px solid #ccc;
padding: 10px;
}
.test-case {
margin-bottom: 10px;
padding: 5px;
border: 1px solid #eee;
}
.test-case.passed {
background-color: #d4edda;
border-color: #c3e6cb;
}
.test-case.failed {
background-color: #f8d7da;
border-color: #f5c6cb;
}
.test-case .message {
font-weight: bold;
}
* This CSS provides basic styling for the page and test results.
4. **JavaScript (`script.js`) Setup:**
* Create `script.js` and add the JavaScript code you want to test. For example, let’s create a simple function that adds two numbers:
javascript
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a – b;
}
function multiply(a, b) {
return a * b;
}
function divide(a, b) {
if (b === 0) {
return ‘Cannot divide by zero’;
}
return a / b;
}
* This `script.js` file defines the `add`, `subtract`, `multiply`, and `divide` functions that you will test.
5. **Test File (`test.js`) Setup:**
* Create `test.js` and add the testing logic. This is where you’ll define your test functions and assertions.
javascript
function assert(condition, message) {
if (!condition) {
throw new Error(‘Assertion failed: ‘ + message);
}
}
function test(name, callback) {
try {
callback();
displayTestResult(name, ‘passed’);
} catch (error) {
displayTestResult(name, ‘failed’, error.message);
}
}
function displayTestResult(name, status, message = ”) {
const testResultsDiv = document.getElementById(‘test-results’);
const testCaseDiv = document.createElement(‘div’);
testCaseDiv.classList.add(‘test-case’, status);
testCaseDiv.innerHTML = ` `;
if (message) {
testCaseDiv.innerHTML += `
`;
}
testResultsDiv.appendChild(testCaseDiv);
}
// Test Cases
test(‘Add function should return the sum of two numbers’, () => {
assert(add(2, 3) === 5, ‘Add function failed’);
assert(add(-1, 1) === 0, ‘Add function failed with negative numbers’);
assert(add(0, 0) === 0, ‘Add function failed with zeros’);
});
test(‘Subtract function should return the difference of two numbers’, () => {
assert(subtract(5, 3) === 2, ‘Subtract function failed’);
assert(subtract(0, 5) === -5, ‘Subtract function failed with negative result’);
assert(subtract(5, 5) === 0, ‘Subtract function failed with same numbers’);
});
test(‘Multiply function should return the product of two numbers’, () => {
assert(multiply(2, 3) === 6, ‘Multiply function failed’);
assert(multiply(-1, 5) === -5, ‘Multiply function failed with negative numbers’);
assert(multiply(0, 5) === 0, ‘Multiply function failed with zero’);
});
test(‘Divide function should return the quotient of two numbers’, () => {
assert(divide(6, 3) === 2, ‘Divide function failed’);
assert(divide(10, 2) === 5, ‘Divide function failed with positive numbers’);
});
test(‘Divide function should handle division by zero’, () => {
assert(divide(5, 0) === ‘Cannot divide by zero’, ‘Divide function failed with division by zero’);
});
* This `test.js` file includes:
* An `assert` function that throws an error if a condition is not met.
* A `test` function that runs a test case and displays the result on the page.
* A `displayTestResult` function that updates the DOM with the test result.
* Test cases for the `add`, `subtract`, `multiply`, and `divide` functions.
6. **Open `index.html` in Your Browser:**
* Open `index.html` in your browser to see the test results.
* You should see the results of your tests displayed on the page. If any tests fail, you’ll see an error message.
## Implementing Vanilla Testing
Here’s a breakdown of the key components for creating vanilla tests:
### 1. Assertion Functions
Assertion functions are the core of any testing framework. They check if a specific condition is true and throw an error if it’s false. This signals that the test has failed. Here are some essential assertion functions you can implement:
* **`assert(condition, message)`:**
* The most basic assertion function. It checks if the `condition` is truthy. If not, it throws an error with the provided `message`.
javascript
function assert(condition, message) {
if (!condition) {
throw new Error(‘Assertion failed: ‘ + message);
}
}
* **`assertEqual(actual, expected, message)`:**
* Checks if the `actual` value is equal to the `expected` value using the `===` operator.
javascript
function assertEqual(actual, expected, message) {
if (actual !== expected) {
throw new Error(`Assertion failed: ${message}. Expected ${expected}, but got ${actual}`);
}
}
* **`assertNotEqual(actual, expected, message)`:**
* Checks if the `actual` value is not equal to the `expected` value using the `!==` operator.
javascript
function assertNotEqual(actual, expected, message) {
if (actual === expected) {
throw new Error(`Assertion failed: ${message}. Expected not to be ${expected}, but got ${actual}`);
}
}
* **`assertTrue(condition, message)`:**
* Checks if the `condition` is true.
javascript
function assertTrue(condition, message) {
if (condition !== true) {
throw new Error(`Assertion failed: ${message}. Expected true, but got ${condition}`);
}
}
* **`assertFalse(condition, message)`:**
* Checks if the `condition` is false.
javascript
function assertFalse(condition, message) {
if (condition !== false) {
throw new Error(`Assertion failed: ${message}. Expected false, but got ${condition}`);
}
}
* **`assertThrows(callback, errorType, message)`:**
* Checks if the `callback` function throws an error of the specified `errorType`.
javascript
function assertThrows(callback, errorType, message) {
try {
callback();
} catch (error) {
if (error instanceof errorType) {
return;
} else {
throw new Error(`Assertion failed: ${message}. Expected ${errorType.name}, but got ${error.constructor.name}`);
}
}
throw new Error(`Assertion failed: ${message}. Expected an error to be thrown.`);
}
### 2. Test Runner
The test runner is responsible for executing your test functions and reporting the results. Here’s a simple test runner implementation:
javascript
function test(name, callback) {
try {
callback();
displayTestResult(name, ‘passed’);
} catch (error) {
displayTestResult(name, ‘failed’, error.message);
}
}
function displayTestResult(name, status, message = ”) {
const testResultsDiv = document.getElementById(‘test-results’);
const testCaseDiv = document.createElement(‘div’);
testCaseDiv.classList.add(‘test-case’, status);
testCaseDiv.innerHTML = ` `;
if (message) {
testCaseDiv.innerHTML += `
`;
}
testResultsDiv.appendChild(testCaseDiv);
}
* **`test(name, callback)`:**
* This function takes a `name` for the test case and a `callback` function containing the test logic.
* It executes the `callback` function within a `try…catch` block to handle any errors.
* If the test passes (no error is thrown), it calls `displayTestResult` with the `passed` status.
* If the test fails (an error is thrown), it calls `displayTestResult` with the `failed` status and the error message.
* **`displayTestResult(name, status, message = ”)`:**
* This function updates the DOM to display the test result.
* It creates a `div` element with the class `test-case` and adds the `status` class (either `passed` or `failed`).
* It sets the inner HTML of the `div` to display the test name and status.
* If there is an error message, it adds an additional span element to display the error message.
* Finally, it appends the `div` element to the `test-results` element in the HTML.
### 3. Writing Test Cases
Test cases are individual tests that verify specific aspects of your code. Here’s how to write test cases using the assertion functions and test runner:
javascript
test(‘Add function should return the sum of two numbers’, () => {
assert(add(2, 3) === 5, ‘Add function failed’);
assert(add(-1, 1) === 0, ‘Add function failed with negative numbers’);
assert(add(0, 0) === 0, ‘Add function failed with zeros’);
});
test(‘Subtract function should return the difference of two numbers’, () => {
assertEqual(subtract(5, 3), 2, ‘Subtract function failed’);
assertEqual(subtract(0, 5), -5, ‘Subtract function failed with negative result’);
assertEqual(subtract(5, 5), 0, ‘Subtract function failed with same numbers’);
});
test(‘Multiply function should return the product of two numbers’, () => {
assert(multiply(2, 3) === 6, ‘Multiply function failed’);
assert(multiply(-1, 5) === -5, ‘Multiply function failed with negative numbers’);
assert(multiply(0, 5) === 0, ‘Multiply function failed with zero’);
});
test(‘Divide function should return the quotient of two numbers’, () => {
assert(divide(6, 3) === 2, ‘Divide function failed’);
assert(divide(10, 2) === 5, ‘Divide function failed with positive numbers’);
});
test(‘Divide function should handle division by zero’, () => {
assertEqual(divide(5, 0), ‘Cannot divide by zero’, ‘Divide function failed with division by zero’);
});
* Each test case is defined using the `test` function, which takes a descriptive name and a callback function.
* Inside the callback function, you use assertion functions to check if the code behaves as expected.
* If any assertion fails, an error is thrown, and the test is marked as failed.
## Advanced Vanilla Testing Techniques
### 1. Mocking and Stubbing
Mocking and stubbing are techniques used to replace external dependencies or complex parts of your code with controlled substitutes. This allows you to isolate the code you’re testing and avoid relying on external factors that might be unreliable.
* **Mocking:** Creating a mock object that mimics the behavior of a real object. You can define the expected inputs and outputs of the mock object and verify that it’s called with the correct arguments.
* **Stubbing:** Replacing a function with a stub that returns a predefined value. This is useful when you want to control the return value of a function without executing its actual implementation.
Here’s an example of how to use mocking and stubbing in vanilla testing:
javascript
// Function to be tested
function fetchData(url, callback) {
fetch(url)
.then(response => response.json())
.then(data => callback(data))
.catch(error => console.error(‘Error fetching data:’, error));
}
// Mock the fetch function
function mockFetch(data) {
return function() {
return Promise.resolve({
json: () => Promise.resolve(data)
});
}
}
// Test case with mocking
test(‘fetchData should call the callback with the fetched data’, () => {
const mockData = { name: ‘Test’, value: 123 };
const mockCallback = jest.fn(); // Replace with vanilla implementation if needed
const originalFetch = window.fetch;
window.fetch = mockFetch(mockData);
fetchData(‘https://example.com/data’, mockCallback);
// Restore the original fetch function after the test
window.fetch = originalFetch;
// Assert that the callback was called with the mock data
assert(mockCallback.mock.calls.length === 1, ‘Callback was not called’); // Replace with vanilla implementation if needed
assert(mockCallback.mock.calls[0][0] === mockData, ‘Callback was not called with the correct data’); // Replace with vanilla implementation if needed
});
### 2. Asynchronous Testing
When testing asynchronous code (e.g., code that uses `setTimeout`, `Promises`, or `async/await`), you need to ensure that your tests wait for the asynchronous operations to complete before making assertions.
* **Callbacks:**
javascript
function delayedGreeting(name, callback) {
setTimeout(() => {
callback(`Hello, ${name}!`);
}, 500);
}
test(‘delayedGreeting should call the callback with the greeting after 500ms’, (done) => {
delayedGreeting(‘World’, (greeting) => {
assert(greeting === ‘Hello, World!’, ‘Greeting is incorrect’);
done(); // Signal that the test is complete
});
});
* **Promises:**
javascript
function fetchData(url) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(‘Data fetched successfully’);
}, 500);
});
}
test(‘fetchData should resolve with the correct message after 500ms’, () => {
return fetchData(‘https://example.com’)
.then(message => {
assert(message === ‘Data fetched successfully’, ‘Message is incorrect’);
});
});
### 3. Testing the DOM
When testing code that interacts with the DOM (e.g., code that modifies elements or responds to events), you need to use DOM manipulation methods to set up the test environment and make assertions about the state of the DOM.
javascript
// Function to be tested
function updateText(elementId, newText) {
const element = document.getElementById(elementId);
element.textContent = newText;
}
// Test case
test(‘updateText should update the text content of the element’, () => {
// Set up the test environment
const testDiv = document.createElement(‘div’);
testDiv.id = ‘test-div’;
document.body.appendChild(testDiv);
// Call the function to be tested
updateText(‘test-div’, ‘Hello, World!’);
// Make assertions about the state of the DOM
const element = document.getElementById(‘test-div’);
assert(element.textContent === ‘Hello, World!’, ‘Text content was not updated correctly’);
// Clean up the test environment
document.body.removeChild(testDiv);
});
### 4. Grouping Tests
To keep your tests organized, you can group related test cases together using functions or objects. This makes it easier to understand and maintain your tests.
javascript
// Grouping tests using an object
const addTests = {
‘Add function should return the sum of two numbers’: () => {
assert(add(2, 3) === 5, ‘Add function failed’);
assert(add(-1, 1) === 0, ‘Add function failed with negative numbers’);
assert(add(0, 0) === 0, ‘Add function failed with zeros’);
},
‘Add function should handle large numbers’: () => {
assert(add(1000000, 2000000) === 3000000, ‘Add function failed with large numbers’);
}
};
// Running grouped tests
for (const testName in addTests) {
if (addTests.hasOwnProperty(testName)) {
test(testName, addTests[testName]);
}
}
## Best Practices for Vanilla Testing
* **Write Clear and Descriptive Test Names:** Test names should clearly describe what the test is verifying.
* **Keep Test Cases Small and Focused:** Each test case should focus on a single aspect of the code.
* **Follow the Arrange-Act-Assert Pattern:**
* **Arrange:** Set up the test environment (e.g., create objects, initialize variables).
* **Act:** Execute the code you want to test.
* **Assert:** Make assertions about the results.
* **Clean Up After Tests:** Remove any changes you made to the DOM or global state during the test.
* **Test Edge Cases and Error Conditions:** Make sure to test edge cases (e.g., empty strings, null values) and error conditions (e.g., invalid input, exceptions).
* **Automate Your Tests:** Run your tests automatically whenever you make changes to the code.
* **Consider Test-Driven Development (TDD):** Write your tests before you write the code, and use the tests to guide your development.
## Conclusion
Vanilla testing provides a solid foundation for understanding the core principles of software testing. By implementing your own testing framework, you gain a deeper appreciation for the abstractions provided by testing libraries and frameworks. While vanilla testing may not be suitable for all projects, it’s a valuable skill that can improve your ability to write robust and reliable code. By following the steps and techniques outlined in this guide, you can create a comprehensive vanilla testing environment and start writing effective tests for your JavaScript code.