Test-Driven Development (TDD) Exercise: Implementing a Jest-like Library
In this exercise, you will learn how to implement a Jest-like library using Test-Driven Development methodology. We will start with basic concepts and gradually move on to advanced ones. Remember to write a test for one behavior at a time and make that single test pass.
Step 1: Describe and export test function
- Write a test that checks if the
describe
function is exported.
// tests/describe.test.js
const { describe } = require('../old/describe');
test('describe function is exported', () => {
expect(typeof describe).toBe('function');
});
- Implement the
describe
function and export it.
// old/describe.js
function describe(description, callback) {
// Implementation will be added later
}
module.exports = { describe };
Step 2: Implement basic test function
- Write a test that checks if the
test
function is exported and accepts a description and callback.
// tests/test.test.js
const { test } = require('../old/test');
test('test function is exported', () => {
expect(typeof test).toBe('function');
});
- Implement the
test
function and export it.
// old/test.js
function test(description, callback) {
// Implementation will be added later
}
module.exports = { test };
Step 3: Create expect function
- Write a test that checks if the
expect
function is exported and accepts a value.
// tests/expect.test.js
const { expect } = require('../old/expect');
test('expect function is exported', () => {
expect(typeof expect).toBe('function');
});
- Implement the
expect
function and export it.
// old/expect.js
function expect(actual) {
return {
// Matchers will be added later
};
}
module.exports = { expect };
Step 4: Implement toBe matcher
- Write a test that checks if the
toBe
matcher is available in theexpect
function and works correctly.
// tests/matchers.test.js
const { expect } = require('../old/expect');
test('toBe matcher is available and works correctly', () => {
expect(1).toBe(1);
expect('a').toBe('a');
expect(true).toBe(true);
});
- Implement the
toBe
matcher in theexpect
function.
// old/expect.js
function expect(actual) {
return {
toBe(expected) {
if (actual !== expected) {
throw new Error(`Expected ${actual} to be ${expected}`);
}
},
};
}
Step 5: Implement more matchers
Repeat the process of adding tests and implementing features for other matchers like toEqual
, toBeNull
, toBeTruthy
, toBeFalsy
, etc.
Step 6: Implement describe function
- Write a test that checks if the
describe
function groups tests together and logs the description.
// tests/describe.test.js
const { describe } = require('../old/describe');
const { test } = require('../old/test');
test('describe function groups tests and logs description', () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
describe('Sample group', () => {
test('Sample test', () => {
expect(1).toBe(1);
});
});
expect(consoleSpy).toHaveBeenCalledWith('Sample group');
});
- Implement the
describe
function to group tests and log descriptions.
// old/describe.js
function describe(description, callback) {
console.log(description);
callback();
}
module.exports = { describe };
Step 7: Implement beforeEach and afterEach hooks
- Write tests to check if
beforeEach
andafterEach
hooks are executed before and after each test, respectively.
// tests/hooks.test.js
const { describe } = require('../old/describe');
const { test } = require('../old/test');
const { beforeEach, afterEach } = require('../old/hooks');
test('beforeEach and afterEach hooks work correctly', () => {
let counter = 0;
describe('Hooks test', () => {
beforeEach(() => {
counter += 1;
});
afterEach(() => {
counter -= 1;
});
test('Sample test 1', () => {
expect(counter).toBe(1);
});
test('Sample test 2', () => {
expect(counter).toBe(1);
});
});
expect(counter).toBe(0);
});
- Implement the
beforeEach
andafterEach
hooks.
// old/hooks.js
let beforeEachCallback = null;
let afterEachCallback = null;
function beforeEach(callback) {
beforeEachCallback = callback;
}
function afterEach(callback) {
afterEachCallback = callback;
}
function runHooks() {
return {
beforeEach: () => {
if (beforeEachCallback) {
beforeEachCallback();
}
},
afterEach: () => {
if (afterEachCallback) {
afterEachCallback();
}
},
};
}
module.exports = { beforeEach, afterEach, runHooks };
- Update the
test
function to execute hooks.
// old/test.js
const { runHooks } = require('./hooks');
function test(description, callback) {
try {
runHooks().beforeEach();
callback();
runHooks().afterEach();
} catch (error) {
console.error(`Test "${description}" failed:`, error.message);
}
}
module.exports = { test };
Step 8: Implement error handling and test summary
- Write a test to check if the test summary is displayed after running all tests.
// tests/summary.test.js
const { test } = require('../old/test');
const { displaySummary } = require('../old/summary');
test('Test summary is displayed after running all tests', () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
test('Sample test 1', () => {
expect(1).toBe(1);
});
test('Sample test 2', () => {
expect(1).toBe(1);
});
displaySummary();
expect(consoleSpy).toHaveBeenCalledWith('Total tests: 2, Passed: 2, Failed: 0');
});
- Implement error handling and test summary.
// old/summary.js
let totalTests = 0;
let passedTests = 0;
let failedTests = 0;
function incrementTestCount() {
totalTests += 1;
}
function incrementPassedTests() {
passedTests += 1;
}
function incrementFailedTests() {
failedTests += 1;
}
function displaySummary() {
console.log(`Total tests: ${totalTests}, Passed: ${passedTests}, Failed: ${failedTests}`);
}
module.exports = { incrementTestCount, incrementPassedTests, incrementFailedTests, displaySummary };
- Update the
test
function to handle errors and update the test summary.
// old/test.js
const {
incrementTestCount,
incrementPassedTests,
incrementFailedTests,
} = require('./summary');
const { runHooks } = require('./hooks');
function test(description, callback) {
incrementTestCount();
try {
runHooks().beforeEach();
callback();
runHooks().afterEach();
incrementPassedTests();
} catch (error) {
incrementFailedTests();
console.error(`Test "${description}" failed:`, error.message);
}
}
module.exports = { test };
At this point, you have implemented a basic Jest-like library using Test-Driven Development. You can continue adding more features, such as:
- Implement
beforeAll
andafterAll
hooks. - Add support for asynchronous tests.
- Enhance error reporting.
- Implement custom matchers.
- Add support for test timeouts.
- Implement test skipping and filtering.
Step 9: Implement beforeAll and afterAll hooks
- Write tests to check if
beforeAll
andafterAll
hooks are executed before the first test and after the last test, respectively.
// tests/hooks.test.js
const { describe } = require('../old/describe');
const { test } = require('../old/test');
const { beforeAll, afterAll } = require('../old/hooks');
test('beforeAll and afterAll hooks work correctly', () => {
let counter = 0;
describe('Hooks test', () => {
beforeAll(() => {
counter += 1;
});
afterAll(() => {
counter -= 1;
});
test('Sample test 1', () => {
expect(counter).toBe(1);
});
test('Sample test 2', () => {
expect(counter).toBe(1);
});
});
expect(counter).toBe(0);
});
- Implement the
beforeAll
andafterAll
hooks.
// old/hooks.js
let beforeAllCallback = null;
let afterAllCallback = null;
function beforeAll(callback) {
beforeAllCallback = callback;
}
function afterAll(callback) {
afterAllCallback = callback;
}
function runHooks() {
return {
beforeAll: () => {
if (beforeAllCallback) {
beforeAllCallback();
}
},
afterAll: () => {
if (afterAllCallback) {
afterAllCallback();
}
},
};
}
module.exports = { beforeAll, afterAll, runHooks };
- Update the
describe
function to execute thebeforeAll
andafterAll
hooks.
// old/describe.js
const { runHooks } = require('./hooks');
function describe(description, callback) {
console.log(description);
runHooks().beforeAll();
callback();
runHooks().afterAll();
}
module.exports = { describe };
Step 10: Add support for asynchronous tests
- Write a test to check if the test function supports asynchronous tests using async/await or callbacks.
// tests/async.test.js
const { test } = require('../old/test');
test('test function supports async/await', async () => {
const result = await new Promise((resolve) => setTimeout(() => resolve(1), 100));
expect(result).toBe(1);
});
test('test function supports callbacks', (done) => {
setTimeout(() => {
expect(1).toBe(1);
done();
}, 100);
});
- Update the
test
function to handle asynchronous tests.
// old/test.js
async function test(description, callback) {
incrementTestCount();
try {
runHooks().beforeEach();
if (callback.length === 0) {
await callback();
} else {
await new Promise((resolve) => callback(resolve));
}
runHooks().afterEach();
incrementPassedTests();
} catch (error) {
incrementFailedTests();
console.error(`Test "${description}" failed:`, error.message);
}
}
module.exports = { test };
Now you have added support for asynchronous tests and more hooks to your Jest-like library. Continue to build on this foundation by implementing other advanced features, such as enhanced error reporting, custom matchers, test timeouts, test skipping, and filtering. Remember to follow the TDD methodology by writing tests for each new feature before implementing it and making a single test pass at a time.
Step 11: Enhance error reporting
- Write a test to check if the error message includes the failing test's description and specific error details.
// tests/error-reporting.test.js
const { test } = require('../old/test');
test('error reporting includes test description and specific error details', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
test('failing test', () => {
expect(1).toBe(2);
});
const expectedMessage = 'Test "failing test" failed: Expected 1 to be 2';
expect(consoleSpy).toHaveBeenCalledWith(expectedMessage);
});
- Update the
test
function to enhance error reporting.
// old/test.js
async function test(description, callback) {
incrementTestCount();
try {
runHooks().beforeEach();
if (callback.length === 0) {
await callback();
} else {
await new Promise((resolve) => callback(resolve));
}
runHooks().afterEach();
incrementPassedTests();
} catch (error) {
incrementFailedTests();
console.error(`Test "${description}" failed:`, error.message);
console.error(`Failed test: "${description}"\n`, error.stack);
}
}
module.exports = { test };
Step 12: Implement custom matchers
- Write a test to check if custom matchers can be added to the library.
// tests/custom-matchers.test.js
const { expect, addMatchers } = require('../old/expect');
test('custom matchers can be added', () => {
addMatchers({
toBeDivisibleBy: (actual, expected) => {
if (actual % expected !== 0) {
throw new Error(`Expected ${actual} to be divisible by ${expected}`);
}
},
});
expect(10).toBeDivisibleBy(2);
});
- Update the
expect
function to allow adding custom matchers.
// old/expect.js
const customMatchers = {};
function addMatchers(newMatchers) {
Object.assign(customMatchers, newMatchers);
}
function expect(actual) {
return {
toBe(expected) {
if (actual !== expected) {
throw new Error(`Expected ${actual} to be ${expected}`);
}
},
...customMatchers,
};
}
module.exports = { expect, addMatchers };
Step 13: Implement test timeouts
- Write a test to check if tests can be given a timeout, and if a timeout error is reported when a test exceeds the specified time.
// tests/timeout.test.js
const { test } = require('../old/test');
test('test timeouts work correctly', async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
await test('timeout test', async (done) => {
setTimeout(() => {
expect(1).toBe(1);
done();
}, 200);
}, 100);
expect(consoleSpy).toHaveBeenCalledWith('Test "timeout test" failed: Test timeout - 100ms exceeded');
});
- Update the
test
function to handle test timeouts.
// old/test.js
async function test(description, callback, timeout = 5000) {
incrementTestCount();
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`Test timeout - ${timeout}ms exceeded`));
}, timeout);
});
try {
runHooks().beforeEach();
if (callback.length === 0) {
await Promise.race([callback(), timeoutPromise]);
} else {
await Promise.race([new Promise((resolve) => callback(resolve)), timeoutPromise]);
}
runHooks().afterEach();
incrementPassedTests();
} catch (error) {
incrementFailedTests();
console.error(`Test "${description}" failed:`, error.message);
console.error(`Failed test: "${description}"\n`, error.stack);
}
}
module.exports = { test };
Step 14: Implement test skipping and filtering
- Write tests to check if tests can be marked as skipped and if a filter function can be used to selectively run tests.
// tests/skipping-filtering.test.js
const { test, skip, filter } = require('../old/test');
test('test skipping works correctly', () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
skip('skipped test', () => {
expect(1).toBe(1);
});
expect(consoleSpy).toHaveBeenCalledWith('Test "skipped test" skipped');
});
test('test filtering works correctly', () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
filter((description) => description.includes('filtered'));
test('filtered test', () => {
expect(1).toBe(1);
});
expect(consoleSpy).toHaveBeenCalledWith('Test "filtered test" skipped');
});
- Update the
test
function to implement test skipping and filtering.
// old/test.js
let filterFunction = null;
function skip(description, callback) {
console.log(`Test "${description}" skipped`);
}
function filter(filterCallback) {
filterFunction = filterCallback;
}
async function test(description, callback, timeout = 5000) {
if (filterFunction && !filterFunction(description)) {
skip(description, callback);
return;
}
incrementTestCount();
// Rest of the implementation
}
module.exports = { test, skip, filter };
Now, you have implemented advanced features such as enhanced error reporting, custom matchers, test timeouts, test skipping, and filtering to your Jest-like library. You can continue adding more features and refining the existing ones. Remember to follow the TDD methodology by writing tests for each new feature before implementing it and making a single test pass at a time.