Legacy code afraid to refactor? Every time I change the code I have to revert to all the logic, right? The test was knocked back?
During recent code refactoring, we encountered various problems. For example, adjusting the code order leads to bugs, the loss of the operation logic, and the error of parameter verification logic.
It takes a lot of time for testing and grayscale verification before going online. In this process, the biggest feeling is: all the refactoring without single test coverage is streaking.
Experienced no single test pain and suffering, after consulting a lot of information and actual combat, so there is this article, hope to give you a single test to provide some reference.
Know a single measurement
What
Unit testing is the testing to verify correctness of a program module (the smallest unit of software design). A program unit is the smallest testable part of an application.
There are many other terms for testing, such as integration test, system test, acceptance test. It is a means for different roles to ensure the quality of the system in different stages.
In my work, I often encounter some invalid single tests, usually starting the Spring container, connecting to the database, calling methods, and then the console output results. These are not single tests. The example code is as follows:
@RunWith(SpringRunner.class)
@SpringBootTest(classes = ApplicationLoader.class)
public class UserServiceTest {
@Autowired
private UserService userService;
@Test
public void testAddUser(a) {
AddUserRequest addUserRequest = new AddUserRequest("zhangsan"."[email protected]"); ResultDTO<Long> addResult = userService.addUser(addUserRequest); System.out.println(addResult); }}Copy the code
Why
In the work of many code is not single test, these projects can also run normally. So why write single tests?
Good single testing improves productivity by reducing the cost of bug discovery and fixing while providing quality in our code delivery. As for single testing making QA happy, that’s just icing on the cake.
Improve productivity. Most of the programmer’s time at work is spent in the testing phase, and coding may be a small part of it.
In particular, when modifying existing code, you have to consider whether incremental code will impact the existing logic, and whether new bugs will be introduced after fixing bugs.
I once found myself in this predicament, spending an afternoon packaging, deploying, testing… “, in an endless loop between fixing and writing bugs, and sometimes in the middle of the night over a low-level bug.
So in the long run, single test can effectively improve work efficiency!
Improving code quality and being testable is often associated with well-designed software, and hard-to-test code is often poorly designed. So effective single testing drives developers to write higher quality code.
Of course, the most immediate benefit of single testing is the reduced bug rate. While single testing does not catch all bugs, it does expose most of them.
Cost saving, single test can ensure the correctness of the underlying logic unit of the program, so that problems can be exposed in the RD self test phase. The earlier a bug is discovered, the cheaper it will be to fix it and the less impact it will have, so it should be exposed as soon as possible.
As the red curve in the figure below shows, the cost of fixing a bug at different stages varies greatly.
Who
The author of the code has the best understanding of the purpose, features, and implementation limitations of the code. There is no better person to write single tests than the author, so often the author of the code is the first responsible person.
When
The best time to write a single test is The sooner, The better. Try not to delay single testing until after the code is written, which may not bring in as much revenue as you’d like.
TDD (Test-driven Development) is an application method in the process of software Development. It is named for its advocacy of writing Test program first and then coding to realize its function.
Tests drive the entire development process: first, they drive the design of the code and the implementation of its functionality; After that, the driver code is redesigned and refactored.
Of course TDD is an ideal state, and for a variety of reasons, it is difficult to fully adhere to the TDD principles, as PM requirements are often variable.
Write single test as you develop, write a small amount of functional code, then write single test, and repeat the two processes until the functional code is developed.
In fact, this scheme is very close to the first one, and when the functional code is developed, the single test is almost complete. This is also the most common and recommended approach.
Single testing after development is often the worst. The first thing to consider is the testability of the code. The finished code may not be teachable, after all, you can play with it as you write it.
Second, it is easy to write test code along the current implementation while ignoring the logic of the actual requirements, which leads to the ineffectiveness of our single test.
Which
Which methods do you need to single test? This perplexed the author for a long time a question! As mentioned above, the higher the single-test coverage is, of course, the better, but we inevitably make some compromises when considering ROI.
Accept imperfections, full coverage is often unrealistic for historical code. We can complete the single test according to the method priority (such as the cost loss, affecting the main business process) to ensure the normal operation of the existing logic.
In the case of incremental code, I don’t think it is necessary to cover all of it. It is generally determined by whether the method under test has processing (business) logic.
For example, in common JavaWeb project code, the Controller layer, DAO layer, and other methods that involve only interface forwarding are often not covered by single test. The various services in the business logic layer need to be heavily tested.
For custom utility classes, regular expressions and other fixed logic, also must be tested. Because this part of the logic is generally common and universal, once the logic error can have a serious impact.
How
A good single test is one that can be executed automatically and checked for results. There should be no external dependencies. The single test execution should be fully automated and run with a local IDE without deployment.
Before writing one side, please refer to the following First principle summarized by our predecessors.
F – Fast – Fast
Test cases usually need to be executed at any time during development; Execution in the release pipeline must also be executed, usually after code is pushed, or test cases are executed at packaging time; And there are often hundreds or thousands of test cases in a project.
Therefore, in order to ensure efficient development and release, fast execution is an important principle of single test. This requires that we do not rely on multiple components like integration tests, and ensure that single tests execute in seconds or even milliseconds.
I – Isolated: isolation
Isolation can also be understood as independence. A good single test is one that focuses on one logical unit or branch of code per test case, ensuring a single responsibility so that problems are more clearly exposed and identified.
There should be no dependencies between each test. In order to ensure the stability and maintenance of the test, test cases must not be called to each other or depend on the order of execution.
Do not rely on or modify other shared resources, such as external data or files, during the test. Ensure that data on shared resources is consistent before and after the test.
Fakes, stubs and mocks
The external dependencies of our code under test are often unpredictable, and we need to make these “changes” manageable, which can be Fake, Stubs, or Mock, depending on the responsibility.
Fake data, “Fake data,” is a simplified version of the objects built for the current scenario that we use as a data source, just like an in-memory database.
For example, in the common three-tier architecture, the business logic layer depends on the data access layer. After the development of the business logic layer is completed, even if the data access layer is not completed, the test of the business logic layer can be completed by constructing Fake data.
UserDO fakeUser = new UserDO("zhangsan"."[email protected]");
public UserVO getUser(Long userId) {
// do something
User user = fakeUser; User User = userDao.getById(userId);
// do something
}
Copy the code
Fake data can be used to test logic, but when the data access layer is developed, you might need to change the code to replace Fake data with actual method calls to complete code integration, which is obviously not an elegant implementation, hence the Stub.
Stub code is a temporary code that replaces real code. Stub code is a specialized implementation of a dependent interface in a test environment.
For example, UserService calls UseDao. In order to test the functions in UserService, you need to build a UserDao interface implementation class UserDaoStub (which returns Fake data). This temporary code is called the stub code.
public class UserDaoStub implements UserDao {
UserDO fakeUser = new UserDO();
{
fakeUser.setUserName("zhangsan");
fakeUser.setEmail("[email protected]");
LocalDateTime dateTime = LocalDateTime.of(2021.7.1.12.30.0);
fakeUser.setCreateTime(dateTime);
fakeUser.setUpdateTime(dateTime);
}
@Override
public UserDO getById(Long id) {
if (Objects.isNull(id) || id <= 0) {
return new UserDO();
}
returnfakeUser; }}Copy the code
This kind of interface-oriented programming makes the programming design principle of replacing interfaces by different implementation classes in different scenarios is often called the Richter substitution principle.
Mock code is very similar to stub code in that it is a temporary code used to replace real code. The difference is that when it is invoked, it will record the invoked information and verify whether the execution action or result is in line with the expected after the execution.
For Mock code, we are concerned with whether the Mock method is called, with what arguments, how many times it is called, and the order in which multiple Mock functions are called.
@Test
public void testAddUser4SendEmail(a) {
/ / GIVEN:
AddUserRequest fakeAddUserRequest = new AddUserRequest("zhangsan"."[email protected]");
// WHEN
ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
// THEN
assertTrue(addResult.isSuccess());
// Verify that sendVerifyEmail is called once and the call parameter is the mailbox specified in our fake data
verify(emailService, times(1)).sendVerifyEmail(any());
verify(emailService).sendVerifyEmail(fakeAddUserRequest.getEmail());
}
Copy the code
Of course, we can modify the implementation of the Stub to achieve the same effect as the Mock.
public class EmailServiceStub implements EmailService{
public int invokeCount = 0;
@Override
public boolean sendVerifyEmail(String email) {
invokeCount ++;
// do something
return true; }}public class UserServiceImplTest {
AddUserRequest fakeAddUserRequest;
private UserServiceImpl userService;
private EmailServiceStub emailServiceStub;
@Before
public void init(a) {
fakeAddUserRequest = new AddUserRequest("zhangsan"."[email protected]");
emailServiceStub = new EmailServiceStub();
userService= new UserServiceImpl();
userService.setEmailService(emailServiceStub);
}
@Test
public void testAddUser4SendEmail(a) {
// GIVEN: fakeAddUserRequest
// WHEN
ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
// THEN: whether the number of times the sending mail interface is called is 1
Assert.assertEquals(emailServiceStub.invokeCount, 1); }}Copy the code
The difference between a Stub and a Mock
The difference between stubs and mocks is that stubs tend to validate results while mocks tend to validate behavior.
For example, if the addUser method is tested in Stub mode, the result returned by the method is concerned, that is, whether the user is added successfully and whether the email is sent successfully. Mocks, on the other hand, tend to validate the behavior that was added, such as the number of sendEmail method calls.
The Mock alternative Stub
Mock and Stub are different in nature, but with the introduction of various Mock frameworks, the boundary between stubs and mocks is becoming more and more blurred, so that Mock can not only perform behavior verification, but also have the ability to fake the implementation of the interface by Stub.
Most mocks provide support for regression to stubs. For example, Mockito can match parameters with anyObject(), any, etc. Using the verify method, you can verify the number of calls and the parameters of the method, which is almost exactly the same as stub.
when(userDao.insert(any())).thenReturn(1L);
when(emailService.sendVerifyEmail(anyString())).thenReturn(true);
Copy the code
In theory, stubs can also be converted to mock. As mentioned above, stub code can be added to implement some expectiation features, which makes the boundary between the two more blurred.
So, if the concept of stubs and mocks is still vague, don’t get too hung up on it, and it won’t stop you from writing a good single test.
R – Repeatable: indicates that the execution can be repeated
Single test can be performed repeatedly and cannot be affected by the external environment. The same test case, even if run multiple times on different machines and in different environments, will produce the same results each time.
Avoid Hidden imput, such as the test code cannot rely on the current date, random numbers, etc., otherwise the program will become unmanageable and unrepeatable.
S — self-verifying: indicates the self-verification
Single test results need to be verified by assertions. That is, after the single test is completed, it is used to determine whether the execution results are consistent with the assumptions, without manual check whether the execution is successful.
Of course, in addition to checking the execution result, you can also check the execution process, such as the number of method calls. The following is the author often see in the work of writing, these are invalid single test.
// Print the result directly
public void testAddUser4DbError(a) {
// GIVEN
fakeAddUserRequest.setUserName("badcase");
// WHEN
ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
// THEN
System.out.println(addResult);
}
// Engulf the exception failure case
public void testAddUser4DbError(a) {
// GIVEN
fakeAddUserRequest.setUserName("badcase");
// WHEN
try {
ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
// THEN
Assert.assertTrue(addResult.isSuccess());
} catch(Exception e) {
System.out.println("Test execution failed"); }}Copy the code
The correct solution is as follows:
@Test
public void testAddUser4DbError(a) {
// GIVEN
fakeAddUserRequest.setUserName("badcase");
// WHEN
ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
// THEN
Assert.assertEquals(addResult.getMsg(), "Failed to add user, please try again later");
}
Copy the code
T — Timely&Thorough
The ideal, of course, is TDD pattern development, or test-driven development. As mentioned earlier, writing the code logic before writing is best, writing the code logic while developing is second, and waiting until the code is stable to fill in the single test can be the least profitable.
In addition to promptness, the author believes that T should have another meaning, namely, Thorough. Ideally every line of code should be covered, and every branch of logic must have a test case.
But trying to achieve 100% test coverage can be exhausting, and even counterproductive to our original purpose of improving efficiency. So it’s better to spend a reasonable amount of time catching most bugs than to spend a lifetime catching all bugs.
Usually we have to consider at least parameters boundaries, special values, normal scenarios (combined with the design document), and exception scenarios to make sure that our core process is correct.
Introduction to the Mock Framework
The best way to do a good job is to use a good tool, and choosing a proper Mock framework is often a great way to get more out of a single test than implementing stubs manually.
To be clear, a Mock framework is not required. As mentioned above, we can implement Stub code to isolate dependencies, and when we need to use Mock objects, we only need to change the implementation of the Stub.
There are many Mock frameworks to choose from, such as Mockito, PowerMock, Spock, EasyMock, JMock, etc. How do you choose the right framework?
If you want to get started in half an hour, try Mockito. It’s as smooth as silk! Of course, if you have the time and are interested in the Groovy language, you might as well spend half a day learning about Spock to make your test code much simpler.
The following is a comparison of several commonly used Mock frameworks. If you are not sure which one to choose, it is worth noting that most Mock frameworks do not support Mock static methods.
A single measurement of actual combat
Writing a single test usually consists of three parts: Given (Mock external dependencies & prepare Fake data), When (call the method under test), and Then (assert the result of execution), which is the same as Spock syntax.
To better understand unit testing, I will write a simple example using Mockito and Spock to give you a sense of the differences between the two.
@Service
@AllArgsConstructor
@NoArgsConstructor
public class UserServiceImpl implements UserService {
@Autowired
private UserDao userDao;
@Autowired
private EmailService emailService;
public ResultDTO<Long> addUser(AddUserRequest request) {
// 1. Verify parameters
ResultDTO<Void> validateResult = validateAddUserParam(request);
if(! validateResult.isSuccess()) {return ResultDTO.paramError(validateResult.getMsg());
}
// 2. Add users
UserDO userDO = request.buildUserDO();
long id = userDao.insert(userDO);
// 3. If the value is added successfully, return to the verification activation email
if (id > 0) {
emailService.sendVerifyEmail(request.getEmail());
return ResultDTO.success(id);
}
return ResultDTO.internalError("Failed to add user, please try again later");
}
/** * Verify adding user parameters */
private ResultDTO<Void> validateAddUserParam(AddUserRequest request) {
if (Objects.isNull(request)) {
return ResultDTO.paramError("Add user parameter cannot be empty");
}
if (StringUtils.isBlank(request.getUserName())) {
return ResultDTO.paramError("Username cannot be empty.");
}
if(! EmailValidator.validate(request.getEmail())) {return ResultDTO.paramError("Mailbox format error");
}
returnResultDTO.success(); }}Copy the code
A mockito-based single test example is shown below. Note that this is pure Java code, with no object to show that the methods called have been statically imported.
@RunWith(MockitoJUnitRunner.class)
public class UserServiceImplTest {
// Fake: Fake data that needs to be constructed in advance
AddUserRequest fakeAddUserRequest;
// Mock: Mock external dependencies
@InjectMocks
private UserServiceImpl userService;
@Mock
private UserDao userDao;
@Mock
private EmailService emailService;
@Before
public void init(a) {
fakeAddUserRequest = new AddUserRequest("zhangsan"."[email protected]");
when(userDao.insert(any())).thenReturn(1L);
when(emailService.sendVerifyEmail(anyString())).thenReturn(true);
}
@Test
public void testAddUser4NullParam(a) {
// GIVEN
fakeAddUserRequest = null;
// WHEN
ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
// THEN
assertEquals(addResult.getMsg(), "Add user parameter cannot be empty");
}
@Test
public void testAddUser4BadEmail(a) {
// GIVEN
fakeAddUserRequest.setEmail(null);
// WHEN
ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
// THEN
assertEquals(addResult.getMsg(), "Mailbox format error");
}
@Test
public void testAddUser4BadUserName(a) {
// GIVEN
fakeAddUserRequest.setUserName(null);
// WHEN
ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
// THEN
assertEquals(addResult.getMsg(), "Username cannot be empty.");
}
@Test
public void testAddUser4DbError(a) {
// GIVEN
when(userDao.insert(any())).thenReturn(-1L);
// WHEN
ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
// THEN
assertEquals(addResult.getMsg(), "Failed to add user, please try again later.");
}
@Test
public void testAddUser4SendEmail(a) {
// GIVEN
// WHEN
ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
// THEN
assertTrue(addResult.isSuccess());
verify(emailService, times(1)).sendVerifyEmail(any()); verify(emailService).sendVerifyEmail(fakeAddUserRequest.getEmail()); }}Copy the code
As mentioned above, Spock can make code more compact, especially in scenarios where there are many branches of code logic. The following is a single test based on Spock.
class UserServiceImplSpec extends Specification {
UserServiceImpl userService = new UserServiceImpl();
AddUserRequest fakeAddUserRequest;
def userDao = Mock(UserDao)
def emailService = Mock(EmailService)
def setup(a) {
// Fake data creation
fakeAddUserRequest = new AddUserRequest(userName: "zhangsan", email: "[email protected]")
// Inject Mock objects
userService.userDao = userDao
userService.emailService = emailService
}
def "testAddUser4BadParam"() {
given:
if (Objects.isNull(userName) || Objects.is(email)) {
fakeAddUserRequest = null
} else {
fakeAddUserRequest.setUserName(userName)
fakeAddUserRequest.setEmail(email)
}
when:
def result = userService.addUser(fakeAddUserRequest)
then:
Objects.equals(result.getMsg(), resultMsg)
where:
userName | email | resultMsg
null | null | "Add user parameter cannot be empty"
"Java Pit Filling Notes" | null | "Mailbox format error"
null | "[email protected]" | "Username cannot be empty."
}
def "testAddUser4DbError"() {
given:
_ * userDao.insert(_) >> -1L
when:
def result = userService.addUser(fakeAddUserRequest)
then:
Objects.equals(result.getMsg(), "Failed to add user, please try again later")
}
def "testAddUser4SendEmail"() {
given:
_ * userDao.insert() >> 1
when:
def result = userService.addUser(fakeAddUserRequest)
then:
result.isSuccess()
1 * emailService.sendVerifyEmail(fakeAddUserRequest.getEmail())
}
}
Copy the code
Thinking summary
Always consider the input-output ratio before you validate your business model. Time and business costs are too high to get a product to market quickly, so when to promote single test requires a higher-order decision.
Tests cannot be ordered wrong, and single tests are no exception. A single test tests only the functionality of the program unit itself. Therefore, it cannot detect integration errors, performance, or other system-level problems.
Single testing can improve code quality, drive code design, help us find problems earlier, and ensure continuous optimization and refactoring. It is an essential skill for engineers.
References:
Blog.testlodge.com/tdd-vs-bdd/ martinfowler.com/articles/mo… Callistaenterprise. Se/blogg/tekni… Segmentfault.com/a/119000003…