提高開發質量的 5 個必要實踐

2023-04-25 18:01:46

單元測試

什麼是單元測試 ?

單元測試通常是指對一個函數或方法測試。單元測試的目的是驗證每個單元的行為是否符合預期,並且在修改程式碼時能夠快速檢測到任何潛在的問題。通過編寫測試用例,我們可以驗證這些模組在特定輸入下是否產生正確的輸出。單元測試的目的是確保每個模組在各種情況下都能正常執行。

寫單元測試的好處

可以帶來以下幾個好處:

  1. 提高程式碼質量:單元測試可以我們提前的發現程式碼中的潛在問題,例如邊界條件、異常情況等,從而減少出錯的概率。
  2. 提高程式碼可維護性:單元測試可以幫助開發人員理解程式碼的功能和實現細節,從而更容易維護和修改程式碼。
  3. 提高程式碼可靠性:修改程式碼後,可以通過單元測試可以幫助開發人員驗證程式碼的正確性,從而提高程式碼的可靠性。

寫單元測試是一種良好的軟體開發實踐,可以提高程式碼質量、可維護性和可靠性,同時也可以提高開發效率和支援持續整合和持續交付。

單元測試入門

上手單元測試,通常同時從靜態測試(Static Test)開始,因為它簡單,好理解,靜態測試(Static Test)是指在編寫測試用例時,我們提前定義好所有的測試方法和測試資料。這些測試方法和資料在編譯時就已經確定,不會在執行時發生變化。Junit 中的靜態測試通常的常規註解,如 @Test、@Before、@After 等。先來看看一組簡單的靜態測試範例。

首先,確保你的 pom.xml 檔案包含 JUnit 的依賴:

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.8.0</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <version>5.8.0</version>
        <scope>test</scope>
    </dependency>
</dependencies>

然後,建立一個簡單的計算器類,通常這裡替換為你實際要測試的業務類:

public class SimpleCalculator {

    public int add(int a, int b) {
        return a + b;
    }

    public int subtract(int a, int b) {
        return a - b;
    }
}

然後在 /test 的相同目錄下建立對應的測試類

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class SimpleCalculatorTest {

    // 在所有測試方法執行前,僅執行一次。這個方法需要是靜態的。
    @BeforeAll
    static void setup() {
        System.out.println("BeforeAll - 初始化共用資源,例如資料庫連線");
    }

    // 在所有測試方法執行後,僅執行一次。這個方法需要是靜態的。
    @AfterAll
    static void tearDown() {
        System.out.println("AfterAll - 清理共用資源,例如關閉資料庫連線");
    }

    // 在每個測試方法執行前,都會執行一次。用於設定測試方法所需的初始狀態。
    @BeforeEach
    void init() {
        System.out.println("BeforeEach - 初始化測試範例所需的資料");
    }

    // 在每個測試方法執行後,都會執行一次。用於清理測試方法使用的資源。
    @AfterEach
    void cleanup() {
        System.out.println("AfterEach - 清理測試範例所用到的資源");
    }

    // 標註一個測試方法,用於測試某個功能。
    @Test
    void testAddition() {
        System.out.println("Test - 測試加法功能");
        SimpleCalculator calculator = new SimpleCalculator();
        assertEquals(5, calculator.add(2, 3), "2 + 3 應該等於 5");
    }

    // 再新增一個測試方法
    @Test
    void testSubtraction() {
        System.out.println("Test - 測試減法功能");
        SimpleCalculator calculator = new SimpleCalculator();
        assertEquals(1, calculator.subtract(3, 2), "3 - 2 應該等於 1");
    }
}

以上程式,可以看到 Junit 常用註解使用說明:

  • @BeforeAll:在所有測試方法執行前,僅執行一次。這個方法需要是靜態的
  • @AfterAll:在所有測試方法執行後,僅執行一次。這個方法需要是靜態的
  • @BeforeEach:在每個測試方法執行前,都會執行一次。用於設定測試方法所需的初始狀態
  • @AfterEach:在每個測試方法執行後,都會執行一次。用於清理測試方法使用的資源
  • @Test:標註一個測試方法,用於測試某個功能

如果是 maven 專案,可以在目錄下執行命令執行測試:

mvn test

輸出結果:

[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running SimpleCalculatorTest
BeforeAll - 初始化共用資源,例如資料庫連線
BeforeEach - 初始化測試範例所需的資料
Test - 測試加法功能
AfterEach - 清理測試範例所用到的資源
BeforeEach - 初始化測試範例所需的資料
Test - 測試減法功能
AfterEach - 清理測試範例所用到的資源
AfterAll - 清理共用資源,例如關閉資料庫連線
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.058 s - in SimpleCalculatorTest
[INFO] 
[INFO] Results:
[INFO]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

或者可以直接在 IDEA 中執行測試,如下:

以上就是靜態測試的簡單範例

動態測試

動態測試(Dynamic Test):動態測試是指在編寫測試用例時,我們可以在執行時生成測試方法和測試資料。這些測試方法和資料在編譯時不確定,而是在執行時根據特定條件或資料來源動態生成。因為在靜態單元測試中,由於測試樣本資料有限,通常很難覆蓋所有情況,覆蓋率到了臨界值就很難提高。JUnit 5 中引入動態測試,相比靜態測試更復雜,當然也更靈活,也更適合複雜的場景。接下來通過一個簡單的範例來展示動態測試和靜態測試的區別,我們建立 MyStringUtil 類,它有一個方法 reverse() 用於反轉字串,如下:

public class MyStringUtil {
    public String reverse(String input) {
        if (input == null) {
            return null;
        }
        return new StringBuilder(input).reverse().toString();
    }
}

在靜態測試類中,我們使用 @Test 定義 3 個方法來嘗試覆蓋 reverse() 可能得多種情況:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class MyStringUtilStaticTest {

    private MyStringUtil stringUtil = new MyStringUtil();

    @Test
    void reverseString() {
        // 反轉字串 'hello'
        assertEquals("olleh", stringUtil.reverse("hello"));
    }

    @Test
    void reverseEmptyString() {
        // 反轉空字串
        assertEquals("", stringUtil.reverse(""));
    }

    @Test
    void handleNullString() {
        // 處理 null 字串
        assertEquals(null, stringUtil.reverse(null));
    }
}

然後用動態測試來實現同樣的測試用例:

import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;

import java.util.Arrays;
import java.util.Collection;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;

public class MyStringUtilDynamicTest {

    private MyStringUtil stringUtil = new MyStringUtil();

    // 使用 @TestFactory 註解定義了一個動態測試工廠方法 reverseStringDynamicTests()
    // 工廠方法返回一個 Collection<DynamicTest>
    @TestFactory
    Collection<DynamicTest> reverseStringDynamicTests() {
        // 包含了 3 個動態測試用例,每個測試用例使用 dynamicTest() 方法建立
        return Arrays.asList(
                dynamicTest("動態測試:反轉字串 'hello'", () -> assertEquals("olleh", stringUtil.reverse("hello"))),
                dynamicTest("動態測試:反轉空字串", () -> assertEquals("", stringUtil.reverse(""))),
                dynamicTest("動態測試:處理 null 字串", () -> assertEquals(null, stringUtil.reverse(null)))
        );
    }
}

在動態測試類中邏輯如下:

  1. 使用 @TestFactory 註解定義了一個動態測試工廠方法 reverseStringDynamicTests()
  2. 工廠方法返回一個 Collection<DynamicTest>,其中包含了 3 個動態測試用例。
  3. 每個測試用例使用 dynamicTest() 方法建立。

以上就是基本的單元測試使用方法,關於 Junit 5 的具體使用並不打算在這裡詳解,有興趣可以去參考 Junit 5 的官方檔案

單元測試 + Dbc

編寫單元測試需要儘可能的遵循 契約式設計 (Design By Contract, DbC) 程式碼風格,關於契約式設計可以參考以下的描述:

契約式設計 (Design By Contract, DbC) 是一種軟體開發方法,它強調在軟體開發中對於每個模組或者函數,應該明確定義其輸入和輸出的約定(契約)。這些契約可以包括前置條件(preconditions)和後置條件(postconditions),以及可能發生的異常情況。在程式碼實現時,必須滿足這些約定,否則就會引發錯誤或者異常。

這樣說可能比較抽象,可以通過以下的範例程式碼來理解,如何使用斷言來實現契約式設計:

public class BankAccount {
    private double balance;

    public BankAccount(double balance) {
        this.balance = balance;
    }
    
    public void withdraw(double amount) {
        assert amount > 0 : "Amount must be positive";
        assert amount <= balance : "Insufficient balance";
        
        balance -= amount;
        
        assert balance >= 0 : "Balance can't be negative";
    }
    
    public double getBalance() {
        return balance;
    }
}

在這個範例中,我們使用了 Java 中的斷言(assertion)來實現契約式設計。具體來說:

  • assert amount > 0 : "Amount must be positive"; 表示取款金額 amount 必須大於 0
  • assert amount <= balance : "Insufficient balance"; 表示取款金額 amount 必須小於等於賬戶餘額 balance
  • assert balance >= 0 : "Balance can't be negative"; 表示取款完成後,賬戶餘額 balance 的值應該為非負數

可以通過使用 JVM 的 -ea 引數來開啟斷言功能,不過因為啟用 Java 本地斷言很麻煩,Guava 團隊新增一個始終啟用的用來替換斷言的 Verify 類。他們建議靜態匯入 Verify 方法。用法和斷言差不多,這裡就不過多贅述了。

測試驅動開發 TDD

測試驅動開發(TDD)是一種軟體開發方法,也是我個人非常推崇的一種軟體開發方法,就是在編寫程式碼之前編寫單元測試。TDD 的核心思想是在編寫程式碼之前,先編寫測試用例。開發人員在編寫程式碼前先思考預期結果,以便能夠編寫測試用例。接著開發人員編寫足夠簡單的程式碼來通過測試用例,再對程式碼進行重構以提高質量和可維護性。

如圖:

TDD

作為 TDD 的長期實踐者,我總結 TDD 能帶來的好處如下:

  1. 提高可維護性:通常我們不敢去維護一段程式碼的原因是沒有測試,TDD 建立的完善測試,可以為重構程式碼提供保障
  2. 更快速的開發:很多開發總想著實現功能後再去補測試,但通常功能實現後,還會有更多的功能,所以儘量在功能開始前先寫測試
  3. 更高質量的交付:這裡就不必多說了,通過測試的程式碼和沒有測試的程式碼,是完全不一樣的。未經測試的程式碼根本不具備上生產的條件

紀錄檔

充足的紀錄檔可以幫助開發人員更好地瞭解程式的執行情況。通過檢視紀錄檔,可以瞭解程式中發生了什麼事情,以及在哪裡發生了問題。這可以幫助開發人員更快地找到和解決問題,從而提高程式的穩定性和可靠性。此外,紀錄檔還可以用於跟蹤程式的效能和行為,以便進行優化和改進。

紀錄檔輸出

通過以下是列印簡單紀錄檔的範例:

  1. 首先,你需要在專案中新增SLF4J的依賴。你可以在Maven或Gradle中新增以下依賴:
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>1.7.30</version>
</dependency>
  1. 接下來,你需要選擇一個SLF4J的實現,例如Logback或Log4j2,並將其新增到專案中。你可以在Maven或Gradle中新增以下依賴:
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.2.3</version>
</dependency>
  1. 在程式碼中,你可以使用以下程式碼列印Hello World:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class HelloWorld {
    private static final Logger logger = LoggerFactory.getLogger(HelloWorld.class);

    public static void main(String[] args) {
        logger.info("Hello World");
    }
}

這將使用SLF4J列印一條資訊,其中包含「Hello World」字串。你可以在控制檯或紀錄檔檔案中檢視此資訊。

紀錄檔等級

主要是為了幫助開發人員更好地控制和管理紀錄檔輸出。SLF4J 定義了多個紀錄檔級別:

紀錄檔級別 內容
TRACE 用於跟蹤程式的細節資訊,通常用於偵錯。
DEBUG 用於偵錯程式,輸出程式中的詳細資訊,例如變數的值、方法的呼叫等。
INFO 用於輸出程式的執行狀態資訊,例如程式啟動、關閉、連線資料庫等。
WARN 用於輸出警告資訊,表示程式可能存在潛在的問題,但不會影響程式的正常執行。
ERROR 用於輸出錯誤資訊,表示程式發生了錯誤,包括致命錯誤。

不同的紀錄檔級別用於記錄不同的資訊。這樣做的目的不僅可以減少不必要的紀錄檔輸出和檔案大小,還可以提供快速定位的能力,例如開發環境通常使用 TRACE、DEBUG 紀錄檔,生產環境通常使用 INFO,WARN 紀錄檔等。這些資訊都可以在 logback.xml 紀錄檔組態檔裡面設定。

紀錄檔設定

以下是一個基本的 logback 組態檔範例,該組態檔將紀錄檔輸出到控制檯和檔案中:

<configuration>
  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
    </encoder>
  </appender>

  <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>/var/log/myapp.log</file>
    <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
      <fileNamePattern>/var/log/myapp.%d{yyyy-MM-dd}.log</fileNamePattern>
      <maxHistory>7</maxHistory>
    </rollingPolicy>
    <encoder>
      <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
    </encoder>
  </appender>

  <root level="INFO">
    <appender-ref ref="CONSOLE" />
    <appender-ref ref="FILE" />
  </root>
</configuration>

在此組態檔中,定義了兩個 appender:

  1. 一個用於將紀錄檔輸出到控制檯(CONSOLE)
  2. 一個用於將紀錄檔輸出到檔案(FILE)

控制檯的紀錄檔格式使用了 pattern 格式化方式,而檔案的紀錄檔使用了 RollingFileAppender 實現每日輪換,並定義了最多儲存 7 天的紀錄檔歷史。同時,定義了一個根(root)級別為 INFO 的 logger,它會將紀錄檔輸出到 CONSOLE 和 FILE 兩個 appender 中,其他紀錄檔級別(TRACE、DEBUG、WARN、ERROR)則按照預設設定輸出到根 logger 中。

程式碼靜態檢查

在 Java 靜態掃描工具可以幫助開發人員在開發過程中及時發現和修復程式碼中的問題和錯誤,從而提高程式碼質量和安全性。這些靜態掃描工具還可以約束程式碼風格,在團隊協助開發中,統一的風格,可以增強團隊共同作業和溝通,可以增加程式碼的可讀性,可維護性,還減少不必要的討論和爭議,有利於後續的 CodeReview 進展。下面是一些常用的 Java 靜態掃描工具:

工具名稱 Github 地址
FindBugs https://github.com/findbugsproject/findbugs
PMD https://github.com/pmd/pmd
Checkstyle https://github.com/checkstyle/checkstyle
SonarQube https://github.com/SonarSource/sonarqube
IntelliJ IDEA https://github.com/JetBrains/intellij-community/

存取它們的 Github 地址也提供了更多的資訊和支援,可以幫助開發人員更好地理解和使用這些工具。另外,建議在開發過程中,將這些工具整合到持續整合和持續交付的流程中,以便自動化地進行程式碼檢查和修復。

Code Review

人工的 CodeReview 通常是開發流程的最後一步,為什麼前面做了那麼多測試和檢查工具,到最後還需要人工檢查呢 ?

因為靜態掃描工具通常只能檢查一些簡單的問題和錯誤,相比人工檢查它存在以下侷限性:

  1. 只能檢查例如語法錯誤、安全漏洞常見的錯誤等。
  2. 只能檢查問題和錯誤,但無法給出更好的建議和解決方案。(它提供的通用解決方案未必是最好的)
  3. 靜態掃描工具只能檢查程式碼是否符合特定的規範和標準,但無法確保程式碼的質量和可讀性。

相比機器掃描,人工 Code Review 可以提供以下不可替代的優勢:

  1. 可以發現更復雜的問題,例如:業務邏輯的問題、不合理的設計、不必要的複雜性等
  2. 相比機器的建議,人工 Code Review 可以根據經驗和知識,提供更好的解決方案和建議
  3. 可以促進團隊共同作業和學習,通過分享和討論程式碼,可以提高開發人員的技能和知識,並提高團隊的凝聚力和效率。

綜上所述,雖然靜態掃描工具可以幫助開發人員自動化地發現程式碼中的問題和錯誤,但 Code Review 仍然是一種必要的軟體開發實踐,可以提高程式碼的質量、可讀性和可維護性,同時也可以促進團隊共同作業和學習。因此,建議在開發過程中,將人工 Code Review 和靜態掃描工具結合起來,以便更全面和深入地稽核和審查程式碼。

總結

在現代軟體開發中,單元測試、TDD、紀錄檔、靜態檢查掃描和人工 Code Review 都是必要的實踐,可以幫助開發人員確保軟體質量、提高程式碼可讀性和可維護性,並促進團隊共同作業和學習。

首先,單元測試是一種測試方法,用於測試程式碼的基本單元,例如函數、方法等。單元測試可以幫助開發人員及早發現和解決程式碼中的問題和錯誤,從而提高程式碼質量和可靠性。同時,單元測試還可以提高程式碼的可讀性和可維護性,使程式碼更易於理解和修改。

其次,TDD(Test-Driven Development,測試驅動開發)是一種開發方法,要求在編寫程式碼之前先編寫測試用例。通過使用 TDD,開發人員可以更好地理解程式碼需求和規範,避免程式碼中的錯誤和問題,並提高程式碼的可讀性和可維護性。

第三,紀錄檔是一種記錄程式執行時狀態和資訊的方法。紀錄檔可以幫助開發人員偵錯程式,發現潛在的錯誤和問題,並提供更好的錯誤處理和處理方案。同時,紀錄檔還可以記錄程式執行時的效能和狀態,從而幫助開發人員分析和優化程式效能。

第四,靜態檢查掃描工具是一種自動化的程式碼稽核和審查工具,可以幫助開發人員及早發現和解決程式碼中的問題和錯誤。通過使用靜態檢查掃描工具,開發人員可以更全面地檢查程式碼中的問題和錯誤,並提高程式碼質量和可讀性。

最後,人工 Code Review 是一種手動稽核和審查程式碼的方法,可以更深入地檢查程式碼中的問題和錯誤,並提供更好的解決方案和建議。人工 Code Review 可以促進團隊共同作業和學習,提高程式碼質量和可讀性,同時還可以遵循特定的編碼規範和標準。

綜上所述,單元測試、TDD、紀錄檔、靜態檢查掃描和人工 Code Review 都是必要的軟體開發實踐,可以提高程式碼質量、可讀性和可維護性,並促進團隊共同作業和學習。在進行軟體開發時,應該儘可能地遵循這些實踐,並使用相應的工具和技術進行程式碼稽核和測試。