Skip to content

Build a testing library from scratch - Part2

Posted on:January 21, 2023

Table of contents

Open Table of contents

Adding some basic features

Ok, we’ve got our very first version ready, let’s add some basic features. Let’s think about what features are missing compared to other libraries:

  1. Most test libraries will automatically check which files should be tested before execution, we need such a feature to reduce our users’ workload.

  2. Most test libraries provide a CLI to process jobs in shell, so we need to get one.

  3. Most test libraries allow users to hook into the lifecycle of tests to avoid repeating setup and teardown code, this is also important for our library.

  4. The first version of our library doesn’t have the ability to transform users’ code, so they can only write javascript code while using our library( we need TypeScript support! ).

So let’s build our new library step by step.

Automatically check which files should be under test

Like Jest, we can have a naming convention that all test files should be formatted as xxx.spec.js or xxx.test.js, with this we can efficiently search our test files with glob pattern.

// import a library called `globby`, which provides a bunch of useful features
// for matching glob patterns.
import { globby } from 'globby'

// prodive an internal `getTestFiles` for automatically check which files 
// should be tested before execution.
const getTestFiles = async () => {
  const paths = await globby(['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts}'], {
    // ignore dependencies , artifacts and config files
    ignore: [
      '**/node_modules/**',
      '**/dist/**',
      '**/cypress/**',
      '**/.{idea,git,cache,output,temp}/**',
      '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
    ],
    absolute: true,
  })

  return paths
}

And then we don’t need users to provide their own getTestFiles to handle that job.

Command Line Interface

This step may be easier than you think, as Node.js provides a useful interface to help implement this process.

Create a new directory called bin at the root of your project, then add code below:

#!/usr/bin/env node

The code above is called hashbang, and adds this line to tell Node.js that it is an executable script.

And then import the getTestFiles function that we declared earlier:

#!/usr/bin/env node

import { getTestFiles, runner } from '../index'

// This line of code will be the part of the code that takes effect.
runner(getTestFiles)

In your package.json, add the following code:

{
    "bin": {
        "tinytest": "./dist/bin/index.js"
    }
}

where tinytest is the name of your cli, which you can change to whatever you like, followed by the build path, which you need to change to your own.

Hooks

With the power of EventEmitter, we can easily get the job done.

Create a new file called hook.ts, then add the following code:

import { EventEmitter } from 'events'

const hookCenter = new EventEmitter()

const beforeEach = (callback: () => void) => {
  hookCenter.on('before-each', callback)
}

const afterEach = (callback: () => void) => {
  hookCenter.on('after-each', callback)
}

export { hookCenter, beforeEach, afterEach }

In your register file, emit the events added in above hooks:

const it = (name: string, callback: (ctx: unknown) => void) => {
  hookCenter.emit('before-each')

  try {
    callback(context)
  } catch (r: unknown) {
    //@ts-ignore
    const reportee = r as IReportee

    if (reportee.passed) {
      console.log(chalk.green(`${name} passed`))
    } else {
      console.warn(chalk.red(`${name} failed`))
      console.warn(
        chalk.green(`Expected: ${JSON.stringify(reportee.expected)}`)
      )
      console.warn(chalk.red(`Received: ${JSON.stringify(reportee.received)}`))
    }
  }

  hookCenter.emit('after-each')
}

TypeScript support

Why do we even need this section to transform code written in TypeScript? Because Nodejs doesn’t provide a way to run it natively, the transformation process before execution is essential.

We choose esbuild to help us finish this job, if you don’t know what esbuild is yet, please check this link out first.

import * as esbuild from 'esbuild'
import { name as thisModuleName } from '../package.json'
// This is the path where you will put the transformed files
import { outputFolderPath } from './shared'

const transform = async (entrys: string[]) => {
  await esbuild.build({
    // original paths of test files
    entryPoints: entrys,
    outdir: outputFolderPath,
    bundle: true,
    // do not include this module (our testing library)
    external: [thisModuleName],
    platform: 'node',
    format: 'esm',
    outExtension: {
      '.js': '.mjs',
    },
  })
}

export { transform }

Then add the transform function to our runner:

// other code...
await transform(testFiles)

  const filenames = await readdir(outputFolderPath, {
    withFileTypes: true,
    encoding: 'utf-8',
  })
// other code...

Congratulations! We’ve done it all!