我們需要做一些改變,也需要撰寫 characterization tests 來固定現有行為。但測試應該寫在哪裡?最簡單的答案是為每個要改變的方法寫測試,但在 legacy code 中,一個地方的改變可能影響遠處的行為。除非在那裡也有測試,否則我們可能永遠不會發現問題。

本章介紹如何透過 effect reasoning(效果推理)來找到最佳的測試位置。

Reasoning About Effects(效果推理)#

在軟體中,每個功能變更都有一連串的 effect chain(效果鏈)。例如改變一個方法中的常數,會改變該方法的回傳值,進而影響呼叫該方法的所有程式碼。

儘管效果可以傳播得很遠,但程式中許多部分不會產生不同的結果,因為它們不會直接或間接呼叫到被變更的方法。

CppClass 範例#

以一個操作 C++ 程式碼的 Java 類別 CppClass 為例:

public class CppClass {
    private String name;
    private List declarations;

    public CppClass(String name, List declarations) {
        this.name = name;
        this.declarations = declarations;
    }

    public int getDeclarationCount() {
        return declarations.size();
    }

    public String getName() {
        return name;
    }

    public Declaration getDeclaration(int index) {
        return ((Declaration)declarations.get(index));
    }

    public String getInterface(String interfaceName, int [] indices) {
        String result = "class " + interfaceName + " {\npublic:\n";
        for (int n = 0; n < indices.length; n++) {
            Declaration virtualFunction
                = (Declaration)(declarations.get(indices[n]));
            result += "\t" + virtualFunction.asAbstract() + "\n";
        }
        result += "};\n";
        return result;
    }
}

建立 CppClass 物件後,可能影響回傳結果的因素:

  1. 有人可以在傳入 constructor 後修改 declarations list(因為是 by reference)
  2. 有人可以改變 declarations 中持有的物件

這些變化都會影響 getInterfacegetDeclarationgetDeclarationCount 的回傳值。

Figure 11.1: declarations impacts getDeclarationCount

Figure 11.2: declarations and the objects it holds impact getDeclarationCount

Figure 11.3: Things that affect getInterface

Figure 11.4: Combined effect sketch

Effect Sketches(效果草圖)#

Effect sketch 是一種圖形化工具,用來表示變數和方法之間的影響關係:

  • 為每個可能被影響的變數和方法畫一個 bubble
  • 從會改變的事物畫箭頭指向它們在 runtime 可能影響的一切

如果你的程式碼結構良好,大部分方法都有簡單的 effect 結構。軟體品質的一個衡量標準是:外部看到的複雜效果,其實是程式碼內部許多更簡單效果的總和。

Reasoning Forward(正向推理)#

在前面的範例中,我們嘗試回溯推理:找出哪些物件會影響某個值。當我們撰寫 characterization tests 時,過程相反——我們從一組物件出發,嘗試找出如果它們停止正常運作,下游會受到什麼影響。

InMemoryDirectory 範例#

public class InMemoryDirectory {
    private List elements = new ArrayList();

    public void addElement(Element newElement) {
        elements.add(newElement);
    }

    public void generateIndex() {
        Element index = new Element("index");
        for (Iterator it = elements.iterator(); it.hasNext(); ) {
            Element current = (Element)it.next();
            index.addText(current.getName() + "\n");
        }
        addElement(index);
    }

    public int getElementCount() {
        return elements.size();
    }

    public Element getElement(String name) {
        for (Iterator it = elements.iterator(); it.hasNext(); ) {
            Element current = (Element)it.next();
            if (current.getName().equals(name)) {
                return current;
            }
        }
        return null;
    }
}

要修改此類別,需要從 generateIndex 移除功能並在 addElement 新增功能。透過效果草圖分析:

  • generateIndex 影響 elements 集合
  • addElement 也影響 elements 集合
  • elements 集合影響 getElementCountgetElement 的回傳值

Figure 11.5: generateIndex affects elements

Figure 11.6: Further effects of changes in generateIndex

Figure 11.7: addElement affects elements

Figure 11.8: Effect sketch of the InMemoryDirectory class

因此,使用者只能透過 getElementCountgetElement 方法感知效果。如果我們能為這些方法寫測試,就能覆蓋所有變更的影響。

在繪製效果草圖時,確保你找到了被檢查類別的所有客戶端。如果類別有 superclass 或 subclass,可能還有其他你尚未考慮到的客戶端。

Effect Propagation(效果傳播)#

效果在程式碼中以三種基本方式傳播:

  1. 回傳值被呼叫者使用
  2. 修改作為參數傳入的物件,這些物件之後被使用
  3. 修改 static 或 global data,之後被使用

