单元测试规范

原则

单元测试文件必须拥有良好的结构和格式;
测试用例的分组名称和用例名称必须清晰易懂;
测试用例必须能描述测试目标的行为;
优先测试代码逻辑(过程)而非执行结果;
单元测试的各项覆盖率指标必须在95%以上;

技术

Jest:https://facebook.github.io/jest

结构

编写单元测试所涉及的文件应存放于以下两个目录:

  • mocks/:模拟文件目录
  • [name].mock.json:【例】单个模拟文件
  • tests/:单元测试目录
  • [target].test.js:【例】单个单元测试文件,[target]与目标文件名保持一致,当目标文件名为index时,采用其上层目录或模块名。

[target].test.js 文件

应按照如下结构编写测试文件,注意其中的空行:

/* eslint global-require: 0 */
const thirdPartyModule = require('thrid-party-module')


describe('@zpfe/module-name' () => {
  const mocks = {}
  
  beforeAll(() => {})
  
  beforeEach(() => {})
  
  test('描述行为', () => {
    mocks.fake.mockReturnValue('控制模拟行为的代码置于最上方')
    
    const target = require('../target.js')
    const result = target.foo('执行目标待测功能')
    
    expcet(result).toBe('断言置于最下方')
  })
})

保证每个describe内部只有mock对象、生命周期钩子函数和test函数,将模拟对象都添加到mocks对象的适当位置,将初始化操作都添加到适当的生命周期函数中。

mocks 对象

常量mocks的结构如下:

