【Practice】Teach you how to implement TDD step by step

e916aeba467c4e07eb874232166b751d.gif

I. Introduction

Domain Driven Design, Test Driven Development.

We introduced the practice of domain-driven design (DDD) in the article "Teaching you to implement DDD hand in hand". This article will discuss test-driven development (TDD). The main contents include: basic understanding of TDD, common misunderstandings of TDD, selection of TDD techniques type, as well as actual combat cases. I hope that through this article, readers can understand and master TDD and apply it to actual development.

2. Basic understanding of TDD

Test-driven development (TDD) is a software development method that requires developers to write test cases before writing code, then write code to satisfy the test cases, and finally run the test cases to verify that the code is correct. The basic process of test-driven development is as follows:

2.1 The first step, writing test cases

Before writing code, write test cases according to the requirements. The test cases should cover all possible situations to ensure the correctness of the code.

This step is also called "red light", because the function is not implemented, the execution of the test case will fail at this time, and an error will be reported when it is executed in the IDE, and the error is red.

420f93e60b33591ef4ee16dc89bb4702.png

2.2 The second step, run the test case

These test cases will all fail since no code is written to satisfy them.

2.3 The third step, write code

Writing code to satisfy the test cases, in the process, we need to write enough code to make all the test cases pass.

This step is also called "green light". When it is successfully executed in the IDE, it is green, which is very vivid.

21e9cc6652093df800a098ebd5fa0901.png

2.4 The fourth step, run the test case

After writing the code, run the test cases to ensure that all use cases pass. If any test case fails, you need to go back to the third step and modify the code until all the test cases pass.

2.5 The fifth step, refactoring the code

After ensuring that all test cases pass, the code can be refactored, such as extracting repeated code into functions or classes, eliminating redundant code, etc.

The purpose of refactoring is to improve the readability, maintainability and extensibility of the code. Refactoring does not change the function of the code, but only optimizes the code, so the code after refactoring must still pass the test cases.

2.6 The sixth step, run the test case

The code after refactoring must also ensure that all test cases pass, otherwise it needs to be modified until the test cases pass.

3. Common misunderstandings of TDD

3.1 Myth 1: Unit testing is TDD

Unit testing is the basis of TDD, but unit testing is not equivalent to TDD.

Unit testing is a method of testing that aims to verify that a single component in the code, such as a class or method, works as expected.

TDD is a software development method that emphasizes writing test cases (that is, unit test cases) before writing code, and continuously running test cases to guide the design and implementation of code. TDD is based on unit testing, and the test cases written by TDD are unit test cases.

TDD also emphasizes the refactoring phase in the test-driven development process, where code structure and design are optimized to improve code quality and maintainability. Unit tests usually do not include a refactoring phase, since they focus primarily on functional verification of unit components.

3.2 Misunderstanding 2: Mistaking integration testing as unit testing

TDD cannot be promoted in many teams, even unit testing cannot be promoted. In the final analysis, everyone has misunderstandings about TDD and unit testing. When many developers write test cases, they think they are writing unit tests, but in fact they write use cases for integration tests. The reason is that they do not understand the difference between unit tests and integration tests.

Unit testing is the process of checking and verifying the smallest testable unit in software, usually a single function or method of code. The object of unit testing is the smallest testable unit in the code, usually a function or method. The scope of unit testing is usually limited to a single function or method, and only focuses on the correctness of the function or method's processing of input data and output data, and does not involve the impact of other functions or methods, nor does it consider the overall function of the system.

Integration testing refers to combining modules that pass unit tests for testing to verify that they can work together and work together. The object of the integration test is a component or module in the system, usually a combination of multiple modules that have passed the unit test for testing. Integration testing can find compatibility issues between modules, data consistency issues, system performance issues, etc.

In actual development, many developers only write test cases for the top-level methods, such as directly writing test cases for the Controller method, and then start the container, read and write external databases, and test the Controller, Service, and Dao all at once to save trouble. This actually writes the integration test case, which results in:

  • Test case responsibility is not single

The unit test case should have a single responsibility, that is, it only verifies the execution logic of the business code, does not ensure the integration with the outside, and the test cases that integrate external services or middleware should be regarded as integration tests.

  • Test case granularity is too large

Only write test cases (integration tests) for the top-level methods, ignoring many public methods in the process, which will lead to low unit test coverage and unguaranteed code quality.

  • Test case execution is too slow

Due to the need to rely on infrastructure (connecting to the database), the execution of test cases will be very slow. If the unit test cannot be executed quickly, developers will often lose patience and will not continue to invest in unit testing.

It can be said that slow execution is a very big reason why unit testing and TDD cannot be promoted.

Conclusion: Unit tests must shield calls to infrastructure (external services, middleware), and unit tests are only used to verify that business logic is executed as expected.

The method for judging whether the use case you write is a unit test case is very simple: you only need to turn off the network of the developer’s computer. If the unit test can be executed locally normally, then the basic test is a unit test, otherwise it is an integration test case.

2.3 Misunderstanding 3. Don’t write unit tests when the project schedule is tight

When developers submit their code for testing, we often require that they pass the self-test before they can be tested. So, what is the basis for passing the self-test? I think the basis for passing the self-test is that the unit test cases written by the developer have passed and covered all the core methods related to this development.

When we schedule the requirements, we can take the self-test time into account and buy enough time for the unit test.

The earlier the unit test is more effective, we can find the errors and defects in the code early and fix them in time, so as to improve the reliability and quality of the code, instead of waiting for the test to be repaired, and the repair cost is higher at this time .

In the case of a tight project schedule, you should insist on writing unit tests, which will not affect the project progress. On the contrary, it can help us improve the quality and reliability of the code, reduce the occurrence of errors and defects, thereby avoiding additional costs and delays caused by errors later.

This article introduces many ways to submit unit test running speed, readers can apply them to actual projects, and reduce the impact of unit test on development time.

2.4 Misunderstanding 4: After the code is completed, add unit tests

Writing unit tests is encouraged at any time and can benefit us from unit testing.

The practice of writing unit tests after the code is complete can cause problems to be overlooked during the development process and discovered later, thereby increasing the cost and risk of fixing problems.

TDD requires writing test cases before writing code. Developers should start writing corresponding test cases before writing code, and run test cases after each code modification to ensure the correctness of the code.

2.5 Misunderstanding 5. Extreme requirements for unit test coverage

Some teams require 100% unit test coverage, while others have no requirement for coverage.

In theory, unit tests should cover all code and all boundary conditions. In practice, we also need to consider the input-output ratio.

In TDD, the test cases written in the red light stage will cover all related public methods and boundary conditions; in the refactoring stage, some execution logic is extracted as private methods, and we require that only operations in these private methods are no longer Boundary judgment is performed, so we don't need to consider the unit test of the private method generated after refactoring.

2.6 Myth 6: Unit tests only need to be run once

Many developers believe that as long as the unit test passes, it is enough to prove that the code they wrote meets the requirements of this iteration, and there is no need to run it again.

In fact, the life cycle of the unit test is the same as that of the project code. The unit test is not only run once, but its impact will continue until the project is offline.

Every time you go online, you should perform a full unit test to ensure that the previous test cases can pass, and the code developed for this requirement does not affect the previous logic. Doing so can avoid many online accidents.

Some old systems, when we are not familiar with the internal logic, how to make the scope of change controllable? The answer is to execute all the unit test cases. If the execution of the previous test cases fails, it means that our development has affected the online logic. What if the old system does not have unit tests? repair. Fortunately, there are many tools for automatically generating unit tests, and readers can study them by themselves.

4. TDD technology selection

4.1 Unit testing framework

Both JUnit and TestNG are excellent Java unit testing frameworks. You can practice TDD completely if you choose one of them. This article uses JUnit 5.

4.2 Mock Object Framework

In unit testing, we often need to use Mock to simulate objects in order to simulate their behavior, making unit testing easier to write.

There are many Mock frameworks, such as Mockito, PowerMock, etc. This article uses Mockito.

4.3 Test coverage

This article uses Jacoco as a test coverage detection tool.

Jacoco is a Java code coverage tool that helps developers monitor the coverage of test cases during code writing to better understand the quality of test cases and the reliability of code. Jacoco can collect coverage information during code execution, and can also generate reports so that developers can better understand the test coverage of the code.

Jacoco also supports the use in build tools such as Maven and Gradle. Developers can integrate by adding Jacoco plugin in pom.xml or build.gradle file.

4.4 Test report

There are many test report frameworks, such as Allure, readers can study and learn by themselves.

Five, TDD case combat

5.1 Strange calculator

In this case, we will implement a strange calculator and practice several steps of TDD through this case.

Due to space limitations, configurations such as Maven pom files and test report generation will not be posted. Readers are invited to check this case code tdd-example/tdd-example-01 by themselves.

The code address of this case is: https://github.com/feiniaojin/tdd-example

5.1.1 First iteration

The odd calculator needs the following:

 
  
