你在測試金字塔的哪一層(下)

《你在測試金字塔的哪一層(上)》中介紹了自動化測試的重要性以及測試金字塔。測試金字塔分爲單元測試、服務測試、UI測試,那它們分別是什麼呢?本期文章讓我們一起詳細看看測試金字塔的不同層次。

一、單元測試

單元測試是指對程序模塊(軟件設計的最小單位)進行正確性檢驗的測試工作,能夠提高代碼質量和可維護性。但“一個單元”的概念是沒有標準答案,每個人可以根據自身所處的編程範式和語言環境確定。在函數式語言中,一個函數可以是一個單元,其單元測試涉及使用不同的參數調用該函數,並斷言其返回了期待的結果。而在面嚮對象語言裏,下至一個方法,上至一個類都有可能視爲一個單元。

單元測試的一個重要好處在於我們可以爲所有的產品代碼類寫單元測試,不需要在意它們的功能或者它們在內部結構中所處的層次。我們可以對controller類進行單元測試,也可以用同樣的方式對repository、領域類或文件讀寫類進行單元測試。一個良好的開端始於堅持一個實現類對應一個測試類的原則。

一個好的單元測試類至少應該測試該類的公共接口,因爲私有方法無法直接進行測試。受保護的和包私有的方法可以被測試類直接調用(如果測試類和生產代碼類的包結構相同),但是測試這些方法可能會過於以來實現細節。

編寫單元測試有一條準則:測試應該覆蓋代碼的所有路徑,包括正常路徑和邊緣路徑,同時不與代碼的實現有過於緊密的耦合。如果測試與產品代碼耦合太緊密,這可能失去單元測試作爲代碼變更保護網的好處,這會導致每次重構測試的失敗,給測試人員增加額外的工作量。因此,我們應該測試可觀察的行爲,而不是過於依賴實現的內部結構。

在編寫單元測試時,我們需要思考:

如果我得輸入是X和Y,輸出會是Z嗎?

而不是這樣:

如果我的輸入是x和y,那麼這個方法會先調用A類,然後調用B類,接着輸出A類和B類返回值相加的結果嗎?

私有方法應該被視爲實現細節。有人認爲,單元測試是毫無意義的工作,爲了獲得高測試覆蓋率就必須測試所有方法,包括getter、setter等瑣碎的代碼。

但這個觀點是錯誤的。我們確實需要測試公共接口,但重要的是不要測試微不足道的代碼。這些代碼不會帶來任何價值,應該節省時間開始其他有意義的工作。

如果你發現自己陷入測試私有方法的困境中,先問問自己爲什麼需要測試私有方法。很可能是一個設計問題,而不僅僅是方法可見性的問題。可能是因爲方法過於複雜,如果通過公共接口來測試它,需要準備大量的數據和環境。在這種情況下,可以考慮將原來的類拆分成兩個類,按照職責進行拆分。將原來急於測試的私有方法移到新的類中,然後讓舊類調用新類上的方法。這樣,原來難以測試的私有方法就變成了公共方法,可以輕鬆添加測試。同時,這種重構還改善了代碼結構,符合單一職責原則。

一個好的測試結構是這樣的:

  • 準備測試數據
  • 調用被測方法
  • 斷言返回的是你期待的結果

有一個口訣可以幫你記住這種結構:“Arrange、Act、Assert”。另一個口訣則是從BDD獲取的靈感:“given、when、then”,即given是準備數據,when是調用方法,then是斷言。

這種模式不僅適用於單元測試,還可以應用於其他更高層次的測試。在任何情況下,這種測試結構都能讓測試保持一致,且易於閱讀。此外,使用這種結構寫出來的測試往往更加簡短、更具表達力。

在明確了要測試什麼以及如何組織單元測試後,我們可以看一個簡化版的Example Controller類:

@RestController
public class ExampleController {
 
    private final PersonRepository personRepo;
 
    @Autowired
    public ExampleController(final PersonRepository personRepo) {
        this.personRepo = personRepo;
    }
 
    @GetMapping("/hello/{lastName}")
    public String hello(@PathVariable final String lastName) {
        Optional foundPerson = personRepo.findByLastName(lastName);
 
        return foundPerson
                .map(person -> String.format("Hello %s %s!",
                        person.getFirstName(),
                        person.getLastName()))
                .orElse(String.format("Who is this '%s' you're talking about?",
                        lastName));
    }
}

一個針對hello(lastname)方法的單元測試可能是這樣的:

public class ExampleControllerTest {
 
    private ExampleController subject;
 
    @Mock
    private PersonRepository personRepo;
 
    @Before
    public void setUp() throws Exception {
        initMocks(this);
        subject = new ExampleController(personRepo);
    }
 
    @Test
    public void shouldReturnFullNameOfAPerson() throws Exception {
        Person peter = new Person("Peter", "Pan");
        given(personRepo.findByLastName("Pan"))
            .willReturn(Optional.of(peter));
 
        String greeting = subject.hello("Pan");
 
        assertThat(greeting, is("Hello Peter Pan!"));
    }
 
