單元測試難?來試試這些套路

阿里妹導讀:測試不該該是一門很高大尚的技術,應該是咱們技術人的基本功。但如今好像慢慢地,單元測試已經脫離了基本功的範疇。筆者曾經在不一樣團隊中推過單元測試,要求過覆蓋率,但發現實施下去很難。後來在不停地刻意練習後,發現阻礙寫UT的只是筆者的心魔,並非時間和項目的問題。在通過一些項目的實踐後,也是有了一些本身的理解和實踐,但願和你們分享一下,和你們探討下如何克服「單元測試」的心魔。css


文末福利:開發者成長計劃,最強助力!html


內功

前人們在單元測試方面的研究不少,有不少的方法論,咱們能夠拿來即用。我簡單介紹兩個方法論,一個概念。但願你們能夠查閱更多的資料,凝聚本身的內功心法。

TDD

Test Driven Development,也被認爲是Test Driven Design,咱們這裏按第一種定義來聊。TDD一改以往的破壞性測試的思惟方式,測試在先、編碼在後,更符合「缺陷預防」的思想。簡單來講,TDD的流程是「紅-綠-重構」三個步驟的循環往復。

  • 紅:測試先行,如今尚未任何實現,跑UT的時候確定不過,測試狀態是紅燈。編譯失敗也屬於「紅」的一種狀況。
    前端


  • 綠:當咱們用最快,最簡單的方式先實現,而後跑一遍UT,測試會經過,變成「綠」的狀態。java


  • 重構:看一下系統中有沒有要重構的點,重構完,必定要保證測試是「綠」的。
    web


業界有不少TDD的呼聲,也有TDD已死的文章。方法原本沒有對錯,只有優劣,咱們要辯證地來看。只能說TDD不是一個銀彈,不能解決全部問題。以筆者本身的經驗,TDD比較適用於輸入輸出很明確的CASE,不少時候咱們在摸索一種新的模式的時候,可能並不太適用。

若是你和前端已經商議好了接口的出參、入參,能夠嘗試一下TDD,一種新的思路,新的思想。

BDD

嚴格來講BDD是TDD衍生出來的一個小分支。但也能夠用於一些不一樣維度的東西。概念你們自行尋找資料。這裏講一下BDD的一點實踐經驗。直接上代碼:

@RunWith(SpringBootRunner.class)@DelegateTo(SpringJUnit4ClassRunner.class)@SpringBootTest(classes = {Application.class})public class ApiServiceTest {
@Autowired    ApiService apiService;
@Test public void testMobileRegister() { AlispResult<Map<String, Object>> result = apiService.mobileRegister(); System.out.println("result = " + result); Assert.assertNotNull(result);        Assert.assertEquals(54,result.getAlispCode().longValue());
AlispResult<Map<String, Object>> result2 = apiService.mobileRegister(); System.out.println("result2 = " + result2); Assert.assertNotNull(result2);        Assert.assertEquals(9,result2.getAlispCode().longValue());
AlispResult<Map<String, Object>> result3 = apiService.mobileRegister(); System.out.println("result3 = " + result3); Assert.assertNotNull(result3); Assert.assertEquals(200,result3.getAlispCode().longValue());    }
@Test public void should_return_mobile_is_not_correct_when_register_given_a_invalid_phone_number() { AlispResult<Map<String, Object>> result = apiService.mobileRegister(); Assert.assertNotNull(result); Assert.assertFalse(result.isSuccess()); }}

第一個UT是以方法維度,把全部場景放到一個方法來測試。

第二個UT是以case爲角度,針對每一個case單獨的測試。

其實TDD裏面有一個概念是隔離性,單元測試之間應該隔離開,不要互相干擾。另外,從命名上,第二種也更好一點。我我的仍是比較推薦如下命名方式的:

  • should:返回值,應該產生的結果
    spring

  • when:哪一個方法sql

  • given:哪一個場景
    typescript


另外BDD或者TDD中也有Task的概念,寫代碼以前先準備好case。你們能夠看一些BDD的文章,本身體會。若是對這個感興趣,能夠在評論區探討。

測試金字塔



上圖來自martin fowler博客的TestPyramid[1]一文,也能夠讀一下《Practical Test Pyramid》[2]。特別棒的文章,但願你們能夠去讀一讀。

上面的金字塔的意思是,從Unit到Service,再到UI,速度愈來愈慢,成本也愈來愈高。