某些語言提供額外機制。例如在 aspect-oriented 語言中,程式設計師可以撰寫 aspects 來影響系統其他區域的行為。

隱蔽的效果傳播#

最隱蔽的方式是透過 global 或 static data。例如一個 Element 類別的 addText 方法中可能隱藏了對全域顯示元件的呼叫:

public void addText(String newText) {
    text += newText;
    View.getCurrentDisplay().addText(newText);
}

光從方法簽章看不出這個副作用。Information hiding 很好,但有時它會隱藏我們需要知道的資訊。

Figure 11.9: Effects through the Element class

Figure 11.10: generateIndex affecting the elements collection

尋找效果的啟發法#

  1. 找出要改變的方法
  2. 如果方法有回傳值,查看其呼叫者
  3. 檢查方法是否修改了任何值——如果有,查看使用這些值的方法
  4. 檢查 superclass 和 subclass 是否也使用了這些 instance variables 和方法
  5. 查看傳入的參數,確認它們或其回傳的物件是否被你要改變的程式碼使用
  6. 查找任何被已識別方法修改的 global variables 和 static data

Tools for Effect Reasoning(效果推理的工具)#

最重要的工具是對程式語言的了解。每個語言都有一些小型的「防火牆」,可以阻止效果傳播。了解它們可以省去很多追蹤工作。

Java 範例:private vs package scope#

// Version 1: private - 效果被限制在類別內部
public class Coordinate {
    private double x = 0;
    private double y = 0;

    public Coordinate(double x, double y) {
        this.x = x; this.y = x;
    }
    public double distance(Coordinate other) {
        return Math.sqrt(
            Math.pow(other.x - x, 2.0) + Math.pow(other.y - y, 2.0));
    }
}
// Version 2: package scope - 效果可能擴散到同 package 的類別
public class Coordinate {
    double x = 0;
    double y = 0;
    // ...
}

C++ 的 mutable 關鍵字#

class Coordinate {
protected:
    mutable double first, second;
};

mutable 關鍵字代表這些變數可以在 const 方法中被修改,不能單純地把 const 當作 const 來看。

了解你的程式語言。 語言的微妙規則可能讓你在效果推理中犯錯。const 不一定真的 const,private 可能不如你想像的那麼 private。

Learning from Effect Analysis(從效果分析中學習)#

經常練習效果分析,你會逐漸對程式碼建立直覺。當你越來越熟悉程式碼庫,就不需要刻意去尋找某些東西——你會發現一些「基本良好準則」。

CppClass 為例:我們注意到有人可以在傳入 constructor 後修改 declarations list。這是一個「但那很蠢」規則的候選:如果我們確定 CppClass 收到的 list 不會再被改變,推理就簡單得多。

Figure 11.11: Effect sketch for CppClass

縮小程式中的效果範圍可以讓程式設計變得更容易。Functional programming 語言(如 Scheme 和 Haskell)就是在極端情況下限制效果。即使在 OO 語言中,限制效果也能讓測試變得更容易。

Simplifying Effect Sketches(簡化效果草圖)#

透過小型的重構可以簡化效果草圖,讓測試決策變得更容易。

例如,如果 getInterface 方法直接存取 declarations list,而 getDeclaration 也直接存取同一個 list,那麼效果草圖就有多個端點。但如果重構 getInterface 讓它內部使用 getDeclaration

Figure 11.12: Effect sketch for CppClass

public String getInterface(String interfaceName, int [] indices) {
    String result = "class " + interfaceName + " {\npublic:\n";
    for (int n = 0; n < indices.length; n++) {
        Declaration virtualFunction = getDeclaration(indices[n]);
        result += "\t" + virtualFunction.asAbstract() + "\n";
    }
    result += "};\n";
    return result;
}

這個微小的改變讓 getInterface 測試同時也涵蓋了 getDeclaration 的行為,減少了需要獨立測試的端點。

Figure 11.13: Effect sketch for Changed CppClass

當我們移除程式碼中的重複,往往會得到更少端點的效果草圖,這通常意味著更簡單的測試決策。

Effects and Encapsulation(效果與封裝)#

封裝很重要,但它重要的原因比我們通常認為的更根本:封裝幫助我們推理程式碼。在良好封裝的程式碼中,需要追蹤的路徑較少。

封裝和測試覆蓋率並不總是一致的,但當它們衝突時,作者傾向於選擇測試覆蓋率。測試可以幫助我們之後獲得更好的封裝。封裝不是目的本身,而是理解的工具。