一、引言
黑盒測試猶如案發現場,只能根據表象推斷事件經過。
代碼評審即深入調查,挖掘蛛絲馬跡的線索,揭示背後的真相。
"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、空指針異常
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是不同入口,都需要修改
七、總結
從業務需求角度出發,剖析代碼邏輯,運用測試經驗,以更高的效率,發現更多的缺陷,這就是代碼評審帶來的燒腦體驗。