    @Test
    public void shouldTellIfPersonIsUnknown() throws Exception {
        given(personRepo.findByLastName(anyString()))
            .willReturn(Optional.empty());
 
        String greeting = subject.hello("Pan");
 
        assertThat(greeting, is("Who is this 'Pan' you're talking about?"));
    }
}

二、集成測試

常見的應用通常需要與外部環境進行集成,如數據庫,文件系統等。爲了更好地隔離測試並提高運行速度,我們通常在寫單元測試時不涉及這些外部依賴。不過,這些交互始終是存在的,需要進行測試覆蓋。這正是集成測試的用途,是應用與所有外部依賴的集成。

對於自動化測試來說,不僅需要運行應用本身,還需要運行與之集成的組件。如果要測試與數據庫的集成,就需要在與運行測試時啓動數據庫。如果要測試從硬盤裏讀取文件的功能,就需要先在集成測試種保存一個文件到硬盤上,然後進行讀取測試。

前面我提到過「單元測試」是一個模糊的術語,集成測試也是如此。我對集成測試更加狹義:每次只測試一個集成點。在進行測試時,我們使用測試替身來代替其他的外部服務、數據庫等。同時,使用契約測試來覆蓋測試替身和真實實現之間的約定。這樣進行的集成測試更快、更獨立、更易理解和調試。

狹義的集成測試主要測試是服務的邊界。從概念上來說,這種測試總是在觸發應用與外部依賴(如文件系統、數據庫、其他服務等)進行集成的行爲。例如,一個數據庫集成測試可能按照以下步驟進行:

  • 啓動數據庫
  • 連接應用到數據庫
  • 調用被測函數,該函數會往數據庫寫數據
  • 讀取數據庫,查看期望的數據是不是被寫到了數據庫裏

另一個例子是通過REST API和外部服務集成的測試,可能會這樣寫:

  • 啓動應用
  • 啓動一個被測外部服務的實例(或者一個具有相同接口的測試替身)
  • 調用被測函數,該函數會從外部服務的API讀取數據
  • 檢查應用是否能正確解析返回結果

集成測試同樣可以寫得很白盒。一些框架在應用啓動後,仍然支持對應用的某些部分進行mock,我們可以驗證正確的交互是否發生。

代碼中所有涉及數據序列化和反序列化的地方都要寫集成測試,保證了對外部系統的數據讀寫操作的正常行。這些場景可能比你想象得更多,比如說:

  • 調用自身服務的 REST API
  • 讀寫數據庫
  • 調用外部服務的 API
  • 讀寫隊列
  • 寫入文件系統

編寫狹義的集成測試時,我們應儘可能在本地運行外部依賴,如啓動本地的MySQL數據庫、針對本地的ext4文件系統進行測試等。如果是與外部服務集成,可以在本地運行該服務的實例,或構建一個在本地運行的模擬真實服務的假服務。

對於無法在本地運行實例的某些第三方服務,可以考慮運行一個專用實例,並在集成測試中指向該實例。這能避免在自動化測試種集成真實的生產環境的服務。在生產環境種生成大量的測試請求可能會干擾日誌記錄,最壞的情況可能是對該服務產生DoS攻擊。通過網絡與服務集成是廣義集成測試的一大特徵,這會導致測試更慢、更難編寫。

在測試金字塔中,集成測試的層級比單元測試更高。與隔離了外部依賴的單元測試相比,集成測試通常需要更長的時間來處理緩慢的外部依賴(如文件系統或數據庫等)。這可能更難寫,因爲我們需要確保外部依賴在測試中正常運行,但它們的優勢在於建立對應用正確訪問外部依賴的信心,這是純粹的單元測試無法做到的。

PersonRepository是代碼裏唯一的數據庫類。它依賴於Spring Data,我們並沒有實際實現它。只需要繼承CrudRepository接口並聲明一個方法名,剩下的就是Spring魔法了,Spring會幫我們實現其他所有的東西。

public interface PersonRepository extends CrudRepository {
    Optional findByLastName(String lastName);
}

Spring Boot提供了完整的CRUD方法,例如findOne,findAll,save,update和delete。我們自定義的方法(findByLastName())繼承了這些基礎功能並實現了根據last name獲取Persons對象的功能。Spring Data會解析方法的返回類型,按照命名規範解析方法名,從而決定如何實現這些方法。

儘管Spring Data已經實現了與數據庫的交互功能,但我認爲需要寫一個數據庫集成測試。首先,它測試了我們自定義的findByLastName方法是否按預期工作。其次,它證明了我們的數據庫類正確地使用了Spring的裝配特性,並且能夠正確地連接到數據庫。

