代碼評審,揭示黑盒背後的真相

一、引言

黑盒測試猶如案發現場,只能根據表象推斷事件經過。

代碼評審即深入調查,挖掘蛛絲馬跡的線索,揭示背後的真相。

"They think I am hiding in the shadows, but I am the shadows."

二、黑盒測試與白盒測試的區別

黑盒測試存在一些侷限性:

  • 可能無法發現與系統實現相關的問題

  • 可能無法覆蓋所有的測試場景

  • 測試效率較低,比如準備物料、模擬場景

  • 強依賴需求文檔,如果文檔不全,測試會漏

對於測試人員來說,可以在代碼評審階段,通過白盒測試改進測試的質量和效率。

三、代碼評審的定義和意義

代碼評審,Code Review(CR),是一種通過檢查代碼來提高代碼質量的過程。

對於測試人員來說,參與代碼評審,可以儘量提前發現問題,減少修復代價,提高效能。

四、代碼評審的形式

多人討論

組織會議,研發牽頭講解代碼,架構和測試參與,討論交流。這是最普遍的一種形式。

Code Diff

查看Code Diff,可以藉助Gitlab或IDEA,比較分支差異或版本差異。

對比時機:

  • 提測前和測試中,自行走查代碼

  • 發現缺陷,定位代碼原因

  • 修復缺陷後,評估影響範圍

  • 上線前,是否夾帶代碼

精準測試

評估測試用例的代碼覆蓋率,查漏補缺,Jacoco的on-the-fly模式支持動態收集代碼覆蓋率數據。

五、代碼評審的方法

面向業務,面向業務,面向業務。重要的事情說三遍。

剛開始做代碼評審,很容易把注意力集中在找代碼規範問題上面,比如命名不規範、註釋不清楚、代碼實現冗長等。這些問題不是測試人員關注的重點,需要由研發團隊或代碼掃描工具來解決。

在做Code Diff時,也沒必要把每個文件、每行代碼的意思搞懂,比如研發對代碼結構做了調整,在diff時要梳理清楚的話,ROI會非常低,因爲既消耗時間,又發現不了問題。

那該怎麼做代碼評審呢? 關注業務:

  1. 跟需求文檔比較,哪些需求是遺漏的,哪些代碼是補充的,哪些代碼是夾帶的

  2. 關注核心業務代碼邏輯,使用條件覆蓋、路徑覆蓋等方法設計測試用例

  3. 優化測試用例,針對代碼實現考慮異常、邊界、冪等、併發等場景

代碼評審要求測試人員具備代碼能力,理解編程語言,掌握軟件設計,熟悉代碼結構和架構,多與開發同學交流,共同優化代碼質量。

六、代碼評審的實際案例

1、空指針異常

import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserService {

    private final SqlSessionTemplate sqlSessionTemplate;
    
    @Autowired
    public UserService(SqlSessionTemplate sqlSessionTemplate) {
        this.sqlSessionTemplate = sqlSessionTemplate;
    }
    
    public void getUserById(String userId) {
        User user = sqlSessionTemplate.selectOne("com.example.mapper.UserMapper.getUserById", userId);
        System.out.println(user.getName()); // 可能導致空指針異常
    }
}

如果取到的 user 對象爲空,就會導致空指針異常。

2、String類型判空用StringUtils.isBlank(),Collection類判空用CollectionUtils.isEmpty()

import org.apache.commons.lang3.StringUtils;

public class ExampleStringUtils {

    public static void main(String[] args) {
        String str1 = "";
        String str2 = null;
        String str3 = "   ";

        if (StringUtils.isBlank(str1)) {
            System.out.println("str1 is blank or null");
        }

        if (StringUtils.isBlank(str2)) {
            System.out.println("str2 is blank or null");
        }

        if (StringUtils.isBlank(str3)) {
            System.out.println("str3 is blank or null");
        }
    }
}
import org.apache.commons.collections4.CollectionUtils;

