[front end] refactoring, tasteful code 02 -- building a test system

Write in front

Code refactoring is an inevitable work in the process of product continuous function iteration. The so-called refactoring is to optimize and adjust the internal implementation without changing the input and output of the program, that is, ensuring the existing functions. This is the second article in the "refactoring, tasteful code" series. The last one is Refactoring, tasteful code -- on the road to refactoring

Build test system

Sharp tools make good work

Refactoring is a valuable and important tool, which enables us to enhance the readability and robustness of the code. But refactoring alone is not enough, because there will be unavoidable mistakes in the process of refactoring, so it is necessary to build a perfect and stable test system.

Self testing code is important

In project development, it takes less time to write code, but the time and cost to find bugs when modifying them is relatively high, which is a nightmare for many people. Some programmers test after writing a large piece of code, which is still difficult to find the potential BUG of the code. Instead, they should test after writing a little function. In fact, it's a little difficult to persuade people to do so, but you'll get twice the result with half the effort when you find potential problems. Don't waste more time because of trying to save trouble.

Of course, many programmers may not have studied software testing at all and can't write test code at all, which is very unfavorable to the development of programmers. In fact, the best time to write test code is to write the corresponding test code before you start coding and before you need to add new functions, because it allows you to focus on the interface rather than the implementation. The pre written test code can set a symbolic end to the development work, that is, once the test code runs normally, it means that the work is also over.

Test Driven Development: "test - > coding - > refactoring", so testing is very important for refactoring.

Examples

In the example, there are two main classes: factory class and supplier class. The constructor of the factory guest class receives a JS object. At this time, we can imagine a JSON data provider.

Factory class

// Factory class
class Factory{
  constructor(doc){
    this._name = doc.name;
    this._producers = [];
    this._totalProduction = 0;
    this._demand = doc.demand;
    this._price = doc.price;
    doc.producers.forEach(d => this.addProducer(new Producer(this,d)))
  }

  // Add supplier
  addProducer(arg){
    this._producers.push(arg);
    this._totalProduction += arg.production;
  }

  // Values and setting functions of various data
  get name(){
    return this._name;
  }
  get producers(){
    return this._producers.slice();
  }

  get totalProduction(){
    return this._totalProduction;
  }
  set totalProduction(arg){
    this._totalProduction = arg;
  }
  
  get demand(){
    return this._demand;
  }
  set demand(arg){
    this._demand = parseInt(arg);
  }
  get price(){
    return this._price;
  }
  set price(arg){
    this._price = parseInt(arg);
  }

  // Calculation of vacancy
  get shortfall(){
    return this._demand - this.totalProduction;
  }

  // Calculation of profit
  get profit(){
    return this.demandValue - this.demandCost;
  }
  // Set estimated sales
  get demandValue(){
    let remainingDemand = this.demand;
    let result = 0;
    this.producers
    .sort((a,b)=>a.cost - b.cost)
    .forEach(p => {
      const contribution = Math.min(remainingDemand, p.production);
      remainingDemand -= contribution;
      result += contribution * p.cost;
    });
    return result;
  }
  // Set cost
  get demandCost(){
    return this.satisfiedDemand * this.price;
  }
  get satisfiedDemand(){
    return Math.min(this._demand , this.totalProduction);
  }
};

Supplier class

// Supplier class -- container for storing data
class Producer{
  constructor(aFactory, data){
    this._factory = aFactory;
    this._cost = data.cost;
    this._name = data.name;
    this._production = data.production || 0;
  }

  // Various value taking and setting functions
  get name(){
    return this._name;
  }
  get cost(){
    return this._cost;
  }
  set cost(arg){
    this._cost = parseInt(arg);
  }

  get production(){
    return this._production;
  }
  set production(amountStr){
    const amount = parseInt(amountStr);
    const newProduction = Number.isNaN(amount) ? 0 : amount;
    this._factory.totalProduction += newProduction - this._production;
    this._production = newProduction;
  }
}

In the above two classes, we can see that the way of updating derived data is a little ugly and the code is complicated. We will refactor it later.

Function to create JSON data:

function sampleFactoryData(){
  return {
    name:"Apple",
    producers:[{
      name:"FOXCOM",
      cost:10,
      production:9
    },{
      name:"SAMSUNG",
      cost:12,
      production:10
    },{
      name:"BYD",
      cost:10,
      production:6
    }],
    demand:200,
    price:16000
  }
};

The overall code is as follows:

computer.js

