Building a Testing Framework for JavaScript - Part One


This is the first post in the Building a Testing Framework for JavaScript series. In this series, we will explore the process of creating a lightweight JavaScript testing framework from scratch.

In this initial post, we'll lay the groundwork by setting up the core components of the framework, including file path resolution, a custom logger, and basic test matchers. By the end of this post, you'll have a solid foundation to build upon as we continue our journey into more advanced features like hooks and test result reporting.

Introduction

I like writing tests. I have been testing in some way ever since I started my career. I started off with manual testing, collating flows and their expected results before I wrote my first automated test.

Michael Ikechi (Mykeels) once paid me to write some tests for Abel. It was my first experience with unit testing an application. I finished up and decided to learn more about testing so I went ahead to take some courses on Test Automation University.

I have come a long way since then. I have written all kinds of tests — unit, integration and my favourite, End-to-End. While I don't start a project with tests, I try to add them as I as go. I like writing checklists and tests are ways of checking the things on that list. They also allow to me to think of edge cases.

But I digress. Today, we will be building our own mock of Jest, called Fest (puns well intended).

Fest

Since we are going to build a lightweight version of Jest, we only need to support the core features. I wanted to make Fest tests itself. Like a meta-testing framework but I decided against it. We might still do that but let's see how it goes.

Core Features

These are the core features that we need to implement:

  • File path resolver
  • Test Runner
  • Test Matcher/Assertion library
  • Handler for hooks like BeforeEach, AfterAll etc
  • Test Result Reporter

Initialization

mkdir fest
cd fest
yarn init

Given this list of features we want to create their respective files.

mkdir -p src && \
mkdir -p test && \
touch src/{resolveTestFiles.js,testRunner.js,assertions.js,hooks.js,reporter.js} && \
touch test/example.test.js && \
touch index.js

File path resolver

The first thing we want to do is get all the files that we need to run. To do this we need to have file match pattern which is also known as glob. But we need

yarn add glob

The pattern we are interested in is test/**/*.test.js. This will match all the files that are in the test directory and any sub-directories and the file extension should be .test.js.

// src/resolveTestFiles.js
import { sync } from "glob";

const pattern = "test/**/*.test.js";

const resolveTestFiles = () => {
  const files = sync(pattern);
  return files;
};

module.exports = resolveTestFiles;

In our index file, we can test this like so.

// index.js
import resolveTestFiles from "./src/resolveTestFiles.js";

console.log(resolveTestFiles());

The console outputs

[ 'test/example.test.js' ]

That looks great. However, we can update this function to be more elaborate to handle cases where it fails to get files, doesn't find a test file or report how many files it found.

// src/resolveTestFiles.js
const resolveTestFiles = () => {
  try {
    const files = sync(pattern);

    if (files.length > 0) {
      console.log(`INFO: Found ${files.length} test file(s)`);
    } else {
      console.error("ERROR: No test files found");
    }
  } catch (error) {
    console.error("ERROR: Failed to get test files");
  }
};

Little update to our index file.

// index.js
resolveTestFiles();

Now we see,

INFO: Found 1 test file(s)

Side Quest: Logger

But, I don't like that we are using console.log and console.error in our code, it is ruining our aesthetic. I am going to use Chalk to add some color to our logs.

Finally, an excuse to use this package.

yarn add chalk
touch src/logger.js
// src/logger.js
import chalk from "chalk";

const log = console.log;
const error = console.error;

const logger = {
  info: (message) => {
    log(chalk.cyanBright(`[INFO] ${message}`));
  },
  pass: (message) => {
    log(chalk.green.inverse.bold(` PASS `) + chalk.greenBright(` ${message}`));
  },
  fail: (message) => {
    log(chalk.red.inverse.bold(` FAIL `) + chalk.redBright(` ${message}`));
  },
  warn: (message) => {
    log(chalk.yellowBright(`[WARN] ${message}`));
  },
  error: (message) => {
    error(chalk.redBright.bold(`[ERROR] ${message}`));
  },
};

