1. 程式人生 > 其它 >第三部分:理論三

第三部分:理論三

第三部分:理論三

編寫可測試程式碼案例實戰

測試類

  • Transaction 是經過抽象簡化之後的一個電商系統的交易類,用來記錄每筆訂單交易的情況。
  • execute() 函式負責執行轉賬操作,將錢從買家的錢包轉到賣家的錢包中。
  • 在execute() 中,真正的轉賬操作是通過呼叫 WalletRpcService RPC 服務來完成的。
  • 在execute() 中,還涉及一個分散式鎖 DistributedLock 單例類,用來避免 Transaction 併發執行,導致使用者的錢被重複轉出。

Transaction 類:

public class Transaction {
    private String id;
    private Long buyerId;
    private Long sellerId;
    private Long productId;
    private String orderId;
    private Long createTimestamp;
    private Double amount;
    private STATUS status;
    private String walletTransactionId;

    // ...get() methods...

    public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
        if (preAssignedId != null && !preAssignedId.isEmpty()) {
            this.id = preAssignedId;
        } else {
            this.id = IdGenerator.generateTransactionId();
        }
        if (!this.id.startWith("t_")) {
            this.id = "t_" + preAssignedId;
        }
        this.buyerId = buyerId;
        this.sellerId = sellerId;
        this.productId = productId;
        this.orderId = orderId;
        this.status = STATUS.TO_BE_EXECUTD;
        this.createTimestamp = System.currentTimestamp();

    }


    public boolean execute() throws InvalidTransactionException {
        if ((buyerId == null || (sellerId == null || amount < 0.0) {
            throw new InvalidTransactionException(...);
        }
        if (status == STATUS.EXECUTED) return true;
        boolean isLocked = false;
        try {
            isLocked = RedisDistributedLock.getSingletonIntance().lockTransction(isLocked);
            if (!isLocked) {
                return false; // 鎖定未成功,返回 false,job 兜底執行
            }
            if (status == STATUS.EXECUTED) return true; // double check
            long executionInvokedTimestamp = System.currentTimestamp();
            if (executionInvokedTimestamp - createdTimestap > 14days) {
                this.status = STATUS.EXPIRED;
                return false;
            }
            WalletRpcService walletRpcService = new WalletRpcService();
            String walletTransactionId = walletRpcService.moveMoney(id, buyerId, status);
            if (walletTransactionId != null) {
                this.walletTransactionId = walletTransactionId;
                this.status = STATUS.EXECUTED;
                return true;
            } else {
                this.status = STATUS.FAILED;
                return false;
            }
        } finally {
            if (isLocked) {
                RedisDistributedLock.getSingletonIntance().unlockTransction(id);
            }
        }
    }
}

測試用例

  1. 正常情況下,交易執行成功,回填用於對賬(交易與錢包的交易流水)用的 walletTransactionId,交易狀態設定為 EXECUTED,函式返回 true。
  2. buyerId、sellerId 為 null、amount 小於 0,返回 InvalidTransactionException。
  3. 交易已過期(createTimestamp 超過 14 天),交易狀態設定為 EXPIRED,返回 false。
  4. 交易已經執行了(status==EXECUTED),不再重複執行轉錢邏輯,返回 true。
  5. 錢包(WalletRpcService)轉錢失敗,交易狀態設定為 FAILED,函式返回 false。
  6. 交易正在執行著,不會被重複執行,函式直接返回 false。

實現測試用例1

  • 向類Transaction 中賦值buyerId、sellerId、productId、orderId,執行transaction.execute()。
  • 此用例有以下四個問題:
    1. 如果要讓這個單元測試能夠執行,我們需要搭建 Redis 服務和 Wallet RPC 服務。搭建和維護的成本比較高。
    2. 我們還需要保證將偽造的 transaction 資料傳送給 Wallet RPC 服務之後,能夠正確返回我們期望的結果,然而 Wallet RPC 服務有可能是第三方(另一個團隊開發維護的)的服務,並不是我們可控的。換句話說,並不是我們想讓它返回什麼資料就返回什麼。
    3. Transaction 的執行跟 Redis、RPC 服務通訊,需要走網路,耗時可能會比較長,對單元測試本身的執行效能也會有影響。
    4. 網路的中斷、超時、Redis、RPC 服務的不可用,都會影響單元測試的執行。
  • 通過繼承 WalletRpcService 類,並且重寫其中的 moveMoney() 函式的方式來實現 mock。
    • 通過 mock (見下文:mock)的方式,我們可以讓 moveMoney() 返回任意我們想要的資料,完全在我們的控制範圍內,並且不需要真正進行網路通訊。
    • 因為 WalletRpcService 是在 execute() 函式中通過 new 的方式建立的,我們無法動態地對其進行替換。也就是說,Transaction 類中的 execute() 方法的可測試性很差,需要通過重構(見下文:重構 Transaction 類)來讓其變得更容易測試。
    • 重構後在單元測試中,使用依賴注入,非常容易地將 WalletRpcService 替換成 MockWalletRpcServiceOne 或 WalletRpcServiceTwo 傳入 setWalletRpcService() 中。
  • RedisDistributedLock 的 mock 替換。
    • RedisDistributedLock 是一個單例類。單例相當於一個全域性變數,我們無法 mock(無法繼承和重寫方法),也無法通過依賴注入的方式來替換。
    • 如果 RedisDistributedLock 是我們自己維護的,可以自由修改、重構,那我們可以將其改為非單例的模式,或者定義一個介面,比如 IDistributedLock,讓 RedisDistributedLock 實現這個介面。接下來我們就可以用前面的方法 mock 替換。
    • 但如果 RedisDistributedLock 不是我們維護的,我們無權去修改這部分程式碼,可以對 transaction 上鎖這部分邏輯重新封裝一下(見下文:重新封裝transaction 上鎖)。
    • 這樣,我們就能在單元測試程式碼中隔離真正的 RedisDistributedLock 分散式鎖這部分邏輯了。可以呼叫 transaction.setTransactionLock()。
  • 我們通過依賴注入和 mock,讓單元測試程式碼不依賴任何不可控的外部服務。完成測試用例1。

測試用例 1 程式碼實現:

public void testExecute() {
	Long buyerId = 123L;
	Long sellerId = 234L;
	Long productId = 345L;
	Long orderId = 456L;
	Transction transaction = new Transaction(null, buyerId, sellerId, productI
	boolean executedResult = transaction.execute();
	assertTrue(executedResult);
}

mock

  • 所謂的 mock 就是用一個“假”的服務替換真正的服務。mock 的服務完全在我們的控制之下,模擬輸出我們想要的資料。
  • mock 的方式主要有兩種,手動 mock 和利用框架 mock。
  • 利用框架 mock 僅僅是為了簡化程式碼編寫,每個框架的 mock 方式都不大一樣。

重構 Transaction 類

  • 應用依賴注入,將 WalletRpcService 物件的建立反轉給上層邏輯,在外部建立好之後,再注入到 Transaction 類中。
  • 重構後的 Transaction 類中,增加 setWalletRpcService() 方法,將 WalletRpcService 物件傳入。
  • 在 execute() 方法中刪除 new WalletRpcService(),直接使用 this.walletRpcService。

重新封裝transaction 上鎖

  • 增加 TransactionLock 類,裡面的方法lock() 和 unlock() 呼叫原來的分散式鎖 DistributedLock 單例類。
  • 然後在 Transaction 中 setTransactionLock() ,依賴注入 TransactionLock 類。

transaction 上鎖程式碼實現:

public class TransactionLock {
	public boolean lock(String id) {
		return RedisDistributedLock.getSingletonIntance().lockTransction(id);
	}
	public void unlock() {
		RedisDistributedLock.getSingletonIntance().unlockTransction(id);
	}
}
public class Transaction {
	//...
	private TransactionLock lock;
	public void setTransactionLock(TransactionLock lock) {
		this.lock = lock;
	}
	public boolean execute() {
		//...
		try {
			isLocked = lock.lock();
			//...
		} finally {
			if (isLocked) {
				lock.unlock();
			}
		}
		//...
	}
}

RedisDistributedLock 分散式鎖單元測試:

public void testExecute() {
	Long buyerId = 123L;
	Long sellerId = 234L;
	Long productId = 345L;
	Long orderId = 456L;
	TransactionLock mockLock = new TransactionLock() {
		public boolean lock(String id) {
			return true;
		}
		public void unlock() {}
	};
	Transction transaction = new Transaction(null, buyerId, sellerId, productI
	transaction.setWalletRpcService(new MockWalletRpcServiceOne());
	transaction.setTransactionLock(mockLock);
	boolean executedResult = transaction.execute();
	assertTrue(executedResult);
	assertEquals(STATUS.EXECUTED, transaction.getStatus());
}

實現測試用例3

  • 將建立時間設定為14天前,來測試交易過期。transaction.setCreatedTimestamp(System.currentTimestamp() - 14days)。
  • 但是,如果在 Transaction 類中,並沒有暴露修改 createdTimestamp 成員變數的 set 方法(也就是沒有定義 setCreatedTimestamp() 函式)呢?
    • 你可能會說,如果沒有 createTimestamp 的 set 方法,我就重新新增一個唄!實際上,這違反了類的封裝特性。
    • 在 Transaction 類的設計中,createTimestamp 是在交易生成時(也就是建構函式中)自動獲取的系統時間,本來就不應該人為地輕易修改。因為,我們無法控制使用者是否會呼叫 set 方法重設 createTimestamp,而重設 createTimestamp 並非我們的預期行為。
    • 程式碼中包含跟“時間”有關的“未決行為”邏輯,我們一般的處理方式是將這種未決行為邏輯重新封裝(見下文:重新封裝未決行為邏輯)。
    • 這樣我們在測試類中可以重寫 isExpired() 方法,隨意更改返回交易是否過期。

交易已過期(createTimestamp 超過 14 天)測試樣例:

public void testExecute_with_TransactionIsExpired() {
	Long buyerId = 123L;
	Long sellerId = 234L;
	Long productId = 345L;
	Long orderId = 456L;
	Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
	transaction.setCreatedTimestamp(System.currentTimestamp() - 14days);
	boolean actualResult = transaction.execute();
	assertFalse(actualResult);
	assertEquals(STATUS.EXPIRED, transaction.getStatus());
}

重新封裝未決行為邏輯

  • 在 Transaction 類中增加 isExpired() 方法,封裝交易是否過期邏輯。
  • 在 execute() 方法中,可以直接呼叫 isExpired() 方法來判斷過期問題。

封裝未決行為程式碼實現:

public class Transaction {
	protected boolean isExpired() {
		long executionInvokedTimestamp = System.currentTimestamp();
		return executionInvokedTimestamp - createdTimestamp > 14days;
	}
	public boolean execute() throws InvalidTransactionException {
		//...
		if (isExpired()) {
			this.status = STATUS.EXPIRED;
			return false;
		}
		//...
	}
}

測試 Transaction 類的建構函式

  • 造函式中並非只包含簡單賦值操作。
  • 交易 id 的賦值邏輯稍微複雜,可以把 id 賦值這部分邏輯單獨抽象到一個方法中 fillTransactionId()。

重構之後的測試用例:

public void testExecute_with_TransactionIsExpired() {
	Long buyerId = 123L;
	Long sellerId = 234L;
	Long productId = 345L;
	Long orderId = 456L;
	Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId)
		protected boolean isExpired() {
			return true;
		}
	};
	boolean actualResult = transaction.execute();
	assertFalse(actualResult);
	assertEquals(STATUS.EXPIRED, transaction.getStatus());
}

最終建構函式的程式碼:

public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) { 
	if (preAssignedId != null && !preAssignedId.isEmpty()) {
		this.id = preAssignedId;
	} else {
		this.id = IdGenerator.generateTransactionId();
	}
	if (!this.id.startWith("t_")) {
		this.id = "t_" + preAssignedId;
	}
	this.buyerId = buyerId;
	this.sellerId = sellerId;
	this.productId = productId;
	this.orderId = orderId;
	this.status = STATUS.TO_BE_EXECUTD;
	this.createTimestamp = System.currentTimestamp();
}

