Skip to main content
Version: 10.2.9

Write unit tests in ODF 2

ODF provides JUnit 5 (Jupiter), Mockito, and AssertJ libraries to develop Java unit tests.

Unit testing benefits

  • Unit testing increases confidence in changing and maintaining code. Running a good unit test every time any code is changed helps you promptly catch any defects introduced due to the alterations. Also, if codes are already made less interdependent to make unit testing possible, the unintended impact of changes to any code is less.
  • Development is faster. How? If you don't have unit testing in place, you write your code and perform that fuzzy "developer test", when you set some breakpoints, fire up the GUI, provide a few inputs that hopefully hit your code, and hope that you are all set. But, if you have unit testing in place, you write the test, write the code, and run the test. Writing tests takes time, but the time is compensated by the less time it takes to run the tests. Also, you don't need to fire up the GUI and provide all those inputs. And, of course, unit tests are more reliable than "developer tests".
  • Development is faster in the long run too. How? The effort required to find and fix defects found during unit testing is much less than the effort required to fix defects found during system testing or acceptance testing.
  • The cost of fixing a defect detected during unit testing is lesser than that of defects detected at higher levels. Compare the cost (time, effort, destruction, humiliation) of a defect detected during acceptance testing or when the software is live.
  • Debugging is easy. When a test fails, the latest changes need to be debugged only. With testing at higher levels, changes made over the span of several days, weeks, and months need to be scanned.

Unit testing

Bot Tasks in ODF 2 are represented as Java classes marked with the @BotTask annotation. All the class dependencies must be provided externally by the DI framework or Feather in ODF 2. We recommend using a constructor injection as it eases test creation.

Let's write a test for a simple Bot Task:

@BotTask
public class GenericTaskExample implements GenericTask {

private final TaskInput taskInput;
private final TaskOutput taskOutput;
private final Logger logger;

@Inject
public GenericTaskExample(TaskInput taskInput, TaskOutput taskOutput, Logger logger) {
this.taskInput = taskInput;
this.taskOutput = taskOutput;
this.logger = logger;
}

@Override
public void run() {
final Optional<String> transactionId = taskInput.getVariable(TaskVariable.TRANSACTION_ID);
if (transactionId.isPresent()) {
logger.info(String.format("Processing transaction %s%n", currentTransaction.get().getUuid()));
} else {
logger.info("No transaction");
}

taskOutput.setColumn("additional_field", "additional_field value");
taskOutput.setColumn("transaction_processed", String.valueOf(transactionId.isPresent()));
}

}

This class has three external dependencies, all of them to be mocked in different ways. We recommend mocking all of them to provide a controlled and predictable environment for test execution. It allows testing the given class logic only without external influences. A test class for the given task looks as follows:

class GenericTaskExampleTest {

@Test
@DisplayName("Should propagate additional field and true transaction processed status to task output")
public void shouldPropagateAdditionalFieldAndTrueTransactionProcessedStatusToTaskOutput() {

// given
// mock method
final TaskInput taskInput = Mockito.mock(TaskInput.class);
Mockito
.when(taskInput.getVariable(Mockito.anyString()))
.thenReturn(Optional.of("UUID"));

// spy without mocking
final TaskOutput taskOutput = Mockito.spy(TaskOutput.class);

// mock the object for method call counting
final Logger logger = Mockito.mock(Logger.class);

final GenericTaskExample genericTaskExample =
new GenericTaskExample(taskInput, taskOutput, logger);

// when
genericTaskExample.run();

// then
Assertions.assertThat(taskOutput.getColumns())
.containsEntry("additional_field", "additional_field value")
.containsEntry("transaction_processed", String.valueOf(true));

Mockito.verify(logger, Mockito.times(1)).info("Processing transaction UUID\n");

}

}

There are three types of mocking used:

  • Mocking a method output to specify object behavior.
  • Spying an object without changing the behavior.
  • Mocking an object to count method calls (no new behavior introduced).
tip

Mockito has a rich API with a lot of capabilities. To get started, read the following articles:

Mocking: common Use Cases

Let's say a few words about most generic Use Cases of mocking during test development.

Override method

In general, all dependencies of a tested class must be initialized out of the tested class. Sometimes, you need to override the method (public or protected) of the object used in the test for providing the required state of the object or predictable method behavior. There are two ways of mocking object behavior in Mockito: mocking and spying.

Mocking

When using mock objects, the default behavior of the method when not stub is to do nothing. If it's a void method, it does nothing when you call the method. If it's a method with a return, it may return null, empty, or the default value.

class GenericTest {
@Test
@DisplayName("Should mock task input")
public void shouldMockTaskInput() {

final TaskInput taskInput = Mockito.mock(TaskInput.class);
Mockito.when(taskInput.getVariable(Mockito.anyString())).thenReturn(Optional.of("UUID"));

// mocked method will return value
Assertions.assertThat(taskInput.getVariable("any name")).contains("UUID");

//non-mocked method will return null
Assertions.assertThat(taskInput.getRequiredVariable("any name")).isEmpty();

}
}

Spying

In spy objects, when you don't stub the method, it calls the real method behavior. If you want to change and mock the method, stub it.

