測試金字塔是對測試的分層描述,在不同層次做不同類型的測試。測試金字塔如何運用到工程實踐,是一件困難的事情。原文作者是一位德國Thoughtworks的軟件開發工程師,本文將回顧傳統的測試金字塔,並結合實例,進行一次有深度的探祕實踐。
自動化測試的重要性
軟件上線前都是要經過測試的,隨着測試技術發展,相比於傳統的手工測試,如今的自動化測試越來越重要,它能夠將成天上週的測試工作縮減到分鐘秒級,提高測試效率,更快發現缺陷。尤其是在敏捷開發、持續交付、DevOps文化中,自動化已經成爲了對測試的基本要求。比如持續交付,使用build pipeline自動測試和部署,隨時能發包到測試環境和生產環境。
測試金字塔
測試金字塔是Mike Cohn在他的書籍《Succeeding with Agile》中提出的概念:
測試金字塔描繪了不同層次的測試,以及應該在各個層次投入多少測試。由底向上包括3層:
-
Unit Tests
-
Service Tests
-
User Interface Tests
這是最原始的測試金字塔,從現代視角來看,這個金字塔顯得過於簡單了,並且可能造成誤導。比如service test不太能定義清楚。比如在react, angular, ember.js等單頁應用中,UI測試並不一定在最頂層,而是可以寫單元測試來測試UI。
但它有2點啓示:
-
編寫不同粒度的測試
-
層次越高,測試投入越少
實踐使用的工具和庫
-
JUnit:單元測試
-
Mockito:mock依賴
-
Wiremock:stub外部服務
-
Pact:編寫CDC測試
-
Selenium:編寫UI自動化
-
REST-assured:編寫REST接口自動化
一個簡單的應用
作者在GitHub上傳了開源項目(795star):
https://github.com/hamvocke/spring-testing
包含了遵循測試金字塔的分層測試的SpringBoot微服務應用。
功能
它提供了3個接口:
GET /hello
返回”Hello World“
GET /hello/{lastname}
返回"Hello {Firstname} {Lastname}"
GET /weather
返回德國柏林的天氣(作者住在這)
整體結構
Spring Service從數據庫取數據,對外提供API返回JSON數據,非常標準的簡單應用。
內部結構
-
Controller提供REST接口,並處理HTTP請求和響應;
-
Repository跟數據庫交互,負責持久化存儲的數據讀寫;
-
Client訪問外部API,比如這裏訪問了darksky.net的Weather API獲取天氣;
-
Domain定義領域模型,比如請求響應的結構體,也叫做POJO;
該應用支持CRUD,使用Spring Data訪問數據庫,數據庫用的也是內存數據庫,並且設計上省略掉了Service層,一切都爲了簡單,方便測試。
單元測試
什麼是單元?
不同人對單元有不同理解,所謂單元,通常指某個函數,單元測試就是使用不同參數來調用函數,驗證是否滿足預期結果。在面嚮對象語言中,單元,可以是單個方法,也可以是整個類。
Mock和Stub
Test Double是“測試複製品“的意思,用來統稱模擬真實對象的假對象:
Mock和Stub都是用來模擬的,它們的區別在於:
Stub只負責模擬,Mock還包括了驗證。
以上是晦澀難懂且無關緊要的理論概念。實際點的,拿本文用到的Mockito和WireMock來說,Mockito用於單元測試mock依賴,WireMock用於集成測試stub外部服務,本質上都是模擬。
測什麼
單元測試什麼都能測,這就是單元測試的好處。
編寫單元測試要遵循原則:一個production class對應一個test class。public要儘可能覆蓋,private無法覆蓋,protected或者package-private可覆蓋可不覆蓋,建議別覆蓋。並且要保證分支覆蓋,包括正常分支和邊界場景。
但是並不是所有的public都需要編寫單元測試,而是要避免瑣碎的測試,比如getters或setters就不要測了,比如一些沒有任何邏輯條件的也不需要測。
測試結構
-
初始化測試數據;
-
調用測試方法;
-
斷言預期結果;
這是所有測試的良好結構設計,不只是單元測試。這三步還有其他叫法:"Arrange, Act, Assert",或者"given", "when", "then"。
實現單元測試
對於以下ExampleController:
@RestController
public class ExampleController {
private final PersonRepository personRepo;
@Autowired
public ExampleController(final PersonRepository personRepo) {
this.personRepo = personRepo;
}
@GetMapping("/hello/{lastName}")
public String hello(@PathVariable final String lastName) {
Optional<Person> foundPerson = personRepo.findByLastName(lastName);
return foundPerson
.map(person -> String.format("Hello %s %s!",
person.getFirstName(),
person.getLastName()))
.orElse(String.format("Who is this '%s' you're talking about?",
lastName));
}
}
編寫單元測試:
public class ExampleControllerTest {
private ExampleController subject;
@Mock
private PersonRepository personRepo;
@Before
public void setUp() throws Exception {
initMocks(this);
subject = new ExampleController(personRepo);
}
@Test
public void shouldReturnFullNameOfAPerson() throws Exception {
Person peter = new Person("Peter", "Pan");
// Mockito模擬輸入輸出
given(personRepo.findByLastName("Pan"))
.willReturn(Optional.of(peter));
String greeting = subject.hello("Pan");
assertThat(greeting, is("Hello Peter Pan!"));
}
@Test
public void shouldTellIfPersonIsUnknown() throws Exception {
// Mockito模擬輸入輸出
given(personRepo.findByLastName(anyString()))
.willReturn(Optional.empty());
String greeting = subject.hello("Pan");
assertThat(greeting, is("Who is this 'Pan' you're talking about?"));
}
}
單元測試使用了JUnit,PersonRepository使用了Mockito模擬數據。第一個測試是驗證入參存在的名字會返回Hello。第二個測試是驗證入參不存在的名字會返回Who。
集成測試
單元測試是模塊內測試,針對模塊之間,就要做集成測試。還有其他部分,比如數據庫、文件系統、遠程調用其他應用等,這些在單元測試中會忽略或者mock掉,也都需要做集成測試。集成測試也有多種理解,可以理解爲全部集成的測試。而作者的想法是單獨集成,一次只集成一個,比如集成測試數據庫,那麼其他部分仍然使用mock:
-
啓動數據庫;
-
應用連接數據庫;
-
調用方法往數據庫寫數據;
-
從數據庫讀數據,驗證數據是剛纔寫入的;
比如集成測試其他服務:
-
啓動應用;
-
啓動其他服務的實例(或者模擬服務);
-
調用方法從其他服務的接口讀數據;
-
驗證當前應用能正確解析響應結果;
實現數據庫集成
PersonRepository:
public interface PersonRepository extends CrudRepository<Person, String> {
Optional<Person> findByLastName(String lastName);
}
PersonRepository繼承了CrudRepository,藉助於Spring Data自動實現了增刪改查,比如findOne
, findAll
, save
, update
, delete
等方法,對於findByLastName方法,Spring Data也會根據返回類型、方法名稱自動判斷進行適配處理。
示例,保存Person到數據庫中,並根據lastName查詢:
@RunWith(SpringRunner.class)
@DataJpaTest
public class PersonRepositoryIntegrationTest {
@Autowired
private PersonRepository subject;
@After
public void tearDown() throws Exception {
// 清理測試數據
subject.deleteAll();
}
@Test
public void shouldSaveAndFetchPerson() throws Exception {
Person peter = new Person("Peter", "Pan");
subject.save(peter);
Optional<Person> maybePeter = subject.findByLastName("Pan");
assertThat(maybePeter, is(Optional.of(peter)));
}
}
實現獨立服務集成
使用Wiremock模擬darksky.net服務:
@RunWith(SpringRunner.class)
@SpringBootTest
public class WeatherClientIntegrationTest {
@Autowired
private WeatherClient subject;
@Rule
public WireMockRule wireMockRule = new WireMockRule(8089);
@Test
public void shouldCallWeatherService() throws Exception {
wireMockRule.stubFor(get(urlPathEqualTo("/some-test-api-key/53.5511,9.9937"))
.willReturn(aResponse()
.withBody(FileLoader.read("classpath:weatherApiResponse.json"))
.withHeader(CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withStatus(200)));
Optional<WeatherResponse> weatherResponse = subject.fetchWeather();
Optional<WeatherResponse> expectedResponse = Optional.of(new WeatherResponse("Rain"));
assertThat(weatherResponse, is(expectedResponse));
}
}
怎麼才能訪問mock的這個服務呢?答案是在application.properties文件中配置:
weather.url = http://localhost:8089
以及WeatherClient實現:
@Autowired
public WeatherClient(final RestTemplate restTemplate,
@Value("${weather.url}") final String weatherServiceUrl,
@Value("${weather.api_key}") final String weatherServiceApiKey) {
this.restTemplate = restTemplate;
this.weatherServiceUrl = weatherServiceUrl;
this.weatherServiceApiKey = weatherServiceApiKey;
}
在集成測試darksky.net服務時,採用的是Wiremock,mock了darksky.net服務,如何驗證mock的服務和真實的服務之間有無差異呢,就要進行契約測試。
契約測試
在微服務架構體系中,應用被拆分成了多個獨立的松耦合的服務,彼此之間通過接口通信:
-
HTTPS
-
RPC
-
消息隊列
每個接口包含2部分:provider和consumer:
比如在HTTPS中,provider提供接口,consumer調用接口;比如在消息隊列中,provider發佈消息,consumer訂閱消息。
所謂契約,就是接口之間相互約定好的定義。傳統的契約過程是這樣的:
-
編寫詳盡的接口定義(契約);
-
根據契約實現provider;
-
把契約同步給consumer;
-
consumer根據契約實現;
-
運行起來手動驗證契約是否達成一致;
-
希望雙方都不要隨意變更契約;
而在CDC(Consumer-Driven Contract tests)中,第5、6步已經被自動化測試取代:
consumer編寫併發布契約測試,provider獲取並執行契約測試,當provider把所有契約測試都實現以後,自然就滿足consumer了。provider會把契約測試放入持續集成中,確保所有契約測試都能始終保持通過,假如consumer發佈了新的契約,契約測試就會失敗,從而提醒provider更新實現。
Consumer Test
使用Pact工具實現契約測試。
build.gradle
:
testCompile('au.com.dius:pact-jvm-consumer-junit_2.11:3.5.5')
WeatherClientConsumerTest:
@RunWith(SpringRunner.class)
@SpringBootTest
public class WeatherClientConsumerTest {
@Autowired
private WeatherClient weatherClient;
@Rule
public PactProviderRuleMk2 weatherProvider =
new PactProviderRuleMk2("weather_provider", "localhost", 8089, this);
@Pact(consumer="test_consumer")
public RequestResponsePact createPact(PactDslWithProvider builder) throws IOException {
return builder
.given("weather forecast data")
.uponReceiving("a request for a weather request for Hamburg")
.path("/some-test-api-key/53.5511,9.9937")
.method("GET")
.willRespondWith()
.status(200)
.body(FileLoader.read("classpath:weatherApiResponse.json"),
ContentType.APPLICATION_JSON)
.toPact();
}
@Test
@PactVerification("weather_provider")
public void shouldFetchWeatherInformation() throws Exception {
Optional<WeatherResponse> weatherResponse = weatherClient.fetchWeather();
assertThat(weatherResponse.isPresent(), is(true));
assertThat(weatherResponse.get().getSummary(), is("Rain"));
}
}
每次運行都會生成一個pact文件,target/pacts/&pact-name>.json
,這個文件就可以拿給provider實現契約,通常做法是讓provider在倉庫中取最新版本文件。
Provider Test
provider加載pact文件並實現契約:
@RunWith(RestPactRunner.class)
@Provider("weather_provider") // same as the "provider_name" in our clientConsumerTest
@PactFolder("target/pacts") // tells pact where to load the pact files from
public class WeatherProviderTest {
@InjectMocks
private ForecastController forecastController = new ForecastController();
@Mock
private ForecastService forecastService;
@TestTarget
public final MockMvcTarget target = new MockMvcTarget();
@Before
public void before() {
initMocks(this);
target.setControllers(forecastController);
}
@State("weather forecast data") // same as the "given()" in our clientConsumerTest
public void weatherForecastData() {
when(forecastService.fetchForecastFor(any(String.class), any(String.class)))
.thenReturn(weatherForecast("Rain"));
}
}
UI測試
UI測試主要驗證應用界面是否正確:
用戶輸入,觸發程序,數據展示給用戶,狀態變更正確。
UI自動化主要基於Selenium來做,由於前端變化大、控件識別難等問題,導致UI自動化失敗率比較高,可以考慮採用截圖的方式,把前後截圖進行對比,來做斷言,當然Selenium已經支持截圖對比了。
端到端測試
端到端測試,通常是指從用戶界面進行測試:
如果沒有用戶界面,也可以指對接口進行測試。
UI端到端測試
使用Selenium和WebDriver實現:
build.gradle
testCompile('org.seleniumhq.selenium:selenium-chrome-driver:2.53.1')
testCompile('io.github.bonigarcia:webdrivermanager:1.7.2')
HelloE2ESeleniumTest
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class HelloE2ESeleniumTest {
private WebDriver driver;
@LocalServerPort
private int port;
@BeforeClass
public static void setUpClass() throws Exception {
ChromeDriverManager.getInstance().setup();
}
@Before
public void setUp() throws Exception {
driver = new ChromeDriver();
}
@After
public void tearDown() {
driver.close();
}
@Test
public void helloPageHasTextHelloWorld() {
driver.get(String.format("http://127.0.0.1:%s/hello", port));
assertThat(driver.findElement(By.tagName("body")).getText(), containsString("Hello World!"));
}
}
接口端到端測試
使用REST-assured實現:
build.gradle
testCompile('io.rest-assured:rest-assured:3.0.3')
HelloE2ERestTest
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class HelloE2ERestTest {
@Autowired
private PersonRepository personRepository;
@LocalServerPort
private int port;
@After
public void tearDown() throws Exception {
personRepository.deleteAll();
}
@Test
public void shouldReturnGreeting() throws Exception {
Person peter = new Person("Peter", "Pan");
personRepository.save(peter);
when()
.get(String.format("http://localhost:%s/hello/Pan", port))
.then()
.statusCode(is(200))
.body(containsString("Hello Peter Pan!"));
}
}
驗收測試
在測試金字塔的位置越高,就越會站在用戶角度進行測試。驗收測試就是完全從用戶角度出發,看系統是否能滿足用戶需求。
簡單示例:
def test_add_to_basket():
# given
user = a_user_with_empty_basket()
user.login()
bicycle = article(name="bicycle", price=100)
# when
article_page.add_to_.basket(bicycle)
# then
assert user.basket.contains(bicycle)
探索測試
探索測試是一種手工測試方法,充分發揮了測試人員的自由和創造力。
探索測試發現缺陷以後,可以補充到自動化測試中,以避免將來出現這個問題。
不要執着於測試術語
單元測試、集成測試、端到端測試、驗收測試,每個人都有自己的不同理解,現在的軟件測試行業,也沒有統一的測試術語,將這些測試類型的邊界明確區分開來。只要我們在公司內部、團隊內部,能對術語達成一致,順暢溝通就可以了。
參考資料:
Thoughtworks研發博客 https://martinfowler.com/articles/practical-test-pyramid.html
Test Double http://xunitpatterns.com/Test Double.html
WireMock和Mockito區別 https://geek-docs.com/mockito/mockito-ask-answer/wiremock-vs-mockito.html
Pact官方文檔 https://docs.pact.io/