阿里妹導讀:測試不該該是一門很高大尚的技術,應該是咱們技術人的基本功。但如今好像慢慢地,單元測試已經脫離了基本功的範疇。筆者曾經在不一樣團隊中推過單元測試,要求過覆蓋率,但發現實施下去很難。後來在不停地刻意練習後,發現阻礙寫UT的只是筆者的心魔,並非時間和項目的問題。在通過一些項目的實踐後,也是有了一些本身的理解和實踐,但願和你們分享一下,和你們探討下如何克服「單元測試」的心魔。css
文末福利:開發者成長計劃,最強助力!html
紅:測試先行,如今尚未任何實現,跑UT的時候確定不過,測試狀態是紅燈。編譯失敗也屬於「紅」的一種狀況。
前端
綠:當咱們用最快,最簡單的方式先實現,而後跑一遍UT,測試會經過,變成「綠」的狀態。java
重構:看一下系統中有沒有要重構的點,重構完,必定要保證測試是「綠」的。
web
@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()); }}
should:返回值,應該產生的結果
springwhen:哪一個方法sql
given:哪一個場景
typescript

契約測試:測試服務與服務之間的契約,接口保證。代價最高,測試速度最慢。
數據庫
集成測試(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(場景)。
經過組合Scenario(場景)+ Fixture(固定設施),構造一個case(用例)。

Case:當用戶正常登陸後,獲取當前登陸信息時,應該返回正確的用戶信息。這是一個簡單的用戶登陸的case,這個case裏面總共有兩個動做、場景,一個是用戶正常登陸,一個是獲取用戶信息,演化爲兩個scenario。
Scenario:用戶正常登陸,確定須要登陸參數,如:手機號、驗證碼等,另外隱含着數據庫中應該有一個對應的用戶,若是登陸時須要與第三方系統進行交互,還須要對第三方系統進行mock或者stub。獲取用戶信息時,確定須要上一階段頒發的憑證信息,另外該憑證多是存儲於一些緩存系統的,因此還須要對中間件進行mock或者stub。
Fixture
利用Builder模式構造請求參數。
利用DataFile來存儲構造用戶的信息,例如DB transaction進行數據的存儲和隔離。
利用Mockito進行三方系統、中間件的Mock。
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")); }}
@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
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
//使用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; }}
使用@Transactional在一些測試的類上,這樣在跑完測試後,數據不會commit,會回滾。但若是測試中對事物的傳播有特殊要求,可能不適用。
通用的trancateAll和initSQL經過在每一個測試前跑清除數據、mock數據的腳本,來達到每一個測試對應一個隔離環境,這樣數據間就不會產生干擾。
PowerMockito.mockStatic(C.class);PowerMockito.when(C.isTrue()).thenReturn(true);
注意:
PowerMock不只僅是用來mock靜態方法的。
不建議mock靜態方法,由於靜態方法的使用場景都是些純函數,大部分的純函數不須要mock。部分靜態方法依賴於一些環境和數據,針對這些方法,須要考慮下究竟是要mock其依賴的數據和方法,仍是真的要mock這個函數,由於一旦mock了這個函數,意味着隱藏了細節。
@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)數據文件
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自己會給測試帶來複雜度,而UnitTest應該簡單,若是UnitTest自己都很複雜了,項目帶來難以估量的測試成本。
Fixture其實能夠在任何場景中使用,由於是底層的複用。
增長了代碼複雜度。
經過IDE工具沒法直接定位的測試文件,折衷的方案是case的命名符合ResouceTest的命名。
刻意練習,簡而言之,就是刻意的練習,它突出的是有目的的練習。刻意練習也有它的一整套過程,在這個過程裏,你須要遵照它的3F法則:
第一,Focus(保持專一)。
第二,Feedback(注重反饋,收集信息)。
第三,Fix it(糾正錯誤,而且進行修改)。
UT自己是一項技術,是須要咱們打磨、練習的,最好的練習方式,就是刻意練習,若是有決心,一個週末在家刻意練習,爲項目中的部分場景加上UT,相信收穫會很豐富。
應不該該連平常環境進行測試?
我的不建議直接連平常環境進行測試,若是兩我的同時在跑測試,那麼頗有可能測試環境的數據會處於混亂狀態。並且UT儘量不要依賴過多的外部環境,依賴越多越複雜。測試仍是簡單點好。
一個類裏面測試太多怎麼辦?
考慮按測試的case區分,也可按測試的方法區分,也能夠按正常、異常場景區分。
不知作別人mock了哪些數據怎麼辦?
儘可能讓你們Mock數據的命名規範,經過Fixutre的複用,來減小新寫測試的成本。
測試結構太複雜?
考慮是否是本身應用的代碼組織就有問題?
測試莫名奇妙起不來?
須要詳細瞭解JUNIT、Spring、PandoraBoot等是如何進行測試環境的mock的,是否是測試間的數據衝突等。詳細的咱們會在方法篇持續更新,遇到問題解決問題。
不熟悉單元測試寫法,儘可能寫簡單的單元測試,覆蓋核心方法。
熟悉單元測試,業務複雜,覆蓋正常、通常異常場景,另外對核心業務邏輯要有單獨的測試。
DEBUG:阿里如今的基礎設施是真的完善,中間件、各類監控、日誌,只要系統埋點夠好,遇到的不少問題均可以解決,即便有一些複雜問題,也能夠local debug。但在一些特殊場景下,將數據MOCK好,利用UT來DEBUG,可能效率更高,你們能夠試試。
測試如文檔:咱們如今開發有不少完善的文檔,但文檔這東西和代碼上畢竟有一層映射關係,若是能快速瞭解業務,完善的測試,有時候也是個不錯的選擇,例如你們學習一些開源框架的時候,都會從測試開始看。
重構:當你想下定決心重構的時候,才發現項目中沒有單元測試,什麼心情?
最後
若是你們對於單元測試有好的實踐,或者對文章中的一些觀點有些共鳴,你們能夠在評論區留言,咱們互相學習一下。你們也能夠在評論區寫出本身的場景,你們一塊兒探討如何針對特定場景來實踐。
相關連接
[1]https://martinfowler.com/bliki/TestPyramid.html
[2]https://martinfowler.com/articles/practical-test-pyramid.html
阿里雲開發者成長計劃來啦!面向整年齡段開發者提供免費雲服務器、學習成長路線及場景體驗實踐,全面幫助開發者輕鬆掌握雲上技能,助推成長,培養數字經濟時代的雲計算技術人才!
識別下方二維碼,或點擊 「閱讀原文」 ,快去參與吧~

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