Skip to content

Build a testing library from scratch - Part1

Posted on:January 19, 2023

Table of contents

Open Table of contents

No longer a black box

The title hints at what follows: yes, we need to build a library ourselves to enable us to understand how it works.

There are several questions we need to know first:

If you don’t know how to answer yet, don’t worry, you’ll get the hang of it in the following sections.

Making a simple runner

What does runner do? Get the files and run, that’s it, there is no magic here.

import { fork } from 'child_process'


// a naive runner without involving parallelism

const runner = async (getTestFiles: () => Promise<string[]>) => {
  // here we use a callback function to inject the implementation
  const testFiles = await getTestFiles()

  // iterate all the files
  for(const testFile of testFiles) {
    
  // here is the key to the runner, we forked a nodejs process 
  // to run our test file
    const cmd = fork(testFile)

    cmd.on('exit', code => process.exit(code ?? 0))
    cmd.on('error', err => {
      throw err
    })
  }

}

That’s all there is to runner, easy, right?

AAA register

We don’t need to know how users will use it, but its interface, it will look like this:


const it = (testSuiteName: string, callback: () => void) => {
    callback()
}

However, if this is all it is, it has no way of making assertions, because it doesn’t do anything more than call the callback function.

An assert helper

We need a function to help us handle assertions, like:


const expect = <T = unknown>(value: T, another: T, comparator: Function) => {

}

Slightly cumbersome and not close enough to natural language, if we want a Jest-like chained call in our assert helper, how should we do?

Simply return an object with a comparator function as its property:

const expect = <T = unkown>(value: T) => {

    return {
        toBe: (another: T) => {
            // some comparisons
        }
    }
}

With the above implementation, we can easily make chain calls: expect(1 + 1).toBe(2).

Report messages to users

We are very close to the first available version of our library and only need to implement a reporter, let’s refactor the code above.

const expect = <T = unknown>(value: T) => {

  return {
    toBe: (another: T) => {
      if(Object.is(value, another)) {
        
        throw {
          passed: true
        }
      }else {

        throw {
          passed: false
        }
      }
    }
  }
}

and then, in our register:

const it = (name: string, callback: () => void) => {
  
  try{
   callback()
  }catch(r: unknown) {
    const passed: boolean = r.passed

    if(passed) {
      console.log(`${name} passed`)
    }else{
      console.warn(`${name} failed`)
    }
  }  
}

We are done! Let’s test it out.

Create two files in your workspace, one named calc.mjs, the other named calc.test.mjs

// calc.mjs
export const calc = (m, n) => m + n
// calc.test.mjs
import { it, expect } from './tiny-testing-lib'
import { calc } from './calc.mjs'

it('calculate one plus one equals two', () => {
    expect(calc(1, 1)).toBe(2)
})

Set up our runner:

// main.mjs
import { runner } from './tiny-testing-lib'
import { join } from 'path'

const getTestFiles = () => {
  const file = join(process.cwd(), './calc.test.mjs')

  return Promise.resolve([file])
}

runner(getTestFiles)

Set up scripts in package.json:

{
    "scripts": {
        "test": "node ./main.mjs"
    }
}

Then type npm run test into your terminal , you will see the magic!

calculate one plus one equals two passed