The implementation principle of front-end unit test framework

Before starting this article, let's talk about the author's feelings about unit testing (or integration testing, e2e testing).

In foreign countries, software engineers attach great importance to software quality, and most of them advocate using TDD to ensure code quality. However, China often does not attach great importance to automated testing. Fundamentally speaking, there are many reasons why automated testing is not popular in China. I won't repeat it here.

But in fact, in large and medium-sized software development, automated testing is actually very important. The author believes that in modern software development, perfect automatic testing, Lint tools and good code design can basically ensure the long-term stable vitality of the software.

Therefore, the author believes that even if not now, in the future, automated testing is also a necessary skill for Engineers (happy off-duty skill, hahaha).

About unit testing

Look at Wikipedia first:

In computer programming, Unit Testing (English: Unit Testing), also known as module testing, is a test to verify the correctness of program modules (the smallest unit of software design). The program unit is the smallest testable part of the application. In process programming, a unit is a single program, function, process, etc; For object-oriented programming, the smallest unit is the method, including the method in the base class (super class), abstract class, or derived class (subclass).

In the front-end context, this can also be simply understood as a test tool function. Generally speaking, unit testing is about verifying whether each function in our application is called correctly. How to judge whether the call is correct? The general considerations are as follows:

  • The number of function calls is reasonable
  • The function arguments are expected
  • The parameter of the function, that is, the return value, meets the expectation

Of course, the function itself may call other functions, or it can be said that the function will rely on other modules and third-party libraries. At the same time, the function may also be synchronous or asynchronous. Therefore, when the tested function is a pure function, it is just to test whether the input and output parameters of the function itself meet the expectations. Otherwise, we need to do a lot of mock work to exclude the test code that is not our goal.

Of course, in our daily work, if we want to write unit tests, we usually use the mature test libraries in the industry, such as jest, mocha, chai, ava, tape, QUnit and so on. In fact, the principles behind most test frameworks are basically similar. So let's learn the principle of unit testing by implementing the simplest unit testing framework!

Test container and assertion Library

The test framework can be basically divided into two parts:

  • Test Runner
  • Assertion Library

brief introduction

The most basic function of test container is to automatically run all tests and summarize the test results. Our common usage is as follows: write test units:

// ./math.test.js
const { sumAsync, subtractAsync } = require('./math');

test('sumAsync adds numbers asynchronously', async () => {
  const result = await sumAsync(3, 7);
  const expected = 10;
  expect(result).toBe(expected);
});

test('subtractAsync subtracts numbers asynchronously', async () => {
  const result = await subtractAsync(7, 3);
  const expected = 4;
  expect(result).toBe(expected);
});
Copy code

Suppose we have the math tool function as follows:

// ./math.js
const sum = (a, b) => a + b;
const subtract = (a, b) => a - b;
const sumAsync = (...args) => Promise.resolve(sum(...args));
const subtractAsync = (...args) => Promise.resolve(subtract(...args));

module.exports = { sum, subtract, sumAsync, subtractAsync };
Copy code

We run the test with jest and feed back the summarized test results at the terminal:

$ jest
 PASS  ./math.test.js
  ✓ sumAsync adds numbers asynchronously (4ms)
  ✓ subtractAsync subtracts numbers asynchronously (1ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.145s
Ran all test suites.
Copy code

The general form of assertion library is as follows:

expect(result).toBe(expected);
expect(func).toHaveBeenCalled();
expect(func).toHaveBeenCalledTimes(1);
expect(func).toHaveBeenCalledWith(arg1, arg2 /* ...args  */);
// ...
Copy code

Does the assertion library look semantic~

Test container implementation example

In fact, the test container is not complex. The simplest implementation is as follows:

// ./test.js
async function test(title, callback) {
  try {
    await callback();
    console.log(`✓ ${title}`);
  } catch (error) {
    console.error(`✕ ${title}`);
    console.error(error);
  }
}
Copy code

It should be noted that async/await is added here to wait for asynchronous logic in test cases.

Assertion library implementation example

There is no black magic in the assertion library. We write the simplest expect (x) The syntax of tobe (y) is as follows:

// ./expect.js
function expect(actual) {
  return {
    toBe(expected) {
      if (actual !== expected) {
        throw new Error(`${actual} is not equal to ${expected}`);
      }
    },
  };
}
Copy code

It's much simpler than expected, isn't it~

A key point here is that if the assertion fails in the assertion function, our choice is to throw an error, then try/catch it in the test container and print the error stack at the same time. (in a simple case, we can also use the assert Library of Node.js to assert.)

In addition, there are many more complex assertion syntax, but that's the basic form. Of course, how to skillfully design the assertion function of test function calls (toHaveBeenCalledTimes) and access parameters (toHaveBeenCalledWith) will be mentioned later.

Automatic injection

Some students may have noticed that in the testing framework, we do not need to manually introduce test and expect functions. Each test file can be used directly. This is actually very simple. Reference codes are as follows:

// ./test-framework.js
// Inject into global objects so that each file can be accessed
global.test = require('./test');
global.expect = require('./expect');

// Load all test cases from the command line:
process.argv.slice(2).forEach(file => {
  // In the test file
  require(file);
});
Copy code

Then run at the terminal:

$ node test-framework.js ./math.test.js
✓ sumAsync adds numbers asynchronously
✓ subtractAsync subtracts numbers asynchronously
 Copy code

is it? That's it!

Next, we just need to do it more gracefully, such as

  • Encapsulate it as a TestRunner object
  • Put the order in the/ In bin
  • Expand more assertion syntax
  • Use glob to match all test files
  • Support configuration (refer to jest.config.js)
  • Test summary statistics
  • Support elegant error stack

Even you can extend it to support DOM testing, because the core logic of DOM testing also uses JSDOM to simulate similar DOM structures in memory according to W3C standards, so as to support assertion testing.

Function test

In the above, we have basically built the simplest test framework, and the file structure is as follows:

.
├── expect.js
├── math.js
├── math.test.js
├── test-framework.js
└── test.js
 Copy code

In fact, in some scenarios, we need to be able to ensure that the function is executed only once and the input parameters when called are accurate.

Because multiple function calls may lead to memory leakage, and parameter input errors may lead to unexpected behavior of the application. Therefore, we need to test the guarantee more finely from the assertion library.

So how does the assertion library do it?

Next, we will expand the assertion library to support richer function testing.

Implementation principle of monitoring the number of incoming calls

Suppose our extension supports these two assertion syntax:

expect(sum).toHaveBeenCalledTimes(1);
expect(sum).toHaveBeenCalledWith(3, 7);
Copy code

You can think about how to design and implement it?

In the test framework, we integrate the following functions:

// ./test-framework.js
global.jest = {
  fn: (impl = () => {}) => {
    const mockFn = (...args) => {
      mockFn.mock.calls.push(args);
      return impl(...args);
    };
    mockFn.originImpl = impl;
    mockFn.mock = { calls: [] };
    return mockFn;
  },
};
Copy code

The fn function is a high-order function, which wraps the incoming function impl to be tested. Mount the mock object to invoke data when it is called in the returned mockFn.

Of course, there is no need for the caller who writes the test case to be aware, just use jest FN package:

const sumMockFn = jest.fn(sum);
Copy code

Next, you only need to test the returned sumMockFn. Essentially, all operations on sumMockFn will be transmitted to sum.

Extended assertion function

So what are we missing Well, that's right. There are also assertion functions:

// ./expect
const { isEqual } = require('lodash');

module.exports = function expect(actual) {
  return {
    toBe(expected) {
      // ...
    },
    toEqual(expected) {
      if (!isEqual(actual, expected)) {
        throw new Error(`${actual} is not equal to ${expected}`);
      }
    },
    toHaveBeenCalledTimes(expected) {
      let actualCallTimes = 0;
      try {
        actualCallTimes = actual.mock.calls.length;
        expect(actualCallTimes).toEqual(expected);
      } catch (err) {
        throw new Error(
          `expect function: ${actual.originImpl.toString()} to call ${expected} times, but actually call ${actualCallTimes} times`
        );
      }
    },
    toHaveBeenCalledWith(...expectedArgs) {
      let actualCallArgs = [];
      try {
        actualCallArgs = actual.mock.calls;
        actualCallArgs.forEach(callArgs => {
          expect(callArgs).toEqual(expectedArgs);
        });
      } catch (err) {
        throw new Error(
          `expect function: ${actual.originImpl.toString()} to be called with ${expectedArgs}, but actually it was called with ${actualCallArgs}`
        );
      }
    },
  };
};
Copy code

Although the code is a little long, it's easy to look at it, isn't it. The key point is to jest FN assert the length and content of the object mock mounted by the wrapped function. What we need to pay attention to here is. We caught expect (x) The error thrown by toequal (y) throws a more user-friendly error.

Finally, we write test cases as follows:

test('sum should have been called once', () => {
  const sumMockFn = jest.fn(sum);
  sumMockFn(3, 7);
  expect(sumMockFn).toHaveBeenCalledTimes(1);
});

test('sum should have been called with `3` `7`', () => {
  const sumMockFn = jest.fn(sum);
  sumMockFn(3, 7);
  expect(sum).toHaveBeenCalledWith(3, 7);
});
Copy code

Run successfully!

$ node test-framework.js ./math.test.js
✓ sum should have been called once
✓ sum should have been called with `3` `7`
Copy code

modular

After our efforts. We have made a decent test framework. But please wait! Is reality really that simple?

Suddenly need to test a new function, this function seems a little different

// ./user.js
const { v4: uuidv4 } = require('uuid');

module.exports = {
  createUser({ name, age }) {
    return {
      id: uuidv4(),
      name,
      age,
    };
  },
};
Copy code

We want to test that this function returns a user object with an id and also calls uuidv4. However, it is found that this function cannot write tests, because the id generated each time is different, so it returns different objects each time. There is no way to simply use expect (x) toEqual(y).

But we can't test the uuid library. Because testing them is meaningless and unrealistic.

Then what shall I do? We still have a way. The extended test framework is as follows:

// ./test-framework.js
global.jest = {
  fn: (impl = () => {}) => {
    // ...
  },
  mock: (mockPath, mockExports = {}) => {
    const path = require.resolve(mockPath);

    require.cache[path] = {
      id: path,
      filename: path,
      loaded: true,
      exports: mockExports,
    };
  },
};
// ...
Copy code

We found that the above mock function uses require Resolve obtains the module loading path, and then in require Cache prepares the constructed cache export object.

Write the test as follows:

// ./user.test.js
jest.mock('uuid', {
  v4: () => 'FAKE_ID',
});

const { createUser } = require('./user');

test('create an user with id', () => {
  const userData = {
    name: 'Christina',
    age: 25,
  };
  const expectUser = {
    ...userData,
    id: 'FAKE_ID',
  };

  expect(createUser(userData)).toEqual(expectUser);
});
Copy code

Because require Cache relationship, we need to put jest Mock mentioned the first call of the file. (the same operation in jest does not need to be advanced, because the test framework automatically advances such operations when running test cases) then the simulated exported v4 object returns a FAKE_ID.

Run the test as follows:

$ node test-framework.js ./user.test.js
✓ create an user with id
 Copy code

Perfect solution~

Application functions in the real world are often not clean and lovely pure functions. Relying on a large number of third-party popular libraries for development is not only our daily work, but also a happy thing in the open source world.

The basic principle of how to exclude third-party dependent libraries for testing is also as above.

Make it more elegant

Call node test framework every time js ./ user. test. JS to run the test, it doesn't look very good. Let's make this testing framework more elegant!

Well, let's name this test framework mjest!

The first step is to create a new bin directory in the project and throw the implementation of the above test framework into the project/ bin/mjest.js.

$ tree ./bin/
./bin/
└── mjest.js
 Copy code

The second step, in mjest Add at the top of JS file Shebang . Use node as the default interpreter.

#!/usr/bin/env node

// mjest code
 Copy code

Step 3, in package Add bin declaration to JSON:

{
  "name": "mjest",
  "version": "1.0.0",
  "description": "mini jest implementation",
  "main": "index.js",
  "bin": {
    "mjest": "./bin/mjest.js"
  }
}
Copy code

Step 4: run npm link under the project path terminal. This command will soft link the bin of the project to the bin in the system:

$ which mjest
/Users/sulirc/.nvm/versions/node/v10.20.1/bin/mjest
 Copy code

Step five! Run the test case with the newly released mjest:

$ mjest ./user.test.js
✓ create an user with id
 Copy code
$ mjest ./math.test.js
✓ sum should have been called once
✓ sum should have been called with `3` `7`
✓ sumAsync adds numbers asynchronously
✓ subtractAsync subtracts numbers asynchronously
 Copy code

We can also use glob syntax for more elegant matching files:

$ mjest *.test.js
✓ sum should have been called once
✓ sum should have been called with `3` `7`
✓ create an user with id
✓ sumAsync adds numbers asynchronously
✓ subtractAsync subtracts numbers asynchronously
 Copy code

The complete test framework code of this article has also been put on github by the author. Welcome to read: github.com/sulirc/mjes...

more

So far, I believe you should have a basic understanding of the principle of the test framework. In jest, there are hook functions such as beforeEach and beforeAll. You can also find ways to implement them yourself. The rich assertion functions in the assertion library can also be broken one by one.

Can't find complete learning materials during self-study?
2021 Introduction to the latest software test collection zero Foundation https://www.bilibili.com/video/BV1ey4y1W7n6/

Can't find a solution to the problem?
You can add my software testing exchange group: 1140267353, which has a large number of software testing learning materials and technology leaders to solve problems online.

By continuously enriching the features and rounding them, we have implemented a test framework.

On the basis of unit testing, in fact, the principle of integration testing framework is not far away. Because integration testing is actually based on unit testing. You can also think.

Keywords: Programming unit testing software testing

Added by SilentQ-noob- on Wed, 09 Feb 2022 14:35:52 +0200