7 tips for java programmers on how to write better unit tests and java Unit Tests
Testing is a very important aspect of development. It can determine the fate of an application to a large extent. Good tests can capture issues that cause application crashes early, but poor tests often lead to failures and downtime.
Although there are three main types of software testing: unit testing, functional testing, and integration testing, in this blog, we will discuss developer-level unit testing. Before I go into the details, let's review the details of these three tests.
I. Types of software development and testing
Unit tests are used to test various code components and ensure that the code works as expected. Unit Tests are compiled and executed by developers. In most cases, use a testing framework such as JUnit or TestNG. Test cases are usually written at the method level and executed automatically.
The integration test checks whether the system works as a whole. Integration testing is also done by developers, but not by testing a single component, but by cross-component testing. The system consists of multiple independent components, such as code, database, and Web server. Integration testing can detect problems such as component wiring, network access, and database problems.
Function Testing checks whether each function is correctly implemented by comparing the results of a given input with the specifications. Generally, this is not at the developer level. Function Testing is performed by a separate test team. Test Cases are compiled based on specifications and the actual results are compared with the expected results. Several tools are available for automated functional testing, such as Selenium and QTP.
As mentioned above, unit testing helps developers determine whether the code works properly. In this blog post, I will provide useful tips for unit testing in Java.
Ii. Use framework for unit testing
Java provides several frameworks for unit testing. TestNG and JUnit are the most popular testing frameworks. Important features of JUnit and TestNG:
Easy to set and run.
Annotations are supported.
Ignore or group and perform some tests together.
Parameter Tests are supported, that is, unit tests are run by specifying different values at run time.
Automated test execution is supported by integration with build tools such as Ant, Maven, and Gradle.
EasyMock is a simulated framework that is complementary to unit test frameworks such as JUnit and TestNG. EasyMock is not a complete framework. It only adds the ability to create simulated objects for testing. For example, we want to test a method that can call the DAO class to obtain data from the database. In this case, EasyMock can be used to create a MockDAO that returns hard-coded data. This allows us to easily test our intended methods without worrying about database access.
3. Use test-driven development with caution
Test-driven development (TDD) is a software development process in which we compile tests based on requirements before starting any code. Because no encoding is available, the test will initially fail. Then write the minimum amount of code to pass the test. Then refactor the code until it is optimized.
The goal is to write a test that covers all requirements, rather than writing code at the beginning, but may not even meet the requirements. TDD is great because it leads to simple modular code and is easy to maintain. The overall development speed is faster and defects are easily discovered. In addition, unit tests are created as a by-product of TDD methods.
However, TDD may not be suitable for all situations. Focusing on the simplest design in designing complex projects allows you to pass test cases without thinking ahead of time can lead to huge code changes. In addition, TDD is difficult to use in systems that interact with legacy systems, GUI applications, or applications that work with databases. In addition, the test must be updated as the code changes.
Therefore, the above factors should be taken into account before deciding to adopt the TDD method, and measures should be taken according to the nature of the project.
Iv. Measure code coverage
Code coverage (expressed in percentages) indicates the amount of code executed during unit tests. Generally, codes with high coverage rate have a lower probability of undetected errors because more source code is executed during testing. Best practices for measuring code coverage include:
Use code override tools, such as Clover, Corbetura, jacco, or Sonar. Tools can improve the quality of testing because they can point out areas of untested Code, allowing you to develop additional tests to cover these areas.
When new features are written, a new test coverage is immediately written.
Make sure that a test case overwrites all the branches of the code, that is, the if/else statement.
High code coverage cannot ensure the test is perfect, so be careful!
The following concat method accepts a Boolean value as the input, and attaches two strings only when the Boolean value is true:
Public String concat (boolean append, String a, String B ){
String result = null;
If (append ){
Result = a + B;
}
Return result. toLowerCase ();
}
The following are the test cases of the above method:
@ Test
Public void testStringUtil (){
String result = stringUtil. concat (true, "Hello", "World ");
System. out. println ("Result is" + result );
}
In this case, the value of the test is true. When the test is executed, it passes. When the code coverage tool runs, it displays 100% of the code coverage because all code in the concat method is executed. However, if the value of the test execution is false, NullPointerException is thrown. Therefore, the code coverage rate of 100% does not really indicate that the test covers all scenarios, nor does it indicate that the test is good.
5. externalize test data as much as possible
Before JUnit4, the data to be run in the test case must be hardcoded into the test case. This leads to restrictions. In order to run the test using different data, the test case code must be modified. However, JUnit4 and TestNG support external test data to run test cases for different datasets without changing the source code.
The following MathChecker class has a way to check whether a number is an odd number:
Public class MathChecker {
Public Boolean isOdd (int n ){
If (n % 2! = 0 ){
Return true;
} Else {
Return false;
}
}
}
The TestNG test cases of the MathChecker class are as follows:
Public class MathCheckerTest {
Private MathChecker checker;
@ BeforeMethod
Public void beforeMethod (){
Checker = new MathChecker ();
}
@ Test
@ Parameters ("num ")
Public void isOdd (int num ){
System. out. println ("Running test for" + num );
Boolean result = checker. isOdd (num );
Assert. assertEquals (result, new Boolean (true ));
}
}
TestNG
The following is the testng. xml configuration file for TestNG. It has the data for which you want to perform the test:
<? Xml version = "1.0" encoding = "UTF-8"?>
<Suite name = "ParameterExampleSuite" parallel = "false">
<Test name = "MathCheckerTest">
<Classes>
<Parameter name = "num" value = "3"> </parameter>
<Class name = "com. stormpath. demo. MathCheckerTest"/>
</Classes>
</Test>
<Test name = "MathCheckerTest1">
<Classes>
<Parameter name = "num" value = "7"> </parameter>
<Class name = "com. stormpath. demo. MathCheckerTest"/>
</Classes>
</Test>
</Suite>
It can be seen that in this case, the test will be executed twice, with values 3 and 7 each. In addition to specifying test data through the XML configuration file, you can also use DataProvider annotations to provide test data in the class.
Vi. JUnit
Similar to TestNG, the test data can also be externalized for JUnit. The following is a JUnit test case of the same MathChecker class as above:
@ RunWith (Parameterized. class)
Public class MathCheckerTest {
Private int inputNumber;
Private Boolean expected;
Private MathChecker mathChecker;
@ Before
Public void setup (){
MathChecker = new MathChecker ();
}
// Inject via constructor
Public MathCheckerTest (int inputNumber, Boolean expected ){
This. inputNumber = inputNumber;
This. expected = expected;
}
@ Parameterized. Parameters
Public static Collection <Object []> getTestData (){
Return Arrays. asList (new Object [] [] {
{1, true },
{2, false },
{3, true },
{4, false },
{5, true}
});
}
@ Test
Public void testisOdd (){
System. out. println ("Running test for:" + inputNumber );
AssertEquals (mathChecker. isOdd (inputNumber), expected );
}
}
It can be seen that the test data to be tested is specified by the getTestData () method. This method can be easily modified to read data from external files, rather than hard-coded data.
7. Use assertions instead of Print statements
Many novice developers are used to writing the System. out. println statement after each line of code to verify that the code is correctly executed. This practice is often extended to unit tests, resulting in messy testing code. In addition to chaos, developers need to manually intervene to verify the output printed on the console to check whether the test is successful. A better way is to use assertions that automatically indicate the test results.
The following stringti class is a simple class that connects two input strings and returns results:
Public class StringUtil {
Public String concat (String a, String B ){
Return a + B;
}
}
Two unit tests of the preceding method are as follows:
@ Test
Public void testStringUtil_Bad (){
String result = stringUtil. concat ("Hello", "World ");
System. out. println ("Result is" + result );
}
@ Test
Public void testStringUtil_Good (){
String result = stringUtil. concat ("Hello", "World ");
AssertEquals ("Hello World", result );
}
TestStringUtil \ _ Bad will always be passed because it has no assertions. Developers must manually verify the test output on the console. If the method returns an error and no developer intervention is required, testStringUtil \ _ Good will fail.
8. Build a test with deterministic results
Some methods do not have deterministic results, that is, the output of the method is not known in advance and can be changed every time. For example, consider the following code: it has a complex function and a method that calculates the time required to execute a complex function (in milliseconds:
Public class DemoLogic {
Private void veryComplexFunction (){
// This is a complex function that has a lot of database access and is time consuming
// To demo this method, I am going to add a Thread. sleep for a random number of milliseconds
Try {
Int time = (int) (Math. rank () * 100 );
Thread. sleep (time );
} Catch (InterruptedException e ){
// TODO Auto-generated catch block
E. printStackTrace ();
}
}
Public long calculateTime (){
Long time = 0;
Long before = System. currentTimeMillis ();
VeryComplexFunction ();
Long after = System. currentTimeMillis ();
Time = after-before;
Return time;
}
}
In this case, each time the calculateTime method is executed, it returns a different value. Writing Test Cases for this method is not useful because the output of this method is variable. Therefore, the test method cannot verify the output of any specific execution.
9. In addition to positive scenes, we also need to test negative and marginal situations.
Usually, developers spend a lot of time and effort writing test cases to ensure that the application works as expected. However, it is also important to test negative test cases. Negative test cases refer to test cases where the test system can process invalid data. For example, consider a simple function that can read 8-character alphanumeric values, which are typed by the user. Test the following negative test cases except for alphanumeric values:
1. The User specifies non-alphanumeric values, such as special characters.
2. The User specifies a null value.
3. You must specify a value greater than or less than 8 characters.
Similarly, the boundary test case test system is applicable to extreme values. For example, if you want to enter numeric values from 1 to 100, values 1 and 100 are boundary values. It is very important to test these values.