咱們能夠從服務端的角度把這三層稍微改一下:

  • 契約測試:測試服務與服務之間的契約,接口保證。代價最高,測試速度最慢。
    數據庫


  • 集成測試(Integration):集成當前spring容器、中間件等,對服務內的接口,或者其餘依賴於環境的方法的測試。
    json


// 加載spring環境@RunWith(SpringBootRunner.class)@DelegateTo(SpringJUnit4ClassRunner.class)@SpringBootTest(classes = {Application.class})public class ApiServiceTest {
@AutowiredApiService apiService;//do some test}

  • 單元測試(Unit Test):純函數,方法的測試,不依賴於spring容器,也不依賴於其餘的環境。




咱們如今寫測試,通常是單元測試和集成測試兩層。針對具體場景,選擇適合本身的測試粒度。

招數

其實寫單元測試是有一些招數的,下面會介紹筆者很喜歡的一種單元測試代碼組織結構,也會介紹一些經常使用的招數,以及使用場景。

常見問題

  • 一個類裏面測試太多怎麼辦?

  • 不知作別人mock了哪些數據怎麼辦?

  • 測試結構太複雜?

  • 測試莫名奇妙起不來?


Fixture-Scenario-Case

FSC(Fixture-Scenario-Case)是一種組織測試代碼的方法,目標是儘可能將一些MOCK信息在不一樣的測試中共享。其結構以下



  • 經過組合Fixture(固定設施),來構造一個Scenario(場景)。


  • 經過組合Scenario(場景)+ Fixture(固定設施),構造一個case(用例)。


下面是一個FSC的示例:



  • Case:當用戶正常登陸後,獲取當前登陸信息時,應該返回正確的用戶信息。這是一個簡單的用戶登陸的case,這個case裏面總共有兩個動做、場景,一個是用戶正常登陸,一個是獲取用戶信息,演化爲兩個scenario。


  • Scenario:用戶正常登陸,確定須要登陸參數,如:手機號、驗證碼等,另外隱含着數據庫中應該有一個對應的用戶,若是登陸時須要與第三方系統進行交互,還須要對第三方系統進行mock或者stub。獲取用戶信息時,確定須要上一階段頒發的憑證信息,另外該憑證多是存儲於一些緩存系統的,因此還須要對中間件進行mock或者stub。


  • Fixture


  • 利用Builder模式構造請求參數。


  • 利用DataFile來存儲構造用戶的信息,例如DB transaction進行數據的存儲和隔離。


  • 利用Mockito進行三方系統、中間件的Mock。


當這樣組織測試時,若是另一個Case中須要用戶登陸,則能夠直接複用用戶登陸的Scenario。也能夠經過複用Fixture來減小數據的Mock。下面咱們來詳細解釋看一下每一層如何實現,show the code。

Case

case是用例的意思,在這裏用例是場景和一些固定設施的組合。這裏要注意的是,儘可能不要直接修改接口的數據,一個場景所依賴的環境應該是另外一個場景的輸出。固然有些特定場景下,仍是須要直接改數據的,這裏不是禁止,而是建議。

public class GetUserInfoCase extends BaseTest {    private String accessToken;
@Autowired    private UserFixture userFixture;
/** * 通用場景的mock */ @Before public void setUp() { //三方系統mock        userFixture.whenFetchUserInfoThenReturn("1", new UserVO());
//依賴的其餘場景 accessToken = new SimpleLoginScenario() .mobile("1234567890") .code("aaa") .login() .getAccessToken();    }
/** * BDD的三段式 */ @Test public void should_return_user_info_when_user_login_given_a_effective_access_token() { Response userInfoResponse = new GetUserInfoScenario() .accessToken(accessToken)                .getUserInfo();
assertThat(userInfoResponse.jsonPath().getString("id"), equals("1")); }}

Scenario

JUNIT的用法就不說了,相信你們都瞭解,這裏提兩個框架REST Assured和Mock MVC。這兩個框架均可以用來作接口測試,Mock MVC是spring原生的,能夠指定加載的Resource,必定程度上能夠提高UT速度,可是和spring是耦合在一塊兒的。REST Assured是脫離Spring的,能夠理解爲利用http進行接口的測試,耦合性更低,使用靈活。二者各有千秋,筆者比較推薦REST Assured。咱們看一下,一個REST Assured打造的Scenario怎麼寫,怎麼用?

@Datapublic class SimpleLoginScenario { // 請求參數 private String mobile;    private String code;
// 登陸結果    private String accessToken;
public SimpleLoginScenario mobile(String mobile) { this.mobile = mobile; return this;    }
public SimpleLoginScenario code(String code) { this.code = code; return this;    }
//登陸,而且保存AccessToken,這裏返回自身,是由於有可能返回參數是多個。 public SimpleLoginScenario login() { Response response = loginWithResponse(); this.accessToken = response.jsonPath().getString("accessToken"); return this;    }
//利用RestAssured進行登陸,這個方法能夠是public,也能夠經過參數傳遞一些驗證方法 private Response loginWithResponse() { return RestAssured.get(API_PATH, ImmutableMap.of("mobile", mobile, "code", code)) .thenReturn();    }
}

Fixture

固定設施部分,主要是用來提供一些固定的組件和數據。儘可能的讓這部分東西有複用性,若是沒複用性,儘可能和測試放在一塊兒,不要干擾他人。

(1)方法

a)Mock