输入:输入一个int类型的参数
处理逻辑:
  (1)入参大于0,计算其减1的值并返回;
  (2)入参等于0,直接返回0;
  (3)入参小于0,计算其加1的值并返回

Then use TDD for development.

  • first step, red light

Write test cases to achieve the above requirements. Note that there are three boundary conditions, which must be fully covered.

public class StrangeCalculatorTest {  
  
  private StrangeCalculator strangeCalculator;  
    
    
  @BeforeEach  
  public void setup() {  
    strangeCalculator = new StrangeCalculator();  
  }  
    
  @Test  
  @DisplayName("入参大于0,将其减1并返回")  
  public void givenGreaterThan0() {  
    //大于0的入参  
    int input = 1;  
    int expected = 0;  
    //实际计算  
    int result = strangeCalculator.calculate(input);  
    //断言确认是否减1  
    Assertions.assertEquals(expected, result);  
  }  
    
  @Test  
  @DisplayName("入参小于0,将其加1并返回")  
  public void givenLessThan0() {  
    //小于0的入参  
    int input = -1;  
    int expected = 0;  
    //实际计算  
    int result = strangeCalculator.calculate(input);  
    //断言确认是否减1  
    Assertions.assertEquals(expected, result);  
  }  
    
  @Test  
  @DisplayName("入参等于0,直接返回")  
  public void givenEquals0() {  
    //等于0的入参  
    int input = 0;  
    int expected = 0;  
      
    //实际计算  
    int result = strangeCalculator.calculate(input);  
    //断言确认是否等于0  
    Assertions.assertEquals(expected, result);  
  }  
}

At this time, the StrangeCalculator class and the calculate method have not been created, and it is normal for the IDE to report a red reminder.

Create the StrangeCalculator class and the calculate method. Note that the business logic is not implemented at this time, and the test case should fail to pass. An UnsupportedOperationException is thrown here.

 
  
public class StrangeCalculator {


  public int calculate(int input) {  
    //此时未实现业务逻辑,因此抛一个不支持操作的异常,以便使测试用例不通过
    throw new UnsupportedOperationException();  
  }  
}

Run all unit tests:

3200ac33297f846811cdff39a1c55087.png

At this point the report test fails:

fcdcb48caf3613b7aa3bcaee14bcb619.png

  • Step two, green light

First implement the logic corresponding to the givenGreaterThan0 test case:

 
  
public class StrangeCalculator {  
  public int calculate(int input) {  
    //大于0的逻辑  
    if (input > 0) {  
      return input - 1;  
    }  
    //未实现的边界依旧抛出UnsupportedOperationException异常
    throw new UnsupportedOperationException();  
  }  
}

Note that we currently only implement the boundary condition of input>0, and we should continue to throw exceptions for other conditions so that it does not pass.

Running the unit tests, at this point there are 3 test cases, only two of which fail.

e2cb09d9a72fcf2f8814546cd65768f9.png

Continue to implement the logic corresponding to the givenLessThan0 use case:

 
  
public class StrangeCalculator {  
  public int calculate(int input) {  


    if (input > 0) {  
      //大于0的逻辑  
      return input - 1;  
    } else if (input < 0) {
      //小于0的逻辑  
      return input + 1;  
    }  
    //未实现的边界依旧抛出UnsupportedOperationException异常
    throw new UnsupportedOperationException();  
  }  
}

Run the unit test, there are 3 test cases at this time, 1 of which is wrong:

1dd029b4569aa7dd147ffb71de019e38.png

Continue to implement the logic corresponding to the givenEquals0 use case:

 
  
public class StrangeCalculator {  
  public int calculate(int input) {  
    //大于0的逻辑  
    if (input > 0) {  
      return input - 1;  
    } else if (input < 0) {  
      return input + 1;  
    } else {  
      return 0;  
    }  
  }  
}

Run unit tests: At this point, all 3 test cases pass:

2f2ff86abb75b84e4d425e2d7c0ece97.png

At this point, open Jacoco's test coverage report (configure the location of report generation as target/jacoco-report in the pom.xml file of tdd-example), and open index.html.

05a019cbae40df63d479a90e6b798478.png

f24ba0e8e2fb69e1d9cf32b6cd352f57.png

61f5d8c9fb81affec65c4b47d946b580.png

0b66f09c7e801b289ca2b42a5ef8642f.png

It can be seen that all the boundary conditions of calculate are covered.

  • The third step, reconstruction

In this case, there are only simple calculations in calculate. In actual development, when we refactor, we can extract specific business operations as private methods, for example:

 
  
public class StrangeCalculator { 