我們在本地運行測試,無需真的安裝PostgreSQL數據庫,而是連接到一個內存H2數據庫,這可以提供更簡單的環境設置。我們在build.gradle中已經將H2定義爲測試依賴項。在測試目錄下的application.properties文件中沒有定義任何spring.datasource屬性,這會告訴Spring Data使用內存數據庫,並在classpath中找到H2運行測試。

當我們真正啓動應用時,可以使用int profile(如把SPRING_PROFILES_ACTIVE=int設置爲int),它會連接到application-int.properties裏定義的PostgreSQL數據庫。

除此以外,使用內存數據庫進行測試實際上是有風險的。畢竟,集成測試針對的數據庫和我們生產用的數據庫是不同。下面是一個集成測試的示例,它先將一個Person對象保存到數據庫中,根據last name查找。

@RunWith(SpringRunner.class)
@DataJpaTest
public class PersonRepositoryIntegrationTest {
    @Autowired
    private PersonRepository subject;
 
    @After
    public void tearDown() throws Exception {
        subject.deleteAll();
    }
 
    @Test
    public void shouldSaveAndFetchPerson() throws Exception {
        Person peter = new Person("Peter", "Pan");
        subject.save(peter);
 
        Optional maybePeter = subject.findByLastName("Pan");
 
        assertThat(maybePeter, is(Optional.of(peter)));
    }
}

三、UI測試

大多數應用都有用戶界面,特別是在web應用的上下文中,我們所談的界面就是指網頁界面。但人們常常忽視除了多彩的網頁頁面,還有許多的REST API界面、命令行界面等。

UI測試的目標是驗證應用的用戶界面是否按預期工作。例如,用戶的輸入要觸發正確的動作、數據要能正確展示給用戶、UI的狀態要發生正確變化等。

大家有時候會將UI測試和端到端測試混爲一談。誠然,端到端測試通常包含了許多UI測試。但UI測試不必非得通過端到端的方式完成。根據技術棧不同,有時UI測試可以很簡單,只需要爲前端的JavaScript代碼寫一些單元測試,同時用樁(stub)將後端隔離開即可。

對於網頁界面而言,UI可以圍繞這些部分測試:行爲、佈局、可用性以及少數人認爲需要測試的設計一致性。測試應用的佈局是否前後一致確實則有些困難。由於應用類型和用戶需求的不同,我們需要確保代碼的更改不會意外破壞頁面的佈局。衆所周知,計算機在判斷某物「看起來是否不錯」方面一直表現不佳。

當我們想測試可用性或一些「看起來對不對」的東西時,就已經超越了自動化測試的範疇。這屬於探索性測試、可用性測試、走廊測試的領域。我們需要向用戶展示產品,觀察他們是否喜歡使用,是否有任何功能會讓他們在使用時感到困惑。

通過用戶界面測試一個已部署好的應用,這是一個典型的端到端測試(也被稱爲廣域棧測試)。端到端測試會讓我們更瞭解軟件能否正常工作,然而它們通常比較脆弱,經常因爲一些意料之外的問題而失敗,並且錯誤信息通常不是真正的根本原因。瀏覽器差異、時間(時序)問題、元素渲染、意外的彈出框…這些問題僅僅是冰山一角,但卻需要花費大量時間進行調試。

在微服務的世界中,誰負責寫這些測試是一個大問題。因爲端到端測試覆蓋到整個服務,這就導致寫端到端測試並不是任何一個團隊的責任。

如果有一個集中的質量保障團隊來編寫端到端測試,這似乎是個不錯的選擇。但是,擁有一個集中式的QA團隊實際上是一種反模式,不符合DevOps的理念。您的團隊應該是真正的跨職能團隊。回答誰應該負責端到端測試的問題並不容易,這與您的組織具體情況相關。也許您的組織中有一些社區實踐或質量協會等機構可以負責這方面的工作。合適的答案與您的組織有關。

此外,端到端測試需要大量的維護成本,且運行速度較慢。試想一下,除非只有幾個微服務,否則根本沒辦法在本地運行端到端測試,因爲這需要啓動所有的服務。

由於維護成本高昂,我們應該儘量將端到端測試的數量減少到最低限度。考慮到應用中對用戶而言具有高價值的交互,並定義產品核心價值的用戶旅程,將這些旅程中最重要的步驟轉化爲自動化的端到端測試。

例如,如果您正在構建一個電子商務網站,最有價值的用戶旅程可能是用戶搜索商品、將其添加到購物車,然後進行付款。只要這個旅程正常工作,您就無需過多擔心。您可以找出一兩個重要的用戶旅程,並使用端到端測試來覆蓋它們。但是,不要過度測試,否則會帶來痛苦。

四、寫在最後

請記住,在測試金字塔中,還有許多更低層級的測試,它們已經全面測試了各種邊緣情況和與其他系統的集成。不需要在高層級測試中重複測試。否則,高維護成本和大量虛假錯誤報告將降低開發速度,最終會讓您對測試失去信心。

文章翻譯來源:https://martinfowler.com/articles/practical-test-pyramid.html

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