您現在的位置是:首頁 > 動作武俠首頁動作武俠

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

簡介使用spring的@Primary來替換一個bean,如果不同的測試需要的bean不同,推薦使用@Configuration + @Import的方式,動態載入Bean@Primary@Component(“cache”)public

ⅵvo怎樣開啟隱藏模式

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

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

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

內功

前人們在單元測試方面的研究很多,有很多的方法論,我們可以拿來即用。我簡單介紹兩個方法論,一個概念。希望大家可以查閱更多的資料,凝聚自己的內功心法。

TDD

Test Driven Development,也被認為是Test Driven Design,我們這裡按第一種定義來聊。TDD一改以往的破壞性測試的思維方式,測試在先、編碼在後,更符合“缺陷預防”的思想。簡單來說,TDD的流程是“紅-綠-重構”三個步驟的迴圈往復。

紅:測試先行,現在還沒有任何實現,跑UT的時候肯定不過,測試狀態是紅燈。編譯失敗也屬於“紅”的一種情況。

綠:當我們用最快,最簡單的方式先實現,然後跑一遍UT,測試會透過,變成“綠”的狀態。

重構:看一下系統中有沒有要重構的點,重構完,一定要保證測試是“綠”的。

業界有很多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> result = apiService。mobileRegister(); System。out。println(“result = ” + result); Assert。assertNotNull(result); Assert。assertEquals(54,result。getAlispCode()。longValue());

AlispResult> result2 = apiService。mobileRegister(); System。out。println(“result2 = ” + result2); Assert。assertNotNull(result2); Assert。assertEquals(9,result2。getAlispCode()。longValue());

AlispResult> 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> result = apiService。mobileRegister(); Assert。assertNotNull(result); Assert。assertFalse(result。isSuccess()); }}

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

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

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

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

when:哪個方法

given:哪個場景

另外BDD或者TDD中也有Task的概念,寫程式碼之前先準備好case。大家可以看一些BDD的文章,自己體會。如果對這個感興趣,可以在評論區探討。

測試金字塔

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

上圖來自martin fowler部落格的TestPyramid[1]一文,也可以讀一下《Practical Test Pyramid》[2]。特別棒的文章,希望大家可以去讀一讀。

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

我們可以從服務端的角度把這三層稍微改一下:

契約測試:測試服務與服務之間的契約,介面保證。代價最高,測試速度最慢。

整合測試(Integration):整合當前spring容器、中介軟體等,對服務內的介面,或者其他依賴於環境的方法的測試。

// 載入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 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 readJsonFromResource(String filename, Class 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

開發者成長計劃

最強資源,最強助力

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

Top