// Factory class
class Factory{
  constructor(doc){
    this._name = doc.name;
    this._producers = [];
    this._totalProduction = 0;
    this._demand = doc.demand;
    this._price = doc.price;
    doc.producers.forEach(d => this.addProducer(new Producer(this,d)))
  }

  // Add supplier
  addProducer(arg){
    this._producers.push(arg);
    this._totalProduction += arg.production;
  }

  // Values and setting functions of various data
  get name(){
    return this._name;
  }
  get producers(){
    return this._producers.slice();
  }

  get totalProduction(){
    return this._totalProduction;
  }
  set totalProduction(arg){
    this._totalProduction = arg;
  }
  
  get demand(){
    return this._demand;
  }
  set demand(arg){
    this._demand = parseInt(arg);
  }
  get price(){
    return this._price;
  }
  set price(arg){
    this._price = parseInt(arg);
  }

  // Calculation of vacancy
  get shortfall(){
    return this._demand - this.totalProduction;
  }

  // Calculation of profit
  get profit(){
    return this.demandValue - this.demandCost;
  }
  // Set estimated sales
  get demandValue(){
    let remainingDemand = this.demand;
    let result = 0;
    this.producers
    .sort((a,b)=>a.cost - b.cost)
    .forEach(p => {
      const contribution = Math.min(remainingDemand, p.production);
      remainingDemand -= contribution;
      result += contribution * p.cost;
    });
    return result;
  }
  // Set cost
  get demandCost(){
    return this.satisfiedDemand * this.price;
  }
  get satisfiedDemand(){
    return Math.min(this._demand , this.totalProduction);
  }
};

// Supplier class -- container for storing data
class Producer{
  constructor(aFactory, data){
    this._factory = aFactory;
    this._cost = data.cost;
    this._name = data.name;
    this._production = data.production || 0;
  }

  // Various value taking and setting functions
  get name(){
    return this._name;
  }
  get cost(){
    return this._cost;
  }
  set cost(arg){
    this._cost = parseInt(arg);
  }

  get production(){
    return this._production;
  }
  set production(amountStr){
    const amount = parseInt(amountStr);
    const newProduction = Number.isNaN(amount) ? 0 : amount;
    this._factory.totalProduction += newProduction - this._production;
    this._production = newProduction;
  }
}


function sampleFactoryData(){
  return {
    name:"Apple",
    producers:[{
      name:"FOXCOM",
      cost:10,
      production:9
    },{
      name:"SAMSUNG",
      cost:12,
      production:10
    },{
      name:"BYD",
      cost:10,
      production:6
    }],
    demand:200,
    price:16000
  }
};

module.exports = {
  sampleFactoryData,
  Factory,
  Producer
}

Initial test

Before testing the code, you need to use the test framework Mocha. Of course, you have to install the relevant packages first:

npm install mocha

Install Mocha > = v3 0.0, npm version should be > = v2.0 14.2. In addition, make sure to use node JS version > = V4 to run mocha

test.js

const {Factory, sampleFactoryData} = require("./computer");
var assert = require('assert');
describe("factory",function(){
  it("shortfall",function(){
    const doc = sampleFactoryData();
    const apple = new Factory(doc);
    assert.equal(apple.shortfall, 175);
  })
})

Remember to use package Set the startup mode in JSON:

 "scripts": {
    "test": "mocha"
  },

Enter the test command on the command line:

$ npm test
> test@1.0.0 test G:\oneu\test
> mocha

  factory
    ✔ shortfall
  1 passing (7ms)

Mocha framework organizes tests by grouping the code. Each group contains a related test. The test needs to be written in an it block. In the above example, the test mainly includes two steps:

  • Set the fixture, that is, the data and objects required for the test
  • Verify that the test fixture has certain characteristics

What we see is that the test is correct. If you want to see the error when the test fails:

assert.equal(apple.shortfall, 5);

Operation results:

$ npm test
> test@1.0.0 test G:\oneu\test
> mocha

  factory
    1) shortfall
  0 passing (10ms)
  1 failing

  1) factory
       shortfall:

      AssertionError [ERR_ASSERTION]: 175 == 5
      + expected - actual

      -175
      +5

      at Context.<anonymous> (test.js:7:12)
      at processImmediate (internal/timers.js:461:21)
      
npm ERR! Test failed.  See above for more details.

We can see that Mocha framework reports which test failed and gives the reason for the test failure for easy search. In fact, the error here is that the actual calculated value is not equal to the expected value.