  public int calculate(int input) {  
    //大于0的逻辑  
    if (input > 0) {  
      return doGivenGreaterThan0(input);  
    } else if (input < 0) {  
      return doGivenLessThan0(input);  
    } else {  
      return doGivenEquals0(input);  
    }  
  }  
  
  private int doGivenEquals0(int input) {  
    return 0;  
  }  
  
  private int doGivenLessThan0(int input) {  
    return input + 1;  
  }  
  
  private int doGivenGreaterThan0(int input) {  
    return input - 1;  
  }  
}

Execute the unit test again and the test passes.

e813bf01c28a53849f7af91e4148db7a.png

Looking at the Jacoco coverage report, you can see that every boundary condition is covered.

5b785233fb221c3e57d1d38abe823b6c.png

5.1.2 Second iteration

The requirements for the second iteration of the odd calculator are as follows:

 
  
(1)针对大于0且小于100的input,不再计算其减1的值,而是计算其平方值;

The requirements of the second version have adjusted the boundary conditions of the previous iteration. We need to sort out new and complete boundary conditions based on this iteration:

 
  
(1)针对大于0且小于100的input,计算其平方值;
(2)针对大于等于100的input,计算其减去1的值;
(3)针对小于0的input,计算其加1的值;
(4)针对等于0的input,返回0

At this time, the input parameters of the previous test cases may no longer meet the new boundary, but we will ignore it for the time being and continue the TDD "red light-green light-refactoring" process.

  • first step, red light

Write a new unit test case in StrangeCalculatorTest to cover the two boundary conditions this time.

 
  
@Test  
@DisplayName("入参大于0且小于100,计算其平方")  
public void givenGreaterThan0AndLessThan100() {  


  int input = 3;  
  int expected = 9;  
  //实际计算  
  int result = strangeCalculator.calculate(input);  
  //断言确认是否计算了平方  
  Assertions.assertEquals(expected, result);  
}  
  
@Test  
@DisplayName("入参大于等于100,计算其减1的值")  
public void givenGreaterThanOrEquals100() {  
  int input = 100;  
  int expected = 99;  
  //实际计算  
  int result = strangeCalculator.calculate(input);  
  //断言确认是否计算了平方  
  Assertions.assertEquals(expected, result);  
}

Running all unit tests, you can see that some test cases fail:

5ecb90f1310b693e0fe761b38aea23f4.png

  • Step two, green light

Implement the business logic for the second iteration:

 
  
public class StrangeCalculator {


  public int calculate(int input) {  
    
    if (input >= 100) {  
      //第二次迭代时,大于等于100的区间还是走老逻辑  
      return doGivenGreaterThan0(input);  
    } else if (input > 0) {  
      //第二次迭代的业务逻辑  
      return input * input;  
    } else if (input < 0) {  
      return doGivenLessThan0(input);  
    } else {  
      return doGivenEquals0(input);  
    }  
  }  
    
  private int doGivenEquals0(int input) {  
    return 0;  
  }  
    
  private int doGivenLessThan0(int input) {  
    return input + 1;  
  }  
    
  private int doGivenGreaterThan0(int input) {  
    return input - 1;  
  }  
}

Execute all test cases. At this time, the two test cases of givenGreaterThan0AndLessThan100 and givenGreaterThanOrEquals100 of the second iteration pass, but givenGreaterThan0 does not pass:

fe778773ded67d23b826faf5ba41737b.png

Why is this? This is because the boundary conditions have changed. The parameter input=1 in the givenGreaterThan0 use case corresponds to the boundary condition of 0<input<100, which has been adjusted at this time. 0<input<100 needs to calculate the square of the input, not the input -1.

We review the unit test cases of the previous iteration, and we can see that the boundary of givenGreaterThan0 has been covered by givenGreaterThan0AndLessThan100 and givenGreaterThanOrEquals100.

On the one hand, the business logic corresponding to givenGreaterThan0 has changed. On the other hand, other test cases have covered the boundary conditions of givenGreaterThan0. Therefore, we can remove givenGreaterThan0.

 
  
@Test  
@DisplayName("入参大于0,将其减1并返回")  
public void givenGreaterThan0() {  
  int input = 1;  
  int expected = 0;  
  int result = strangeCalculator.calculate(input);  
  Assertions.assertEquals(expected, result);  
}


@Test  
@DisplayName("入参大于0且小于100,计算其平方")  
public void givenGreaterThan0AndLessThan100() {  
  //于0且小于100的入参  
  int input = 3;  
  int expected = 9;  
  //实际计算  
  int result = strangeCalculator.calculate(input);  
  //断言确认是否计算了平方  
  Assertions.assertEquals(expected, result);  
}  
  