export default logger;

Then we update our resolveTestFiles function to look like this:

// src/resolveTestFiles.js

//...
const resolveTestFiles = () => {
  try {
    const files = sync(pattern);

    if (files.length > 0) {
      logger.info(`Found ${files.length} test file(s)`);
    } else {
      logger.info("No test files found");
    }
  } catch (error) {
    logger.error("Failed to get test files");
  }
};
//...

Running the tests

Now that we have been able to get the test files, the next step is running them. This involves simply looping through the test files and importing them.

But, we need to modify the resolveTestFiles function to return an array of test files.

// src/resolveTestFiles.js
import { sync } from "glob";
import logger from "./logger.js";

const pattern = "test/**/*.test.js";

const resolveTestFiles = () => {
  try {
    const files = sync(pattern);
    return files;
  } catch (error) {
    logger.error("Failed to get test files.");
    return [];
  }
};

export default resolveTestFiles;

Now, we have simplified our resolverTestFiles function to run the test files in an array. We also handled a scenario where Glob throws an error if it can't find any files matching the pattern specified.

Our testRunner function is going to check if there are files available, loop through the array and try to import the files and if any of these steps fails, we log the error.

// testRunner.js
import path from "path";
import logger from "./logger.js";
import resolveTestFiles from "./resolveTestFiles.js";

const runTests = async () => {
  const testFiles = resolveTestFiles();

  if (testFiles.length > 0) {
    logger.info(`Found ${testFiles.length} test file(s)`);

    for (const testFile of testFiles) {
      logger.info(`Running test file: ${testFile}`);
      try {
        await import(path.resolve(testFile));
      } catch (error) {
        logger.error(
          `Failed to import test file: ${testFile}. Error: ${error.message}`
        );
      }
    }
  } else {
    logger.error("No test files found.");
    process.exit(1);
  }
};

export default runTests;

Updating our index.js file to run the tests.

// index.js
import runTests from "./testRunner.js";

runTests();

We see this in our console

INFO: Found 1 test file(s)
INFO: Running test file: test/example.test.js

Test Matcher/Assertion Framework

We have built a way to get our test files and a way to run the test files — just believe me bro. The next thing we need to build are the matchers and we are going to call our assertion framework, validate.

// src/assertion.js
const validate = (actual) => {
  return {
    isEqual: (expected) => {
      if (JSON.stringify(actual) !== JSON.stringify(expected)) {
        throw new Error(
          `Expected ${actual} to be ${expected}, but it was not.`
        );
      }
    },
    isNotEqual: (expected) => {
      if (JSON.stringify(actual) === JSON.stringify(expected)) {
        throw new Error(
          `Expected ${actual} to not be ${expected}, but it was.`
        );
      }
    },
    isTruthy: () => {
      if (!actual) {
        throw new Error(`Expected ${actual} to be truthy, but it was falsy.`);
      }
    },
    isFalsy: () => {
      if (actual) {
        throw new Error(`Expected ${actual} to be falsy, but it was truthy.`);
      }
    },
  };
};

export default validate;

These are basic validators that we are going to use, you can extend this to include more. The logic is straightforward, we are silently passing validations and only throwing an error when they fail.

Great! Let's revisit our checklist for the core features:

  • File path resolver
  • Bonus: Logger
  • Test Runner
  • Test Matcher/Assertion library
  • Handler for hooks like BeforeEach, AfterAll etc
  • Test Result Reporter

Conclusion

We've made significant progress in building our lightweight testing framework, Fest. So far, we've implemented the file path resolver, created a custom logger with Chalk, set up the basic structure for running our tests and we have added an Assertion library. These components provide a solid foundation of our testing framework.

In the next part, we'll dive deeper into implementing hooks, and building a test result reporter. Implementing these functionalities are key to the core of building a testing framework.

Stay tuned for Part 2, where we'll complete our journey in building Fest.