mockito挺通用的,並且spring也提供了@MockBean,能夠直接將Mock一個bean放入spring的容器中。而後能夠利用mockito提供的方法對方法進行模擬或者驗證。代碼示例:

public class MockitoTest { @MockBean(classes = CacheImpl.class)  private Cache cache;
@Test public void should_return_success() { // 固定參數,固定返回值      Mockito.when(cache.get("KEY")).thenReturn("VALUE");
// 動態參數,固定返回值      Mockito.when(cache.get(Mockito.anyString())).thenReturn("VALUE");
// 動態參數,固定返回值 Mockito.when(cache.get(Mockito.anyString())).then((invocation) -> { String key = (String) invocation.getArguments()[0]; return "VALUE";      });
// 固定參數,異常      Mockito.when(cache.get("KEY")).thenThrow(new RuntimeException("ERROR"));
// 驗證調用次數 Mockito.verify(cache.get("KEY"), Mockito.times(1)); }}

(b)stub


stub是打樁,關於打樁和mock的區別,請自行百度,這裏只是想展現一下,在spring的環境下,覆蓋原有bean達到stub的效果。

//使用spring的@Primary來替換一個bean,若是不一樣的測試須要的bean不一樣,推薦使用@Configuration + @Import的方式,動態加載Bean@Primary@Component("cache")public class CacheStub implements Cache {
@Override public String get(String key) { return null;  }
@Override public int setex(String key, Integer ttl, String element) { return 0;  }
@Override public int incr(String key, Integer ttl) { return 0;  }
@Override public int del(String key) { return 0; }}

(c)嵌入式DB

這裏簡單介紹幾種嵌入式DB,能夠自行選擇使用。


(d)直連DB + Transaction

除了使用嵌入式的DB,也能夠直連環境,但不推薦,由於環境上的數據是多變的,若是測試出現問題,排查的複雜度會增長。這裏其實想強調下@Transactional。由於Mock的數據最好作到隔離,好比一個接口的操做是批量刪除數據,有可能會把一個其餘測試依賴的數據刪除掉,這樣問題一旦出現很難排查,由於單獨跑每一個測試都是經過的,可是一塊兒跑就會出問題。這裏推薦兩種作法:

  • 使用@Transactional在一些測試的類上,這樣在跑完測試後,數據不會commit,會回滾。但若是測試中對事物的傳播有特殊要求,可能不適用。


  • 通用的trancateAll和initSQL經過在每一個測試前跑清除數據、mock數據的腳本,來達到每一個測試對應一個隔離環境,這樣數據間就不會產生干擾。


(e)PowerMock

PowerMock是用來建立一些靜態方法的Mock的,若是你的代碼中會調用一些靜態方法,可是靜態方法依賴於一些其餘複雜的邏輯或者資源。可使用這個包。

PowerMockito.mockStatic(C.class);PowerMockito.when(C.isTrue()).thenReturn(true);

注意:


  • PowerMock不只僅是用來mock靜態方法的。