@Test  
@DisplayName("入参大于等于100,计算其减1的值")  
public void givenGreaterThanOrEquals100() {  
  //于0且小于100的入参  
  int input = 100;  
  int expected = 99;  
  //实际计算  
  int result = strangeCalculator.calculate(input);  
  //断言确认是否计算了平方  
  Assertions.assertEquals(expected, result);  
}

After removing givenGreaterThan0, re-execute the unit test:

6eafcda627ebae9f85c0ff75f48fdfce.png

This time the execution passed and we also maintained the test case under the latest business rules.

  • The third step, reconstruction

Once the test case passes, we can proceed to refactoring.

First, extract the logic within the boundary of 0<input<100 to form a private method;

Secondly, the doGivenGreaterThan0 method under the boundary condition of input>=0 is no longer worthy of its name, so it is renamed to doGivenGreaterThanOrEquals100.

The refactored code is as follows:

 
  
public class StrangeCalculator {  


  public int calculate(int input) {  
    
    if (input >= 100) {  
      //第二次迭代时,大于等于100的区间还是走老逻辑  
      // return doGivenGreaterThan0(input);  
      return doGivenGreaterThanOrEquals100(input);  
    } else if (input > 0) {
      //第二次迭代的业务逻辑
      return doGivenGreaterThan0AndLessThan100(input);  
    } else if (input < 0) {  
      return doGivenLessThan0(input);  
    } else {  
      return doGivenEquals0(input);  
    }  
  }  
    
  private int doGivenGreaterThan0AndLessThan100(int input) {  
    return input * input;  
  }  
    
  private int doGivenEquals0(int input) {  
    return 0;  
  }  
    
  private int doGivenGreaterThanOrEquals100(int input) {  
    return input + 1;  
  }  
    
  private int doGivenGreaterThan100(int input) {  
    return input - 1;  
  }  
}

5.1.3 Third iteration

The third iteration and subsequent iterations are all developed according to the ideas of the second iteration.

5.2 TDD practice of the three-tier architecture of the anemia model

The model of the anemia three-tier architecture is an anemia model, so it is only necessary to discuss the three layers of Controller, Service, and Dao separately.

5.2.1 Dao layer unit test cases

Strictly speaking, the test of the Dao layer is an integration test, because the SQL statement of the Dao layer is actually written to the database for execution. Only when we actually connect to the database for integration testing can we confirm whether it is executed normally.

For the Dao layer test, we want to verify whether the Mapper method we wrote can operate normally, for example, a ResultMap misses a field, and a certain #{} does not assign a value normally.

We introduce an in-memory database (such as H2 database), and simulate the external database through the in-memory database integrated into the application, which ensures the independence of unit testing and improves the speed of Dao layer unit testing. Spot some problems ahead of time.

The configuration of the H2 memory database can be viewed in detail in the project case tdd-example/tdd-example-02 supporting this article. The case address is as follows: https://github.com/feiniaojin/tdd-example

The following is the mapper reverse generated by mybatis-generator, which we use as an example of Dao layer unit testing. Generally speaking, the reverse generated mapper is a trusted code, so it will not be tested again, and this is just a case.

The code of Dao layer Mapper is as follows:

 
  
public interface CmsArticleMapper {  
  int deleteByPrimaryKey(Long id);  
    
  int insert(CmsArticle record);  
    
  CmsArticle selectByPrimaryKey(Long id);  
    
  List<CmsArticle> selectAll();  
    
  int updateByPrimaryKey(CmsArticle record);  
}

The test code of Dao layer Mapper is as follows:

 
  
@ExtendWith(SpringExtension.class)  
@SpringBootTest  
@AutoConfigureTestDatabase  
public class CmsArticleMapperTest {  
    
  @Resource  
  private CmsArticleMapper mapper;  
    
  @Test  
  public void testInsert() {  
    CmsArticle article = new CmsArticle();  
    article.setId(0L);  
    article.setArticleId("ABC123");  
    article.setContent("content");  
    article.setTitle("title");  
    article.setVersion(1L);  
    article.setModifiedTime(new Date());  
    article.setDeleted(0);  
    article.setPublishState(0);  
    int inserted = mapper.insert(article);  
    Assertions.assertEquals(1, inserted);  
  }  
    
  @Test  
  public void testUpdateByPrimaryKey() {  
    CmsArticle article = new CmsArticle();  
    article.setId(1L);  
    article.setArticleId("ABC123");  
    article.setContent("content");  
    article.setTitle("title");  
    article.setVersion(1L);  
    article.setModifiedTime(new Date());  
    article.setDeleted(0);  
    article.setPublishState(0);  
    int updated = mapper.updateByPrimaryKey(article);  
    Assertions.assertEquals(1, updated);  
  }  
    
