node学习记录(2)-文件模块-制作一个使用命令行操作的todo list

源码链接:https://github.com/wuyangqin/node-todo-list

使用node的fs文件模块实现了一个简单的用命令行操作的todo list

技术细节

  • 使用node.js fs模块实现文件读写
  • 使用commander.js编写命令行命令
  • 使用inquirer.js 实现用户与命令行的交互
  • 使用jest.js 模拟fs,编写单元测试

效果演示

安装

1
npm install -g node-todo-xx

or

1
yarn global add node-todo-xx

使用

1
2
3
todo # 查看所有任务列表,能够操作任务增删改查
todo add 任务名 # 添加一个任务
todo clear # 清空所有任务

todo 查看任务列表

查看任务列表

对某项任务进行操作

todo add 添加任务

添加任务

todo clear 清除任务

添加任务

实现过程

逻辑梳理

该项目的实现逻辑如图所示:

目录结构

1
2
3
4
5
6
7
├── __mocks__
| └── fs.js # 模拟fs模块
├── __test__ # 存放测试文件
| └── db.test.js
├── cli.js # 命令行操作逻辑
├── db.js # 读写任务的函数
└── index.js # 任务的操作逻辑

读写文件

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
const os = require('os');
const path = require('path');
const fs = require('fs');

const homedir = os.homedir(); // 获取home目录
const home = process.env.HOME || homedir; // 如果用户自己设置了HOME的环境变量
const dbPath = path.join(home,'.todo')

const db = {
read(path = dbPath) {
return new Promise((resolve, reject) => {
// 'a+': Open file for reading and appending. The file is created if it does not exist.
fs.readFile(path,{ flag: 'a+'}, (readError,data) => {
if (readError) return reject(readError)
let list
try {
list = JSON.parse(data.toString())
} catch (e) {
list = []
}
resolve(list)
})
})
},
write(list, path = dbPath) {
return new Promise((resolve, reject) => {
const string = JSON.stringify(list)
fs.writeFile(path,string + '\n',(writeError)=>{
if(writeError) return reject(writeError)
resolve()
})
})
}
}

module.exports = db

单元测试

测试命令

1
yarn test

fs mock

从上面的逻辑梳理图可以看到,这个项目最主要的就是对任务文件进行操作的两个函数,因此将围绕这两个函数进行单元测试的编写。

然而编写单元测试有一条原则就是,测试代码不要与外界进行交互,比如如果我通过测试代码读取用户硬盘上的某个文件,而这个文件路径刚好在用户硬盘中存在,这就很尴尬啦。因此jest提供了对node模块进行mock的功能——相当于对node的模块进行接管,调用我们自己编写的方法。具体实现如下:

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
// 在项目根目录创建'__mock__'文件夹, 并创建'fs.js'文件 

const fs = jest.genMockFromModule('fs') // 创建模拟的fs模块
const _fs = jest.requireActual('fs') // 引入实际的fs模块

Object.assign(fs, _fs) // 将原fs模块的属性复制给我们创建的模拟fs模块

let readMocks = {}
fs.setReadFileMock = (path, error, data) => {
readMocks[path] = [error, data]
}
fs.readFile = (path,options,callback) => {
if (callback === undefined) callback = options
if (path in readMocks) {
callback(...readMocks[path])
} else {
_fs.readFile(path, options, callback)
}
}

let writeMocks = {}
fs.setWriteFileMock = (path, fn) => {
writeMocks[path] = fn
}
fs.writeFile = (path, data, options, callback)=>{
if (callback === undefined) callback = options
if (path in writeMocks) {
writeMocks[path](path, data, options, callback)
} else {
_fs.writeFile(path, data, options, callback)
}
}

fs.clearMocks = () => { // 清除mock
readMocks = {}
writeMocks = {}
}

module.exports = fs

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
// __test__文件夹用来存放测试文件,在'db.spec.js'中编写读写文件两个函数的测试代码
const db = require('../db.js');
const fs = require('fs');
jest.mock('fs') // jest将fs接管

describe('db', () => {
afterEach(()=>{
fs.clearMocks()
})

it ('can read', async () => {
const data = [{title: 'hi', done: false}]
fs.setReadFileMock('/test',null, JSON.stringify(data))
const list = await db.read('/test')
expect(list).toStrictEqual(data)
})

it ('can write', async () => {
let fakeFile
fs.setWriteFileMock('/test1', (path, data, callback) => {
fakeFile = data
callback(null)
})
const list = [{title: 'hi', done: false}]
await db.write(list,'/test1')
expect(fakeFile).toBe(JSON.stringify(list) + '\n')
})
})