工作3年,還不會寫單元測試?新技能get!

Java填坑筆記發表於2021-07-07

歷史遺留程式碼不敢重構?
每次改程式碼都要回歸所有邏輯?
提測被打回?

在近期的程式碼重構的過程中,遇到了各式各樣的問題。比如調整程式碼順序導致bug,取反操作邏輯丟失,引數校驗邏輯被誤改等。

上線前需要花大量時間進行測試和灰度驗證。在此過程最大的感受就是:一切沒有單測覆蓋的重構都是裸奔

經歷了沒有單測痛苦磨難,查閱很多資料和實戰之後,於是就有了這篇文章,希望能給你的單測提供一些參考。

認識單測

What

單元測試是針對程式模組(軟體設計的最小單位)來進行正確性檢驗的測試工作。程式單元是應用的最小可測試部件。

關於測試的名詞還有很多,如整合測試,系統測試,驗收測試。是在不同階段,不同角色來共同保證系統質量的一種手段。

筆者在工作中經常遇到一些無效單測,通常是啟動Spring容器,連線資料庫,呼叫方法然後控制檯輸出結果。這些並不能算是單測。示例程式碼如下:

@RunWith(SpringRunner.class)
@SpringBootTest(classes = ApplicationLoader.class)
public class UserServiceTest {
    @Autowired
    private UserService userService;
    @Test
    public void testAddUser() {
        AddUserRequest addUserRequest = new AddUserRequest("zhangsan", "zhangsan@163.com");
        ResultDTO<Long> addResult = userService.addUser(addUserRequest);
        System.out.println(addResult);
    }
}

Why

在工作中很多程式碼是沒有單測的,這些專案也能正常得執行。那麼為什麼要編寫單測呢?

好的單測在能夠提供我們程式碼交付質量的同時,減少bug發現和修復的成本,進而提高工作效率。至於單測能夠讓QA開心,則只是錦上添花。

提升工作效率,在工作中程式設計師的大多數時間都耗費在了測試階段,編碼往往可能只佔一小部分。

尤其是在修改已有程式碼時候,不得不考慮增量程式碼是否會對原有邏輯帶來衝擊,以及修復bug之後是否引入的新的bug。

筆者就曾陷入如此困境,一下午時間都在重複著打包,部署,測試...,在改bug和寫bug之間無限迴圈,有時也會因為一個低階bug抓心撓肝剛到後半夜。

所以長遠來看,單測是能夠有效提高工作效率的!

提升程式碼質量,可測試通常與軟體的設計良好程式相關,難以測試的程式碼一般設計上都有問題。所以有效的單測會驅動開發者寫出更高質量程式碼。

當然,單測帶來最直接的收益就是能夠減少bug率,雖然單測不能捕獲所有bug,但是的確能夠暴露出大多數bug。

節省成本,單測能夠確保程式的底層邏輯單元的正確性,讓問題能夠在RD自測階段暴露出來。bug越早發現,修復成本往往更低,帶來的影響也會更小,所以bug應該儘早暴露。

如下圖紅色曲線所示,在不同階段修復bug的成本差別是巨大的。

Who

程式碼的作者最瞭解程式碼的目的、特點和實現的侷限性。寫單測沒有比作者更適合的人選了,所以往往程式碼作者往往是第一責任人。

When

編寫單測的時機,一般是 The sooner, the better(越早越好)。儘量不要將單測拖延到程式碼編寫完之後,這樣帶來的收益可能不盡如人意。

TDD(Test-Driven Development)測試驅動開發,是一種軟體開發過程中的應用方法,以其倡導先寫測試程式,然後編碼實現其功能得名。

測試驅動著整個開發過程:首先,驅動程式碼的設計和功能的實現;其後,驅動程式碼的再設計和重構。

當然TDD是一種理想的狀態,由於種種原因,想要完全遵守TDD原則,是有一定難度的,畢竟PM的需求往往是可變的。

邊開發邊寫單測,先寫少量功能程式碼,緊接著寫單測,重複這兩個過程,直到完成功能程式碼開發。

其實這種方案跟第一種已經很接近,當功能程式碼開發完時,單測也差不多完成了。這種方案也是最常見和推薦的方式。

開發後再補單測,效果往往是最差的。首先,要考慮的是程式碼的可測性,已經完成的程式碼可能並不具備可測試性,畢竟寫程式碼的時候可以任意發揮。

