33 Testing Functional Testing

33 Testing Functional Testing #

Hello, I am Ishikawa.

In the previous lecture, we gained a concrete understanding of Test-Driven Development (TDD) through the implementation of abstract TDD using the Red-Green-Refactor cycle. Today, we will further master the implementation of this development pattern through concrete unit testing.

Comparison of Testing Tools #

Currently, there are many third-party testing tools available on the market that revolve around JavaScript, so here we don’t need to reinvent the wheel. We can use existing testing frameworks to help us with testing. First, let’s compare several popular frameworks: Mocha, Jest, and Jasmine.

These three tools are all based on assertion functions to help improve the readability and extensibility of our tests. They also support tracking the progress of the tests and generating final test result reports to understand the code coverage. In addition to these features, Jest provides better support for Mock/Stub testing and can generate snapshots to compare test results before and after. It also supports multi-threading.

Image

Minimal Unit Testing #

Let’s look at a simple example. First, we need to install Jest. This requires Node and NPM. You can check if Node and NPM are already installed by running the following commands in the Terminal. If the returned information includes the relevant version numbers, it means that both tools are already installed.

node -v
npm -v

Next, we need to install Jest. In the Terminal, we can install Jest by running the following command. The --global flag allows us to run Jest tests directly from the command-line client, such as Terminal or Command Prompt.

npm install jest --global

Now, let’s say we want to write a Fibonacci sequence function. Following the principles of test-driven development, we start by creating a test file called fib.test.js that includes the following test case: if we input 7, the Fibonacci sequence result should be 13.

test('The Fibonacci result of 7 is 13', () => {
  expect(fib(7, 0, 1)).toBe(13);
});

We can run the above test script using the following command:

jest fib.test.js

At this point, if we run the test, it will definitely fail. This is because we haven’t created the Fibonacci sequence function yet! So, this step represents the “red” part of the red-green-refactor cycle.

Now, knowing that we need to create a Fibonacci function to pass the test, we can create one and save it in fib.js. At the end of this file, we export the function as a module so that we can import and reference it in the previously created test file.

function fib(n, lastlast, last){
  if (n == 0) {
    return lastlast;
  }
  if (n == 1) {
    return last;
  }
  return fib(n-1, last, lastlast + last);
}

module.exports = fib;

Then, we can import the Fibonacci function in the previous test case.

var fib = require('./fib');

test('The Fibonacci result of 7 is 13', () => {
  expect(fib(7, 0, 1)).toBe(13);
});

When we run the above file again using the previous command, we can see that the test passes. This represents the “green” part of the red-green-refactor cycle. Since this is a relatively simple test, we don’t need to refactor, so when we reach this point, we can consider the test complete.

Matching Data Types #

In the previous lecture on data types, we discussed some common pitfalls in JavaScript assignment, such as the difference in results between value comparison and strict comparison, as well as data types that may return a falsy value other than a boolean. Therefore, when testing, we should also pay attention to whether the expected result matches the actual result. Jest provides many built-in methods to help us match data types.

Let’s take a look at two examples. In the first example, we can see that when we use toEqual for comparison, undefined is ignored, so the test passes. But when we use toStrictEqual, we can see the result of strict comparison, so the test fails. In the second example, we can see that because the value of a number can be NaN, which is a falsy value, the test result is passed.

// Example 1
test('check equal', () => {
  var obj = { a: undefined, b: 2 }
  expect(obj).toEqual({b: 2});
});

test('check strict equal', () => {
  var obj = { a: undefined, b: 2 }
  expect(obj).toStrictEqual({b: 2});
});

// Example 2
test('check falsy', () => {
  var num = NaN;
  expect(num).toBeFalsy();
});

The toBe() used in the previous Fibonacci example, does it represent comparison or strict comparison? Actually, it is neither, toBe() uses Object.is. Other methods for testing truthiness include toBeNull(), toBeUndefined(), toBeDefined(), and toBeTruthy(). Similarly, when using them, we must pay attention to their actual meanings.

In addition to strict comparison and falsy values, another issue we mentioned in the section on data types is the precision loss of floating-point numbers. To address this issue, we can see that when we add 0.1 and 0.2, we know it is not equal to 0.3, but equal to 0.30000000000000004 (\(0.3+4\\times10^{-17}\)). So expect(0.1+0.2).toBe(0.3) fails, but if we use toBeCloseTo(), we can see that the approximate result passes the test.

In addition to comparing close numbers, Jest also provides methods like toBeGreaterThan(), toBeGreaterThanOrEqual(), toBeLessThan(), and toBeLessThanOrEqual() to help us compare numbers.

test('adding floating point numbers', () => {
  var value = 0.1 + 0.2;
  expect(value).toBe(0.3);        // fails
});

test('adding floating point numbers', () => {
  var value = 0.1 + 0.2;
  expect(value).toBeCloseTo(0.3); // passes
});

Now that we’ve covered numbers, let’s take a look at strings and arrays. In the two examples below, we can use toMatch() with regular expressions to test whether a word exists in a string. Similarly, we can use toContain() to see if an element is present in an array.

test('word has love', () => {
  expect('I love animals').toMatch(/love/);
});

test('word does not have hate', () => {
  expect('I love peace and no war').not.toMatch(/hate/);
});

var nameList = ['Lucy', 'Jessie'];
test('Is Jessie in the list', () => {
  expect(nameList).toContain('Jessie');
});

Nested Structure Test #

Next, let’s take a look at the test with nested structure. We can group a set of tests together by nesting them under a describe block. For example, if we have a class representing a rectangle, we can structure the test as follows:

  • The outermost layer describes the rectangle class.
  • The middle layer calculates the area of the rectangle.
  • The innermost layer tests the setting of width and height.

