The Holy Trinity: JUnit5, Mockito, and Instancio
Unit tests that are heavenly easy to write and maintain
Unit testing is a software testing technique where individual components of an application (units) are tested in isolation.
Unit testing ensures that new code does not introduce bugs or lead to poor software design. In this process, developers write test cases that exercise the UUTs (Units Under Test) to verify that they produce the expected output for a given set of inputs.
Framework support simplifies and speeds up the process involved in writing unit tests. This article gives you a brief overview of three Java test libraries you can use together: JUnit 5, Mockito, and Instancio.
Setting Up the Project
Throughout this tutorial, we will use Maven with JUnit “Jupiter” (JUnit 5), Mockito for JUnit and Instancio for JUnit along with AssertJ.
You can add the dependencies below to your pom.xml
file.
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.instancio</groupId>
<artifactId>instancio-junit</artifactId>
<version>${instancio.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj.version}</version>
<scope>test</scope>
</dependency>
JUnit 5
JUnit 5 is the latest version of the popular Java testing framework, JUnit. Designed to work with Java 8 and above, it introduces features like parameterized tests, dynamic tests and test templates.
The UUT
CardRepository
will be the unit under test.
public class CardRepository {
protected static boolean checkCN(String cardNumber) {
int sum = 0;
boolean alternate = false;
for (int i = cardNumber.length() - 1; i >= 0; i--) {
int n = Integer.parseInt(cardNumber.substring(i, i + 1));
if (alternate) {
n *= 2;
if (n > 9) {
n = (n % 10) + 1;
}
}
sum += n;
alternate = !alternate;
}
return (sum % 10 == 0);
}
protected static boolean checkED(int expDate) {
LocalDate now = LocalDate.now();
final int expYear = 2000 + expDate % 100;
if (expYear > now.getYear()) {
return true;
}
final int expMonth = expDate / 100;
return (expYear == now.getYear() && expMonth > now.getMonthValue());
}
protected static boolean checkCVC(int cvc) {
return (cvc > 0 && cvc < 1000);
}
/**
* Validates:
* <ol>
* <li>Card Number using the Luhn Algorithm. <a href="https://en.wikipedia.org/wiki/Luhn_algorithm">wiki</a></li>
* <li>Expiration Date checking that it is prior to the current month and year.</li>
* <li>CVC checking that it is between 0 and 1000. See <a href="https://en.wikipedia.org/wiki/Card_security_code">wiki</a></li>
* </ol>
*
* @param card the card instance of {@link CardDetails}
* @return {@code true} if the card is valid, or {@code false} otherwise.
*/
public boolean validateCard(CardDetails card) {
return checkCN(card.number()) && checkED(card.expDate()) && checkCVC(card.cvc());
}
}
The class includes the method validateCard()
that needs to be tested.
Simple Tests
To test CardRepository
, we have a set of data with valid and invalid values.
public class CardDetailsMockData {
public static final String VALID_CN = "4111111111111111";
public static final String INVALID_CN = "4111111111111110";
public static final int VALID_ED = 1299;
public static final int INVALID_ED = 1201;
public static final int VALID_CVC = 123;
public static final int INVALID_CVC = 1000;
private CardDetailsMockData() {
}
}
CardDetailsMockData
holds the following constants:
VALID_CN
: A valid Card Number.INVALID_CN
: An invalid Card Number.VALID_ED
: A valid Expiration Date.INVALID_ED
: An invalid Expiration Date.VALID_CVC
: A valid Card Validation Code.INVALID_CVC
: An invalid Card Validation Code.
Using the mock data above, we can create a test case for the validateCard()
method where the input data is valid:
@Test
void validateCard_ValidCard_Test() {
CardRepository repository = new CardRepository();
boolean valid = repository.validateCard(
new CardDetails(VALID_CN, VALID_ED, VALID_CVC)
);
assertThat(valid).isTrue();
}
Parameterized Tests
Methods with the @Test
annotation are tests that you can run multiple times for a single set of data. Parameterized tests, on the other hand, make it possible to run a test multiple times with different arguments.
The next code example defines test cases for multiple invalid card information.
@ParameterizedTest
@MethodSource
void validateCard_invalidCard_ParameterizedTest(CardDetails cardDetails) {
CardRepository repository = new CardRepository();
boolean valid = repository.validateCard(cardDetails);
assertThat(valid).isFalse();
}
static Stream<CardDetails> validateCard_invalidCard_ParameterizedTest() {
return Stream.of(
new CardDetails(INVALID_CN, VALID_ED, VALID_CVC),
new CardDetails(VALID_CN, INVALID_ED, VALID_CVC),
new CardDetails(VALID_CN, VALID_ED, INVALID_CVC)
// Other test cases are ignored for simplicity...
);
}
Here, instead of a @Test
method, we have declared a @ParameterizedTest
method.
In addition, we have a source that provides the arguments for each invocation. There is a wide range of source annotations supported by JUnit. In this example, we use the @MethodSource
annotation.
@MethodSource
requires a static
method with the same name as the test method that returns a Java Stream of objects, in this case CardDetails
.
Instancio
The problem with the last example is that it requires a lot of boilerplate code to manually set up data for more complex classes. Fortunately, Instancio provides a convenient way to create test data objects.
This library can help you create more comprehensive and effective unit tests by automating data setup, saving you time and reducing the likelihood of errors in your tests.
The UUT
In this example, we test the PaymentRepository
class.
public class PaymentRepository {
private final ConcurrentMap<UUID, Payment> payments;
public PaymentRepository(ConcurrentMap<UUID, Payment> payments) {
this.payments = payments;
}
/**
* Stores a payment record.
*
* @param payment the payment instance of {@link Payment}
* @return the payment id instance of {@link UUID}
*/
public UUID save(Payment payment) {
UUID id = UUID.randomUUID();
payments.put(id, payment);
return id;
}
/**
* Updates payment status.
*
* @param id the payment id instance of {@link UUID}
* @param status the payment status instance of {@link PaymentStatus}
* @return {@code true} if the payment was updated, or {@code false} otherwise.
*/
public boolean updateStatus(UUID id, PaymentStatus status) {
return Objects.nonNull(
payments.computeIfPresent(id, update(status))
);
}
protected static BiFunction<UUID, Payment, Payment> update(PaymentStatus status) {
return (id, payment) -> new Payment(payment.amount(), payment.method(), status, payment.card());
}
}
This class includes two methods to be tested: save()
and updateStatus()
.
Instancio API
@Test
void save_Test() {
ConcurrentMap<UUID, Payment> payments = new ConcurrentHashMap<>();
PaymentRepository paymentRepository = new PaymentRepository(payments);
Payment givenPayment = Instancio.of(Payment.class)
.generate(field("amount"), generators ->
generators.doubles().range(.5, 10_000.))
.generate(field(CardDetails.class, "cvc"),
generators -> generators.ints().range(1, 999))
.generate(field(CardDetails.class, "number"),
generators -> generators.finance().creditCard().masterCard())
.set(field(CardDetails.class, "expDate"), "1233")
.create();
UUID id = paymentRepository.save(givenPayment);
assertThat(id).isNotNull();
assertThat(payments).containsKey(id).containsValue(givenPayment);
}
Random objects can be created with Instancio.of().create()
, a builder API that allows you to customize data objects.
You can customize values with the generate()
method, via built-in generators. Instancio provides generators for strings, numeric types, collections, arrays, dates, and even credit card numbers!
It is also possible to set a non-random value using the set()
method.
InstancioSource Annotation
In most cases, using the @InstancioSource
annotation with the @ParameterizedTest
method is the most effective way to generate data objects.
To get started, it is necessary to register InstancioExtension.class
with the @ExtendWith
annotation.
We can accomplish this as follows:
import org.instancio.junit.InstancioExtension;
import org.instancio.junit.InstancioSource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
@ExtendWith(InstancioExtension.class)
class PaymentRepositoryTest {
@ParameterizedTest
@InstancioSource
void updateStatus_Test(Payment givenPayment, UUID givenId, PaymentStatus givenStatus) {
ConcurrentMap<UUID, Payment> payments = new ConcurrentHashMap<>();
PaymentRepository paymentRepository = new PaymentRepository(payments);
payments.put(givenId, givenPayment);
boolean updated = paymentRepository.updateStatus(givenId, givenStatus);
assertThat(updated).isTrue();
assertThat(payments.get(givenId).status()).isEqualTo(givenStatus);
}
}
Instancio will provide populated objects as parameters in the test method.
Using @InstancioSource
there are two limitations to know:
- It cannot provide instances of generic types. For instance, there is no way to specify a Java collection.
- You cannot customize provided objects as you would with the builder API.
Mockito
When it comes to unit testing, one thing to keep in mind is that unit tests SHOULD NOT rely on external dependencies. Thankfully, Mockito provides a convenient way to create mock objects.
A mock object is a fake object that simulates the behavior of a real object in a controlled way. This allows you to test your code in isolation.
Mock objects can be created for any class or interface, and you can specify the behavior of its methods. This allows you to tell Mockito what a method should return or whether it throws an exception.
In addition, Mockito provides powerful verification features that allow you to verify which methods were called on the mock objects and with what parameters. This helps you ensure that the code under test behaves as expected and interacts correctly with the objects it depends on.
With Mockito, you can also use a Behavior-Driven development syntax. The Mockito BDD approach encourages clear, concise descriptions of the behavior of what is being tested.
The UUT
PaymentService
is the unit under test.
public class PaymentService {
private final PaymentRepository paymentRepository;
private final CardRepository cardRepository;
public PaymentService(PaymentRepository paymentRepository, CardRepository cardRepository) {
this.paymentRepository = paymentRepository;
this.cardRepository = cardRepository;
}
/**
* Registers a {@link Payment} record based on the logic below.
* <pre>
* IF CardDetails is present THEN
* create Payment with status PENDING_VALIDATION
* validate CardDetails
* update Payment status to OK or ERROR, depending on whether the card is valid
* OR ELSE
* create Payment with status OK
* </pre>
*
* @param amount the payment amount instance of {@link BigDecimal}
* @param optionalCard the {@link Optional} parameter with the {@link CardDetails}
*/
public void registerPayment(Double amount, Optional<CardDetails> optionalCard) {
optionalCard.ifPresentOrElse(cardDetails -> {
UUID id = paymentRepository.save(
new Payment(amount, cardDetails, PaymentStatus.PENDING_VALIDATION));
boolean isValidCard = cardRepository.validateCard(cardDetails);
paymentRepository.updateStatus(id,
isValidCard ? PaymentStatus.OK : PaymentStatus.ERROR);
}, () -> paymentRepository.save(
new Payment(amount, PaymentStatus.OK))
);
}
}
This class includes the registerPayment()
method to be tested, and depends on the PaymentRepository
and CardRepository
classes.
Mockito API
To use Mockito, the most convenient way is to register MockitoExtension.class
with the @ExtendWith
annotation.
Here is how the tests might look like:
import org.instancio.junit.InstancioExtension;
import org.instancio.junit.InstancioSource;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
@ExtendWith({MockitoExtension.class, InstancioExtension.class})
class PaymentServiceTest {
@InjectMocks PaymentService paymentService;
@Mock CardRepository cardRepository;
@Mock PaymentRepository paymentRepository;
@ParameterizedTest
@InstancioSource
void registerPayment_ShouldSaveCashPaymentWithStatusOK_Test(
Double givenAmount, UUID givenId
) {
//given
given(paymentRepository.save(any(Payment.class))).willReturn(givenId);
//when
paymentService.registerPayment(givenAmount, Optional.empty());
//then
then(paymentRepository).should().save(any(Payment.class));
then(cardRepository).shouldHaveNoInteractions();
}
@ParameterizedTest
@InstancioSource
void registerPayment_ShouldSaveCardPaymentWithStatusOK_Test(
Double givenAmount, CardDetails givenCard, UUID givenId
) {
//given
given(paymentRepository.save(any(Payment.class))).willReturn(givenId);
given(cardRepository.validateCard(givenCard)).willReturn(true);
given(paymentRepository.updateStatus(givenId, PaymentStatus.OK)).willReturn(true);
//when
paymentService.registerPayment(givenAmount, Optional.of(givenCard));
//then
then(paymentRepository).should().save(any(Payment.class));
then(cardRepository).should().validateCard(eq(givenCard));
then(paymentRepository).should().updateStatus(eq(givenId), eq(PaymentStatus.OK));
}
@ParameterizedTest
@InstancioSource
void registerPayment_ShouldSaveCardPaymentWithStatusERROR_Test(
Double givenAmount, CardDetails givenCard, UUID givenId
) {
//given
given(paymentRepository.save(any(Payment.class))).willReturn(givenId);
given(cardRepository.validateCard(givenCard)).willReturn(false);
given(paymentRepository.updateStatus(givenId, PaymentStatus.ERROR)).willReturn(true);
//when
paymentService.registerPayment(givenAmount, Optional.of(givenCard));
//then
then(paymentRepository).should().save(any(Payment.class));
then(cardRepository).should().validateCard(eq(givenCard));
then(paymentRepository).should().updateStatus(eq(givenId), eq(PaymentStatus.ERROR));
}
}
Here:
- The
@Mock
annotation creates a mock object from the annotated class or interface. - The
@InjectMocks
annotation automatically injects mock objects annotated with@Mock
through constructor injection, setter injection, or property injection. - The
given().willReturn()
structure provides a fixed return value for the method call. - The
then().should…
structure provides verification methods of behavior on the mock object.
Summary
In this article, we looked at some of the key features of three popular test libraries, such as JUnit 5, Mockito, and Instancio.
All these libraries have a rich set of functionalities for testing. Please check the following links for more details:
- https://junit.org/junit5/docs/current/user-guide/#writing-tests
- https://www.instancio.org/user-guide/#junit-jupiter-integration
- https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html
Thanks for reading. I hope this was helpful!
The example code is available on GitHub.