  • 不建議mock靜態方法,由於靜態方法的使用場景都是些純函數,大部分的純函數不須要mock。部分靜態方法依賴於一些環境和數據,針對這些方法,須要考慮下究竟是要mock其依賴的數據和方法,仍是真的要mock這個函數,由於一旦mock了這個函數,意味着隱藏了細節。


(2)數據

(a)Builder模式

數據最簡單的mock方式就是Builder,而後本身手填各類參數,但有些對象有幾十個字段,而你的一個測試只須要改其中的兩個字段,你該怎麼辦?Copy、Paste?

@Builder@Datapublic class UserVO { private String name; private int age; private Date birthday;}
public class UserVOFixture { // 注意:這裏是個Supplier,並非一個靜態的實例,這樣能夠保證每一個使用方,維護本身的實例 public static Supplier<UserVO.UserVOBuilder> DEFAULT_BUILDER = () -> UserVO.builder().name("test").age(11).birthday(new Date());}

(b)數據文件


有時候經過builder構造對象的時候,字段太多,而且數據的來源是前端或者其餘服務提供的json。這個時候能夠將這個數據存儲到文件中,利用一些工具方法,將數據讀取成制定的文件。這也是數據mock的經常使用手段。我這裏是以json爲例,其實sql等數據也能夠這樣。

數據文件的優勢:可承載的數據量大、編輯方便。

public class UserVOFixture {
public static UserVO readUser(String filename) { return readJsonFromResource(filename, UserVO.class);  }
public static <T> T readJsonFromResource(String filename, Class<T> clazz) { try { String jsonString = StreamUtils.copyToString(new ClassPathResource(filename).getInputStream(), Charset.defaultCharset()); return JSON.parseObject(jsonString, clazz); } catch (IOException e) { return null; } }}

使用場景

在筆者的實踐中, 目前主要把FSC是用在接口測試上,也就是測試金字塔的Integration Test部分,放在這個層次,有幾個緣由:

  • FSC自己會給測試帶來複雜度,而UnitTest應該簡單,若是UnitTest自己都很複雜了,項目帶來難以估量的測試成本。


  • Fixture其實能夠在任何場景中使用,由於是底層的複用。


缺陷

  • 增長了代碼複雜度。


  • 經過IDE工具沒法直接定位的測試文件,折衷的方案是case的命名符合ResouceTest的命名。


校場

從簡單到複雜

上面咱們介紹了測試金字塔,越考上層,複雜度越高。因此剛接觸單元測試的同窗,能夠從「單元測試」的層次開始練習,能夠練習Builder,Fixture怎麼寫,方法怎麼Mock。若是你感受這些都到了拿來即用的階段,那就能夠往上層寫,考慮下怎麼給項目增長一些通用的基礎設施,來減小測試的總體複雜度。

刻意練習:3F原則


刻意練習,簡而言之,就是刻意的練習,它突出的是有目的的練習。刻意練習也有它的一整套過程,在這個過程裏,你須要遵照它的3F法則:


  • 第一,Focus保持專一

  • 第二,Feedback(注重反饋,收集信息)。

  • 第三,Fix it(糾正錯誤,而且進行修改)。


UT自己是一項技術,是須要咱們打磨、練習的,最好的練習方式,就是刻意練習,若是有決心,一個週末在家刻意練習,爲項目中的部分場景加上UT,相信收穫會很豐富。


打造本身的測試環境

本身要不斷的摸索,什麼樣的組織方式,什麼樣的工具方法是適合本身項目的。軟件工程中沒有銀彈,沒有最好,只有合適。

常見問題

  • 應不該該連平常環境進行測試?


  • 我的不建議直接連平常環境進行測試,若是兩我的同時在跑測試,那麼頗有可能測試環境的數據會處於混亂狀態。並且UT儘量不要依賴過多的外部環境,依賴越多越複雜。測試仍是簡單點好。


  • 一個類裏面測試太多怎麼辦?


  • 考慮按測試的case區分,也可按測試的方法區分,也能夠按正常、異常場景區分。


  • 不知作別人mock了哪些數據怎麼辦?


  • 儘可能讓你們Mock數據的命名規範,經過Fixutre的複用,來減小新寫測試的成本。


  • 測試結構太複雜?


  • 考慮是否是本身應用的代碼組織就有問題?


  • 測試莫名奇妙起不來?