  @Test  
  public void testSelectByPrimaryKey() {  
    CmsArticle article = mapper.selectByPrimaryKey(2L);  
    Assertions.assertNotNull(article);  
    Assertions.assertNotNull(article.getTitle());  
    Assertions.assertNotNull(article.getContent());  
  }  
}

5.2.2 Service layer unit test cases

The layer of focus, in order to ensure the efficiency of use case execution and shield infrastructure calls, all calls to infrastructure in the Service layer should be Mocked.

The code of the Service layer is as follows:

 
  
@Service  
public class ArticleServiceImpl implements ArticleService {  
    
  @Resource  
  private CmsArticleMapper mapper;  
    
  @Resource  
  private IdServiceGateway idServiceGateway;  
    
  @Override  
  public void createDraft(CreateDraftCmd cmd) {  
    
    CmsArticle article = new CmsArticle();  
    article.setArticleId(idServiceGateway.nextId());  
    article.setContent(cmd.getContent());  
    article.setTitle(cmd.getTitle());  
    article.setPublishState(0);  
    article.setVersion(1L);  
    article.setCreatedTime(new Date());  
    article.setModifiedTime(new Date());  
    article.setDeleted(0);  
    mapper.insert(article);  
  }  
    
  @Override  
  public CmsArticle getById(Long id) {  
    return mapper.selectByPrimaryKey(id);  
  }  
}

The test code of the Service layer is as follows:

 
  
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE,  
classes = {ArticleServiceImpl.class})  
@ExtendWith(SpringExtension.class)  
public class ArticleServiceImplTest {  
  
  @Resource  
  private ArticleService articleService;  
    
  @MockBean  
  IdServiceGateway idServiceGateway;  
    
  @MockBean  
  private CmsArticleMapper cmsArticleMapper;  
    
  @Test  
  public void testCreateDraft() {  
    
    Mockito.when(idServiceGateway.nextId()).thenReturn("123");  
    Mockito.when(cmsArticleMapper.insert(Mockito.any())).thenReturn(1);  
      
    CreateDraftCmd createDraftCmd = new CreateDraftCmd();  
    createDraftCmd.setTitle("test-title");  
    createDraftCmd.setContent("test-content");  
    articleService.createDraft(createDraftCmd);  
      
    Mockito.verify(idServiceGateway, Mockito.times(1)).nextId();  
    Mockito.verify(cmsArticleMapper, Mockito.times(1)).insert(Mockito.any());  
  }  
    
  @Test  
  public void testGetById() {  
    CmsArticle article = new CmsArticle();  
    article.setId(1L);  
    article.setTitle("testGetById");  
    Mockito.when(cmsArticleMapper.selectByPrimaryKey(Mockito.any())).thenReturn(article);  
      
    CmsArticle byId = articleService.getById(1L);  
      
    Assertions.assertNotNull(byId);  
    Assertions.assertEquals(1L,byId.getId());  
    Assertions.assertEquals("testGetById",byId.getTitle());  
  }
}

Through Jacoco's coverage report, you can see that the logic of Service is covered:

1da0f83cd82af7085eaeb0bec701b106.png

5.2.3 Controller layer unit test cases

A very thin layer, as expected, does not involve business logic. If it only involves the conversion of internal and external models, unit testing can be ignored. If you really want to test it, you can use MockMvc.

The code of the Controller is as follows:

 
  
@RestController  
@RequestMapping("/article")  
public class ArticleController {  
    
  @Resource  
  private ArticleService articleService;  
    
  @RequestMapping("/createDraft")  
  public void createDraft(@RequestBody CreateDraftCmd cmd) {  
    articleService.createDraft(cmd);  
  }  
    
  @RequestMapping("/get")  
  public CmsArticle get(Long id) {  
    CmsArticle article = articleService.getById(id);  
    return article;  
  }
}

The test code of the Controller is as follows:

 
  
@ExtendWith(SpringExtension.class)  
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK,  
classes = {ArticleController.class})  
@EnableWebMvc  
public class ArticleControllerTest {  
    
  @Resource  
  WebApplicationContext webApplicationContext;  
    
  MockMvc mockMvc;  
    
  @MockBean  
  ArticleService articleService;  
    
  //初始化mockmvc  
  @BeforeEach  
  void setUp() {  
    mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();  
  }  
    