import java.util.ArrayList;
import java.util.List;

public class ExampleCollectionUtils {

    public static void main(String[] args) {
        List<String> list1 = new ArrayList<>();
        List<String> list2 = null;

        if (CollectionUtils.isEmpty(list1)) {
            System.out.println("list1 is empty or null");
        }

        if (CollectionUtils.isEmpty(list2)) {
            System.out.println("list2 is empty or null");
        }
    }
}

3、寫操作的事務一致性

@Service
public class UserService {

    private final UserMapper userMapper;
    private final AccountMapper accountMapper;

    // 省略構造方法

    public void addUserAndDeductBalance(User user, double amount) {
        try {
            userMapper.insertUser(user); // 插入用戶信息
            accountMapper.deductBalance(user.getAccountId(), amount); // 扣除賬戶餘額

            // 其他寫操作...
        } catch (Exception e) {
            // 處理異常
        }
    }
}

沒有使用 @Transactional,不會進行事務管理和回滾,如果執行accountMapper.deductBalance()時異常,那麼已經執行的 userMapper.insertUser() 操作無法回滾,用戶信息被插入但賬戶餘額未扣除,導致數據的不一致性。

4、根據判斷條件補充用例

public class ECommerceSystem {
    public static void main(String[] args) {
        String productCategory = "electronics";
        float productPrice = 999.99f;
        int userPoints = 100;

        if ("electronics".equals(productCategory)) {
            if (productPrice > 1000) {
                if (userPoints > 50) {
                    applyDiscount(0.2f);
                } else {
                    applyDiscount(0.1f);
                }
            } else {
                if (userPoints > 100) {
                    applyDiscount(0.15f);
                } else {
                    applyDiscount(0.05f);
                }
            }
        } else if ("clothing".equals(productCategory)) {
            if (productPrice > 500) {
                if (userPoints > 100) {
                    applyDiscount(0.3f);
                } else {
                    applyDiscount(0.2f);
                }
            } else {
                if (userPoints > 50) {
                    applyDiscount(0.1f);
                } else {
                    applyDiscount(0.05f);
                }
            }
        } else {
            if (productPrice > 100) {
                if (userPoints > 10) {
                    applyDiscount(0.1f);
                } else {
                    applyDiscount(0.05f);
                }
            }
        }
    }

    private static void applyDiscount(float discount) {
        System.out.println("Applying discount of " + discount * 100 + "%");
        // 執行折扣邏輯
    }
}

複雜的判斷條件,文檔很可能描述不全所有場景,需要針對代碼實現,補充測試用例。

5、代碼放在不同位置,影響範圍變小

public class ShoppingCart {
    private List<Product> products = new ArrayList<>();

    public void addToCart(Product product) {
        products.add(product);
        updateCartTotal();
    }

    public void removeFromCart(Product product) {
        products.remove(product);
        updateCartTotal();
    }

    private void updateCartTotal() {
        float total = 0;
        for (Product product : products) {
            total += product.getPrice();
        }
        System.out.println("Cart Total: " + total);
    }
}

public class Product {
    private String name;
    private float price;

    // constructor, getters and setters

    public float getPrice() {
        return price;
    }
}

如果需要更改計算總金額的邏輯,只需修改 updateCartTotal() 方法即可,而不需要修改調用該方法的其他部分代碼,測試點更少,影響面更小。

6、for循環性能優化

public class PerformanceOptimization {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
        
        // 原始的 for 循環
        long startTime = System.nanoTime();
        for (int i = 0; i < numbers.size(); i++) {
            int number = numbers.get(i);
            System.out.println(number);
        }
        long endTime = System.nanoTime();
        long elapsedTime = endTime - startTime;
        System.out.println("原始 for 循環耗時: " + elapsedTime + " 納秒");