其次,補單測時容易順著當前實現去寫測試程式碼,而忽略實際需求的邏輯是什麼,導致我們的單測是無效的。

Which

究竟哪些方法需要進行單測?這個困擾筆者很久的一個問題!如上文所說,單測覆蓋率當然是越高越好,不過我們在考慮ROI時難免會做出一些妥協。

接受不完美,對於歷史程式碼,全覆蓋往往是不現實的。我們可以根據方法優先順序(如照成資損,影響業務主流程)針對性補全單測,保證現有邏輯能正常執行。

對於增量程式碼,筆者認為沒有必要全部覆蓋,一般根據被測方法是否有處理(業務)邏輯來決定。

比如常見的JavaWeb專案程式碼中,Controller層,DAO層以及其他僅涉及介面轉發相關的方法,往往不需要單測覆蓋。而業務邏輯層的各種Service則需要重點測試。

對於自定義的工具類,正規表示式等固定邏輯,也是必須要測試的。因為這部分邏輯一般都是公共且通用的,一旦邏輯錯誤會產生比較嚴重的影響。

How

好的單測一定是能夠自動執行並查執行結果的,也不應當對外部有依賴,單測的執行應當是完全自動化,並且無需部署,本地IDE就能執行。

在寫單側前,不妨參考以下前人總結好的First原則。

F—Fast:快速

在開發過程中通常需要隨時執行測試用例;在釋出流水線中執行也必須執行,常見的就是push程式碼後,或者打包時先執行測試用例;況且一個專案中往往有成百上千個測試用例。

所以為了保證開發和釋出效率,快速執行是單測的重要原則。這就要求我們不要像整合測試一樣依賴多個元件,確保單測在秒級甚至毫秒級執行完畢。

I—Isolated:隔離

隔離性也可以理解為獨立性,好的單測是每個測試用例只關注一個邏輯單元或者程式碼分支,保證單一職責,這樣能更清晰的暴露問題和定位問題。

每個單測之間不應該產生依賴,為了保證單測穩定可靠且便於維護,單測用例之間決不能互相呼叫,也不能依賴執行的先後次序。

資料資源隔離,測試時不要依賴和修改外部資料或檔案等其他共享資源,做到測試前後共享資源資料一致。

Fake,Stub和Mock

我們的被測試程式碼存在的外部依賴的行為往往是不可預測的,我們需要將這些"變化"變得可控,根據職責不同,可以分為Fake,Stubs,Mock三種。

假資料(Fake), 一些針對當前場景構建的簡化版的物件,這些物件作為資料來源供我們使用,職責就像記憶體資料庫一樣。

比如在常見的三層架構中,業務邏輯層需要依賴資料訪問層,當業務邏輯層開發完成後即使資料訪問層沒有開發完成,也能通過構建Fake資料的方式完成業務邏輯層的測試。

UserDO fakeUser = new UserDO("zhangsan", "zhangsan@163.com");

public UserVO getUser(Long userId) {
  // do something
  User user = fakeUser;  // 測試階段替換:User user = userDao.getById(userId);
  // do something
}

Fake資料雖然可以測試邏輯,但是當資料訪問層開發完畢後可能需要修改程式碼,將Fake資料替換為實際的方法呼叫來完成程式碼整合,顯然這不是一種優雅的實現,於是便有了Stub。

樁程式碼(Stub)是用來代替真實程式碼的臨時程式碼,是在測試環境對依賴介面的一種專門實現。

比如,UserService中呼叫了UseDao,為了對UserService中的函式進行測試,這時候需要構建一個UserDao介面的實現類UserDaoStub(返回Fake資料),這個臨時程式碼就是所謂的樁程式碼。

public class UserDaoStub implements UserDao {
    UserDO fakeUser = new UserDO();
    {
        fakeUser.setUserName("zhangsan");
        fakeUser.setEmail("zhangsan@163.com");
        LocalDateTime dateTime = LocalDateTime.of(2021, 7, 1, 12, 30, 0);
        fakeUser.setCreateTime(dateTime);
        fakeUser.setUpdateTime(dateTime);
    }
    @Override
    public UserDO getById(Long id) {
        if (Objects.isNull(id) || id <= 0) {
            return new UserDO();
        }
        return fakeUser;
    }
}

這種面向介面程式設計,使得在不同場景下通過不同的實現類替換介面的程式設計設計原則就是我們常說的里氏替換原則