實戰總結

  • 重構之後的程式碼,不僅可測試性更好,而且從程式碼設計的角度來說,也遵從了經典的設計原則和思想。
  • 程式碼的可測試性可以從側面上反應程式碼設計是否合理。
  • 在平時的開發中,我們也要多思考一下,這樣編寫程式碼,是否容易編寫單元測試,這也有利於我們設計出好的程式碼。

其他常見的 Anti-Patterns

未決行為

  • 所謂的未決行為邏輯就是,程式碼的輸出是隨機或者說不確定的,比如,跟時間、隨機數有關的程式碼。
public class Demo {
	public long caculateDelayDays(Date dueTime) {
		long currentTimestamp = System.currentTimeMillis();
		if (dueTime.getTime() >= currentTimestamp) {
			return 0;
		}
		long delayTime = currentTimestamp - dueTime.getTime();
		long delayDays = delayTime / 86400;
		return delayDays;
	}
}

全域性變數

  • 前面我們講過,全域性變數是一種面向過程的程式設計風格,有種種弊端。實際上,濫用全域性變數也讓編寫單元測試變得困難。
  • 文中舉例:
    • RangeLimiter 表示一個 [-5, 5] 的區間
    • position 初始在 0 位置
    • move() 函式負責移動 position
    • position 是一個靜態全域性變數
  • 為 RangeLimiterTest 類是為其設計的單元測試,有兩個測試用例:
    • testMove_betweenRange() 中分別 move(1)、move(3)、move(-5)
    • testMove_exceedRange() 中 move(6)
  • 問題:
    • 測試用例 testMove_betweenRange() 執行之後,position 的值變成了 -1
    • 測試用例 testMove_exceedRange() 執行之後,position 的值變成了 5,move() 函式返回 true,assertFalse 語句判定失敗
    • 所以第二個測試用例執行失敗
  • 如果 RangeLimiter 類有暴露重設(reset)position 值的函式,我們可以在每次執行單元測試用例之前,把 position 重設為 0,這樣就能解決剛剛的問題。
  • 不過,每個單元測試框架執行單元測試用例的方式可能是不同的。有的是順序執行,有的是併發執行。對於併發執行的情況,即便我們每次都把 position 重設為 0,也並不奏效。