  @Test  
  void testCreateDraft() throws Exception {  
    
    CreateDraftCmd cmd = new CreateDraftCmd();  
    cmd.setTitle("test-controller-title");  
    cmd.setContent("test-controller-content");  
      
    ObjectMapper mapper = new ObjectMapper();  
    String valueAsString = mapper.writeValueAsString(cmd);  
      
    Mockito.doNothing().when(articleService).createDraft(Mockito.any());  
      
    mockMvc.perform(MockMvcRequestBuilders  
    //访问的URL和参数  
    .post("/article/createDraft")  
    .content(valueAsString)  
    .contentType(MediaType.APPLICATION_JSON))  
    //期望返回的状态码  
    .andExpect(MockMvcResultMatchers.status().isOk())  
    //输出请求和响应结果  
    .andDo(MockMvcResultHandlers.print()).andReturn();  
  }  
    
  @Test  
  void testGet() throws Exception {  
    
    CmsArticle article = new CmsArticle();  
    article.setId(1L);  
    article.setTitle("testGetById");  
      
    Mockito.when(articleService.getById(Mockito.any())).thenReturn(article);  
      
    mockMvc.perform(MockMvcRequestBuilders  
    //访问的URL和参数  
    .get("/article/get").param("id","1"))  
    //期望返回的状态码  
    .andExpect(MockMvcResultMatchers.status().isOk())
    .andExpect(MockMvcResultMatchers.jsonPath("$.id").value(1L))
    //输出请求和响应结果  
    .andDo(MockMvcResultHandlers.print()).andReturn();  
  }  
}

Through Jacoco's coverage report, you can see that the logic of the Controller is covered:

01face44fc65db59ff60a32eac665a0b.png

5.3 TDD combat under DDD

For the actual combat of TDD under DDD, we will take the case project ddd-example-cms in the article "Teaching You to Implement DDD" as an example to explain, and the case code will be implemented in this project.

The ddd-example-cms project address is: https://github.com/feiniaojin/ddd-example-cms

The test cases of each layer in DDD can refer to the anemia model, and only need to make minor adjustments:

The test cases of the Application layer can be written with reference to the unit test cases of the Service layer;

The test case code of the Infrastructure layer can be written with reference to the Dao layer unit test case;

The User Interface layer can be written with reference to the unit test cases of the Controller layer;

I won’t go into details here, and the detailed implementation can be viewed in the case project ddd-example-cms.

5.3.1 Unit testing of entities

The unit test of an entity needs to consider two aspects: creating an entity must cover its business rules; business operations must compound its business rules.

 
  
@Data  
public class ArticleEntity extends AbstractDomainMask {  
    
  /**  
  * article业务主键  
  */  
  private ArticleId articleId;  
    
  /**  
  * 标题  
  */  
  private ArticleTitle title;  
    
  /**  
  * 内容  
  */  
  private ArticleContent content;  
    
  /**  
  * 发布状态,[0-待发布;1-已发布]  
  */  
  private Integer publishState;  
    
  /**  
  * 创建草稿  
  */  
  public void createDraft() {  
    this.publishState = PublishState.TO_PUBLISH.getCode();  
  }  
    
  /**  
  * 修改标题  
  *  
  * @param articleTitle  
  */  
  public void modifyTitle(ArticleTitle articleTitle) {  
    this.title = articleTitle;  
  }  
    
  /**  
  * 修改正文  
  *  
  * @param articleContent  
  */  
  public void modifyContent(ArticleContent articleContent) {  
    this.content = articleContent;  
  }  
  
  /**  
  * 发布  
  */
  public void publishArticle() {  
    this.publishState = PublishState.PUBLISHED.getCode();  
  }  
}

The test case is as follows:

 
  
public class ArticleEntityTest {  
    
  @Test  
  @DisplayName("创建草稿")  
  public void testCreateDraft() {  
    ArticleEntity entity = new ArticleEntity();  
    entity.setTitle(new ArticleTitle("title"));  
    entity.setContent(new ArticleContent("content12345677890"));  
    entity.createDraft();  
    Assertions.assertEquals(PublishState.TO_PUBLISH.getCode(), entity.getPublishState());  
  }  
    
  @Test  
  @DisplayName("修改标题")  
  public void testModifyTitle() {  
    ArticleEntity entity = new ArticleEntity();  
    entity.setTitle(new ArticleTitle("title"));  
    entity.setContent(new ArticleContent("content12345677890"));  
    ArticleTitle articleTitle = new ArticleTitle("new-title");  
    entity.modifyTitle(articleTitle);  
    Assertions.assertEquals(articleTitle.getValue(), entity.getTitle().getValue());  
  }  
    