Mock 程式碼和樁程式碼非常類似,都是用來代替真實程式碼的臨時程式碼。不同的是在被呼叫時,會記錄被呼叫資訊,執行完畢後驗證執行動作或結果是否符合預期。

對於 Mock 程式碼來說,我們的關注點是 Mock 方法有沒有被呼叫,以什麼樣的引數被呼叫,被呼叫的次數,以及多個 Mock 函式的先後呼叫順序。

    @Test
    public void testAddUser4SendEmail() {
        // GIVEN:
        AddUserRequest fakeAddUserRequest = new AddUserRequest("zhangsan", "zhangsan@163.com");
        // WHEN
        ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
        // THEN
        assertTrue(addResult.isSuccess());
        // 驗證sendVerifyEmail的呼叫1次,並且呼叫引數為我們fake資料中指定的郵箱
        verify(emailService, times(1)).sendVerifyEmail(any());
        verify(emailService).sendVerifyEmail(fakeAddUserRequest.getEmail());
    }

當然,我們也可以通過修改Stub的實現,達到和Mock一樣的效果。

public class EmailServiceStub implements EmailService{
    public int invokeCount = 0;
    @Override
    public boolean sendVerifyEmail(String email) {
        invokeCount ++;
        // do something
        return true;
    }
}
public class UserServiceImplTest {
    AddUserRequest fakeAddUserRequest;
    private UserServiceImpl userService;
    private EmailServiceStub emailServiceStub;
    @Before
    public void init() {
        fakeAddUserRequest = new AddUserRequest("zhangsan", "zhangsan@163.com");
        emailServiceStub = new EmailServiceStub();
        userService= new UserServiceImpl();
        userService.setEmailService(emailServiceStub);
    }
    @Test
    public void testAddUser4SendEmail() {
        // GIVEN: fakeAddUserRequest
        // WHEN
        ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
        // THEN:傳送郵件介面被呼叫次數是否為1
        Assert.assertEquals(emailServiceStub.invokeCount, 1);
    }
}

Stub和Mock的區別

Stub和Mock的區別在於,Stub偏向於結果驗證,Mock則更加偏向於行為驗證。

比如,測試addUser方法時,如果是Stub方式則關注方法返回結果,即使用者是否新增成功,郵件是否傳送成功;而Mock方式則傾向於本次新增的行為驗證,比如sendEmail方法呼叫次數等。

Mock替代Stub

Mock和Stub本質上是不同的,但是隨著各種Mock框架的引入,Stub和Mock的邊界越來越模糊,使得Mock不僅可以進行行為驗證,同樣也具備Stub對介面的假實現的能力。

目前大多數的mock工具都提供mock退化為stub的支援,以Mockito為例,我們可以通過anyObject(), any等方式對引數的進行匹配;使用verify方法可以對方法的呼叫次數和引數進行檢驗,這和stub就幾乎沒有本質區別了。

when(userDao.insert(any())).thenReturn(1L);
when(emailService.sendVerifyEmail(anyString())).thenReturn(true);

stub理論上也是可以向mock的方向做轉化,上文也提及stub是可以通過增加程式碼來實現一些expectiation的特性,而從使得兩者的界限更加的模糊。

所以,如果對於Stub和Mock的概念還是比較模糊,也不必過度糾結,這並不影響寫出優秀的單測。

R—Repeatable:可重複執行

單測是可以重複執行的,不能受到外界環境的影響。 同一測試用例,即使是在不同的機器,不同的環境中執行多次,每次執行都會產生相同的結果。

避免隱式輸入(Hidden imput),比如測試程式碼中不能依賴當前日期,隨機數等,否則程式就會變得不可控從而變得不可重複執行。

S—Self-verifying:自我驗證

單測需要通過斷言進行結果驗證,即當單測執行完畢之後,用來判斷執行結果是否和假設一致,無需人工檢查是否執行成功。

當然,除了對執行結果進行檢查,也能對執行過程進行校驗,如方法呼叫次數等。下面是筆者在工作中經常見到的寫法,這些都是無效的單測。

// 直接列印結果
public void testAddUser4DbError() {
    // GIVEN
    fakeAddUserRequest.setUserName("badcase");
    // WHEN
    ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
    // THEN
    System.out.println(addResult);
}
// 吞沒異常失敗case
public void testAddUser4DbError() {
    // GIVEN
    fakeAddUserRequest.setUserName("badcase");
    // WHEN
  	try {
      ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
   	 // THEN
      Assert.assertTrue(addResult.isSuccess());
    } catch(Exception e) {
        System.out.println("測試執行失敗");
    }
}

