上一篇:助教指南,持續更新...
// Version: 0.0.4
許多人,所不知道的是,每一種編程語言都有其對應的單元測試框架,對程序在不同階段的測試環節也概念模糊。在實際動手編寫程序許久之后才聽說“單元測試”、“模塊測試”、“集成測試”這三個重要的測試階段。從一個程序的角色來說,“單元測試”、“模塊測試”、“集成測試”這三個部分就是最核心的測試環節。斷言,就像任何階梯型技術一樣,通常情況下“單元測試”、“模塊測試”、和“集成測試”三者的最佳比例應該保持7:2:1的黃金比例([1], 如果不對,請你提出更合理的比例,並論證)。拋開你一定會做的集成測試不說,斷言,優秀的程序應該像獵人一樣對單元測試和模塊測試保持敏感。回到開頭,每一種編程語言都有其對應的單元測試框架,你可以從這些編程語言相關的單元測試框架茫茫多的文檔里,從HelloWorld開始學習單元測試。斷言,基本上你也就只會學了單元測試框架的HelloWorld,然后就再也不看這些寫的十分冗長的文檔了。
那么,本文就是為你這樣重視單元測試和模塊測試的優秀的程序提供的一個獨特的的視角。開門見山,我們會以最直接的方式展示游離在單元測試和模塊測試之間的邊界地帶。即,如何一本萬利地掌握並實踐單元測試和模塊測試;這種方式應該被作為一種基本思想深深植入你的編程思維里面,並成為你編寫可靠程序的利器。是的,呼應標題,我們很直接的,就是要通過命令行的方式測試程序,把通過命令行測試程序作為一種基本的手段天天使用。
通過本文,將經歷三個主要過程:
- 過程A:一次從github fork代碼的操作
- 過程B:第一次完整的完全使用命令行+編輯器做:
- 編譯、運行、分析、修改(代碼、日志)、測試、調試、提交到github的過程。
- 過程C:第二次完整的完全使用命令行+編輯器做:
- 編譯、運行、分析、設計、修改(代碼、日志)、測試、調試、提交到github的過程。
通過本文,將傳遞以下重要的概念:
- 使用命令行參數暴露程序核心功能
- 使用日志分析程序的數據和指令流程
- 使用斷言的方式確保程序的狀態符合預期
- 使用批量調用命令行程序的方式測試
- 區分“手工執行命令行”和“自動化的對命令行程序測試”的概念
准備素材
由於不希望長篇累牘(但也不會太短),我們可以復用網絡上針對傳統單元測試寫的入門級教程作為基礎,例如下面兩個素材:
- https://www.cnblogs.com/SivilTaram/p/software_pretraining_java.html
- http://www.cnblogs.com/SivilTaram/p/software_pretraining_cpp.html
准備環境
- 安裝現代編輯器:https://code.visualstudio.com/ ,實際上你使用什么編輯器/IDE,都是可以的,正如Microsoft收購了Github之后,Github的新CEO Nat Friedman 所說的:
“開發者都有自己的喜好,選擇哪個編輯器完全取決於他們自己。”[2]. - 在你的本機安裝好Java環境和C++開發環境,這部分步驟通常是充滿配置感的,如果你遇到了防火牆的問題,應當通過各種途徑繞過它。
- 擁有一個Github賬號。
HelloWorld
配置好了環境,接下來這個步驟是清晰可見的。你只要:
- 已經從素材的教程里學會git的操作方法,若不熟悉,這里有一打簡明指南:
- 從素材的github倉庫上fork代碼到自己的github倉庫,並clone到自己的本機:
現在,你的本地工程目錄應該如下:
.
├── LICENSE
├── README.md
└── src
└── Main.java
通過觀察,很難相信如今還能有這么簡單的只有一個Java源代碼的程序。但這正如我們希望的簡單可處理:
- 選擇,不使用Java IDE去處理該程序。
- 選擇,使用
java
,javac
等命令行來處理該程序。這么做的理由十分充分:使用IDE/編輯器開發Java代碼十分常見,但基本上你也可以選擇同時使用命令行來做各種編程工作的切口。
通過查閱諸如“Java命令行如何使用”,“如何在命令行下使用Java”,“如何在命令行下執行Java程序”,“How to run java program in command line”,“java”,“jav
ac”之類的關鍵字,你很快上手編譯並測試了該程序:
javac Main.java
java Main
輸出:
40*89-81
40*89-81=3479
修改代碼
已有的程序可以正確執行並輸出結果這十分重要,如果素材里提供的程序是有BUG的或者環境耦合嚴重,則我們可能在這個步驟會花費數倍、數十倍的時間。也許此時你已經在所需的編輯器、源代碼管理工具、語言開發環境上耗費了諸多時間。
原文素材里面使用了一個叫做JUnit的單元測試框架對源代碼里Main.java
里的方法public static String Solve(String formula)
做了單元測試,並能在IDE里調試。而,本文拋棄了單元測試框架,並且不打算讓你F11到單步調試這種常規工具里。我們會使用日志診斷代碼。[3]
閱讀Main.java想必不會花費你的很多時間,不過我們只需關心public static void main(String[] args)
方法即可,源代碼如下:
public static void main(String[] args) {
String question = MakeFormula();
System.out.println(question);
String ret = Solve(question);
System.out.println(ret);
}
這個代碼的邏輯,用人類語言表達就是:
- 生成四則運算題目
- 輸出四則運算題目
- 計算四則運算題目
- 輸出四則運算結果
修改這個程序,用人類語言表達就是:
- 從命令行參數輸入四則運算題目
- 輸出四則運算題目
- 計算四則運算題目
- 輸出四則運算結果
翻譯回Java代碼就是:
public static void main(String[] args) {
// 1. 從命令行直接輸入題目
String question = String.join("",args);
// 2. 修改輸出日志,以和之前的形式做區分
System.out.println("question from commandline:"+question);
String ret = Solve(question);
System.out.println(ret);
}
重新編譯並執行該程序,輸入之前已經驗證過能正確執行的四則運算:
javac Main.java
java Main "40*89-81"
可以得到期待中的結果:
question from commandline:40*89-81
40*89-81=3479
測試用例
上述的java Main "40*89-81"
就是在使用命令行測試程序時的一個例子,即,測試用例。這個測試唯一的作用就是表明該程序能接受簡單的輸入。既然是測試,我們來嘗試輸入各種奇怪的表達式,看看效果怎樣。
先看看這個程序是否是個toy程序:
- 測試除零:
java Main "1/0"
輸出:
question from commandline:1/0
Exception in thread "main" java.lang.ArithmeticException: / by zero
at Main.Solve(Main.java:87)
at Main.main(Main.java:10)
很顯然,立刻就驗證了這是一個toy程序。“代碼寫的這么渣,一看就是小學沒畢業,不知道不能除零么!!”。當然,要修改代碼,就需要讀懂原來代碼的邏輯。很顯然Solve
函數里對除數為零並沒有做防御處理,實用的程序會根據上下文里對異常處理的需求做相應的處理。此處我們可以僅僅簡單處理:
- 捕獲除法異常
- 結束Solve程序,拋出更友好的結果。
修改代碼如下:
try{
calcStack.push(String.valueOf(a1 / b1));
}catch(ArithmeticException e){
return "ERROR:"+a1+"/ 0 is not allowed.";
}
再次測試下代碼:
- 測試除零:
java Main "1/0"
輸出:
question from commandline:1/0
ERROR:0/ 0 is not allowed.
結果看上去更好了么?
分析代碼
其實是有問題的,新輸出的錯誤信息:“ERROR:0/ 0 is not allowed.” 暴露了四則運算算法的潛在問題。不用看算法的細節,我們的輸入是“1/0”,出現異常的地方應該期待的是“a1=1”,怎么會輸出“0/0”的錯誤信息呢?
通過對方法Solve的分析,我們可以看到代碼明顯的分為了兩部分。如果你對編譯技術十分熟悉,或者部分熟悉,你也可以猜得出這代碼大致分為parser-executor兩部分。
parser部分:
Stack<String> tempStack = new Stack<>();//Store number or operator
Stack<Character> operatorStack = new Stack<>();//Store operator
int len = formula.length();
int k = 0;
for(int j = -1; j < len - 1; j++){
char formulaChar = formula.charAt(j + 1);
if(j == len - 2 || formulaChar == '+' || formulaChar == '-' || formulaChar == '/' || formulaChar == '*') {
if (j == len - 2) {
tempStack.push(formula.substring(k));
}
else { // NOTE(fanfeilong): 此處我們吐槽下原作者留下的代碼風格不統一的問題
if(k < j){
tempStack.push(formula.substring(k, j + 1));
}
if(operatorStack.empty()){
operatorStack.push(formulaChar); //if operatorStack is empty, store it
}else{
char stackChar = operatorStack.peek();
if ((stackChar == '+' || stackChar == '-')
&& (formulaChar == '*' || formulaChar == '/')){
operatorStack.push(formulaChar);
}else {
tempStack.push(operatorStack.pop().toString());
operatorStack.push(formulaChar);
}
}
}
k = j + 2;
}
}
while (!operatorStack.empty()){ // Append remaining operators
tempStack.push(operatorStack.pop().toString());
}
executor部分:
Stack<String> calcStack = new Stack<>();
for(String peekChar : tempStack){ // Reverse traversing of stack
if(!peekChar.equals("+") && !peekChar.equals("-") && !peekChar.equals("/") && !peekChar.equals("*")) {
calcStack.push(peekChar); // Push number to stack
}else{
int a1 = 0;
int b1 = 0;
if(!calcStack.empty()){
b1 = Integer.parseInt(calcStack.pop());
}
if(!calcStack.empty()){
a1 = Integer.parseInt(calcStack.pop());
}
switch (peekChar) {
case "+":
calcStack.push(String.valueOf(a1 + b1));
break;
case "-":
calcStack.push(String.valueOf(a1 - b1));
break;
case "*":
calcStack.push(String.valueOf(a1 * b1));
break;
default:
try{
calcStack.push(String.valueOf(a1 / b1));
}catch(ArithmeticException e){
return "ERROR:"+a1+"/ 0 is not allowed.";
}
break;
}
}
}
return formula + "=" + calcStack.pop();
executor部分一眼可以(或者兩眼)看出來是在做什么事情,大概是類似這樣的cpu:
[],[35+1-]
-> [3],[5+1-]
-> [3,5],[+1-]
-> [],(3+5)[1-]
-> [8],[1-]
-> [8,1],[-]
-> [],(8-1),[]
-> [7]
-> 7
撥的一手好算盤。那么顯然parser部分的作用就是把"3+5-1"轉換成"35+1-"。不過根據本節開頭的證據,顯然parser部分是有問題的。
日志系統
測試暴露出來的蛛絲馬跡都是不能放過的。是時候放出那句話了([5]):
找BUG這件事,只要讀代碼+理解代碼就好了。
當然,可以對其稍加闡釋,以便轉化為更可操作性的步驟。
- 對代碼的真正理解,是解決BUG的核心要義。
- 對關鍵路徑添加日志,使得數據和指令的流動清晰可見。
第1句是同義重復,第2句話才是本節想要引入的。在不查閱四則運算算法的時候(事實上很多代碼並沒有一個公開清晰的文檔可以查閱,閱讀看似難以理解的代碼是一個合格程序日常的重要組成部分),我們希望真正理解代碼就要分析代碼。當然你可以使用現代IDE提供的調試技術,便利的對代碼深入深出。
現在,采用原始的方式,希望直接在parser部分代碼里添加日志。通過觀察“數據”的流動,parser部分的核心數據流動是在tempStack
和operatorStack
兩個棧上。我們的日志應該重點跟蹤這兩個數據的流動。定制一個簡單的針對Stack的日志函數,它滿足:
- 能直接打印Stack
- 帶有時間戳、文件名、行號
現在就自己動手加一個:
public static void DumpStack(String tip, Stack stack){
// now
Date date = new Date();
long times = date.getTime();
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String now = formatter.format(date);
// file+lineNo
int lineNo = Thread.currentThread().getStackTrace()[2].getLineNumber();
String fileName = Thread.currentThread().getStackTrace()[2].getFileName();
String location = ""+fileName+":"+lineNo;
// output
System.out.println("["+now+"]"+tip+Arrays.toString(stack.toArray())+","+location);
}
現在就用來跟蹤parser的數據流動:
for(int j = -1; j < len - 1; j++){
char formulaChar = formula.charAt(j + 1);
if(j == len - 2 || formulaChar == '+' || formulaChar == '-' || formulaChar == '/' || formulaChar == '*') {
String index = "[j:"+j+",k:"+k+",char:"+formulaChar+"]";
if (j == len - 2) {
tempStack.push(formula.substring(k));
DumpStack(index+"tempStack:",tempStack); // <1>
}
else {
if(k < j){
tempStack.push(formula.substring(k, j + 1));
DumpStack(index+"tempStack:",tempStack); // <2>
}
if(operatorStack.empty()){
operatorStack.push(formulaChar); //if operatorStack is empty, store it
DumpStack(index+"operatorStack:",operatorStack); // <3>
}else{
char stackChar = operatorStack.peek();
if ((stackChar == '+' || stackChar == '-')
&& (formulaChar == '*' || formulaChar == '/')){
operatorStack.push(formulaChar);
DumpStack(index+"operatorStack:",operatorStack); // <4>
}else {
tempStack.push(operatorStack.pop().toString());
DumpStack(index+"tempStack:",tempStack); // <5>
operatorStack.push(formulaChar);
DumpStack(index+"operatorStack:",operatorStack); // <6>
}
}
}
k = j + 2;
}
}
while (!operatorStack.empty()){ // Append remaining operators
tempStack.push(operatorStack.pop().toString());
DumpStack("tempStack:",tempStack); // <7>
}
我們從<1>到<7>添加了7處數據流動的日志。現在就來編譯-測試一下:
javac Main.java
java Main "40*89-81"
輸出:
question from commandline:40*89-81
[2018-06-20 01:03:33][j:1,k:0,char:*]tempStack:[40],Main.java:63
[2018-06-20 01:03:33][j:1,k:0,char:*]operatorStack:[*],Main.java:67
[2018-06-20 01:03:33][j:4,k:3,char:-]tempStack:[40, 89],Main.java:63
[2018-06-20 01:03:33][j:4,k:3,char:-]tempStack:[40, 89, *],Main.java:76
[2018-06-20 01:03:33][j:4,k:3,char:-]operatorStack:[-],Main.java:79
[2018-06-20 01:03:33][j:6,k:6,char:1]tempStack:[40, 89, *, 81],Main.java:58
[2018-06-20 01:03:33]tempStack:[40, 89, *, 81, -],Main.java:89
40*89-81=3479
很好,這個純手工打造的日志函數麻雀雖小,五臟俱全。每一條日志包含時間、日志信息、文件名、行號。
有了日志系統,我們就可以開始分析parser的數據流動,這就能便利的理解代碼。可以看到:
- 程序從左往右掃描表達式:
"40*89-81"
- 一直掃描到操作符OP,把操作符前面的數字全部丟進tempStack
- 如果operatorStack為空,直接把操作符丟進operatorStack
- 如果operatorStack不為空,則:
- 如果,operatorStack棧頂的操作符的優先級低於當前遇到的OP,則當前操作符也直接丟進operatorStack
- 否則,operatorStack棧頂的操作符彈出進入tempStack,當前操作符則丟進operatorStack
如果觀察上面的日志行號,添加空行之后,可以看的更清晰:
question from commandline:40*89-81
[2018-06-20 01:03:33][j:1,k:0,char:*]tempStack:[40],Main.java:63
[2018-06-20 01:03:33][j:1,k:0,char:*]operatorStack:[*],Main.java:67
[2018-06-20 01:03:33][j:4,k:3,char:-]tempStack:[40, 89],Main.java:63
[2018-06-20 01:03:33][j:4,k:3,char:-]tempStack:[40, 89, *],Main.java:76
[2018-06-20 01:03:33][j:4,k:3,char:-]operatorStack:[-],Main.java:79
[2018-06-20 01:03:33][j:6,k:6,char:1]tempStack:[40, 89, *, 81],Main.java:58
[2018-06-20 01:03:33]tempStack:[40, 89, *, 81, -],Main.java:89
40*89-81=3479
那么,為什么"1/0"出問題呢?跑下日志:
java Main "1/0"
輸出:
question from commandline:1/0
[2018-06-20 01:10:19][j:0,k:0,char:/]operatorStack:[/],Main.java:67
[2018-06-20 01:10:19][j:1,k:2,char:0]tempStack:[0],Main.java:58
[2018-06-20 01:10:19]tempStack:[0, /],Main.java:89
ERROR:0/ 0 is not allowed.
對比一下,立刻可以看出,首次數據流動發生在67行,而之前的首次是在63行,63行是什么代碼呢?如下:
if(k < j){
tempStack.push(formula.substring(k, j + 1));
DumpStack(index+"tempStack:",tempStack);
}
可以看到,原來的parser在首次遇到操作符時的索引變量是:"[j:0,k:0,char:/]",此時,k===j,因此不會進入63行的邏輯,也就是:
- 一直掃描到操作符OP,把操作符前面的數字全部丟進tempStack
重現BUG
可以預計,類似的問題可以在同類型測試用例上出現,例如:
java Main "1/1"
輸出:
question from commandline:1/1
[2018-06-20 01:17:29][j:0,k:0,char:/]operatorStack:[/],Main.java:67
[2018-06-20 01:17:29][j:1,k:2,char:1]tempStack:[1],Main.java:58
[2018-06-20 01:17:29]tempStack:[1, /],Main.java:89
1/1=0
但是稍加偏移就不會出現:
java Main "100/1"
輸出:
question from commandline:100/1
[2018-06-20 01:17:21][j:2,k:0,char:/]tempStack:[100],Main.java:63
[2018-06-20 01:17:21][j:2,k:0,char:/]operatorStack:[/],Main.java:67
[2018-06-20 01:17:21][j:3,k:4,char:1]tempStack:[100, 1],Main.java:58
[2018-06-20 01:17:21]tempStack:[100, 1, /],Main.java:89
100/1=100
進一步,只要遇到一位數,程序必然出現BUG,以至於會出現十分荒謬的結果,差之毫厘,謬以千里:
java Main "100+2-3/10"
輸出:
question from commandline:100+2-3/10
[2018-06-20 01:27:26][j:2,k:0,char:+]tempStack:[100],Main.java:63
[2018-06-20 01:27:26][j:2,k:0,char:+]operatorStack:[+],Main.java:67
[2018-06-20 01:27:26][j:4,k:4,char:-]tempStack:[100, +],Main.java:76
[2018-06-20 01:27:26][j:4,k:4,char:-]operatorStack:[-],Main.java:79
[2018-06-20 01:27:26][j:6,k:6,char:/]operatorStack:[-, /],Main.java:73
[2018-06-20 01:27:26][j:8,k:8,char:0]tempStack:[100, +, 10],Main.java:58
[2018-06-20 01:27:26]tempStack:[100, +, 10, /],Main.java:89
[2018-06-20 01:27:26]tempStack:[100, +, 10, /, -],Main.java:89
100+2-3/10=-10
可見,BUG產生的原因是變量j
只前進了一步就碰到了操作符,此時j
等於0,同時k
保持不動也等於0,從而未能正確處理。
斷言
至此,可以對源代碼做直接的BUG修正。但是可以多加思考一下,63行處的代碼,真的有必要if(k<j)
么?
嚴密的邏輯,一個程序就是一個狀態機。每一個狀態的改變,都會導致程序向下一個狀態轉換。如何證明一個程序的狀態切換是正確的呢?有一種方式是像數學一樣嚴格證明程序的正確性,對程序做形式驗證。但是,通常程序的互聯網程序開發並不會如此做,這有其本身的學習曲線和成本問題。但是,理解程序狀態機的內在語義,則十分有助於形成嚴密的邏輯。[4]
我們關心可操作性勝過理論和完美,在開發實踐中,使用斷言來嚴格的檢查程序的狀態是十分有用的。因此,純手工制造一個斷言函數,該函數滿足:
- 判斷條件是否滿足
- 如果不滿足,直接讓程序崩潰
代碼如下:
public static void Assert(boolean condition, String errorLog){
if(!condition){
// now
Date date = new Date();
long times = date.getTime();
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String now = formatter.format(date);
// file+lineNo
StackTraceElement[] elements = Thread.currentThread().getStackTrace();
int lineNo = elements[2].getLineNumber();
String fileName = elements[2].getFileName();
String location = ""+fileName+":"+lineNo;
System.out.println("["+now+"]"+errorLog+","+location);
for(int i=0; i<elements.length; i++) {
System.out.println(elements[i]);
}
System.exit(1);
}
}
根據分析,當遇到操作符並且不是末尾的時候,一定是要把前面的非操作符丟進tempStack的,所以63行前后的代碼修改為:
Assert(k<j,"k is not less then j, [k:"+k+",j:"+j+"]");
tempStack.push(formula.substring(k, j + 1));
DumpStack(index+"tempStack:",tempStack);
編譯,測試非邊界用例:
javac Main.java
java Main "100+2-30/10"
輸出:
question from commandline:100+20-30/10
[2018-06-20 01:56:00][j:2,k:0,char:+]tempStack:[100],Main.java:88
[2018-06-20 01:56:00][j:2,k:0,char:+]operatorStack:[+],Main.java:92
[2018-06-20 01:56:00][j:5,k:4,char:-]tempStack:[100, 20],Main.java:88
[2018-06-20 01:56:00][j:5,k:4,char:-]tempStack:[100, 20, +],Main.java:101
[2018-06-20 01:56:00][j:5,k:4,char:-]operatorStack:[-],Main.java:104
[2018-06-20 01:56:00][j:8,k:7,char:/]tempStack:[100, 20, +, 30],Main.java:88
[2018-06-20 01:56:00][j:8,k:7,char:/]operatorStack:[-, /],Main.java:98
[2018-06-20 01:56:00][j:10,k:10,char:0]tempStack:[100, 20, +, 30, 10],Main.java:83
[2018-06-20 01:56:00]tempStack:[100, 20, +, 30, 10, /],Main.java:114
[2018-06-20 01:56:00]tempStack:[100, 20, +, 30, 10, /, -],Main.java:114
100+20-30/10=117
測試邊界用例:
java Main "100+2-3/10"
輸出:
question from commandline:100+20-3/10
[2018-06-20 01:56:03][j:2,k:0,char:+]tempStack:[100],Main.java:88
[2018-06-20 01:56:03][j:2,k:0,char:+]operatorStack:[+],Main.java:92
[2018-06-20 01:56:03][j:5,k:4,char:-]tempStack:[100, 20],Main.java:88
[2018-06-20 01:56:03][j:5,k:4,char:-]tempStack:[100, 20, +],Main.java:101
[2018-06-20 01:56:03][j:5,k:4,char:-]operatorStack:[-],Main.java:104
[2018-06-20 01:56:03]k is not less then j, [k:7,j:7],Main.java:86
java.lang.Thread.getStackTrace(Thread.java:1556)
Main.Assert(Main.java:57)
Main.Solve(Main.java:86)
Main.main(Main.java:14)
可見,使用了斷言,可以讓錯誤在盡量靠近錯誤現場的地方被探測到。
BUG修正
測試、重現、添加日志、分析、使用斷言,是時候修正一下當前的BUG了。前面的斷言應該被改成正確的版本:
Assert(k<=j,"k is not less then j, [k:"+k+",j:"+j+"]");
tempStack.push(formula.substring(k, j + 1));
DumpStack(index+"tempStack:",tempStack);
編譯:
javac Main.java
測試1:
java Main "1/1"
輸出:
question from commandline:1/1
[2018-06-20 02:03:37][j:0,k:0,char:/]tempStack:[1],Main.java:88
[2018-06-20 02:03:37][j:0,k:0,char:/]operatorStack:[/],Main.java:92
[2018-06-20 02:03:37][j:1,k:2,char:1]tempStack:[1, 1],Main.java:83
[2018-06-20 02:03:37]tempStack:[1, 1, /],Main.java:114
1/1=1
測試2:
java Main "100+2-3/10"
輸出:
question from commandline:100+20-3/10
[2018-06-20 02:02:11][j:2,k:0,char:+]tempStack:[100],Main.java:88
[2018-06-20 02:02:11][j:2,k:0,char:+]operatorStack:[+],Main.java:92
[2018-06-20 02:02:11][j:5,k:4,char:-]tempStack:[100, 20],Main.java:88
[2018-06-20 02:02:11][j:5,k:4,char:-]tempStack:[100, 20, +],Main.java:101
[2018-06-20 02:02:11][j:5,k:4,char:-]operatorStack:[-],Main.java:104
[2018-06-20 02:02:11][j:7,k:7,char:/]tempStack:[100, 20, +, 3],Main.java:88
[2018-06-20 02:02:11][j:7,k:7,char:/]operatorStack:[-, /],Main.java:98
[2018-06-20 02:02:11][j:9,k:9,char:0]tempStack:[100, 20, +, 3, 10],Main.java:83
[2018-06-20 02:02:11]tempStack:[100, 20, +, 3, 10, /],Main.java:114
[2018-06-20 02:02:11]tempStack:[100, 20, +, 3, 10, /, -],Main.java:114
100+20-3/10=120
練習:
- 修復了一個BUG么,拔出蘿卜帶出泥,新的問題是沒有正確處理分數的情況。
- 這算不上BUG,可以算是不支持的Feature,請你添加代碼支持分數的情況。
批量測試:
在經歷了相對精細的一組分析之后,我們還是只能一個一個通過命令行測試程序。有沒辦法方便的添加測試用例,批量執行呢?
對於命令行程序而言,做到這點很簡單。例如在該程序里,使用你熟悉的語言添加一個命令行批量執行的程序即可。
使用Java的版本如下:
- 創建Test.java
- 編寫如下簡單易懂的代碼:
import java.lang.Exception;
import java.io.BufferedReader;
import java.io.InputStreamReader;
public class Test {
public static void main(String[] args) {
String[] tests = new String[]{
"0+1",
"0-1",
"0*0",
"1/0",
"100+20-3/10",
"100+20-30/10"
};
int successCount=0;
System.out.println("TEST BEGIN");
System.out.println("----------");
for(int i=0;i<tests.length;i++){
int ret = runTest(tests[i]);
if(ret==0){
successCount++;
System.out.println("[SUCCESS]:"+tests[i]);
}else{
System.out.println("[FAILED]:"+tests[i]+", ret:"+ret);
}
}
int failedCount = tests.length-successCount;
System.out.println("----------");
System.out.println("TEST END, "+successCount+" success, "+failedCount+" failed.");
}
private static int runTest(String exp) {
StringBuffer output = new StringBuffer();
int ret=0;
Process p;
try {
p = Runtime.getRuntime().exec(new String[]{"java","Main",exp});
BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()));
String line = "";
while ((line = reader.readLine())!= null) {
output.append(line + "\n");
}
ret = p.waitFor();
} catch (Exception e) {
e.printStackTrace();
ret = -1;
}
System.out.println(output.toString());
return ret;
}
}
編譯,測試:
- javac Test.java
- java Test
輸出:
TEST BEGIN
----------
[Ljava.lang.String;@7852e922
question from commandline:0+1
[2018-06-20 02:57:27][j:0,k:0,char:+]tempStack:[0],Main.java:88
[2018-06-20 02:57:27][j:0,k:0,char:+]operatorStack:[+],Main.java:92
[2018-06-20 02:57:27][j:1,k:2,char:1]tempStack:[0, 1],Main.java:83
[2018-06-20 02:57:27]tempStack:[0, 1, +],Main.java:114
0+1=1
[SUCCESS]:0+1
[Ljava.lang.String;@7852e922
question from commandline:0-1
[2018-06-20 02:57:27][j:0,k:0,char:-]tempStack:[0],Main.java:88
[2018-06-20 02:57:27][j:0,k:0,char:-]operatorStack:[-],Main.java:92
[2018-06-20 02:57:27][j:1,k:2,char:1]tempStack:[0, 1],Main.java:83
[2018-06-20 02:57:27]tempStack:[0, 1, -],Main.java:114
0-1=-1
[SUCCESS]:0-1
[Ljava.lang.String;@7852e922
question from commandline:0*0
[2018-06-20 02:57:27][j:0,k:0,char:*]tempStack:[0],Main.java:88
[2018-06-20 02:57:27][j:0,k:0,char:*]operatorStack:[*],Main.java:92
[2018-06-20 02:57:27][j:1,k:2,char:0]tempStack:[0, 0],Main.java:83
[2018-06-20 02:57:27]tempStack:[0, 0, *],Main.java:114
0*0=0
[SUCCESS]:0*0
[Ljava.lang.String;@7852e922
question from commandline:1/0
[2018-06-20 02:57:28][j:0,k:0,char:/]tempStack:[1],Main.java:88
[2018-06-20 02:57:28][j:0,k:0,char:/]operatorStack:[/],Main.java:92
[2018-06-20 02:57:28][j:1,k:2,char:0]tempStack:[1, 0],Main.java:83
[2018-06-20 02:57:28]tempStack:[1, 0, /],Main.java:114
ERROR:1/ 0 is not allowed.
[SUCCESS]:1/0
[Ljava.lang.String;@7852e922
question from commandline:100+20-3/10
[2018-06-20 02:57:28][j:2,k:0,char:+]tempStack:[100],Main.java:88
[2018-06-20 02:57:28][j:2,k:0,char:+]operatorStack:[+],Main.java:92
[2018-06-20 02:57:28][j:5,k:4,char:-]tempStack:[100, 20],Main.java:88
[2018-06-20 02:57:28][j:5,k:4,char:-]tempStack:[100, 20, +],Main.java:101
[2018-06-20 02:57:28][j:5,k:4,char:-]operatorStack:[-],Main.java:104
[2018-06-20 02:57:28][j:7,k:7,char:/]tempStack:[100, 20, +, 3],Main.java:88
[2018-06-20 02:57:28][j:7,k:7,char:/]operatorStack:[-, /],Main.java:98
[2018-06-20 02:57:28][j:9,k:9,char:0]tempStack:[100, 20, +, 3, 10],Main.java:83
[2018-06-20 02:57:28]tempStack:[100, 20, +, 3, 10, /],Main.java:114
[2018-06-20 02:57:28]tempStack:[100, 20, +, 3, 10, /, -],Main.java:114
100+20-3/10=120
[SUCCESS]:100+20-3/10
[Ljava.lang.String;@7852e922
question from commandline:100+20-30/10
[2018-06-20 02:57:28][j:2,k:0,char:+]tempStack:[100],Main.java:88
[2018-06-20 02:57:28][j:2,k:0,char:+]operatorStack:[+],Main.java:92
[2018-06-20 02:57:28][j:5,k:4,char:-]tempStack:[100, 20],Main.java:88
[2018-06-20 02:57:28][j:5,k:4,char:-]tempStack:[100, 20, +],Main.java:101
[2018-06-20 02:57:28][j:5,k:4,char:-]operatorStack:[-],Main.java:104
[2018-06-20 02:57:28][j:8,k:7,char:/]tempStack:[100, 20, +, 30],Main.java:88
[2018-06-20 02:57:28][j:8,k:7,char:/]operatorStack:[-, /],Main.java:98
[2018-06-20 02:57:28][j:10,k:10,char:0]tempStack:[100, 20, +, 30, 10],Main.java:83
[2018-06-20 02:57:28]tempStack:[100, 20, +, 30, 10, /],Main.java:114
[2018-06-20 02:57:28]tempStack:[100, 20, +, 30, 10, /, -],Main.java:114
100+20-30/10=117
[SUCCESS]:100+20-30/10
----------
TEST END, 6 success, 0 failed.
到這里,你可以任意改進你的測試程序。例如,從配置文件里讀取你的測試用例等等。
適用性
通過為程序核心功能編寫命令行接口,然后針對命令行接口進行測試,可以在單元測試/模塊測試的交集地帶獲得良好的回報。其中一個好處在於,針對命令行程序的測試具有很好的適用性(或者取一個更好的描述)。無論你的程序是單進程程序、多進程程序;是客戶端軟件還是服務端軟件,都可以通過提供命令行接口進行測試。並且具有良好的跨語言能力,你不必拘泥於語言限定的測試框架,在實際開發中,整個系統的組件很多時候是混合式的,使用了多種語言,跨越了不同設備。而命令行的方式能更普遍的適用於各種情況。
測試狗
命令行程序可以輕易的用后台服務或者定時程序定時、自動化的跑測試用例,而這是持續集成的基本要素:在程序運行期間、程序新發布版本、...的時候,都有自動運行的測試。
eat your dog food, always run.
提交修改
查看代碼變動:git status
On branch java
Your branch is up to date with 'origin/java'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)
modified: src/Main.java
Untracked files:
(use "git add <file>..." to include in what will be committed)
src/Test.java
no changes added to commit (use "git add" and/or "git commit -a")
添加改動:git add .
添加日志:git commit -m 'add log and test'
提交:git push origin master
出現錯誤信息:
error: src refspec master does not match any.
error: failed to push some refs to 'https://github.com/fanfeilong/Calculator.git'
查看下分支情況:git branch -a
* java
remotes/origin/HEAD -> origin/java
remotes/origin/cplusplus
remotes/origin/java
可以看到:
remotes
開頭的是github上的遠程分支, 有兩個,分別是origin/cplusplus
和origin/java
,其中origin
表示遠程主機名,cplusplus
和java
是遠程主機origin
下的兩個分支- 當前本地分支(打星號開頭的)是
java
推送命令的規則是:git push 遠程主機名 本地分支名:遠程分支
。則,我們應該把本地java
分支往遠程主機origin
上的java
分支推送:git push origin java:java
。其中,如果本地分支和遠程分支名字一樣,可以簡寫:git push origin java
。這個規則許多同學也並不十分清楚,在這里做一個清晰的解釋。
git url: https://github.com/fanfeilong/Calculator/commit/5901c0a8d43cecaf9a2a5cb53f17ccc3b6fb7653
證明
OK,到目前為止,還缺了什么?Test.java 可以一鍵批量“測試”Main.java,並輸出漂亮的統計結果,一切看上去都在掌控之中。但似乎少了點什么,這並不是一開始就能看的清楚的。回到主題,我們的核心目標是“測試”,換一種說法,你從小到大,都在做各種測試。例如從一年級開始,每個學期每個課程都有“單元測試”,老師出題,學生做題,然后最重要的是老師改題,並把結果報給學生。除了那些開放性題目,大部分情況下老師在出題的同時也把答案准備好了,特別是數學。
說起數學,一個很重要的事情就是證明。實際上,測試也是在做證明,我們來看下Test.java做了什么:
- 調用Main.java批量執行四則運算表達式。
- 輸出批量執行的統計結果。
- 統計結果會報告進程執行返回碼為0的有幾個,返回碼不為0的有幾個。
- 你,作為一個老師,通過你的眼睛,看一下每一個測試用例的計算結果,精確到知道每個用例是否錯了。
看到了么,第3個步驟,只是檢測了“執行返回碼為0”的情況。那么“執行返回碼為0”是否等價於“四則運算表達式計算結果正確”呢?顯然,答案是:No!這也是最重要的地方,如果你理解了這兩個不同,你就立刻捕捉到了“手工執行命令行程序,肉眼看看結果”為什么和“自動測試”並不等價這個概念。許多同學都認為只要手工多次執行程序,看看結果對就算是做了測試。是的,它是一次測試過程,但並不算一次“自動測試”過程。
自動測試的要義應該是:
程序執行測試用例,並自動檢查結果是否符合預期定義的結果。
等價設計
既然還沒有達到目的,我們就不能停止腳步。首先,我們希望做到“執行返回碼為0”等價於“四則運算表達式計算結果正確”。
那么,就從Main.java開始改造,讓Main.java的命令行參數接收:
- 四則運算表達式。
- 預期的答案。
程序的示例調用如下:
java Main -q "1+1" -a 2
- 命令行選項
-q
指定了問題 - 命令行選項
-a
指定了答案
程序的新邏輯應該是這樣的:
- 轉換命令行參數,得到四則運算表達式和答案
- 輸出四則運算表達式
- 計算四則運算表達式,得到結果
- 如果計算完成,返回計算結果
- 如果計算失敗,返回失敗信息
- 如果程序中有預期的不對邏輯,則應該直接退出程序並返回非0錯誤碼 <1>
- 校驗計算結果,
- 如果匹配正確,則程序結束,程序的退出碼是默認值0 <2>
- 如果匹配失敗,則程序退出,返回非0退出碼 <3>
這樣,在上述三個程序退出的地方,程序退出碼是否為0,等價於“四則運算表達式計算結果正確”。
新的實現
第1,實現轉換命令行參數:
public static String[] ParseOptions(String[] args){
String[] result = new String[2]; // 直接使用數組返回,你可以用自定義結構,例如一個class
for(int i=0;i<args.length;i++){
if(args[i].equals("-q")){
result[0] = args[i+1];
i++;
}else if(args[i].equals("-a")){
if(args.length>i+1){
result[1] = args[i+1];
i++;
}
}
}
return result;
}
第2,改造Solve,使得它能返回錯誤信息和正確答案。
public static String[] Solve(String formula){
String[] result = new String[2];
...
Stack<String> calcStack = new Stack<>();
for(String peekChar : tempStack){ // Reverse traversing of stack
...
try{
calcStack.push(String.valueOf(a1 / b1));
}catch(ArithmeticException e){
//數組第一個元素用來返回錯誤信息
result[0] = "ERROR:"+a1+"/ 0 is not allowed.";
return result;
}
break;
}
}
}
// 數組第2個元素用來返回正確答案,當然你可以用更好的自定義結構
result[1] = calcStack.pop();
return result;
}
第3,檢測計算結果:
public static void CheckResult(String question, String answer, String[] ret){
String errorMsg = ret[0];
String result = ret[1];
if(errorMsg!=null){
System.out.println("[error] solve:"+question + "=" + result+", calc error:"+errorMsg);
System.exit(1);
}else{
if(answer==null){
System.out.println(question + "=" + result);
}else{
if(result.equals(answer)){
System.out.println("[info] solve:"+question + "=" + result+", answer matched:"+answer);
}else{
System.out.println("[error] solve:"+question + "=" + result+", answer not matched:"+answer);
System.exit(1);
}
}
}
}
最后,看看最新版本的Main函數:
public static void main(String[] args) {
// 解析命令行參數
String[] options = ParseOptions(args);
String question = options[0];
String answer = options[1];
// 計算表達式
System.out.println("question from commandline:"+question);
String[] ret = Solve(question);
// 比對計算結果
CheckResult(question, answer, ret);
}
編譯:
javac Main.java
來一個證明:
java Main -q "1+1" -a 2
輸出:
question from commandline:1+1
[2018-06-21 19:50:48][j:0,k:0,char:+]tempStack:[1],Main.java:137
[2018-06-21 19:50:48][j:0,k:0,char:+]operatorStack:[+],Main.java:141
[2018-06-21 19:50:48][j:1,k:2,char:1]tempStack:[1, 1],Main.java:132
[2018-06-21 19:50:48]tempStack:[1, 1, +],Main.java:163
[info] solve:1+1=2, answer matched:2
來一個搗蛋鬼:
java Main -q "1+1" -a 3
輸出:
question from commandline:1+1
[2018-06-21 19:52:23][j:0,k:0,char:+]tempStack:[1],Main.java:137
[2018-06-21 19:52:23][j:0,k:0,char:+]operatorStack:[+],Main.java:141
[2018-06-21 19:52:23][j:1,k:2,char:1]tempStack:[1, 1],Main.java:132
[2018-06-21 19:52:23]tempStack:[1, 1, +],Main.java:163
[error] solve:1+1=2, answer not matched:3
看到了么,解放了我們的肉眼,程序自動輸出比對結果。
批量測試,Again
最后的最后,稍加改進,讓Test.java完成升級:
import java.lang.Exception;
import java.io.BufferedReader;
import java.io.InputStreamReader;
public class Test {
public static void main(String[] args) {
String[][] tests = {
{"0+1","1"},
{"0-1","-1"},
{"0*1","0"},
{"0/1","0"},
{"1/0",""},
{"100+20-3/10","120"},
{"100+20-30/10","117"}
};
int successCount=0;
System.out.println("TEST BEGIN");
System.out.println("----------");
for(int i=0;i<tests.length;i++){
String question = tests[i][0];
String answer = tests[i][1];
int ret = runTest(question,answer);
if(ret==0){
successCount++;
System.out.println("[SUCCESS], question:"+question+", answer:"+answer);
}else{
System.out.println("[FAILED], question:"+question+", answer:"+answer+", ret:"+ret);
}
System.out.println("\n");
}
int failedCount = tests.length-successCount;
System.out.println("----------");
System.out.println("TEST END, "+successCount+" success, "+failedCount+" failed.");
}
private static int runTest(String question, String answer) {
StringBuffer output = new StringBuffer();
int ret=0;
Process p;
try {
p = Runtime.getRuntime().exec(new String[]{"java","Main","-q",question,"-a",answer});
BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()));
String line = "";
while ((line = reader.readLine())!= null) {
output.append(line + "\n");
}
ret = p.waitFor();
} catch (Exception e) {
e.printStackTrace();
ret = -1;
}
System.out.println(output.toString());
return ret;
}
}
是時候展示“編寫可命令行測試程序”的威力了:
- 編譯:
javac Test.java
- 執行:
java Test
輸出:
TEST BEGIN
----------
question from commandline:0+1
[2018-06-21 20:05:42][j:0,k:0,char:+]tempStack:[0],Main.java:135
[2018-06-21 20:05:42][j:0,k:0,char:+]operatorStack:[+],Main.java:139
[2018-06-21 20:05:42][j:1,k:2,char:1]tempStack:[0, 1],Main.java:130
[2018-06-21 20:05:42]tempStack:[0, 1, +],Main.java:161
[info] solve:0+1=1, answer matched:1
[SUCCESS], question:0+1, answer:1
question from commandline:0-1
[2018-06-21 20:05:42][j:0,k:0,char:-]tempStack:[0],Main.java:135
[2018-06-21 20:05:42][j:0,k:0,char:-]operatorStack:[-],Main.java:139
[2018-06-21 20:05:42][j:1,k:2,char:1]tempStack:[0, 1],Main.java:130
[2018-06-21 20:05:42]tempStack:[0, 1, -],Main.java:161
[info] solve:0-1=-1, answer matched:-1
[SUCCESS], question:0-1, answer:-1
question from commandline:0*1
[2018-06-21 20:05:43][j:0,k:0,char:*]tempStack:[0],Main.java:135
[2018-06-21 20:05:43][j:0,k:0,char:*]operatorStack:[*],Main.java:139
[2018-06-21 20:05:43][j:1,k:2,char:1]tempStack:[0, 1],Main.java:130
[2018-06-21 20:05:43]tempStack:[0, 1, *],Main.java:161
[info] solve:0*1=0, answer matched:0
[SUCCESS], question:0*1, answer:0
question from commandline:0/1
[2018-06-21 20:05:43][j:0,k:0,char:/]tempStack:[0],Main.java:135
[2018-06-21 20:05:43][j:0,k:0,char:/]operatorStack:[/],Main.java:139
[2018-06-21 20:05:43][j:1,k:2,char:1]tempStack:[0, 1],Main.java:130
[2018-06-21 20:05:43]tempStack:[0, 1, /],Main.java:161
[info] solve:0/1=0, answer matched:0
[SUCCESS], question:0/1, answer:0
question from commandline:1/0
[2018-06-21 20:05:43][j:0,k:0,char:/]tempStack:[1],Main.java:135
[2018-06-21 20:05:43][j:0,k:0,char:/]operatorStack:[/],Main.java:139
[2018-06-21 20:05:43][j:1,k:2,char:0]tempStack:[1, 0],Main.java:130
[2018-06-21 20:05:43]tempStack:[1, 0, /],Main.java:161
[error] solve:1/0=null, calc error:ERROR:1/ 0 is not allowed.
[FAILED], question:1/0, answer:, ret:1
question from commandline:100+20-3/10
[2018-06-21 20:05:43][j:2,k:0,char:+]tempStack:[100],Main.java:135
[2018-06-21 20:05:43][j:2,k:0,char:+]operatorStack:[+],Main.java:139
[2018-06-21 20:05:43][j:5,k:4,char:-]tempStack:[100, 20],Main.java:135
[2018-06-21 20:05:43][j:5,k:4,char:-]tempStack:[100, 20, +],Main.java:148
[2018-06-21 20:05:43][j:5,k:4,char:-]operatorStack:[-],Main.java:151
[2018-06-21 20:05:43][j:7,k:7,char:/]tempStack:[100, 20, +, 3],Main.java:135
[2018-06-21 20:05:43][j:7,k:7,char:/]operatorStack:[-, /],Main.java:145
[2018-06-21 20:05:43][j:9,k:9,char:0]tempStack:[100, 20, +, 3, 10],Main.java:130
[2018-06-21 20:05:43]tempStack:[100, 20, +, 3, 10, /],Main.java:161
[2018-06-21 20:05:43]tempStack:[100, 20, +, 3, 10, /, -],Main.java:161
[error] solve:100+20-3/10=120, answer not matched:119.7
[FAILED], question:100+20-3/10, answer:119.7, ret:1
question from commandline:100+20-30/10
[2018-06-21 20:05:43][j:2,k:0,char:+]tempStack:[100],Main.java:135
[2018-06-21 20:05:43][j:2,k:0,char:+]operatorStack:[+],Main.java:139
[2018-06-21 20:05:43][j:5,k:4,char:-]tempStack:[100, 20],Main.java:135
[2018-06-21 20:05:43][j:5,k:4,char:-]tempStack:[100, 20, +],Main.java:148
[2018-06-21 20:05:43][j:5,k:4,char:-]operatorStack:[-],Main.java:151
[2018-06-21 20:05:43][j:8,k:7,char:/]tempStack:[100, 20, +, 30],Main.java:135
[2018-06-21 20:05:43][j:8,k:7,char:/]operatorStack:[-, /],Main.java:145
[2018-06-21 20:05:43][j:10,k:10,char:0]tempStack:[100, 20, +, 30, 10],Main.java:130
[2018-06-21 20:05:43]tempStack:[100, 20, +, 30, 10, /],Main.java:161
[2018-06-21 20:05:43]tempStack:[100, 20, +, 30, 10, /, -],Main.java:161
[info] solve:100+20-30/10=117, answer matched:117
[SUCCESS], question:100+20-30/10, answer:117
----------
TEST END, 5 success, 2 failed.
提交,Again
- 查看修改狀態:
git status
On branch java
Your branch is up to date with 'origin/java'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)
modified: Main.java
modified: Test.java
no changes added to commit (use "git add" and/or "git commit -a")
- 添加改動:
git add .
- 添加改動日志:
git commit -m 'add commandline options, let calculator test able'
- 提交:
git push origin java
git url: https://github.com/fanfeilong/Calculator/commit/bd98e79392f5847694eb50a099e5026527d85b8e
思考與練習
- 針對命令行程序的測試,什么時候算“單元測試”,什么時候算“模塊測試”?
- 這取決於通過命令行參數暴露出來的功能粒度。
- 希望全覆蓋實現單元測試:
- 一種方式是簡單的讓命令行程序內部自己包含對自己的測試代碼,然后通過
-test
接口暴露 - 為內部所有重要接口編寫命令行參數,實現全覆蓋
- 一種方式是簡單的讓命令行程序內部自己包含對自己的測試代碼,然后通過
- 希望只針對關鍵特性測試:
- 程序只暴露核心的對外功能,只對模塊級別做測試。
- 從上面最新的的github倉庫上fork代碼,並改進Calculator:
- 支持分數四則計算。
- 添加測試用例:
-1+3+5
,分析並改進程序。
- 請你修改DumpStack,改進日志函數,支持:
- 日志帶有進程Id、線程Id。
- 日志支持分類,可以輸出
[info]
,[warn]
,[error]
三種不同的分類日志。 - 接受命令行參數指定日志級別,例如
-log_level error
只顯示error級別日志。這使得Test.java的輸出可以按不同粒度控制。 - 日志支持寫入到文件,便於事后分析。
- 擴展Test.java,使得其:
- 可以自動隨機生成各種不同類型的測試用例
- 針對你的服務端程序,例如數據庫增、刪、查改程序的http接口。
- 分離出SQL查詢的關鍵函數,作為一個公共輔助函數。
- 對該公共輔助函數添加關鍵日志。
- 使用命令行編寫對數據庫http接口的測試用例。
- 針對你的GUI程序:
- 分離出非UI的部分模塊。
- 對非UI的模塊編寫命令行程序。
- 對命令行程序編寫測試用例。
- 能理解並手工打造工具后,再看看現成的IDE集成的測試工具:
- Java單元測試之JUnit篇: https://www.cnblogs.com/happyzm/p/6482886.html
參考
- [1] https://testing.googleblog.com
- [2] https://news.cnblogs.com/n/598772/
- [3] https://blog.codingnow.com/2018/05/ineffective_debugger.html
- [4] http://lamport.azurewebsites.net/video/intro.html
- [5] http://futurice.com/blog/why-debugging-is-all-about-understanding
致謝
讀者反饋了多處錯別字問題,一並感謝:
@zhmin @李簫年
--end--