写了多年代码,我还是写不好单元测试

有人说,当你开始怀旧的时候,你可能就已经老了。我想,当你整天想着给自己的代码编写单元测试的时候,作为一个程序员,可能你也老了。

从业十多年了,多数情况下,我还是需要编写代码的,但是写了这么多年代码,我觉得,我硬是没有学会怎么写单元测试。至少无法做到在自己的项目里大规模的使用单元测试。

最近我在实现一个开源项目,就是 HexoPress,一个博客的客户端软件。这款客户端软件,定位是开源软件,我希望给项目增加足够多和覆盖足够全的测试,好帮助更多的参与者能有机会参加项目。另外,我真的觉得自己要掌握如何编写单元测试了,不能这个地方永远空白。

先来看一个我项目里的类,Config,我想第一个给这个类编写测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import { app } from 'electron'
import EventEmitter from 'events'
import { existsSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'

const userDataPath = app.getPath('userData')
const configFilePath = join(userDataPath, 'config.json')

class Config extends EventEmitter {
constructor () {
super()
this.defaultConfig = {}
this.config = null
}

readConfig () {
if (this.config !== null) {
return
}
if (existsSync(configFilePath)) {
const data = readFileSync(configFilePath)
this.config = JSON.parse(data.toString())
} else {
this.writeConfig(this.defaultConfig)
this.config = this.defaultConfig
}
}

writeConfig (config) {
try {
writeFileSync(configFilePath, JSON.stringify(config))
} catch (err) {
console.error(err)
}
}

get (key) {
this.readConfig()
if (key in this.config) {
return this.config[key]
} else {
return null
}
}

set (key, value) {
this.readConfig()
if (this.config[key] === value) {
return
}
this.config[key] = value
this.writeConfig(this.config)
this.emit('config:changed', key, value)
}
}

const config = new Config()

export default config

这个类的功能非常简单。从一个文件中,加载配置到内存。然后,提供一个key-value读写的 API,在初始化的时候,加载配置文件,在写入的时候,写入文件进行持久化。上面的代码就是完完整整的原始代码。

因为这是一个 JS 的 Module,所以,我利用这个特性,实现了一个单例。文件末尾,我导出了一个类实例。这样,因为模块的机制,这个对象不会被反复创建。然后,当我想要测试的时候,我发现,这个模块没法导入 class,只能导入对象。这可能还是一个小问题,毕竟都是公有方法,就算只有对象也能创建。

但是,class 最前面,还有两行很讨厌的东西,就是 userDataPath 和 configFilePath 这两个变量。很容易判断,这两个变量被耦合了,对这个模块来说,这两个变量是模块内部可见的,但是不在类的里面定义。如果我测试的时候,想指定别的路径作为配置文件,而不是用 config.json 的时候,测试就很难写了。非要这么做的话,你只能想办法把 app.getPath 这个方法给 mock 掉,让它返回一个指定的目录,这样测试运行的时候,就不会覆盖 config.json 了,不在同一个目录。幸好 JS 足够灵活,即使代码写成这样,仍然有办法给 mock 掉。其他语言恐怕就难了。

如果想把代码写得更利于测试,应该更多使用“组合”或者“注入”的方式。

比如:

1
2
3
4
5
6
constructor (filePath) {
super()
this.configFilePath = filePath
this.defaultConfig = {}
this.config = null
}

这样测试的时候,就不用考虑 mock 的问题,很方便就可以通过构造传参实现测试了。

当然,如果想在测试里通过构造传参的话,那么就不能导出一个实例变量,怎么才能又实现单例模式,又导出类定义呢?比如,我们显然可以两者都导出,测试的时候,我可以不去测实例变量,只是用类定义自己实例化来测试。不写单元的话,你就不需要去考虑这种问题。