        // 使用增強 for 循環
        startTime = System.nanoTime();
        for (int number : numbers) {
            System.out.println(number);
        }
        endTime = System.nanoTime();
        elapsedTime = endTime - startTime;
        System.out.println("增強 for 循環耗時: " + elapsedTime + " 納秒");
    }
}

如果for循環裏面接口調用或計算量大,可能會導致性能問題。

7、finally塊的return覆蓋try-catch塊中的return

public class ReturnInFinally {
    public static void main(String[] args) {
        System.out.println(testMethod());
    }
    
    public static int testMethod() {
        try {
            System.out.println("Inside try block");
            return 1;
        } catch (Exception ex) {
            System.out.println("Inside catch block");
            return 2;
        } finally {
            System.out.println("Inside finally block");
            return 3;
        }
    }
}

如果確實需要在 finally 塊中執行一些清理或資源釋放操作,並希望保留 try-catch 塊中的返回結果,可以將返回值存儲在一個變量中,在 finally 塊之後再進行返回。

8、多表同時更新,使用分佈式事務

try {
    // 開啓分佈式事務
    beginDistributedTransaction();
    
    // 執行事務操作1
    updateTable1();
    
    // 執行事務操作2
    updateTable2();
    
    // 執行事務操作3
    updateTable3();
    
    // 提交分佈式事務
    commitDistributedTransaction();
} catch (Exception e) {
    // 回滾分佈式事務
    rollbackDistributedTransaction();
    // 處理異常
    handleException(e);
}

假設有兩個服務,一個是訂單服務,負責處理用戶下單和創建訂單;另一個是庫存服務,負責管理商品的庫存數量。當用戶下單時,訂單服務需要創建訂單並扣減對應商品的庫存。可能會出現數據不一致:在訂單服務創建訂單之後,庫存服務還未扣減庫存的情況下發生了故障,導致訂單已經創建但庫存沒有被正確扣減。這會導致訂單和庫存之間的數據不一致。如果只是簡單地依次執行兩個操作,無法保證它們的原子性。

9、冪等

public class OrderService {
    public String createOrder(OrderData orderData) {
        // 生成訂單號
        String orderId = generateOrderId();

        // 檢查訂單是否已經存在
        if (!isOrderExist(orderId)) {
            // 創建訂單
            saveOrder(orderId, orderData);

            // 扣減庫存
            decreaseInventory(orderData);

            return "訂單創建成功";
        } else {
            return "訂單已存在";
        }
    }

    private String generateOrderId() {
        // 省略具體實現
        return "123456789";
    }

    private boolean isOrderExist(String orderId) {
        // 省略具體實現
        return false;
    }

    private void saveOrder(String orderId, OrderData orderData) {
        // 省略具體實現
    }

    private void decreaseInventory(OrderData orderData) {
        // 省略具體實現
    }
}

如果發起重複請求,上個請求還未處理完,可能會重複創建相同訂單。考慮使用分佈式鎖來保證接口的冪等性。

10、執行頻率高的代碼日誌,增加級別判斷

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

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

    public void myMethod() {
        // 判斷日誌級別是否爲 INFO
        if (logger.isInfoEnabled()) {
            String message = "This is an info message.";
            logger.info(message);
        }
    }
}

11、枚舉類

public enum InvoiceStatus {
    PENDING("待處理"),
    APPROVED("已批准"),
    REJECTED("已拒絕"),
    CANCELLED("已取消"),
    PAID("已支付");
    // 省略定義
}

如果篩選幾個枚舉作爲狀態判斷,可能不準確。

12、更多業務類案例:

  • 代碼未找到需求相關實現,參考需求文檔

  • 上下游接口字段未對齊,參考接口文檔

  • 修改了公共方法,迴歸範圍擴大

  • 修改了方法A1,未修改方法A2,A1和A2是不同入口,都需要修改

七、總結

從業務需求角度出發,剖析代碼邏輯,運用測試經驗,以更高的效率,發現更多的缺陷,這就是代碼評審帶來的燒腦體驗。

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