Tests drive the code

如何測試

feature → story → scenario→ step → given when then and but

原則

  1. 3A 原則 為了增加測試程式碼的可讀性,可以使用 3A 原則的格式來寫測試程式碼。

  2. Arrange(初始、期望結果):初始化目標物件和相依物件,設定物件之間的互動方式,並指定功能執行後的預期結果。

  3. Act(實際呼叫):執行待測試的功能。
  4. Assert(驗證):驗證結果是否符合預期
  1. FIRST 原則 好的單元測試應該要符合 FIRST 原則,分別是 F(Fast)、I(Independent)、R(Repeatable)、S(Self-validating) 和 T(Timely)。

綠燈-紅燈-重購

紅綠燈示意圖

graph LR red --> green --> refactor refactor --> red
  1. 新的測試一開始會失敗
  2. 試著讓測試通過
  3. 最後優化程式和測試 -------------------------------;

開發步驟

  1. 建立TDD 測試環境
  2. 導入mock Framework
  3. 綠燈-紅燈-重購
  4. 測試開發
  5. 程式實作開發
  6. 重構

建立 TDD測試環境

  1. 建立Test資料夾,順位同main
  2. 利用SpringFrameWork 提供的spring test
  3. 建立入口 Class ,加上@AutoConfigureMockMvc ,@SpringBootTest(classes = Application.class)
  4. @ActiveProfiles( "test_yaml" ) 設定使用的Yaml Properties
  5. 測試時在Test package底下,建立同等順位的package,測試主要商業邏輯;service層邏輯
  6. 建立要測試的Service TEST類別
  7. 在撰寫的Test Method 上加@Test 表示要測試的method

常見錯誤

  1. 一次編寫很多測試才實現

如何修改遺留代碼

  1. 確定修改點
  2. 找出測試點
  3. 消除依賴
  4. 編寫測試
  5. TDD 循環( 紅燈綠燈重構) -----------------------------------------;

Junit annotation

  • @CsvFileSource:表示读取指定CSV文件内容作为参数化测试入参
  • @MethodSource:表示读取指定方法的返回值作为参数化测试入参(注意方法返回需要是一个流)
  • @ValueSource: 为参数化测试指定入参来源,支持八大基础类以及String类型,Class类型
  • @NullSource: 表示为参数化测试提供一个null的入参
  • @EnumSource: 表示为参数化测试提供一个枚举入参
  • @FileSource:用于从外部Json或Yaml文件中读取入参
  • @Test: annotation 在test的類別上

策略模式與TDD

assertEquals()

分層測試

  • springMVC
  • controller Layer
  • Service Layer
  • Repository Layer
  • 在同樣的package分層下建立ClassTest 類別 --------------------------------------------;

BDD Style Syntax

//given-precondition or setup
//when-action or the behaviour testing
//then-verify the output

範例

  1. controller 範例
    //呼叫API基本測試
    String url = URL_PREFIX + "/TestURL";
    Request request = new Request();
    request.setApiId("001");
    try {
        String s = objectMapper.writeValueAsString(request);
   mockMvc.perform(MockMvcRequestBuilders.post(url)
    .content(s)
    .contentType(MediaType.APPLICATION_JSON)
    .accept(MediaType.APPLICATION_JSON))
    .andExpect(MockMvcResultMatchers.status().isOk());
    } catch (Exception e) {
        throw new RuntimeException(e);
        }
  • service 範例

    mock 依賴物件

ServiceTest(WorkClient workClient , CommonClient commonClient ) {
        this.workClient = mock(WorkClient .class);
        this.commonClient = mock(CommonClient .class)
    }

assertThrow 確認會拋出例外

BpaasException bpaasException = assertThrows(BpaasException.class, () -> {
            ruleSettingHelper.getSendPCSRFlag(request);
        });

doReturn 用法

doReturn(pmJobFunctionResponse).when(commonServiceClient).pmJobFunction( ArgumentMatchers.argThat(x -> x.getJob().equals("CPM")));
  • 參數驗證
@Captor //annotation用法
private SomeService service;
//method內直接使用
ArgumentCaptor<Person> argument = ArgumentCaptor.forClass(Person.class);
verify(mock).doSomething(argument.capture());
assertEquals("John", argument.getValue().getName());

ex

ArgumentCaptor<Map> argument = ArgumentCaptor.forClass(Map.class);
        verify(restTemplateSender).sendToTrigForm(eq("http://localhost:8080"), eq("key"),argument.capture());
        Assertions.assertTrue(argument.getValue().containsKey("orderLine"));
@ExtendWith(MockitoExtension.class)
class EmailServiceUnitTest {
    @Mock
    DeliveryPlatform platform;

