我们大家在开发的时候,可能遇到过这种情况,想要启动一个开发服务器,执行完服务启动命令之后,系统却提示你监听的端口号已经被占用了
虽然修改一下端口号不是很麻烦,但就是很不爽,所以就想着有没有一个工具来将占用端口的进程给直接干掉。
有了这么个需求,当然要发挥程序员的主观能动性,自己实现它啦。刚好最近又看了一些关于 TDD 的资料,趁这个机会也实践一下,毕竟纸上得来终觉浅,绝知此事要躬行。
完整源码地址github
什么是TDD
TDD,也就是测试驱动开发(Test-Driven development),是一种“测试先行”的程序设计方法论,其基本流程围绕着测试->编码(重构)->测试的循环展开。
TDD有三个重要的原则:
- 除非为了通过一个单元测试,否则不允许编写任何产品代码。
- 在一个单元测试中只允许编写刚好能够导致失败的内容。
- 一次只能写通过一项单元测试的产品代码,不能多写。
- You are not allowed to write any production code unless it is to make a failing unit test pass.
- You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures.
- You are not allowed to write any more production code than is sufficient to pass the one failing unit test.
根据TDD三原则,开发过程如下图
在我们的需求开发过程中将遵循上述原则,来展示如何使用TDD开发我们的应用
需求分析
在正式开始编码前,我们先对需求做个分析,梳理一下整个流程,并确定我们开发的业务重点。
那我们的需求是什么呢?具体来说,是检查用户所输入的端口号,如果这个端口号被占用了,就显示占用的进程信息,并询问用户是否结束这个进程。
根据需求的描述,需求的业务流程如下:
- 用户输入端口号
- 根据端口号获取占用的进程信息
- 显示信息并提示用户是否结束进程
- 根据用户选择,结束用户进程并退出程序或是直接退出
整理测试用例
根据 TDD 三原则,在开始编写代码前需要先写测试,所以我们需要先整理出测试用例,在对当前组件需求梳理一遍后,我们整理出如下测试用例:
- 如果用户输入端口号不是数字,应该返回提示信息
- 如果没有找到对应端口的占用进程,应该返回提示信息
- 如果找到对应端口的占用进程,应该返回进程信息
- 如果用户选择结束进程,应该结束该进程并退出程序
- 如果用户选择直接退出,应该直接退出程序
用例梳理好了,我们就可以准备开始编码了
前期准备
我们使用 typescript 来开发这个程序,使用 jest 来进行测试,所以先执行 npm init -y
来初始化我们的项目工作区,然后添加相关的依赖
npm i typescript jest ts-jest @types/jest --dev
复制代码
生成 typescript 配置文件
npx typescript --init
复制代码
因为使用了 ts 来开发,所以我们使用 ts-jest 这个 jest 插件来生成配置
npx ts-jest config:init
复制代码
修改 package.json 文件
"scripts": {
+ "test": "jest"
- "test": "echo \"Error: no test specified\" && exit 1"
},
复制代码
在根目录新建 src 文件夹并修改 tsconfig.json 文件
{
... other code
+ "include": ["./src/**/*.ts"]
}
复制代码
这样我们的前期准备工作就完成了。
分析依赖,建立抽象接口
接下来我们分析需求与测试用例,识别其中的依赖,并对其建立抽象接口。一般来说我们可以将最容易抽象的依赖 ————网络,I/O 以及中间件等外部依赖抽象成接口。
我们的程序在运行时需要接收用户的命令行输入,并输出文本提示,还需要通过执行外部命令来获取端口号占用进程信息与结束进程,那么抽象出对应的 Input Output 以及 Command 接口,在使用 ProcessInfo 这个接口来描述进程信息的数据结构。
在 src 文件夹内新建 interfaces.ts 文件然后定义以下接口
//interfaces.ts
export interface Input {
receive(): Promise<string>;
}
export interface Output {
write(text: string): Promise<void>;
}
export interface Command {
findProcessByPort(port: number): ProcessInfo | null;
killProcess(process: ProcessInfo): void;
}
export interface ProcessInfo {
pid: number;
name: string;
}
复制代码
在 src 文件夹新建 handler.ts, 然后定义 Handler 类,通过构造函数将依赖的接口注入
// handler.ts
import { Command, Input, Output } from "./interfaces";
export class Handler {
private input: Input;
private output: Output;
private command: Command;
constructor(input: Input, output: Output, command: Command) {
this.input = input;
this.output = output;
this.command = command;
}
async handle(): Promise<void> {}
}
复制代码
编写测试->测试失败->编码->通过测试->重构
现在一切准备就绪,可以写我们的第一个测试了
1.写测试
describe("Handler", () => {
test("should output tips if user input value not number", async () => {
const input: jest.Mocked<Input> = {
receive: jest.fn()
};
const output: jest.Mocked<Output> = {
write: jest.fn()
};
const command: jest.Mocked<Command> = {
findProcessByPort: jest.fn(),
killProcess: jest.fn()
};
const handler = new Handler(input, output, command);
input.receive.mockResolvedValue("1024a");
await handler.handle();
expect(output.write).toBeCalledWith("端口号应该为纯数字,请输入有效的端口号");
});
});
复制代码
2.编写代码
注意我们只需要编写刚好能让测试通过的代码
export class Handler {
/// 省略其他代码
async handle(): Promise<void> {
const inputStr = await this.input.receive();
const port = Number(inputStr);
if (Number.isNaN(port)) {
await this.output.write("端口号应该为纯数字,请输入有效的端口号");
return;
}
}
}
复制代码
3.通过测试
运行 npm run test
PASS src/handler.test.ts
Handler
√ should output tips if user input value not number (8 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.762 s, estimated 3 s
Ran all test suites.
复制代码
4.继续写测试
很好!测试顺利通过了,接下来是第二个测试
// handler.test.ts
test("should output tips message if port not be use", async () => {
const input: jest.Mocked<Input> = {
receive: jest.fn()
};
const output: jest.Mocked<Output> = {
write: jest.fn()
};
const command: jest.Mocked<Command> = {
findProcessByPort: jest.fn(),
killProcess: jest.fn()
};
const handler = new Handler(input, output, command);
input.receive.mockResolvedValue("1024");
command.findProcessByPort.mockReturnValue(null);
await handler.handle();
expect(command.findProcessByPort).toBeCalledWith(1024);
expect(output.write).toBeCalledWith("端口号:1024 未被使用");
});
复制代码
同样我们编写代码通过测试
// handler.ts Handler.handle
const processInfo = this.command.findProcessByPort(port);
if (processInfo === null) {
await this.output.write(`端口号:${port} 未被使用`);
return;
}
复制代码
PASS src/handler.test.ts
Handler
√ should output tips if user input value not number
√ should output tips message if port not be use (1 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 0.364 s, estimated 1 s
Ran all test suites related to changed files.
复制代码
5.重构
现在回头检查一下我们的测试文件,发现代码散发着腐烂的坏味道,我们重复了两次一摸一样的测试类构造过程,是时候重构了。
const makeSut = () => {
const input: jest.Mocked<Input> = {
receive: jest.fn()
};
const output: jest.Mocked<Output> = {
write: jest.fn()
};
const command: jest.Mocked<Command> = {
findProcessByPort: jest.fn(),
killProcess: jest.fn()
};
const handler = new Handler(input, output, command);
return {
input,
output,
command,
handler
};
};
describe("Handler", () => {
let input: jest.Mocked<Input>;
let output: jest.Mocked<Output>;
let command: jest.Mocked<Command>;
let handler: Handler;
// 每次执行test之前都会调用
beforeEach(() => {
const sut = makeSut();
input = sut.input;
output = sut.output;
command = sut.command;
handler = sut.handler;
});
test("should output tips if user input value not number", async () => {});
test("should output tips message if port not be use", async () => {});
});
复制代码
测试代码也属于源码的一部分,也应该不断的进行重构,否则随着测试的增多,腐坏的味道将散发到整个项目。
接下来只要不断编写测试,紧接着编写业务代码让测试通过,查找代码里的坏味道并重构,如此循环,直到所有的测试用例都通过。完整的测试用例请查看源码,就不在此展开。
总结
上面是我对一个需求应用TDD的简单实践,在实际开发过程中应用TDD是有一点成本的,TDD也不是银弹,是否使用TDD也需要根据项目的需求,人员构成等进行取舍。