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

歷史遺留代碼不敢重構? 每次改代碼都要回歸全部邏輯? 提測被打回?html

在近期的代碼重構的過程當中,遇到了各式各樣的問題。好比調整代碼順序致使bug,取反操做邏輯丟失,參數校驗邏輯被誤改等。java

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

經歷了沒有單測痛苦磨難,查閱不少資料和實戰以後,因而就有了這篇文章,但願能給你的單測提供一些參考。正則表達式

認識單測

What

單元測試是針對程序模塊(軟件設計的最小單位)來進行正確性檢驗的測試工做。程序單元是應用的最小可測試部件。數據庫

關於測試的名詞還有不少,如集成測試,系統測試,驗收測試。是在不一樣階段,不一樣角色來共同保證系統質量的一種手段。編程

筆者在工做中常常遇到一些無效單測,一般是啓動Spring容器,鏈接數據庫,調用方法而後控制檯輸出結果。這些並不能算是單測。示例代碼以下:segmentfault

@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

在工做中不少代碼是沒有單測的,這些項目也能正常得運行。那麼爲何要編寫單測呢?markdown

好的單測在可以提供咱們代碼交付質量的同時,減小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())
    }
}
複製代碼

思考總結

在驗證商業模式以前,時刻要想考慮投入產出比。時間和商業成本過高不利於產品快速推向市場,因此何時推廣單測,須要更高階的人決策。

測試不可能序錯誤,單測也不例外。單測只測試程序單元自身的功能。所以,它不能發現集成錯誤、性能、或者其餘系統級別的問題。

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

參考資料:

blog.testlodge.com/tdd-vs-bdd/ martinfowler.com/articles/mo… callistaenterprise.se/blogg/tekni… segmentfault.com/a/119000003…

相關文章
相關標籤/搜索