  • 須要詳細瞭解JUNIT、Spring、PandoraBoot等是如何進行測試環境的mock的,是否是測試間的數據衝突等。詳細的咱們會在方法篇持續更新,遇到問題解決問題。


心魔

單元測試這件事,實施的時候仍是有不少阻力的,筆者原來給本身也找過不少理由,不管是用來講服領導的,仍是說服本身的。下面是筆者對於這些理由的一些思考,但願能和你們有一些共鳴。

不會寫

雖然很不肯意認可這個事,但最後仍是認可了本身是真的不會寫單元測試。剛接觸單元測試的時候,看了看junit的文檔,心想單元測試,不就是個「Assert」嗎,有啥不會的,這東西好學。後來實施過程當中發現,單元測試不只僅是「Assert」,還須要準備環境,Mock數據,復現場景,驗證。着實是個麻煩事。

後來反思,爲何單元測試麻煩?一開始學習ORM框架的時候不麻煩嗎?一開始學Spring不麻煩嗎?後來熟悉了Bean的生命週期、BeanFactory、BeanProcessor等,Spring已經不是個麻煩事了。仔細想一想,本身對單元測試的理解僅僅是:「一個Mock加一個Assert」。僅僅學了幾個框架,看了幾篇文章,還作不到把單元測試這件事真正落地。

在落地單元測試的時候,有一些常見的問題:

場景太複雜,須要的數據太多,怎麼處理?

能夠直接使用JSON、SQL將現有數據修改後導入到系統中。這樣的話可能須要mock的數據就不會那麼多了,能夠提煉一些工具類,直接從resource中讀取數據文件,導入到數據庫、或者提供給mock方法使用。

也能夠構建一些Fixture,將本身系統中UT的數據固定下來,這樣,若是前面一個同窗已經mock過相關數據了,再新寫UT的時候能夠拿來即用。構建 Fixture能夠用工廠模式、構建者模式等來達到數據隔離的效果,避免相互干擾。

好多東西都是和中間件或者其餘系統頻繁交互,怎麼寫測試?

數據庫層面可使用內存型數據庫「H2」、"Embedded Mysql"、「Embedded PostgreSql」等。

若是以上都不能解決問題,可使用mockito直接mock相應的Bean。

單元測試的粒度問題,這個方法該不應寫UT,另一個方法爲何不須要寫UT?

單元測試的粒度沒有標準答案,筆者本身總結了一些寫UT粒度方面的方法:

  • 不熟悉單元測試寫法,儘可能寫簡單的單元測試,覆蓋核心方法。


  • 熟悉單元測試,業務複雜,覆蓋正常、通常異常場景,另外對核心業務邏輯要有單獨的測試。


測試如何複用?

測試應該是有組織、有結構的,就像咱們寫業務代碼同樣,會想着如何在代碼層面複用、如何在功能層面複用、如何在業務維度複用。單元測試也應該有結構,能夠儘可能複用一些前人的經驗。簡單來講,測試的複用也分爲三個維度:數據、場景、用例,好的代碼結構應該儘可能的能讓測試複用,讓增長UT再也不是從頭開始。

不想寫

寫測試有什麼用?

不少人都寫過單元測試的文章,羅列過不少單元測試的不少好處,這裏就不贅述了。這裏講幾個感觸比較深的用處吧?

  • DEBUG:阿里如今的基礎設施是真的完善,中間件、各類監控、日誌,只要系統埋點夠好,遇到的不少問題均可以解決,即便有一些複雜問題,也能夠local debug。但在一些特殊場景下,將數據MOCK好,利用UT來DEBUG,可能效率更高,你們能夠試試。


  • 測試如文檔:咱們如今開發有不少完善的文檔,但文檔這東西和代碼上畢竟有一層映射關係,若是能快速瞭解業務,完善的測試,有時候也是個不錯的選擇,例如你們學習一些開源框架的時候,都會從測試開始看。


  • 重構:當你想下定決心重構的時候,才發現項目中沒有單元測試,什麼心情?


價值不高

在面對複雜的接口時,經常須要Mock不少數據來支撐一個小的點,不少時候心裏感受沒價值,由於一個if-else的變更,居然須要準備N份數據,得不償失。

後來反思,爲何一個if-else的變更,須要準備N份數據?若是這個接口一開始寫的時候就有健全的UT,那一個if-else的變動還須要準備N份數據嗎?大機率不須要了吧,有可能只須要改一個測試case就行了。因此說如今成本高,未來成本會更高,如今作了,作的好一點,後面可能成本就低了。

筆者觀點:寫單元測試,應該比寫代碼的成本更低。


這個不用說吧,通用理由,你們都明白。路是人踩出來的,總要有人要先走。Why not you?

最後

若是你們對於單元測試有好的實踐,或者對文章中的一些觀點有些共鳴,你們能夠在評論區留言,咱們互相學習一下。你們也能夠在評論區寫出本身的場景,你們一塊兒探討如何針對特定場景來實踐。

相關連接

[1]https://martinfowler.com/bliki/TestPyramid.html
[2]https://martinfowler.com/articles/practical-test-pyramid.html




開發者成長計劃
最強資源,最強助力


阿里雲開發者成長計劃來啦!面向整年齡段開發者提供免費雲服務器、學習成長路線及場景體驗實踐,全面幫助開發者輕鬆掌握雲上技能,助推成長,培養數字經濟時代的雲計算技術人才!


識別下方二維碼,或點擊 「閱讀原文」 ,快去參與吧~



戳我,去開發者成長計劃。

本文分享自微信公衆號 - 阿里巴巴技術質量(AlibabaTechQA)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索