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

單元測試

什麼是單元測試 ?

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

寫單元測試的好處

可以帶來以下幾個好處:

  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 都是必要的軟件開發實踐,可以提高代碼質量、可讀性和可維護性,並促進團隊協作和學習。在進行軟件開發時,應該儘可能地遵循這些實踐,並使用相應的工具和技術進行代碼審覈和測試。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章