    @InjectMocks
    EmailService emailService;

    @Captor
    ArgumentCaptor<Email> emailCaptor;

    @Test
    void whenDoesSupportHtml_expectHTMLEmailFormat() {
        String to = "info@baeldung.com";
        String subject = "Using ArgumentCaptor";
        String body = "Hey, let'use ArgumentCaptor";

        emailService.send(to, subject, body, true);

        verify(platform).deliver(emailCaptor.capture());

        Email capturedEmail = emailCaptor.getValue();

        assertEquals(Format.HTML, capturedEmail.getFormat());
    }
}
//Using @Captor with Mockito.when().thenReturn()2:
private ArgumentCaptor<ArgumentUsedInMockedClass> captor;

@Test
public void testMethod() {
    Result result = new Result();
    result.setResultId(RESULT_ID);

    Mockito.verify(mockedClass).doSomething(captor.capture());
    Mockito.when(mockedClass.doSomething(captor.getValue())).thenReturn(result);
}
//Using @Captor in a real-world scenario3
public class SearchServiceTest {
    @Test
    void testSearchService() {
        SearchEngine searchEngine = mock(SearchEngine.class);
        SearchService searchService = new SearchService(searchEngine);

        ArgumentCaptor<String> searchTermCaptor = ArgumentCaptor.forClass(String.class);

        String searchTerm = "AEM JUNIT5 test examples";

        when(searchEngine.performSearch(searchTermCaptor.capture())).thenReturn(List.of("result1", "result2"));

        List<String> searchResults = searchService.search(searchTerm);

        verify(searchEngine).performSearch(searchTermCaptor.capture());

        assertEquals(searchTerm, searchTermCaptor.getValue());
        assertEquals(2, searchResults.size());
    }
}
  • DataJpaTest

    測試儲存至H2 DB ,只注入Entity物件 application propeties 設定 spring.profiles.active=test 新增application-test-properties

spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect

建立測試類別 使用
@ExtendWith(Class)
@DataJpaTest //autoconfigure in-memory embedded db for testing @AutoConfigureTestDatabase(replace= AutoConfigureTestDatabase.Replace.NONE)關閉H2 DB 只掃描Entity和Repository的spring bean
ex: givenEmployeeObjectWhenSaveThenReturnSavedEmployee

//@ExtendWith(Class)  
@DataJpaTest
//不使用H2 DB加上此註解
@AutoConfigureTestDatabase(replace= AutoConfigureTestDatabase.Replace.NONE)
public class EntityRepositoryTest {
    private DataSource dataSource;
    private JdbcTemplate jdbcTemplate;
    private EntityManager entityManager;
    private HeaderRepository headerRepository;


    public EntityRepositoryTest (DataSource dataSource, JdbcTemplate jdbcTemplate, EntityManager entityManager, HeaderRepository headerRepository) {
        this.dataSource = dataSource;
        this.jdbcTemplate = jdbcTemplate;
        this.entityManager = entityManager;
        this.headerRepository= headerRepository;
    }

    public void givenEmployeeObjectWhenSaveThenReturnSavedEmployee(){
        //given
        Employee employee =Emloyee.builder()
        .firstName()
        .lastName()
        .builer();
        //When 
        Employee savedEmployee = employeeRepository.save(employee);
        //Then
        Assertions.assertThat(saveEmployee).isNotNull();  
        Assertions.assertThat(saveEmployee.getId()).isGreaterThan(0);
    }
}
//Testing the Update Operation
@Test
void givenUser_whenUpdated_thenCanBeFoundByIdWithUpdatedData() {
    testUser.setUsername("updatedUsername");
    userRepository.save(testUser);

    User updatedUser = userRepository.findById(testUser.getId()).orElse(null);

    assertNotNull(updatedUser);
    assertEquals("updatedUsername", updatedUser.getUsername());
}
// Testing the findByUsername() Method
@Test
void givenUser_whenFindByUsernameCalled_thenUserIsFound() {
    User foundUser = userRepository.findByUsername("testuser");

    assertNotNull(foundUser);
    assertEquals("testuser", foundUser.getUsername());
}

常用技巧

BeforeAfter

@BeforeEach
public void setUp() {
    // Initialize test data before each test method
    testUser = new User();
    testUser.setUsername("testuser");
    testUser.setPassword("password");
    userRepository.save(testUser);
}

@AfterEach
public void tearDown() {
    // Release test data after each test method
    userRepository.delete(testUser);
}

nested

通常,我们可以在一个测试类中,可以写若干个 @test,但是会有一个问题,假如这个测试类以后要加更多的 feature,就会让整个类越来越混乱。