  @Test  
  @DisplayName("修改正文")  
  public void testModifyContent() {  
    ArticleEntity entity = new ArticleEntity();  
    entity.setTitle(new ArticleTitle("title"));  
    entity.setContent(new ArticleContent("content12345677890"));  
    ArticleContent articleContent = new ArticleContent("new-content12345677890");  
    entity.modifyContent(articleContent);  
    Assertions.assertEquals(articleContent.getValue(), entity.getContent().getValue());  
  }  
    
  @Test  
  @DisplayName("发布")  
  public void testPublishArticle() {  
    ArticleEntity entity = new ArticleEntity();  
    entity.setTitle(new ArticleTitle("title"));  
    entity.setContent(new ArticleContent("content12345677890"));  
    entity.publishArticle();  
    Assertions.assertEquals(PublishState.PUBLISHED.getCode(), entity.getPublishState());  
  }  
}

5.3.2 Unit testing of value objects

The unit test of the value object mainly needs to cover its business rules. Take the value object of ArticleTitle as an example:

 
  
public class ArticleTitle implements ValueObject<String> {  
  
  private final String value;  
    
    
  public ArticleTitle(String value) {  
    this.check(value);  
    this.value = value;  
  }  
    
  private void check(String value) {  
    Objects.requireNonNull(value, "标题不能为空");  
    if (value.length() > 64) {  
      throw new IllegalArgumentException("标题过长");  
    }  
  }  
    
  @Override  
  public String getValue() {  
    return this.value;  
  }  
}

Its unit test is:

 
  
public class ArticleTitleTest {  
  
  @Test  
  @DisplayName("测试业务规则,ArticleTitle为空抛异常")  
  public void whenGivenNull() {  
    Assertions.assertThrows(NullPointerException.class, () -> {  
      new ArticleTitle(null);  
    });  
  }  
    
  @Test  
  @DisplayName("测试业务规则,ArticleTitle值长度大于64抛异常")  
  public void whenGivenLengthGreaterThan64() {  
    Assertions.assertThrows(IllegalArgumentException.class, () -> {  
      new ArticleTitle("11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111");  
    });  
  }  
    
  @Test  
  @DisplayName("测试业务规则,ArticleTitle小于等于64正常创建")  
  public void whenGivenLengthEquals64() {  
    ArticleTitle articleTitle = new ArticleTitle("1111111111111111111111111111111111111111111111111111111111111111"); 
    Assertions.assertEquals(64, articleTitle.getValue().length());  
  }  
}

5.3.3 Factory unit test

 
  
@Component  
public class ArticleDomainFactoryImpl implements ArticleFactory {  
  
@Override  
  public ArticleEntity newInstance(ArticleTitle title, ArticleContent content) {  
    ArticleEntity entity = new ArticleEntity();  
    entity.setTitle(title);  
    entity.setContent(content);  
    entity.setArticleId(new ArticleId(UUID.randomUUID().toString()));  
    entity.setPublishState(PublishState.TO_PUBLISH.getCode());  
    entity.setDeleted(0);  
    Date date = new Date();  
    entity.setCreatedTime(date);  
    entity.setModifiedTime(date);  
    return entity;  
  }  
}

We implement the Factory in the Application layer, and the test cases of the ArticleDomainFactoryImpl are very similar to the test cases of the Service layer. The test code is as follows:

 
  
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE,  
classes = {ArticleDomainFactoryImpl.class})  
@ExtendWith(SpringExtension.class)  
public class ArticleDomainFactoryImplTest {  
    
  @Resource  
  private ArticleFactory articleFactory;  
    
  @Test  
  @DisplayName("Factory创建新实体")  
  public void testNewInstance() {  
    
    ArticleTitle articleTitle = new ArticleTitle("title");  
    ArticleContent articleContent = new ArticleContent("content1234567890");  
      
    ArticleEntity instance = articleFactory.newInstance(articleTitle, articleContent);  
    
    // 创建新实体
    Assertions.assertNotNull(instance); 
    // 唯一标识正确赋值
    Assertions.assertNotNull(instance.getArticleId()); 
  }  
}

6. Summary

This article introduces the basic concepts and implementation methods of TDD, and provides a three-tier architecture of the anemia model and a practical case of TDD under DDD. We need to understand that making any change will have a difficult start, and converting existing software development methods to TDD is no exception, but as long as we persist, we will eventually benefit from TDD.

-end-

Guess you like

Origin blog.csdn.net/jdcdev_/article/details/131318554