const mocks = {
  zpfe: {
    // @zpfe模块,若有,将包名转换为驼峰式以便访问,比如:koaMiddleware
    log: {
      info: jest.fn()
    }
  },
  dependencies: {
    thirdPartyModule1: {
    // 第三方依赖模块,若有
  },
  files: {
    // 本地依赖文件
    router: jest.fn()
  },
  others: {
    // 公共假对象
    ctx: jest.fn()
  }
}

请注意,mocks对象的价值在于保存模拟依赖项及部分复用对象,请勿添加不涉及模拟也没有被复用的内容。

生命周期函数

在beforeAll中设置依赖模拟,比如:

beforeAll(() => {
  jest.mock('@zpfe/log', () => mocks.zpfe.log)
  jest.mock('../router.js', () => mocks.files.router)
  jest.spyOn(console, 'log')
})

在beforeEach中进行每个单元测试运行前需要的重置工作,比如:

beforeEach(() => {
  process.env.NODE_ENV = 'production'
})

describe 函数

若模块包含多个文件,则每个文件对应专门的测试文件,其describe应这样写:

describe('@zpfe/module-name: file-name' () => {})

目标对象

提倡在每个test函数中require目标文件,若综合评估之后,能确定将require目标文件的代码提取到生命周期钩子函数中也不会产生干扰或混乱,则可以考虑提取,比如:

describe('@zpfe/module-name' () => {
  let moduleName
  
  beforeEach(() => {
    moduleName = require('../target.js')
  })
})

test 函数

请用空行分隔test函数内不同目的的代码块(比如模拟、执行目标、和断言)。

请勿在测试中编写try...catch...,应明确断言是否抛出异常,并根据需要断言抛出的错误信息及日志记录情况。

命名

describe表示分组,其名称应属于下列几种情况之一:

  • 模块名,比如:@zpfe/module-name
  • 组件名,比如:a-input
  • 隶属于模块或组件的文件名,比如:a-input/nativa-control
  • 功能名,比如:props
  • 条件名,比如:当 NODE_ENV = production 时

test表示测试用例,其名称应当明确表示其行为,比如:当 disabled 属性被设置为非 Boolean 类型时,抛出异常。不允许将describe的命名规则应用到test。

良好的命名有助于组织测试用例,使其更能充当文档之用。当某个测试用例失败时,良好的结构和命名能让读者快速了解其影响范围,比如:

[FAILED] a-input > props -> disabled -> 当传入非 Boolean 类型的值时,抛出异常

模拟

请查阅Jest文档,以详细了解Jest所提供的各类模拟API。

临时替换默认实现

若在mocks对象中初始化了实现,又需要在测试用例当中临时修改其实现,可以这样做:

const mocks = {
  others: {
    foo: jest.fn(() => 'foo')
  }
}
test('demo', () => { 
  mocks.others.foo.mockImplementationOnce(() => 'bar') 
})

mockImementOnce会临时修改默认实现,且只生效一次,故不会影响其他测试用例。

若需要在测试用例当中临时修改模拟函数的实现,且模拟函数会被多次调用,就应该使用另外一种方式实现,比如:

const mocks = {
  others: {
    foo: jest.fn()
  }
}
beforeEach(() => {
  mocks.others.foo.mockImplementation(() => 'foo')
})
test('demo', () => {
  mocks.others.foo.mockImplementation(() => 'bar')
})

即在mocks对象中只定义模拟函数,不定义具体实现,在beforeEach钩子函数中定义具体实现,使得每个测试用例都会重新初始化该实现,接着在具体测试用力中使用mockImementation彻底替换掉默认实现。

断言

请查阅Jest文档,以详细了解Jest所提供的各类断言API。

断言参数

若需要断言调用函数时的参数传递,可使用:

expect(mocks.zpfe.log.info).toHaveBeenCalledWith('观察C ZooKeeper客户端')

若需要部分匹配参数,可使用:

expect(mocks.zpfe.log.info).toHaveBeenCalledWith(expect.stringContaining('观察'), expect.objectContaining({ key: 'value' }))

调试

在VS Code中,打开测试文件,选中调试配置【调试 Jest 测试】,按【F5】即可。

Vue 组件测试

技术

@vue/test-utils:https://vue-test-utils.vuejs.org

结构

单元测试文件在tests目录内的组织形式应与目标文件在src目录保持一致,并按照如下顺序结构组织组件的单元测试文件:

describe('组件:a-component-name', () => {
  const mocks = {}
 
  beforeAll()


  // 仅针对 props 定义进行基础测试,不测试 props 如何使用
  describe('props', () => {
    describe('prop-name', () => {
      test('类型应为 xxx')
      test('默认值应为 xxx')
      test('有效性校验')      
    })
  })
  
  // 仅针对可被用户使用的嵌套组件族进行嵌套校验测试
  describe('受限嵌套', () => {
    test('当父组件不为 xxx 时,抛出异常')
    test('当子组件不为 xxx 时,抛出异常')
  }) 
  
  // 仅针对 slots 渲染位置进行基础测试
  describe('slots', () => {
    test('default')
    test('named-slot')
  })  
  
  // 根据实际情况,结合 props 和 slots 进行各种场景下的渲染测试
  describe('render', () => {
    test('使用 prop-name 来渲染 xxx')
  })
  
  // 测试所有公开方法,不测试私有方法
  describe('methods', () => {
    describe('method-name', () => {
      test('行为')
    })
  })
  
  // 触发并测试所有事件是否正常触发
  // 若 props 中包含 value,则 events 中必须包含 input
  describe('events', () => {
    describe('event-name', () => {
      test('当 xxx 时,触发此事件')
    })
  }) 
  
  // 测试UI交互是否能正常响应(忽略与 events 测试雷同,则可忽略)
  describe('交互', () => {
    test('当点击 xxx 时, 如此这般')
  })
}

挂载组件

按照如下规则挂载组件:

在挂载时传递 props;
挂载产生的对象应命名为 e;
若组件需要使用原生DOM方法,请启用 attachToDocument;

比如:

const target = mount(ComponentName, {
  propsData: {
    foo: 'bar'
  },
  attachToDocument: true
})

组件依赖关系

除非互相依赖的组件之间定义了嵌套校验,否则优先考虑模拟子组件来进行父组件的测试。比如:

const target = mount(APaginationWithJumper, {
  stubs: {
    'dependent-component': true
  }
}
// 通过 target.find('dependent-component-stub').vm来模拟或控制其行为
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容