class GenericTest {
@Test
@DisplayName("Should spy task output")
public void shouldSpyTaskOutput() {

final TaskOutput taskOutput = Mockito.spy(TaskOutput.class);
Mockito.when(taskOutput.getVariablesToRemove()).thenReturn(Collections.singleton("variable"));

// mocked method will return pre-defined value
Assertions.assertThat(taskOutput.getColumns()).isEmpty();

// non mocked value will return real object state
Assertions.assertThat(taskOutput.getColumns()).isEmpty();
}
}

Override fields

Sometimes, if the class used in implementation isn't designed to be tested or stubbed, for example, when the field is initialized by the code which requires a real environment, you need to override the private field of this object. If there is no ability to change the solution design, a reflection can override the private field.

// "datastoreProperties" field is initialized improperly by default (because it gets created from mocked object)
Field datastorePropertiesField = CustomDatastoreService.class.getDeclaredField("datastoreProperties");
datastorePropertiesField.setAccessible(true);
datastorePropertiesField.set(service, dsProperties);

In general, avoid such design patterns.

Mocking: examples

Now, let's write a test for tasks designed for processing email entities.

@BotTask
@Requires(DemoOdfModule.class)
public class ExtractEmailBodyTask implements GenericTask {

private static final String EMAIL_UUID_CONTEXT_VAR = "sys_email_id";
private static final String EMAIL_BODY_CONTEXT_VAR = "sys_email_body";

private final EmailRepository emailRepository;
private final EmailService emailService;

private final TaskInput taskInput;
private final TaskOutput taskOutput;

@Inject
public ExtractEmailBodyTask(EmailRepository emailRepository, EmailService emailService, TaskInput taskInput, TaskOutput taskOutput) {
this.emailRepository = emailRepository;
this.emailService = emailService;
this.taskInput = taskInput;
this.taskOutput = taskOutput;
}

@Override
public void run() {
final Optional<UUID> emailId = taskInput.getVariable(EMAIL_UUID_CONTEXT_VAR).map(UUID::fromString);
final Optional<String> body = emailId
.flatMap(id -> emailRepository.findById(id)
.map(emailService::normalizeBody));

body.ifPresent(content -> taskOutput.setColumn(EMAIL_BODY_CONTEXT_VAR, content));
}
}

The task operates with repositories and services, provided as external dependencies. You can test the class in the same way as all other classes in Java—by mocking all the services and repositories and calling a method that contains business logic.

Mock simple dependencies

In the easiest case, you can mock all the dependencies right in the test and then use in the constructor.

class ExtractEmailBodyTaskTest {

@Test
@DisplayName("Should run extraction of the email body")
public void shouldRunExtractionOfTheEmailBody() {

// given
final String emailUuid = UUID.randomUUID().toString();

final TaskInput taskInput = Mockito.mock(TaskInput.class);
Mockito.when(taskInput.getVariable(ExtractEmailBodyTask.EMAIL_UUID_CONTEXT_VAR))
.thenReturn(Optional.of(emailUuid));

final Email emailEntity = Mockito.mock(Email.class);

final EmailRepository emailRepository = Mockito.mock(EmailRepository.class);
Mockito.when(emailRepository.findById(Mockito.eq(UUID.fromString(emailUuid))))
.thenReturn(Optional.of(emailEntity));

final EmailService emailService = Mockito.mock(EmailService.class);
Mockito.when(emailService.normalizeBody(Mockito.eq(emailEntity))).thenReturn("email body");

final TaskOutput taskOutput = Mockito.spy(TaskOutput.class);

// when
new ExtractEmailBodyTask(emailRepository, emailService, taskInput, taskOutput).run();

// then
Assertions.assertThat(taskOutput.getColumns())
.containsEntry(ExtractEmailBodyTask.EMAIL_BODY_CONTEXT_VAR, "email body");

}
}

In this example, all mocks are created and stubbed in the test method. While stubbing methods with parameters, you can use Mockito.eq() to mock the known parameters and Mockito.any() if you can't predict input or a value doesn't matter. Note that the framework doesn't work with real values without these methods. In the given case, the test works because emailEntity is the same for both places. Otherwise, implement the equals method for the correct test work.

Inject mocks

You can rewrite the test in a more clear manner using the @InjectMock annotation and MockitoExtension.

@ExtendWith(MockitoExtension.class)
class ExtractEmailBodyTaskTest {

@Mock TaskInput taskInput;
@Mock Email emailEntity;
@Mock EmailRepository emailRepository;
@Mock EmailService emailService;
@Spy TaskOutput taskOutput;

@InjectMocks ExtractEmailBodyTask task;

@BeforeEach
public void setUp() {
final String emailUuid = UUID.randomUUID().toString();
Mockito.when(taskInput.getVariable(ExtractEmailBodyTask.EMAIL_UUID_CONTEXT_VAR))
.thenReturn(Optional.of(emailUuid));

Mockito.when(emailRepository.findById(Mockito.eq(UUID.fromString(emailUuid))))
.thenReturn(Optional.of(emailEntity));
}

@Test
@DisplayName("Should run extraction of the email body")
public void shouldRunExtractionOfTheEmailBody() {

// given
Mockito.when(emailService.normalizeBody(Mockito.eq(emailEntity))).thenReturn("email body");

// when
task.run();

// then
Assertions.assertThat(taskOutput.getColumns())
.containsEntry(ExtractEmailBodyTask.EMAIL_BODY_CONTEXT_VAR, "email body");
}
}

In this case, the Mockito Framework makes all the initialization job, and you define the mock behavior. The code becomes more readable and cleaner.