Test 测试

前言

这篇文章主要从较为宏观的层面写如何进行单元测试,文中所使用的例子都基于Jest+React testing library。

什么是测试

测试,指的是通过专门的仪器设备以设计合理的实验方法来获取被测对象的数据,最终将数据与某个标准进行比较进而得出结论。 —百度百科

对于编写程序,测试所指即会缩小很多。因为程序只由两部分组成,数据与函数。数据指的是通过硬件如硬盘或内存等储存的内容,而函数指的是输入数据、处理数据和输出数据的这个过程。编程领域的测试主要关注在函数上,当然,测试也离不开数据的测定,因为函数的三个阶段都是在与数据打交道。

函数的定义与测试的3A模型其实即为相似,AAA (Arrange-Act-Assert),Arrange准备数据,Act处理数据,Assert判定数据。在接下来的介绍中,我将会以这3A模型为维度将测试进行分别介绍。

测试的组成

Arrange 组织数据

在组织数据阶段,为被测函数准备输入数据与mock依赖。输入数据部分根据具体业务进行模拟即可,mock依赖是我们写测试时真正头痛的部分。以模块为边界,可以将依赖进一步划分,外部依赖和内部依赖。

对于外部模块的依赖,我们可以进一步地细分成四种情况。

例子:

文件结构都一致:

  1. 被测对象引用的是外部模块对象的方法
    // userUtil.js 外部依赖
    export const userUtil = {
        getUserName() {
            return 'Bob'
        }
    }
    // user.js 被测函数
    import {userUtil} from "./userUtil";
    
    export const getUserInfo = () => {
        const userName = userUtil.getUserName()
    
        return {user: {name: userName, age: 18}}
    } 

    使用jest.spyOn

    // user.test.js
    import {getUserInfo} from "../user";
    import {userUtil} from "../userUtil";
    
    describe("user", () => {
        it('should get user info', () => {
            jest.spyOn(userUtil, 'getUserName').mockReturnValue('zzq')
    
            expect(getUserInfo()).toEqual({user: {name: "zzq", age: 18}});
        })
    });
  1. 被测对象引用的是外部模块对象的属性
    // userUtil.js 外部依赖
    export const userUtil = {
        userName: 'Bob'
    }
    // user.js 被测函数
    import {userUtil} from "./userUtil";
    
    export const getUserInfo = () => {
        const userName = userUtil.userName;
    
        return {user: {name: userName, age: 18}}
    }

    使用jest.spyOn + 将原依赖模块的对象属性更改为等价的方法

    // userUtil.js 外部依赖
    export const userUtil = {
        get userName() {
            return "Bob"
        }
    }
    // user.test.js
    import {getUserInfo} from "../user";
    import {userUtil} from "../userUtil";
    
    describe("user", () => {
        it('should get user info', () => {
            jest.spyOn(userUtil, "userName", "get").mockReturnValue("zzq")
    
            expect(getUserInfo()).toEqual({user: {name: "zzq", age: 18}});
        })
    });

    这种对于属性的mock比较少见。

  1. 被测对象引用的是外部模块的函数
    // userUtil.js 外部依赖
    export const getUserName = () => ({userName: 'Bob'})
    // user.js 被测函数
    import {getUserName} from "./userUtil";
    
    export const getUserInfo = () => {
        const userName = getUserName().userName
    
        return {user: {name: userName, age: 18}}
    }
    1. 使用*星号将函数以模块的形式引入,再使用jest.spyOn来mock这个模块的函数
      // user.test.js
      import {getUserInfo} from "../user";
      import * as userUtil from "../userUtil";
      
      describe("user", () => {
          it('should get user info', () => {
              jest.spyOn(userUtil, "getUserName").mockReturnValue({userName: 'zzq'})
      
              expect(getUserInfo()).toEqual({user: {name: "zzq", age: 18}});
          })
      });
    1. 使用jest.mock来mock掉整个模块+再通过mockReturnValue来mock这个函数的返回
      // user.test.js
      import {getUserInfo} from "../user";
      import * as userUtil from "../userUtil"
      
      jest.mock("../userUtil")
      describe("user", () => {
          it('should get user info', () => {
              userUtil.getUserName.mockReturnValue({userName: 'zzq'})
      
              expect(getUserInfo()).toEqual({user: {name: "zzq", age: 18}});
          })
      });
    1. 使用jest.mock来mock掉整个模块+jest.requireActual获取真实返回+mock部分返回
      // user.test.js
      import {getUserInfo} from "../user";
      
      jest.mock("../userUtil", ()=>{
          const originalModule = jest.requireActual("../userUtil");
      
          return {
              __esModule: true,
              ...originalModule,
              getUserName : () => ({userName: 'zzq'})
          }
      })
      
      describe("user", () => {
          it('should get user info', () => {
              expect(getUserInfo()).toEqual({user: {name: "zzq", age: 18}});
          })
      });
  1. 被测对象引用的是外部模块的变量
    // userUtil.js 外部依赖
    export const name = "Bob"
    // user.js 被测函数
    import {name} from "./userUtil";
    
    export const getUserInfo = () => {
        return {user: {name: name, age: 18}}
    }

    使用Object.defineProperty进行mock变量

    // user.test.js
    import {getUserInfo} from "../user";
    import * as userUtil from "../userUtil";
    
    const originName = userUtil.name;
    
    describe("user", () => {
    		// 及时重置数据,避免污染
        afterEach(() => {
            Object.defineProperty(userUtil, 'name', {
                value: originName,
                writable: true,
            })
        })
    
        it('should get user info', () => {
            Object.defineProperty(userUtil, 'name',{
                value: 'zzq',
                writable: true
            })
    
            expect(getUserInfo()).toEqual({user: {name: "zzq", age: 18}});
        })
    });

