Java ♨️ 利用 Interface 快速讓程式碼有可測試性

Jayden Lin
程式猿吃香蕉
Published in
7 min readMay 4, 2024

筆者曾任職 Yahoo,現在區塊鏈產業打滾,《經典駭客攻擊教程:給每個人的網站安全入門》線上課程講師 ,粉絲團《程式猿吃香蕉🍌

上一篇文章所說,Java 在 Spring IoC 環境下,大部分時候做測試是容易的,即使沒有 Spring 也能使用 Mockito 等工具來完成任務。但如果沒有這些工具呢?當重構遺留的程式碼有時不得不使用 Interface 這個特殊技巧「繞路而行」,因此再寫一篇文章談這個方法。

這篇文章重點討論「可測試性」這件事,無關於商業邏輯對物件的抽象(Abstraction),這個方法單純是為了讓程式碼能有測試案例 (Test Case) 保護,可以進入到下個階段的重構,直接看程式碼吧!

━━

假設你要測試的對象是 Service 這個類別,並針對 getCost 方法來寫單元測試。我們可以看到 Service 和 Calculator 類別有依賴關係 (如下)。而 Calculator 是一個歷史遺留的類別 (Legacy Class),並不希望對它有過多的改動。

public class Service {
Calculator calculator;
Service(Calculator calculator) {
this.calculator = calculator;
}

BigDecimal getCost() {
//...複雜的運算等等
return calculator.calculatePosition()
.multiply(this.something())//Service內的邏輯
.multiply(calculator.calculateEntryPrice());
}
}

接著來看看 Calculator 長什麼樣子 (如下程式碼)。

public class Calculator {
A a;
B b;
C c;
public Calculator(A a, B b, C c) {
this.a = a;
this.b = b;
this.c = c;
}

public BigDecimal calculateEntryPrice() {
Result result = a.call();
//...複雜的運算等等
return result.value
.max(b.query())
.subtract(c.something());
}

public BigDecimal calculatePosition() {
//...複雜的運算等等
return c.something()
.min(b.query())
.subtract(c.something());
}
}

Calculator 的建構子 (Constructor) 非常複雜,依賴於 A, B, C 三個物件,如果 Service 不對 Calculator 解除依賴的話,單元測試會沒辦法實作,即使硬要寫測試 (此時也無法被稱為單元測試了,依賴項太多) 會像下面這樣:

class ServiceTest {

@Test
void testGetCost() {
//gievn
////Calculator難以初始化
Calculator calculator = new Calculator(
new A(new KK()),//初始化A, 而A又依賴於KK,所以還需初始化KK
new B(),//初始化B
new C()//初始化C
);
Service service = new Service(calculator);
//when
BigDecimal result = service.getCost();
//then
MatcherAssert.assertThat(result, Matchers.is(new BigDecimal("90")));
}
}

我們可以看到 Calculator 非常難以初始化,如果要採用繼承和覆寫 (Override) 的方法來造一個 Calculator 類別的假物件 CalculatorFake 也是工程浩大。如下面程式碼展示的,需要一口氣造假 A, B, C 物件。

//使用繼承和覆寫的技法
public class CalculatorFake extends Calculator {
public CalculatorFake(A a, B b, C c) {
super(a, b, c);
}
//覆寫calculatePosition和 calculateEntryPrice...
}

這時候 Interface 就派上用場了,我們可以用一個特殊的設計,讓 Calculator 去實作 ICalculator 這個介面(Interface),程式碼會像這樣:

//創建介面,裡面有原Calculator的兩個方法 calculateEntryPrice, calculatePosition
public interface ICalculator {
BigDecimal calculateEntryPrice();

BigDecimal calculatePosition();
}

//Service改為依賴介面ICalculator
public class Service {
ICalculator calculator;
Service(ICalculator calculator) {
this.calculator = calculator;
}
}

//Calculator實作ICalculator
public class Calculator implements ICalculator{
//...some code
}

接著透過讓 CalculatorFake 假物件實作 ICalculator (如下),在測試裡面就可以直接用這個假物件來取代原本複雜的 Calculator 了。

public class CalculatorFake implements ICalculator {
@Override
public BigDecimal calculateEntryPrice() {
//造假的值
return new BigDecimal("10");
}

@Override
public BigDecimal calculatePosition() {
//造假的值
return new BigDecimal("9");
}
}

最後,單元測試的程式碼會像是這樣

    @Test
void testGetCost() {
//gievn
ICalculator calculator = new CalculatorFake();//使用假物件
Service service = new Service(calculator);
//when
BigDecimal result = service.getCost();
//then
MatcherAssert.assertThat(result, Matchers.is(new BigDecimal("90")));
}

可以執行測試了!測試通過!(如下圖)

━━

以上就是怎麼利用 Interface 快速做可測試性的設計。這時候使用 Interface 並不是因為商業邏輯上 Calculator 需要擴充,而是為了重構的過程中能夠順利加上單元測試。

使用這個技巧的好處是:

  • 當假物件初始化過於複雜時,可以讓創建簡化
  • 如果要造假的對象(e.g. Calculator) 是遺留的程式碼 (Legacy Code),幾乎很少改動,可以用這個方法先避開它,進行其他部分的重構。

在實務上抽取 Interface 時,通常會使用更有意義的命名,例如 CostCalculator 或是 OrderSizeCalculator 等等,以配合物件導向的抽象,也讓我們更了解 Calculator 的職責。

若是喜歡我分享的內容,歡迎幫我按個拍手,可拍 50下,給我一點鼓勵,或是加入我的粉絲團《程式猿吃香蕉🍌,一起分享軟體知識與心得!

--

--

Jayden Lin
程式猿吃香蕉

曾在 Yahoo 擔任 Lead Engineer,負責廣告系統,帶團隊做跨國開發,現任職區塊鏈產業。也是《程式猿吃香蕉》團隊創辦人,喜歡將實用的軟體知識以簡單生動的方式講給大家聽 😄😄😄