There will be multiple tests in the actual test system, which can help us run quickly and find bugs. Good testing can give us simple and clear error feedback, which is particularly important for self-test code. In the actual development, it is recommended to run the test code frequently to check the progress of the new code and whether there are errors in the reconstruction process.

Retest

When adding new functions, the style is to observe everything the tested class does, and then test each behavior of the class, including various boundary conditions that may make it abnormal. Testing is a risk driven behavior. The goal of testing is to find bugs that appear in the milk or may appear in the future.

Remember: if you try to write too many tests, you may not get the expected results, which will lead to insufficient tests. In fact, even a little testing can benefit a lot.

Then, we write a code to test and calculate the total profit, which can basically test the total profit in the test fixture.

const {Factory, sampleFactoryData} = require("./computer");
var assert = require('assert');
var expect = require("expect");
describe("factory",function(){
  const apple = new Factory(sampleFactoryData());
  it("shortfall",function(){
    assert.equal(apple.shortfall, 175);
  })

  it("profit",()=>{
   
    expect(apple.profit).equal(230);
  })
})

Modify test fixture

After loading the test fixture, you can write some test code to explore its characteristics, but in practical application, the fixture will be updated frequently because the user will modify the value on the interface. You should know that most updates are completed by setting functions, but it is unlikely that any bugs will appear.

it("change production",()=>{
  apple.producers[0].production = 20;
  assert.equal(apple.shortfall,164);
  assert.equal(apple.profit,-575620);
})

According to the common test mode of the above code, get the initial standard fixture configured before each, then check it as necessary, and finally verify whether it shows the expected behavior. In a word, they are "configuration - > inspection - > verification", "preparation - > behavior - > assertion", etc.

Detection boundary conditions

In the previous series of descriptions, all tests focus on normal behavior, which is usually called "normal path", which refers to the scene where everything works normally and the user's use mode meets the specification, which is actually the ideal condition. Extrapolating the test to the boundary of these conditions can check the performance of the software when the operation is wrong.

Whenever you get a set, you can see what happens when the set is empty.

describe("no producers",()=>{
  let noProducers;
  beforeEach(()=>{
    const data = {
      name: "no producers",
      producers:[],
      demand:30,
      price:20
    }
    noProducers = new Factory(data);
  });

  it("shortfall",()=>{
    assert.equal(noProducers.shortfall,30);

  })

  it("profit",()=>{
    assert.equal(noProducers.profit,0);
  })
  
})

If you get the numerical type, 0 will be a good boundary condition:

it("zero demand",()=>{
  apple.demand = 0;
  assert.equal(apple.shortfall,-36);
  assert.equal(apple.profit,0);
})

Similarly, you can test whether a negative value is a boundary type:

it("negativate demand",()=>{
  apple.demand = -1;
  assert.equal(apple.shortfall,-37);
  assert.equal(apple.profit,15990 );
})

We know that the string received by the setting function is obtained by user input. Although it has been limited to fill in numbers, it may still be an empty string.

it("empty string demand",()=>{
  apple.demand = "";
  assert.equal(apple.shortfall,NaN);
  assert.equal(apple.profit,NaN);
})

The above is to focus the test fire there considering the boundary conditions that may go wrong. Refactoring should ensure that the observable behavior does not change. Don't write the test because the test can't capture all bugs, because the test can indeed capture most bugs. No test can prove that a program has no BUG, but testing can improve programming speed.

Testing is much more than that

The tests introduced in this paper belong to unit tests, which are responsible for testing some code units, and run quickly and conveniently. Unit test is the main way of self-test code, and it is the test type that occupies the most in the test system. The functions of other test types are:

  • Focus on integration testing between components
  • Verify the running results of the software across several levels
  • Test to find performance problems.

When writing test code, we should form a good habit: when encountering a BUG, we should first write a test to clearly reproduce it. Only when the test passes can it be regarded as the BUG has been repaired.

Of course, there is no absolute measure of how many tests to write to ensure the stability of the system. Personally, it is to introduce a defect into the code. How confident should you be to find and solve it from the test set.

Summary

Writing a good test program can greatly improve our programming speed, even without refactoring.

Reference articles

Refactoring - improving the design of existing code (2nd Edition)
Reflections on front-end reconstruction

Write at the end

I'm a front-end chicken. Thank you for reading. I will continue to share more excellent articles with you. This article refers to a large number of books and articles. If there are mistakes and mistakes, I hope I can correct them.

More recent articles, please pay attention to the author's Nuggets account, a firefly and the front end of the official account of gravity.

Keywords: Javascript

Added by able on Fri, 28 Jan 2022 17:24:10 +0200