1. 简介

Karma本质上是一个工具,它生成一个Web服务器,该Web服务器针对每个连接的浏览器针对测试代码执行源代码。对每个浏览器进行的每个测试的结果都会经过检查,并通过命令行显示给开发人员,以便可以查看哪些浏览器和测试通过或失败。可以通过以下方式捕获浏览器:通过访问Karma服务器正在侦听的URL手动捕获(通常是http://localhost:9876),也可以通过让Karma知道运行Karma时启动哪些浏览器来自动捕获。 Karma还监视配置文件中指定的所有文件,并且只要任何文件发生更改,它都会通过向测试服务器发送信号通知所有捕获的浏览器再次运行测试代码来触发测试运行。然后,每个浏览器都将源文件加载到IFrame中,执行测试并将结果报告给服务器。服务器从所有捕获的浏览器中收集结果,并将其提供给开发人员。

2. 架构图

karma-architecture.png

Karma总体架构模型是Client-Server(C/S)结构 (客户端-服务器)。
通过socket进行双向通信。

3. 工作流程概述

空白文件.svg
启动后,Karma加载插件和配置文件,然后先运行预处理,预处理结束后启动其本地Web服务器以监听连接,测试报告组件注册相关的“浏览器”事件,以便当测试结束后输出测试报告。然后,Karma启动零个,一个或多个浏览器,并将其起始页面设置为Karma服务器URL。
当浏览器连接时,Karma将提供client.html页面;当该页面在浏览器中运行时,它将通过websocket连接回服务器。服务器看到websocket连接后,便指示客户端(通过websocket)执行测试。
客户端页面会从服务器打开带有context.html页面的iframe。服务器使用配置生成此context.html页面。该页面包括测试框架适配器,要测试的代码和测试代码。
当浏览器加载此上下文页面时,onload事件处理程序通过postMessage将上下文页面连接到客户端页面。
框架适配器此时负责:运行测试,通过通过客户端页面进行消息传递来报告错误或成功。发送到客户端页面的消息通过WebSocket转发到Karma服务器。
服务器将这些消息重新分配为“浏览器”事件。收听“浏览器”事件的reporter获取数据。他们可以打印,保存到文件或将数据转发到其他服务。

4. 主要代码结构

karma主要代码结构,对karma源码有兴趣的可以参考此结构去阅读。

  1. .
  2. ├── bin
  3. └── karma # 命令运行入口
  4. ├── client # 浏览器内运行
  5. ├── constants.js # 变量模板,运行时由Node替换
  6. ├── karma.js # 主类:运行单元测试
  7. ├── main.js # 浏览器端入口文件
  8. └── updater.js # 更新运行状态到页面banner等
  9. ├── context # 用于iframe和parentWindow的沟通
  10. ├── karma.js
  11. └── main.js
  12. ├── lib # 服务端主要代码
  13. ├── browser.js # 控制浏览器状态 接收socket信息,发送EventEmit触发相应的事件
  14. ├── browser_collection.js # 浏览器集合
  15. ├── browser_result.js
  16. ├── cli.js # 入口文件
  17. ├── completion.js
  18. ├── config.js
  19. ├── constants.js
  20. ├── detached.js # 分离运行karma
  21. ├── emitter_wrapper.js
  22. ├── events.js # KarmaEventEmitter 事件
  23. ├── executor.js # 通知浏览器执行测试
  24. ├── file-list.js # 文件管理系统
  25. ├── file.js
  26. ├── helper.js
  27. ├── index.js
  28. ├── init
  29. ├── color_schemes.js
  30. ├── formatters.js
  31. ├── log-queue.js
  32. └── state_machine.js
  33. ├── init.js # karma init命令
  34. ├── launcher.js # 启动浏览器
  35. ├── launchers
  36. ├── base.js
  37. ├── capture_timeout.js
  38. ├── process.js
  39. └── retry.js
  40. ├── logger.js # log4js 日志模块
  41. ├── middleware # http服务中间件
  42. ├── common.js
  43. ├── karma.js
  44. ├── proxy.js
  45. ├── runner.js
  46. ├── source_files.js
  47. ├── stopper.js
  48. └── strip_host.js
  49. ├── plugin.js
  50. ├── preprocessor.js # 预处理
  51. ├── reporter.js # 测试报告
  52. ├── reporters
  53. ├── base.js
  54. ├── base_color.js
  55. ├── dots.js
  56. ├── dots_color.js
  57. ├── multi.js
  58. ├── progress.js
  59. └── progress_color.js
  60. ├── runner.js # run命令使用
  61. ├── server.js # 主流程 Manager
  62. ├── stopper.js
  63. ├── temp_dir.js
  64. ├── watcher.js # 文件监控
  65. └── web-server.js # http服务
  66. ├── static
  67. ├── client.html
  68. ├── client_with_context.html # runInParent为false时,不用iframe
  69. ├── context.html
  70. ├── debug.html
  71. ├── debug.js
  72. └── favicon.ico

5. 主要模块的实现

5.1 服务端

karma-server.png
服务端架构

5.1.1 文件监控Watcher

文件监控使用的是包装Node原生API(fs.watchfs.watchFile)库:chokidar
Watcher通过chokidar监视特定文件和目录。
每次这些文件或目录更改时,chokidar会发出事件。Watcher监听这些事件并在内部调用FS模块。

我们项目遇到的相关问题: 我们项目在yarn run test:watch时,如果测试报告包含测试覆盖率coverage。入口文件glob能匹配coverage生成的文件,会导致 Watcher的change事件不停触发。

  1. function watch (patterns, excludes, fileList, usePolling, emitter) {
  2. const watchedPatterns = getWatchedPatterns(patterns)
  3. const chokidar = require('chokidar')
  4. const watcher = new chokidar.FSWatcher({
  5. usePolling: usePolling,
  6. ignorePermissionErrors: true,
  7. ignoreInitial: true,
  8. ignored: createIgnore(watchedPatterns, excludes)
  9. })
  10. watchPatterns(watchedPatterns, watcher)
  11. watcher
  12. .on('add', (path) => fileList.addFile(helper.normalizeWinPath(path)))
  13. .on('change', (path) => fileList.changeFile(helper.normalizeWinPath(path)))
  14. .on('unlink', (path) => fileList.removeFile(helper.normalizeWinPath(path)))
  15. .on('error', log.debug.bind(log))
  16. emitter.on('exit', (done) => {
  17. watcher.close()
  18. done()
  19. })
  20. return watcher
  21. }

5.1.2 文件系统 FileList

文件系统模块的主要目的是最大程度地减少对实际文件的访问,以及减少网络流量。
FileList实例包含有关被测项目的元数据。这些文件是通过配置文件中glob规则匹配的(例如test/**/*.spec.js

FileList在内部维护存储bucket列表,一个bucket代表了一个可以匹配多个文件的单个glob。
一个bucket包含了一个File对象列表。每个对象代表一个通过glob匹配的真实文件。

  1. class FileList {
  2. constructor (patterns, excludes, emitter, preprocess, autoWatchBatchDelay) {
  3. this._patterns = patterns || []
  4. this._excludes = excludes || []
  5. this._emitter = emitter
  6. this._preprocess = preprocess
  7. this.buckets = new Map()
  8. }
  9. _refresh () {
  10. // ... 之前代码省略
  11. this._patterns.map(async ({ pattern, type, nocache, isBinary }) => {
  12. if (helper.isUrlAbsolute(pattern)) {
  13. this.buckets.set(pattern, [new Url(pattern, type)])
  14. return
  15. }
  16. const mg = new Glob(pathLib.normalize(pattern), { cwd: '/', follow: true, nodir: true, sync: true })
  17. const files = mg.found
  18. .filter((path) => {
  19. if (this._findExcluded(path)) {
  20. log.debug(`Excluded file "${path}"`)
  21. return false
  22. } else if (matchedFiles.has(path)) {
  23. return false
  24. } else {
  25. matchedFiles.add(path)
  26. return true
  27. }
  28. })
  29. .map((path) => new File(path, mg.statCache[path].mtime, nocache, type, isBinary))
  30. if (nocache) {
  31. log.debug(`Not preprocessing "${pattern}" due to nocache`)
  32. } else {
  33. // NOTE: preprocess
  34. await Promise.all(files.map((file) => this._preprocess(file)))
  35. }
  36. // NOTE: buckets用于存储build完的文件
  37. this.buckets.set(pattern, files)
  38. if (_.isEmpty(mg.found)) {
  39. log.warn(`Pattern "${pattern}" does not match any file.`)
  40. } else if (_.isEmpty(files)) {
  41. log.warn(`All files matched by "${pattern}" were excluded or matched by prior matchers.`)
  42. }
  43. })
  44. // ... 之后代码省略
  45. }
  46. }

FileList会根据在配置文件中的顺序对存储桶中的文件进行排序,由单个glob匹配的结果按字母顺序。
当文件发生任何变化时,例如添加新文件,删除文件或更改文件时,FileList会将这些变更传达给系统的其余部分。

示例:changeFile

  1. async changeFile (path, force) {
  2. const pattern = this._findIncluded(path)
  3. const file = this._findFile(path, pattern)
  4. if (!file) {
  5. log.debug(`Changed file "${path}" ignored. Does not match any file in the list.`)
  6. return this.files
  7. }
  8. const [stat] = await Promise.all([statAsync(path), this._refreshing])
  9. if (force || stat.mtime > file.mtime) {
  10. file.mtime = stat.mtime
  11. await this._preprocess(file)
  12. // 预处理(webpack处理) lib/preprocessor.js createPriorityPreprocessor返回的preprocess方法
  13. log.info(`Changed file "${path}".`)
  14. this._emitModified(force)
  15. // 发出file_list_modified事件
  16. }
  17. return this.files
  18. // file-list的 get files () 取出buckets中的文件
  19. }

它发出file_list_modified事件并与事件一起传递单个参数-一个文件列表。有两个Watcher在监听事件:

file_list_modified事件

  1. const emit = () => {
  2. this._emitter.emit('file_list_modified', this.files)
  3. }
  1. web server 接收文件列表并返回一个Promise参数为文件列表,用于中间件生成context.html。

lib/web-server.js

  1. function createFilesPromise (emitter, fileList) {
  2. // Set an empty list of files to avoid race issues with
  3. // file_list_modified not having been emitted yet
  4. let files = fileList.files
  5. emitter.on('file_list_modified', (filesParam) => { files = filesParam })
  6. return {
  7. then (...args) {
  8. return Promise.resolve(files).then(...args)
  9. }
  10. }
  11. }
  1. Manager 触发测试运行。

lib/server.js

  1. if (config.autoWatch) {
  2. this.on('file_list_modified', () => {
  3. this.log.debug('List of files has changed, trying to execute')
  4. if (config.restartOnFileChange) {
  5. socketServer.sockets.emit('stop')
  6. // 停止正在运行的单元测试
  7. }
  8. executor.schedule()
  9. // 通过socket,重新运行新一轮单元测试
  10. })
  11. }

我们遇到的问题:
之前匹配入口文件的规则是./**/*.spec.ts,这样每个被匹配到的文件都会作为一个入口。
而每个入口文件都会打一成个包,被存储在FileList在内部维护存储的bucket中去,所以内存被撑爆了。

运行时的bucket,打包出来的代码是直接存在File对象上的content上的
所以推荐在使用karma时只设置一个入口文件
karma-buckets.png
截图为运行时FileListbuckets

5.1.3 web-server

web-server被实现成一个handlers列表。
当接收到请求时,第一个handler被调用。每个处理程序可以处理请求并生成响应,也可以调用下一个处理程序。
第一个参数是http.IncomingMessage包含有关请求信息的对象。
第二个参数是http.ServerResponse对象,本质上是可写流。如果handler知道如何处理该请求,可以将响应写入该流中。
最后一个参数是一个函数-下一个处理程序。如果此处理程序不知道如何处理该请求,它将通过调用此函数将请求传递给下一个请求。

示例:lib/middleware/stopper.js收到请求为/stop时关闭程序

  1. function createStopperMiddleware (urlRoot) {
  2. return function (request, response, next) {
  3. if (request.url !== urlRoot + 'stop') return next()
  4. response.writeHead(200)
  5. log.info('Stopping server')
  6. response.end('OK')
  7. process.kill(process.pid, 'SIGINT')
  8. }
  9. }

web-server的实现类似于express。
主要通过不同的中间件来实现对请求的处理如:静态文件,运行,停止,源文件….

5.2 客户端

karma-client.png

5.2.1 Manager

客户端Manager是通过socket.io实现的,是客户端的最顶层。
它为特测试框架适配器提供客户端API,与服务器进行通信。
客户端Manager在主HTML中运行,但API暴露给实际执行测试代码的iframe。
客户Manager的生命周期是多个测试套件运行-持续到客户端关闭或由开发人员手动重新启动为止。

5.2.2 Iframe

客户端包含一个用来运行所有测试的iframe。
触发测试运行会重新加载此iframe。
iframe 的来源是context.html,这是一个包含一堆<script>和所有包含文件的HTML文件。
所有测试和源文件,测试框架及其适配器都已加载到此iframe中。

5.2.3 Adapter 适配器

适配器Adapter 基本上是一个包装了测试框架的代码,可转换测试框架与Karma客户ManagerAPI之间的通信。
适配器必须实现方法__karma__.start.Karma 在开始执行测试时调用此方法。
测试框架运行结束后调用Manager.result方法。

karma-mocha示例:karma-mocha/lib/adapter.js

  1. ;(function (window) {
  2. var reportTestResult = function (karma, test) {
  3. // 调用karma的result方法
  4. karma.result(result)
  5. }
  6. var createMochaReporterConstructor = function (tc, pathname) {
  7. return function (runner) {
  8. // mocha运行结束
  9. runner.on('test end', function (test) {
  10. reportTestResult(tc, test)
  11. })
  12. }
  13. }
  14. var createMochaStartFn = function (mocha) {
  15. // mocha启动
  16. return function (config) {
  17. mocha.run()
  18. }
  19. }
  20. // karma 开始测试
  21. window.__karma__.start = createMochaStartFn(window.mocha)
  22. })(window)

5.3 客户端和服务端通讯

客户端和服务器之间的通信是基于socket.io库。

5.3.1 客户端向服务端通讯

客户端事件列表

事件名 描述
register 发送客户端的信息(例如:id,name,version)
result 测试结束
complete 客户端执行了所有测试
error 客户端发生了错误
info 数据 (如:日志,数量)
start 开始测试 (如:日志,数量)

5.3.2 服务端向客户端通讯

服务端事件列表

事件名 描述
execute 让客户端开始执行测试
stop 服务器停止

5.4 优化

5.4.1 缓存

主要优化了两点:网络操作和文件访问。

网络操作:会给根据文件内容用sha1为文件添加sha的属性来标识哪些文件发生了变更。从而减少网络请求。
Web服务器,会根据url设置高速缓存的header,以使浏览器不再要请求该文件。
文件访问:服务端会把文件内容加载到内存中去,除了变更的文件,不会重复读取文件。

示例:根据文件内容增加hashlib/preprocessor.js

async function runProcessors (preprocessors, file, content) {
  try {
    for (const process of preprocessors) {
      content = await executeProcessor(process, file, content)
    }
  } catch (error) {
    file.contentPath = null
    file.content = null
    throw error
  }

  file.contentPath = null
  file.content = content
  file.sha = CryptoUtils.sha1(content)
}

web-server设置缓存lib/middleware/common.js


function setNoCacheHeaders (response) {
  response.setHeader('Cache-Control', 'no-cache')
  response.setHeader('Pragma', 'no-cache')
  response.setHeader('Expires', (new Date(0)).toUTCString())
}

function setHeavyCacheHeaders (response) {
  // 默认设置了1年的缓存。
  response.setHeader('Cache-Control', 'public, max-age=31536000')
}

运行时的缓存。只有format-date单元测试的资源被重新加载
karma-cache.png
截图为运行时浏览器请求的静态资源,可以清楚的看到单元测试中未变动的文件使用了缓存。