In addition to nested structure, we can also use beforeEach and afterEach to perform setup and teardown tasks before and after each group of tests.

describe('Rectangle class', ()=> {
  describe('area is calculated when', ()=> {
    test('sets the width', ()=> { ... });
    test('sets the height', ()=> { ... });
  });
});

Responsive Asynchronous Testing #

We say that many tests in frontend development are event-driven. When we talked about asynchronous programming, we also mentioned that frontend development cannot be separated from asynchronous events. Therefore, testing tools usually have support for testing asynchronous calls. Taking Jest as an example, it supports callback, promise/then, and the async/await syntax we mentioned before. Now, let’s go into detail for each of these modes.

First, let’s take a look at the callback pattern. If we solely use callbacks, there is a problem. That is, when the asynchronous operation just returns a result and the callback hasn’t been executed yet, the test will already be executed. To avoid this situation, we can use a function parameter called “done”. The test will only start after the callback of done() is executed.

test('Data is: price is 21', done => {
  function callback(error, data) {
    if (error) {
      done(error);
      return;
    }
    try {
      expect(data).toBe({price: 21});
      done();
    } catch (error) {
      done(error);
    }
  }
  fetchData(callback);
});

Next, let’s take a look at the usage of promise/then and async/await. As we mentioned before, async/await is actually syntactic sugar for the base syntax of promise/then. We can also use await in combination with resolve and reject. In the following examples, we can see that after getting the data, we can compare it with the expected value to obtain the test result.

// Example 1: promise/then
test('Data is: price is 21', () => {
  return fetchData().then(data => {
    expect(data).toBe({price: 21});
  });
});

// Example 2: async/await
test('Data is: price is 21', async () => {
  var data = await fetchData();
  expect(data).toBe({price: 21});
});

Mock and Stub Testing #

Finally, let’s take a look at Mock and Stub, but what’s the difference between them?

In fact, Mock and Stub both replace dependencies in the tested function. The difference is that Stub manually replaces the implementation of an interface, while Mock uses function replacement. Mock can help us simulate functions with return values. For example, the myMock function below can simulate returning different values in a series of calls.

var myMock = jest.fn();
console.log(myMock()); // returns undefined

myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true);

console.log(myMock(), myMock(), myMock(), myMock()); // returns 10, 'x', true, true

Here, Jest uses Continuation Passing Style (CPS), which we discussed earlier in functional programming. This approach helps us avoid using Stub as much as possible. Implementing Stub requires a lot of manual work, and because it is not the actual interface, manually implementing the complex logic of the real interface not only cannot guarantee consistency with the actual interface, but also incurs additional development costs. Using CPS-based Mocks can replace Stubs and save work during the mocking process.

var filterTestFn = jest.fn();

// Returns `true` for the first time, and `false` thereafter
filterTestFn.mockReturnValueOnce(true).mockReturnValueOnce(false);

var result = [11, 12].filter(num => filterTestFn(num));

console.log(result); // returns [11]
console.log(filterTestFn.mock.calls[0][0]); // returns 11
console.log(filterTestFn.mock.calls[1][0]); // returns 12

Extension: UI Automation Testing #

In the above example, we saw unit testing. However, in the frontend scenario, we can hardly do without UI testing. Previously, if we wanted to test UI responsiveness, we had to manually click on elements on the screen to obtain relevant feedback. But is there any automated way for developers to do this?

This brings us to headless browsers and automation testing. A headless browser refers to a browser that does not require a monitor. It can help us perform frontend automation testing.

For example, Google has developed Puppeteer, an automation testing tool based on Node.js. It provides an API interface to control Chrome through the developer tools protocol. Puppeteer runs in headless mode by default, but can be configured to run in head mode. Puppeteer can also be used in combination with Jest through either preset or manual configuration. If the preset method is chosen, relevant third-party libraries can also be installed via NPM.

npm install --save-dev jest-puppeteer

After installation, you can add "preset": "jest-puppeteer" to the preset configuration of Jest.

{
  "preset": "jest-puppeteer"
}

Next, let’s take “GeekTime” as an example. If we want to test whether the title of GeekTime is displayed correctly, we can achieve it through the following test. In this process, we don’t use a monitor, but the program can automatically access the homepage of GeekTime and check if the page title matches the expected result.

describe('GeekTime', () => {
  beforeAll(async () => {
    await page.goto('https://time.geekbang.org/');
  });

  it('should have the title "GeekTime-Easy Learning, Efficient Learning-GeekBang"', async () => {
    await expect(page.title()).resolves.toMatch('GeekTime-Easy Learning, Efficient Learning-GeekBang');
  });
});

Summary #

Through today’s class, we have seen how to implement red-green-refactor, which was mentioned in the previous lecture, in unit testing. At the same time, we have also seen that the results of the tests need to be compared with assertions, so we need to be particularly careful when comparing return values of different data types to avoid pitfalls.

Next, we saw how to combine related tests together in nested structure testing, and how to set up relevant preconditions and postconditions. In today’s world where event-driven design is becoming increasingly important, we also learned how to handle asynchronous responses in testing, how to simulate interface feedback using mocks when real interfaces and logic have not been implemented yet, and how to avoid complex logic implementation and inconsistencies with real interfaces through CPS (Continuation Passing Style).

Thought Questions #

Today we mentioned that Jest has support for snapshots and multithreading, can you think of their use cases and implementation methods?

Feel free to share your answers, exchange learning experiences, or ask questions in the comments section. If you feel that you have gained something, you are also welcome to share today’s content with more friends. See you in the next class!