The point of writing tests is not only to ensure that your code works now, but to ensure that the code will continue to work in the future.
What does a typical test run involve?
- Prepration
- Provide test input
- Running the test case
- Provide expected output
- Verify output (expected vs actual)
- Do something if the test fails (expected and output do not match)
3, 5, 6 are common tasks while 1, 2, 4 change based on your test. Instead of doing everything yourself, offload the common tasks to a framework — JUnit is the defacto unit testing framework in Java. The current JUnit framework release is 5.
Problems with JUnit4
- Outdated. > 10 years old
- No support for newer testing patterns
- Was playing catch up with newer java features
- Had a monolothic architecture (one jar that had it all)
JUnit5 Architecture
Setup
Let’s test the deposit and withdraw functionality of the Account aggregate:
public class Transaction {
public enum Type {
CREDIT, DEBIT, HOLD
};
public Transaction(String accountId, Type type, BigDecimal amount, String reference) {
this.id = UUID.randomUUID().toString();
this.transactionTime = System.currentTimeMillis();
this.accountId = accountId;
this.type = type;
this.amount = amount;
this.reference = reference;
}
private final String id;
private final String accountId;
private final long transactionTime;
private final Type type;
private final BigDecimal amount;
private final String reference;
//Getters
}
public class Account {
private String id;
private String userId;
private BigDecimal balance = BigDecimal.ZERO;
Transaction deposit(BigDecimal amount, String reference) {
balance = balance.add(amount);
return new Transaction(id, Transaction.Type.CREDIT, amount, reference);
}
Transaction withdraw(BigDecimal amount, String reference) {
balance = balance.subtract(amount);
return new Transaction(id, Transaction.Type.DEBIT, amount, reference);
}
//Getters and Setters
}
Add the jupiter-engine and jupiter-api dependency.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>5.8.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Create a test class in the src/java/test directory using the same package convention (so that you get access to all default properties of the class you want to test) and annotate your methods with @test (from the jupiter jar!) or use your IDE to create a test:
class AccountTest {
@Test
public void testDeposit() {
Account account = new Account();
Transaction transaction1 = account.deposit(BigDecimal.TEN, "FY 23-24 qtr 2 interest");
Transaction transaction2 = account.deposit(BigDecimal.ONE, "Transaction XYZ Surcharge waiver");
assertEquals(BigDecimal.valueOf(11), account.getBalance());
assertEquals(Transaction.Type.CREDIT, transaction1.getType());
assertEquals(BigDecimal.TEN, transaction1.getAmount());
}
}
Running the test from the IDE:
Assertions (Leverage JUnit’s way of reporting an error!)
import static org.junit.jupiter.api.Assertions.*; //static import - contains all the assert methods!
assertEquals(expected, actual) //Example
Using assertions you can assert/verify if your code is producing the desired results or not. Your test will fail if these assertions fail. Example, let’s change our deposit method and introduce a bug:
Transaction deposit(BigDecimal amount, String reference) {
//We are not using the return value of the add method.
//Note: BigDecimal is an immutable object!
balance.add(amount);
return new Transaction(id, Transaction.Type.CREDIT, amount, reference);
}
If we run our test case now, it will fail:
Be a little more descriptive when your test fails:
assertEquals(BigDecimal.valueOf(11), account.getBalance(), "The account balance after two deposits of 10 and 1 should be 11");
Asserting Exceptions using assertThrows
public class MathUtils {
//Other methods omitted
public int divide(int a, int b) {
return a / b;
}
}
class MathUtilsTest {
@Test
void testDivideBy0() {
MathUtils mathUtils = new MathUtils();
/*
assertThrows(Exception class, Executable)
public interface Executable {
void execute() throws Throwable;
}
*/
assertThrows(ArithmeticException.class, () -> mathUtils.divide(1, 0),
"Divide by 0 should raise an arithmetic exception");
}
}
Running tests using maven instead of your IDE
If you try to run mvn test, the tests are not executed!
Use the maven surefire plugin. This will allow you to run tests using the mvn test command (The plugin binds a goal to execute all test when the test phase is executed). You can then potentially automate test execution in your CI/CD pipeline!
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M7</version>
</plugin>
</plugins>
</build>
The Test Life Cycle and Hooks (BeforeAll, BeforeEach, AfterEach, AfterAll)
class AccountTest
private Account account;
@BeforeAll
public static void beforeAll() {
System.out.println("In before all");
}
@BeforeEach
public void beforeEach() {
System.out.println("In before each");
account = new Account();
}
@AfterEach
public void afterEach() {
System.out.println("In after each");
}
@AfterAll
public static void afterAll() {
System.out.println("In after all");
}
@Test
public void testDeposit() {
Transaction transaction1 = account.deposit(BigDecimal.TEN, "FY 23-24 qtr 2 interest");
Transaction transaction2 = account.deposit(BigDecimal.ONE, "Transaction XYZ Surcharge waiver");
assertEquals(BigDecimal.valueOf(11), account.getBalance(), "The account balance after two deposits of 10 and 1 should be 11");
assertEquals(Transaction.Type.CREDIT, transaction1.getType());
assertEquals(BigDecimal.TEN, transaction1.getAmount());
}
@Test
public void testWithdraw() {
Transaction transaction = account.withdraw(BigDecimal.TEN, "ATM withdrawal");
assertEquals(BigDecimal.valueOf(-10), account.getBalance(), "The account balance after a withdrawal of 10 should be -10");
assertEquals(Transaction.Type.DEBIT, transaction.getType());
assertEquals(BigDecimal.TEN, transaction.getAmount());
}
}
Important — The @BeforeAll and @AfterAll are called before the instance of the test class is created/after the instance of the test class is destroyed. Therefore, they should be static methods!
Paramaterised Tests
Allows you to execute a single test method multiple times with different parameters
Also allows you to specify the source of your tests
In order to use parameterised test, add the junit-jupiter-params artifact
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<scope>test</scope>
</dependency>
Then annotate your test methods with @ParameterizedTest + Specify a Source. Example:
public class MathUtils {
public double areaOfCircle(double radius) {
return Math.PI * radius * radius;
}
public int add(int a, int b) {
return a + b;
}
public int divide(int a, int b) {
return a / b;
}
}
@ParameterizedTest
@ValueSource(doubles = {1d, 2d, 3d, 4d, 5d})
void testCircleArea(double radius) {
MathUtils math = new MathUtils();
double expected = Math.PI * radius * radius;
double actual = math.areaOfCircle(radius);
assertEquals(expected, actual, "Area of circle is PI * r * r");
}
As you can see, the single test method was run 5 times with values 1, 2, 3, 4, and 5 passed to the double radius parameter respectively.
With the @ValueSource you can pass a single literal value (ints, strings, floats, doubles, etc. to your test case)
Use @NullSource, @EmptySource, @NullAndEmpty source to pass null/empty sources
Use @EnumSource to pass enums
Use @CsvSource to pass in multiple literal arguments
@ParameterizedTest
@CsvSource({"1, 1, 2", "2, 2, 4", "5, 5, 10", "15, 15, 30", "50, 50, 100"})
void testAdd(int a, int b, int expected) {
MathUtils math = new MathUtils();
int actual = math.add(a, b);
assertEquals(expected, actual, "adding " + a + ", " + b + " should result in " + expected);
}
Use @MethodSource to provide more complex arguments
@ParameterizedTest
@MethodSource("divideSource")
void testDivideBy0(int a, int b) {
/*
assertThrows(Exception class, Executable)
public interface Executable {
void execute() throws Throwable;
}
*/
assertThrows(ArithmeticException.class, () -> this.math.divide(a, b),
"Divide by 0 should raise an arithmetic exception");
}
//Should be static and return a stream/collection of Arguments
//Each Arguments item will initiate a test execution using said arguments!
//Can be in a separate class as well. You'll then need to use fully qualified name in the @MethodSource annotation
//Arguments.of(...Object) - Can pass complex arguments
static Stream<Arguments> divideSource() {
return Stream.of(
Arguments.of(1, 0),
Arguments.of(2, 0),
Arguments.of(3, 0)
);
}
Mockito
Mockito is a Java-based mocking framework that is used for unit testing of Java applications. It allows you to create mock objects that simulate the behavior of real objects, and verify their interactions and expectations. Mockito can be used with other testing frameworks, such as JUnit and is useful for testing components that depend on other components, but are not yet available or are difficult to access. By using mock objects, you can isolate the component under test and focus on its logic and functionality.
Add the mockito-core and the mockito-junit-jupiter (A 3rd party extension library for junit and mockito integration) dependency
<!-- https://mvnrepository.com/artifact/org.mockito/mockito-core -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>4.9.0</version>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.mockito/mockito-junit-jupiter -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>4.9.0</version>
<scope>test</scope>
</dependency>
Let’s extend our Accounts functionality and add a service. This example assumes you have a SpringBoot project (with Spring Data). The Account and Transaction Repository are Spring Data repositories.
@Service
public class AccountApplicationService {
@Autowired
private AccountRepository accountRepository;
@Autowired
private TransactionRepository transactionRepository;
@Transactional
public void deposit(String accountId, BigDecimal amount, String reference) {
Account account = accountRepository.getAccountById(accountId).orElseThrow();
Transaction transaction = account.deposit(amount, reference);
transactionRepository.save(transaction);
accountRepository.save(account);
}
}
Let’s try to test AccountApplicationService. To test it, I want to Mock the AccountRepository and TransactionRepository.
@ExtendWith(MockitoExtension.class)
class AccountApplicationServiceTest {
private Account account;
//Create a mock for this dependency
@Mock
private AccountRepository accountRepository;
//Create a mock for this dependency
@Mock
private TransactionRepository transactionRepository;
//@InjectMocks creates an instance of this class and injects the mocks that are created with the @Mock (or @Spy) annotations into this instance
@InjectMocks
private AccountApplicationService accountApplicationService;
@BeforeEach
public void beforeEach() {
this.account = new Account();
//Whenever the accountRepository.getAccountById method is called,
//return the given account object
Mockito.when(accountRepository.getAccountById(Mockito.anyString())).thenReturn(Optional.of(account));
}
@Test
public void testDeposit() {
accountApplicationService.deposit("temp", BigDecimal.TEN, "FY 23-24 qtr 2 interest");
assertEquals(BigDecimal.TEN, account.getBalance());
//Verify if the deposit call resulted in a call to the save method of the transactionRepository
Mockito.verify(transactionRepository).save(Mockito.any());
}
}
Top comments (0)