public class RangeLimiter {
	private static AtomicInteger position = new AtomicInteger(0);
	public static final int MAX_LIMIT = 5;
	public static final int MIN_LIMIT = -5;
	public boolean move(int delta) {
		int currentPos = position.addAndGet(delta);
		boolean betweenRange = (currentPos <= MAX_LIMIT) && (currentPos >= MIN_LIMIT);
		return betweenRange;
	}
}
public class RangeLimiterTest {
	public void testMove_betweenRange() {
		RangeLimiter rangeLimiter = new RangeLimiter();
		assertTrue(rangeLimiter.move(1));
		assertTrue(rangeLimiter.move(3));
		assertTrue(rangeLimiter.move(-5));
	}
	public void testMove_exceedRange() {
		RangeLimiter rangeLimiter = new RangeLimiter();
		assertFalse(rangeLimiter.move(6));
	}
}

靜態方法

  • 靜態方法跟全域性變數一樣,也是一種面向過程的程式設計思維。
  • 在程式碼中呼叫靜態方法,有時候會導致程式碼不易測試。主要原因是靜態方法也很難 mock。
  • 只有在這個靜態方法執行耗時太長、依賴外部資源、邏輯複雜、行為未決等情況下,我們才需要在單元測試中 mock 這個靜態方法。

複雜繼承

  • 相比組合關係,繼承關係的程式碼結構更加耦合、不靈活,更加不易擴充套件、不易維護。實際上,繼承關係也更加難測試。
  • 如果父類需要 mock 某個依賴物件才能進行單元測試,那所有的子類、子類的子類……在編寫單元測試的時候,都要 mock 這個依賴物件。
  • 對於層次很深(在繼承關係類圖中表現為縱向深度)、結構複雜(在繼承關係類圖中表現為橫向廣度)的繼承關係,越底層的子類要 mock 的物件可能就會越多。
  • 如果我們利用組合而非繼承來組織類之間的關係,類之間的結構層次比較扁平,在編寫單元測試的時候,只需要 mock 類所組合依賴的物件即可。

高耦合程式碼

  • 如果一個類職責很重,需要依賴十幾個外部物件才能完成工作,程式碼高度耦合,那我們在編寫單元測試的時候,可能需要 mock 這十幾個依賴的物件。