正解如下:

@Test
public void testAddUser4DbError() {
  // GIVEN
  fakeAddUserRequest.setUserName("badcase");
  // WHEN
  ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
  // THEN
  Assert.assertEquals(addResult.getMsg(), "新增使用者失敗,請稍後重試");
}

T—Timely&Thorough:及時,全面

理想狀態當然是TDD模式開發,即測試驅動開發。如前面提到的,編寫程式碼邏輯之前寫最佳,邊開發邊寫次之,等程式碼穩定執行再來補單測收益可能是最低的。

除了及時性,筆者認為T應當有另一層含義,即全面性(Thorough)。理想情況下每行程式碼都要被覆蓋到,每一個邏輯分支都必須有一個測試用例。

不過想要100%的測試覆蓋率是非常耗費精力的,甚至會和我們最初提高效率的初衷相悖。所以花合理的時間抓出大多數bug,要好過窮盡一生抓出所有bug

通常情況下我們要至少考慮到引數的邊界,特殊值,正常場景(與設計文件結合)以及異常場景,保證我們的核心流程是正確的。

Mock框架簡介

工欲善其事必先利其器,選擇一個合適的Mock框架與手動實現Stub比,往往能夠讓我們的單測事半功倍。

需要說明的是,Mock框架並不是必須的。正如上文所說,我們可以實現Stub程式碼來隔離依賴,當需要使用到Mock物件時,我們只需要對Stub的實現稍作修改即可。

市面上有許多Mock框架可供選擇,如常見的Mockito,PowerMock,Spock,EasyMock,JMock等。如何選擇合適的框架呢?

如果你想半個小時就能上手,不妨試試Mockito,絕對如絲般順滑!當然,如果有時間並且對Groovy語言感興趣,不妨花半天時間瞭解下Spock,能讓測試程式碼更加精簡。

以下是幾種常用的Mock框架對比,不知道怎麼選時,不妨根據現狀,需要注意的是,大部分Mock框架都不支援Mock靜態方法

單測實戰

寫單測一般包括3個部分,即Given(Mock外部依賴&準備Fake資料),When(呼叫被測方法)以及Then(斷言執行結果),這種寫法和Spock的語法結構也是一致的。

為了更好的理解單元測試,筆者將針對如下程式碼,分別使用Mockito和Spock寫一個簡單的示例,讓大家感受一下兩者的各自的特點和不同。

@Service
@AllArgsConstructor
@NoArgsConstructor
public class UserServiceImpl implements UserService {

    @Autowired
    private UserDao userDao;

    @Autowired
    private EmailService emailService;

    public ResultDTO<Long> addUser(AddUserRequest request) {
        // 1. 校驗引數
        ResultDTO<Void> validateResult = validateAddUserParam(request);
        if (!validateResult.isSuccess()) {
            return ResultDTO.paramError(validateResult.getMsg());
        }

        // 2. 新增使用者
        UserDO userDO = request.buildUserDO();
        long id = userDao.insert(userDO);

        // 3. 新增成功,返回驗證啟用郵件
        if (id > 0) {
            emailService.sendVerifyEmail(request.getEmail());
            return ResultDTO.success(id);
        }
        return ResultDTO.internalError("新增使用者失敗,請稍後重試");
    }

    /**
     * 校驗新增使用者引數
     */
    private ResultDTO<Void> validateAddUserParam(AddUserRequest request) {
        if (Objects.isNull(request)) {
            return ResultDTO.paramError("新增使用者引數不能為空");
        }
        if (StringUtils.isBlank(request.getUserName())) {
            return ResultDTO.paramError("使用者名稱不能為空");
        }
        if (!EmailValidator.validate(request.getEmail())) {
            return ResultDTO.paramError("郵箱格式錯誤");
        }
        return ResultDTO.success();
    }
}

基於Mockito的單測示例如下,需要注意的下面是純java程式碼,沒有物件顯示呼叫的方法都是已經靜態匯入過的。

@RunWith(MockitoJUnitRunner.class)
public class UserServiceImplTest {
    // Fake:需要提前構造的假資料
    AddUserRequest fakeAddUserRequest;

    // Mock: mock外部依賴
    @InjectMocks
    private UserServiceImpl userService;
    @Mock
    private UserDao userDao;
    @Mock
    private EmailService emailService;

