7 Tips for Writing Better Unit Tests in Java
Want to write better unit tests in Java? Look no further. From test-driven development to measuring code coverage, here are seven tips to get the job done.
Join the DZone community and get the full member experience.
Join For FreeTesting is a very important aspect of development and can largely determine the fate of an application. Good testing can catch application-killing issues early on, but poor testing invariably leads to failure and downtime.
While there are three main types of software testing: unit testing, functional testing, and integration testing, in this blog post, I am going to talk about developer-level unit testing. Before I dive into the specifics, let’s review – at a high level – what each type of testing entails.
Types of Software Development Tests
Unit tests are used to test individual code components and ensure that code works the way it was intended to. Unit tests are written and executed by developers. Most of the time a testing framework like JUnit or TestNG is used. Test cases are typically written at a method level and executed via automation.
Integration tests check if the system as a whole works. Integration testing is also done by developers, but rather than testing individual components, it aims to test across components. A system consists of many separate components like code, database, web servers, etc. Integration tests are able to spot issues like wiring of components, network access, database issues, etc.
Functional tests check that each feature is implemented correctly by comparing the results for a given input against the specification. Typically, this is not done at a developer level. Functional tests are executed by a separate testing team. Test cases are written based on the specification and the actual results are compared with the expected results. Several tools are available for automated functional testing like Selenium and QTP.
As mentioned earlier, unit testing helps developers to determine whether the code works correctly. In this blog post, I will provide helpful tips for unit testing in Java.
1. Use a Framework for Unit Testing
Java provides several frameworks that for unit testing. TestNG and JUnit are the most popular testing frameworks. Some important features of JUnit and TestNG:
- Easy to setup and run.
- Supports annotations.
- Allows certain tests to be ignored or grouped and executed together.
- Supports parameterized testing, i.e. running a unit test by specifying different values at run time.
- Supports automated test execution by integrating with build tools like Ant, Maven, and Gradle.
EasyMock is a mocking framework that is complementary to a unit testing framework like JUnit and TestNG. EasyMock is not a full-fledged framework by itself. It simply adds the ability to create mock objects to facilitate testing. For example, a method we want to test may invoke a DAO class that gets 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 the method that we intend to without having to bother about the database access.
2. Use Test Driven Development Judiciously!
Test-driven development (TDD) is a software development process in which tests are written based on the requirements before any coding begins. Since there is no code yet, the test will initially fail. The minimum amount of code is then written to pass the test. The code is then refactored until it is optimized.
The goal is to write tests that cover all the requirements as against simply writing code first that may not even meet the requirements. TDD is great as it leads to simple modular code that is easy to maintain. Overall development speeds up and defects are easily identified. Also, unit tests get created as a by-product of the TDD approach.
However, TDD may not be suitable in all situations. In projects where the design is complicated, focusing on the simplest design to pass the test cases and not thinking ahead can result in huge code changes. Also the TDD approach is difficult to use for systems which interact with legacy systems, GUI applications or applications that work with databases. Also, the tests need to be updated as the code changes.
So before deciding on TDD approach, the above factors should be kept in mind and a call should be taken based on the nature of the project.
3. Measure Code Coverage
Code coverage measures (in percentage) how much of the code is executed when the unit tests are run. Normally, code with high coverage has a decreased chance of containing undetected bugs, as more of its source code has been executed in the course of testing. Some best practices for measuring code coverage include:
- Use a code coverage tool like Clover, Corbetura, JaCoCo, or Sonar. Using a tool can improve testing quality, as these tools can point out areas of the code that are untested, allowing you to develop additional tests to cover these areas.
- Whenever new functionality is written, immediately write new tests to cover.
- Ensure that there are test cases that cover all the branches of the code, i.e. if/else statements.
High code coverage does not guarantee the tests are perfect, so beware!
The concat
method below accepts a boolean value as input, and appends the two strings passed in only if 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 is a test case for the above method:
@Test
public void testStringUtil() {
String result = stringUtil.concat(true, "Hello ", "World");
System.out.println("Result is "+result);
}
In this case, the test is executed with a value of true
. When the test is executed, it will pass. When a code coverage tool is run, it will show 100% code coverage as all the code in the concat
method is executed. However, if the test is executed with a value of false
, a NullPointerException
will be thrown. So 100% code coverage is not really an indication of whether the test has covered all the scenarios and the test is good.
4. Externalize test data wherever possible
Prior to JUnit4, the data for which the test case was to be run has to be hardcoded into the test case. This created a restriction that in order to run the test with different data, the test case code had to be modified. However, JUnit4 as well as TestNG support externalizing the test data so that the test cases can be run for different datasets without having to change the source code.
The MathChecker
class below has a method which checks if a number is odd:
public class MathChecker {
public Boolean isOdd(int n) {
if (n%2 != 0) {
return true;
} else {
return false;
}
}
}
The following is a TestNG test case for the MathChecker class:
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
(the configuration file for TestNG) that has the data for which the test is to be executed:
<?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>
3
and
7
. In addition to specifying the test data via the XML configuration file, it can also be provided in a class via the
DataProvider annotation.
JUnit
Similar to TestNG, test data can also be externalized for JUnit. The following is a JUnit test case for 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);
}
}
As can be seen, the test data for which the test is to be executed is specified by the getTestData() method. This method can easily be modified to read the data from an external file instead of having hardcoded data.
5. Use Assertions Instead of Print Statements
Many new developers are in the habit of writing a System.out.println
statement after each line of code to verify the code executed correctly. This practice often extended to unit tests, leading to cluttered test code. Along with the clutter, this requires manual intervention by developers to verify the output printed on the console to check if the test ran successfully or not. A better approach is to use assertions which automatically indicate test results.
The following StringUtil
class is a simple class with one method that concatenates two input strings and returns the result:
public class StringUtil {
public String concat(String a,String b) {
return a + b;
}
}
The following are two unit tests for the method above:
@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);
}
The testStringUtil\_Bad
will always pass as it has no assertions. A developer manually needs to verify the output of the test at the console. The testStringUtil\_Good
will fail if the method returns a wrong result and does not require developer intervention.
6. Build tests that have deterministic results
Some methods do not have a deterministic result, i.e. the output of that method is not known beforehand and can vary each time. For example, consider the following code that has a complex function and a method that calculates the time required (in milliseconds) for executing the complex function:
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.random()*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 will return a different value. Writing a test case for this method would not be of any use as the output of the method is variable. Thus, the test method will not be able to verify the output for any particular execution.
7. Test negative scenarios and borderline cases, in addition to positive scenarios
Often, developers spend a huge amount of time and effort in writing test cases that ensure the application works as expected. However, it is important to test negative test cases as well. A negative test case is a test case that tests if a system can handle invalid data. For example, consider a simple function which reads an alphanumeric value of length 8, typed by a user. In addition to alphanumeric values, the following negative test cases should be tested:
- User specifies non-alphanumeric values like special characters.
- User specifies blank value.
- User specifies a value which is larger or smaller than 8 characters.
Similarly, a borderline test case tests if the system works well for extreme values. For example, if a user is expected to enter a numeric value from 1 to 100, 1 and 100 are the borderline values and it is very important to test the system for these values.
Ready to get testing? Great! Want to learn more about adding authentication to your webapp or API? We’ve got you covered there too! Learn more about how Stormpath supports complete Identity Management across the Java and Spring ecosystems in our product documentation, or through any of these great resources:
Published at DZone with permission of Reshma Bidikar, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments