Graduate Program KB

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

  1. 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');
});
  1. 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

  1. 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');
});
  1. 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

  1. 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');
});
  1. 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

  1. Write a test that checks if the toBe matcher is available in the expect 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);
});
  1. Implement the toBe matcher in the expect 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

  1. 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');
});
  1. 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

  1. Write tests to check if beforeEach and afterEach 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);
});
  1. Implement the beforeEach and afterEach 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 };
  1. 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

  1. 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');
});
  1. 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 };
  1. 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 and afterAll 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

  1. Write tests to check if beforeAll and afterAll 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);
});
  1. Implement the beforeAll and afterAll 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 };
  1. Update the describe function to execute the beforeAll and afterAll 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

  1. 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);
});
  1. 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

  1. 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);
});
  1. 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

  1. 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);
});
  1. 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

  1. 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');
});
  1. 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

  1. 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');
});
  1. 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.