軟件開發工程師談測試金字塔實踐

測試金字塔是對測試的分層描述,在不同層次做不同類型的測試。測試金字塔如何運用到工程實踐,是一件困難的事情。原文作者是一位德國Thoughtworks的軟件開發工程師,本文將回顧傳統的測試金字塔,並結合實例,進行一次有深度的探祕實踐。

自動化測試的重要性

軟件上線前都是要經過測試的,隨着測試技術發展,相比於傳統的手工測試,如今的自動化測試越來越重要,它能夠將成天上週的測試工作縮減到分鐘秒級,提高測試效率,更快發現缺陷。尤其是在敏捷開發、持續交付、DevOps文化中,自動化已經成爲了對測試的基本要求。比如持續交付,使用build pipeline自動測試和部署,隨時能發包到測試環境和生產環境。

測試金字塔

測試金字塔是Mike Cohn在他的書籍《Succeeding with Agile》中提出的概念:

測試金字塔描繪了不同層次的測試,以及應該在各個層次投入多少測試。由底向上包括3層:

  1. Unit Tests

  2. Service Tests

  3. User Interface Tests

這是最原始的測試金字塔,從現代視角來看,這個金字塔顯得過於簡單了,並且可能造成誤導。比如service test不太能定義清楚。比如在react, angular, ember.js等單頁應用中,UI測試並不一定在最頂層,而是可以寫單元測試來測試UI。

但它有2點啓示:

  1. 編寫不同粒度的測試

  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就不要測了,比如一些沒有任何邏輯條件的也不需要測。

測試結構

  1. 初始化測試數據;

  2. 調用測試方法;

  3. 斷言預期結果;

這是所有測試的良好結構設計,不只是單元測試。這三步還有其他叫法:"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:

  1. 啓動數據庫;

  2. 應用連接數據庫;

  3. 調用方法往數據庫寫數據;

  4. 從數據庫讀數據,驗證數據是剛纔寫入的;

比如集成測試其他服務:

  1. 啓動應用;

  2. 啓動其他服務的實例(或者模擬服務);

  3. 調用方法從其他服務的接口讀數據;

  4. 驗證當前應用能正確解析響應結果;

實現數據庫集成

PersonRepository:

public interface PersonRepository extends CrudRepository<Person, String> {
    Optional<Person> findByLastName(String lastName);
}

PersonRepository繼承了CrudRepository,藉助於Spring Data自動實現了增刪改查,比如findOnefindAllsaveupdatedelete等方法,對於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訂閱消息。

所謂契約,就是接口之間相互約定好的定義。傳統的契約過程是這樣的:

  1. 編寫詳盡的接口定義(契約);

  2. 根據契約實現provider;

  3. 把契約同步給consumer;

  4. consumer根據契約實現;

  5. 運行起來手動驗證契約是否達成一致;

  6. 希望雙方都不要隨意變更契約;

而在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/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章