对于内部模块的依赖,我们可以通过将依赖改为参数对被测函数进行注入。

例子:
function getPlanet () {
  return 'world';
}

export default function getGreeting (_getPlanet = getPlanet) {
  return `hello ${_getPlanet()}!`;
}
import getGreeting from '../greeting.dependency-injection';

describe('getGreeting', () => {
  it('默认值', () => {
    expect(getGreeting()).toBe('hello world!');
  });

  it('输出 mars', () => {
    expect(getGreeting(() => 'mars')).toBe('hello mars!');
  });

  it('输出 jupiter', () => {
    expect(getGreeting(() => 'jupiter')).toBe('hello jupiter!');
  });

  it('回到默认值', () => {
    expect(getGreeting()).toBe('hello world!');
  });
});

为了测试而修改被测代码是我们不希望发生的,当这种情况发生时,我们可以再思考一下

对于Arrange阶段,上面的案例更接近逻辑的测试,数据准备阶段还有与redux数据管理相关的Provider准备,与ui相关的ThemeProvider的准备,不在此进行展开了。

Act 处理数据

在处理数据阶段,我主要介绍三个React-testing-library提供的重要的函数

例子:
// App.js
import React from "react";

const App = () => {
  return (
    <div>
      <h1>Hello World!</h1>
    </div>
  );
};

export default App;
// App.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
 
import App from './App';
 
describe('App', () => {
  test('renders App component', () => {
    render(<App />);
 
    expect(screen.queryByText(/Hello World!/)).toBeDefined();
  });
})

我们可以通过各种元素的属性获取它如testId,role,text等,另外获取元素的方式还分成了三种:

from React Testing Library

React Testing Library的指导原则是

The more your tests resemble the way your software is used, the more confidence they can give you.

因此我们应该首先尝试用 getBy 来进行一开始渲染页面时元素的获取,其次采用 awaitfindBy 进行异步内容的获取,最后再通过 queryBy 这种接近程序员思维的方式进行获取。当获取完元素后,我们可以对这些元素进行下一步的操作或者判断

例子
// App.js
import React from "react";

const App = () => {
    return (
        <div>
            <input type="text"/>
        </div>
    );
};

export default App;
// App.test.js
import React from 'react';
import {fireEvent, render, screen} from '@testing-library/react';

import App from './App';

describe('App',  () => {
    test('renders App component',async () => {
        render(<App/>);

        fireEvent.change(screen.getByRole("textbox"), {
            target: {value: 'test'},
        })

        expect(screen.getByRole("textbox").value).toBe("test");
    });
})

Assertion 断言数据

在判断数据阶段,主要介绍的就是matcher了,matcher可以帮助我们以不同的方式去断言我们的结果,通过expect函数,它会返回一个expectation objects,然后我们再通过进一步调用matchers。

常用的matcher有

结合第三方的库如@testing-library/jest-dom,它为我们提供了更多的关于DOM的Matcher API,常见的有

总结

本文以3a测试模式为依据,简单介绍了自己在写react组件测试过程中遇到的一些概念。通过写这篇文章更近一步让我意识到测试是个极其庞大的领域,这篇文章主要关注在以Jest+React Testing Library为背景的单元测试上。日后待接触更多的测试如集成测试、E2E测试、UI测试等,将再尝试进行其它测试内容的介绍。感谢你的阅读,欢迎指出我的错误和与我讨论!

参考

Jest实践指南

Jest官网

React Testing Library官网

十分钟学会编程的本质

React Testing Library使用总结