所以这个时候,就会考虑把这个测试类拆分,分成若干个测试类,把相关的Case放到另外一个文件中。使得整个Case成为一个树状结构。

那假如以后的文件越来越多的话,那就考虑创建多个文件夹。让Case依然保持树状结构。

JUnit提供了另外一种方法,@Nested,在一个测试类中,通过 @Nested标注内部类,也可以使得用例保持树状结构,以下面的例子举例。

public class NestedTest {
    @Nested
    class FirstNestedClass {
        @Test
        void test() {
            System.out.println("FirstNestedClass.test()");
        }
    }

    @Nested
    class SecondNestedClass {
        @Test
        void test() {
            System.out.println("SecondNestedClass.test()");
        }
    }
}

DisplayName

顯示名稱

@ParameterizedTest(name = "{index} {0} is 30 days long")
@EnumSource(value = Month.class, names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"})
void someMonths_Are30DaysLong(Month month) {
    final boolean isALeapYear = false;
    assertEquals(30, month.length(isALeapYear));
}
@ParameterizedTest(name = ARGUMENTS_WITH_NAMES_PLACEHOLDER)
@ValueSource(ints = {1, 3, 5, -3, 15, Integer.MAX_VALUE}) // six numbers
void isOdd_ShouldReturnTrueForOddNumbers(int number) {
    assertTrue(Numbers.isOdd(number));
}

常用assert語法

  • assertArrayEquals
  • assertEquals
  • assertTrue and assertFalse
  • assertNull and assertNotNull
  • assertSame and assertNotSame
  • fail
  • assertAll
  • assertIterableEquals
  • assertLinesMatch
  • assertNotEquals
  • assertThrows
  • assertTimeout and assertTimeoutPreemptively

mock

@Mock:
在Mockito中用于创建mock对象,使用方法如下:
@Mock
private ClassName mockedObject;
上面代码创建了一个名为mockedObject,类型为ClassName的mock对象,该对象所有的方法被置空,根据测试代码逻辑的需要使用

@ExtendWith(MockitoExtension.class)
class ConstructorInjectionUnitTest {
    Function<String, String> function;

    public ConstructorInjectionUnitTest(@Mock Function<String, String> function) {
        this.function = function;
    }

    @Test
    void whenInjectedViaArgumentParameters_thenSetupCorrectly() {
        when(function.apply("bael")).thenReturn("dung");
        assertEquals("dung", function.apply("bael"));
    }
}
//@InjectMocks Annotation
//mock example
@Mock
Map<String, String> wordMap;

@InjectMocks
MyDictionary dic = new MyDictionary();

@Test
public void whenUseInjectMocksAnnotation_thenCorrect() {
    Mockito.when(wordMap.get("aWord")).thenReturn("aMeaning");

    assertEquals("aMeaning", dic.getMeaning("aWord"));
}

public class MyDictionary {
    Map<String, String> wordMap;

    public MyDictionary() {
        wordMap = new HashMap<String, String>();
    }
    public void add(final String word, final String meaning) {
        wordMap.put(word, meaning);
    }
    public String getMeaning(final String word) {
        return wordMap.get(word);
    }
}
// Injecting a Mock Into a Spy
@Mock
Map<String, String> wordMap;

@Spy
MyDictionary spyDic = new MyDictionary();

@Test 
public void whenUseInjectMocksAnnotation_thenCorrect() { 
    Mockito.when(wordMap.get("aWord")).thenReturn("aMeaning"); 

    assertEquals("aMeaning", spyDic.getMeaning("aWord")); 
}
//Instead of using the annotation, we can now create the spy manually:
@Mock
Map<String, String> wordMap; 

MyDictionary spyDic;

@@BeforeEach
public void init() {
    MockitoAnnotations.openMocks(this);
    spyDic = Mockito.spy(new MyDictionary(wordMap));
}

MyDictionary(Map<String, String> wordMap) {
    this.wordMap = wordMap;
}

injectmock

@InjectMock:
这是一个注入mock对象的操作,参考如下代码:
@Mock
private ClassName mockedObject;
@InjectMock
private TestedClass TestedObj = new TestedClass();
这段代码中,@InjectMock下面声明了一个待测试的对象,若该对象有类型为ClassName的成员变量,@Mock定义的mock对象将会被注入到这个待测试的对象中,既TestedObj的类型为ClassName的成员被直接赋值为mockedObject。(熟悉依赖注入的同学应该很容易理解)

spy

@spy使用的真实的对象实例,调用的都是真实的方法,所以通过这种方式进行测试,在进行sonar覆盖率统计时统计出来是有覆盖率;

mock spy injectMock ref

參考來源 : https://baeldung-cn.com/mockito-annotations

ATDD(驗收測試)

從RestController開始測試