    @Before
    public void init() {
        fakeAddUserRequest = new AddUserRequest("zhangsan", "zhangsan@163.com");
        when(userDao.insert(any())).thenReturn(1L);
        when(emailService.sendVerifyEmail(anyString())).thenReturn(true);
    }

    @Test
    public void testAddUser4NullParam() {
        // GIVEN
        fakeAddUserRequest = null;
        // WHEN
        ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
        // THEN
        assertEquals(addResult.getMsg(), "新增使用者引數不能為空");
    }
    @Test
    public void testAddUser4BadEmail() {
        // GIVEN
        fakeAddUserRequest.setEmail(null);
        // WHEN
        ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
        // THEN
        assertEquals(addResult.getMsg(), "郵箱格式錯誤");
    }
    @Test
    public void testAddUser4BadUserName() {
        // GIVEN
        fakeAddUserRequest.setUserName(null);
        // WHEN
        ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
        // THEN
        assertEquals(addResult.getMsg(), "使用者名稱不能為空");
    }

    @Test
    public void testAddUser4DbError() {
        // GIVEN
        when(userDao.insert(any())).thenReturn(-1L);
        // WHEN
        ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
        // THEN
        assertEquals(addResult.getMsg(), "新增使用者失敗,請善後重試");
    }

    @Test
    public void testAddUser4SendEmail() {
        // GIVEN
        // WHEN
        ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
        // THEN
        assertTrue(addResult.isSuccess());
        verify(emailService, times(1)).sendVerifyEmail(any());
        verify(emailService).sendVerifyEmail(fakeAddUserRequest.getEmail());
    }

}

正如上文提到的,Spock能夠讓程式碼更加精簡,尤其是在程式碼邏輯分支比較多的場景下。下面是基於Spock的單測。

class UserServiceImplSpec extends Specification {
    UserServiceImpl userService = new UserServiceImpl();
    AddUserRequest fakeAddUserRequest;
    def userDao = Mock(UserDao)
    def emailService = Mock(EmailService)

    def setup() {
        // Fake資料建立
        fakeAddUserRequest = new AddUserRequest(userName: "zhangsan", email: "zhangsan@163.com")
        // 注入Mock物件
        userService.userDao = userDao
        userService.emailService = emailService
    }

    def "testAddUser4BadParam"() {
        given:
        if (Objects.isNull(userName) || Objects.is(email)) {
            fakeAddUserRequest = null
        } else {
            fakeAddUserRequest.setUserName(userName)
            fakeAddUserRequest.setEmail(email)
        }
        when:
        def result = userService.addUser(fakeAddUserRequest)
        then:
        Objects.equals(result.getMsg(), resultMsg)
        where:
        userName   | email              | resultMsg
        null       | null               | "新增使用者引數不能為空"
        "Java填坑筆記" | null               | "郵箱格式錯誤"
        null       | "javaTKBJ@163.com" | "使用者名稱不能為空"
    }

    def "testAddUser4DbError"() {
        given:
        _ * userDao.insert(_) >> -1L
        when:
        def result = userService.addUser(fakeAddUserRequest)
        then:
        Objects.equals(result.getMsg(), "新增使用者失敗,請稍後重試")
    }

    def "testAddUser4SendEmail"() {
        given:
        _ * userDao.insert() >> 1
        when:
        def result = userService.addUser(fakeAddUserRequest)
        then:
        result.isSuccess()
        1 * emailService.sendVerifyEmail(fakeAddUserRequest.getEmail())
    }
}

思考總結

在驗證商業模式之前,時刻要想考慮投入產出比。時間和商業成本太高不利於產品快速推向市場,所以什麼時候推廣單測,需要更高階的人決策。

測試不可能序錯誤,單測也不例外。單測只測試程式單元自身的功能。因此,它不能發現整合錯誤、效能、或者其他系統級別的問題。

單測能夠提高程式碼質量,驅動程式碼設計,幫助我們更早發現問題,保障持續優化和重構,是工程師的一項必備技能。

參考資料:

https://blog.testlodge.com/tdd-vs-bdd/
https://martinfowler.com/articles/mocksArentStubs.html
https://callistaenterprise.se/blogg/teknik/2010/11/12/stubs-n-mocks/
https://segmentfault.com/a/1190000039101791

感覺有用,記得點個贊 : )

相關文章