DEV Community

Ishan Soni
Ishan Soni

Posted on

Introduction to unit testing in Java

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?

  1. Prepration
  2. Provide test input
  3. Running the test case
  4. Provide expected output
  5. Verify output (expected vs actual)
  6. 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

  1. Outdated. > 10 years old
  2. No support for newer testing patterns
  3. Was playing catch up with newer java features
  4. Had a monolothic architecture (one jar that had it all)

JUnit5 Architecture

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

}
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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());

    }

}
Enter fullscreen mode Exit fullscreen mode

Running the test from the IDE:

Assert Passed

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

If we run our test case now, it will fail:

Assert failed

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");
Enter fullscreen mode Exit fullscreen mode

Assert descriptive

Asserting Exceptions using assertThrows

public class MathUtils {

    //Other methods omitted

    public int divide(int a, int b) {
        return a / b;
  }

}
Enter fullscreen mode Exit fullscreen mode

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");
    }

}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

The Test Life Cycle and Hooks (BeforeAll, BeforeEach, AfterEach, AfterAll)

Test life cycle

Hooks

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());
    }

}
Enter fullscreen mode Exit fullscreen mode

Hooks result

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>
Enter fullscreen mode Exit fullscreen mode

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;
  }

}
Enter fullscreen mode Exit fullscreen mode
@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");
}
Enter fullscreen mode Exit fullscreen mode

Value source

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);
}
Enter fullscreen mode Exit fullscreen mode

CSV source

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)
  );
}
Enter fullscreen mode Exit fullscreen mode

Method source

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>
Enter fullscreen mode Exit fullscreen mode

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);
    }

}
Enter fullscreen mode Exit fullscreen mode

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());
    }

}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)