How to Implement Effective Unit Tests In Hexagonal Architecture
Welcome to the post about unit test implementation in hexagonal architecture. Please pay attention that this post is not about what is unit test or hexagonal architecture.
I create a sample application and you can get the full source codes from here. This sources contains unit tests also but we will have deeper look on unit tests codes step by step.
So what is our story and which tech stacks are used? We have a developer hiring company and business partners. We provide two APIs for partners. One is listing all developers and other one is hiring a developer. There are some business rules on hiring API. A developer can be hired only once for the same date. We use spring boot, lombok, H2 in memory database, JUnit and mocking for unit tests.
There are several approach to unit test. I use fake implementations in domain hexagon and mocking in infra hexagon. Because i want to minimum dependencies in domain hexagon.
Lets start with domain hexagon unit tests. I do not write unit tests of model, entity, DTO etc. Only enums, use case handlers, adapters and repositories.
First unit test is about enums. You can ask why we need this? I think adding to enums or deleting from enums may cause some logical changes.
package com.farukbozan.medium.hiring.developer.enums;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DeveloperTypeTest {
@Test
void should_get_enums() {
//GIVEN
//WHEN
//THEN
assertEquals(3, DeveloperType.values().length);
}
}
We just check the element count of enums. If there is adding or deleting our tests will fail and we will know that some logics are changed.
Now it is turn of use case handlers. Before writing unit tests, i have to point that these handlers have ports and need to implementation of ports. So here are the implementation of ports for handlers.
package com.farukbozan.medium.hiring.developer.port;
import com.farukbozan.medium.hiring.developer.enums.DeveloperType;
import com.farukbozan.medium.hiring.developer.model.DeveloperModel;
import java.util.List;
public class FakeDeveloperDataPort implements DeveloperDataPort {
@Override
public List<DeveloperModel> getAllDevelopers() {
return List.of(DeveloperModel.builder()
.devId(1L)
.type(DeveloperType.PYTHON)
.name("Dev1")
.build());
}
}
package com.farukbozan.medium.hiring.developer.port;
import com.farukbozan.medium.hiring.developer.usecase.HiringUseCase;
import java.time.LocalDate;
public class FakeExistDeveloperHiringDataPort implements DeveloperHiringDataPort {
@Override
public boolean existsByDeveloperIdAndBetweenStartDateAndEndDate(Long developerId, LocalDate hiringDate) {
return true;
}
@Override
public void hire(HiringUseCase useCase) {
// empty for fake implementation.
}
}
package com.farukbozan.medium.hiring.developer.port;
import com.farukbozan.medium.hiring.developer.usecase.HiringUseCase;
import java.time.LocalDate;
public class FakeNotExistDeveloperHiringDataPort implements DeveloperHiringDataPort {
@Override
public boolean existsByDeveloperIdAndBetweenStartDateAndEndDate(Long developerId, LocalDate hiringDate) {
return false;
}
@Override
public void hire(HiringUseCase useCase) {
// empty for fake implementation.
}
}
These fake port implementations will help us to execute domain logics by use case handlers.
package com.farukbozan.medium.hiring.developer.usecase.handler;
import com.farukbozan.medium.hiring.developer.enums.DeveloperType;
import com.farukbozan.medium.hiring.developer.port.FakeDeveloperDataPort;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DeveloperListingUseCaseHandlerTest {
@Test
void should_get_all_developers() {
//GIVEN
var handler = new DeveloperListingUseCaseHandler(new FakeDeveloperDataPort());
//WHEN
var result = handler.handle();
//THEN
assertEquals(1, result.size());
assertEquals(1L, result.get(0).getDevId());
assertEquals("Dev1", result.get(0).getName());
assertEquals(DeveloperType.PYTHON, result.get(0).getType());
}
}
In the above, we implemented the unit test of DeveloperListingUseCaseHandlerTest. FakeDeveloperDataPort helped us to provide data as if from database. Then finally we asserts the values coming from FakeDeveloperDataPort. We do not interest in what DeveloperDataPort does in DeveloperListingUseCaseHandler because our main focus is flow of use case handler.
Now second use case handler unit test.
package com.farukbozan.medium.hiring.developer.usecase.handler;
import com.farukbozan.medium.hiring.developer.exception.DeveloperNotFreeException;
import com.farukbozan.medium.hiring.developer.port.FakeExistDeveloperHiringDataPort;
import com.farukbozan.medium.hiring.developer.port.FakeNotExistDeveloperHiringDataPort;
import com.farukbozan.medium.hiring.developer.usecase.HiringUseCase;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
class DeveloperHiringUseCaseHandlerTest {
@Test
void should_throw_exception() {
//GIVEN
var useCase = HiringUseCase.builder()
.developerId(1L)
.hiringDate(LocalDate.now())
.build();
var handler = new DeveloperHiringUseCaseHandler(new FakeExistDeveloperHiringDataPort());
//WHEN
//THEN
assertThrows(DeveloperNotFreeException.class, () -> handler.handle(useCase));
}
@Test
void should_hire() {
//GIVEN
var useCase = HiringUseCase.builder()
.developerId(1L)
.hiringDate(LocalDate.now())
.build();
var handler = new DeveloperHiringUseCaseHandler(new FakeNotExistDeveloperHiringDataPort());
//WHEN
//THEN
assertDoesNotThrow(() -> handler.handle(useCase));
}
}
We have two cases in this unit test because DeveloperHiringUseCaseHandler has a if condition in handle method. So we need two separate unit tests to cover all branches. As in DeveloperListingUseCaseHandler, we do not interest in what DeveloperHiringDataPort does in background. We completely focus on the logic of domain hexagon. So there two fake port implementations. We can create a single abstract fake port implementation and then override abstract method in new operation. You are free to select which way.
In first case, developer is hired for the given date before and so we have to get exception and asserts this.
In the second case, developer is not hired yet for the given date so we do not expect exception. Everything must go ok.
Great! We completed the domain hexagon unit tests. It is turn to infra hexagon unit tests.
Before starting i need to point that project has a data.sql file under the resources folder. So when spring boot application is up, data will be inserted to in memory tables.
insert into developer(developer_id, developer_name, developer_type)
values (1, 'Developer1', 'JAVA');
insert into developer(developer_id, developer_name, developer_type)
values (2, 'Developer2', 'JAVA');
insert into developer(developer_id, developer_name, developer_type)
values (3, 'Developer3', 'GO');
insert into developer(developer_id, developer_name, developer_type)
values (4, 'Developer4', 'GO');
insert into developer(developer_id, developer_name, developer_type)
values (5, 'Developer5', 'PYTHON');
Starting from repositories unit tests.
package com.farukbozan.medium.hiring.developer.repository;
import com.farukbozan.medium.hiring.DeveloperHiringInfraApplication;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = DeveloperHiringInfraApplication.class)
class DeveloperRepositoryTest {
@Autowired
protected DeveloperRepository developerRepository;
@Test
void should_get_all_developers() {
//GIVEN
//WHEN
var result = developerRepository.findAll();
//THEN
assertEquals(5, result.size());
}
}
We used a data.sql files and there are 5 insertions. So we asserts 5 developers. Please note that we use
@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = DeveloperHiringInfraApplication.class)
because, need to running spring boot application to insert data. When this unit test is run a spring boot application will run up and then shut down after the assertions.
package com.farukbozan.medium.hiring.developer.repository;
import com.farukbozan.medium.hiring.DeveloperHiringInfraApplication;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = DeveloperHiringInfraApplication.class)
class DeveloperHiringRepositoryTest {
@Autowired
protected DeveloperHiringRepository developerHiringRepository;
@Test
void should_get_empty_hiring() {
//GIVEN
//WHEN
var result = developerHiringRepository.findAll();
//THEN
assertEquals(0, result.size());
}
@Test
void should_not_exist() {
//GIVEN
//WHEN
var result = developerHiringRepository.existsByDeveloperIdAndHiringDate(1L, LocalDate.now());
//THEN
assertFalse(result);
}
}
In second repository unit test, we assert that there is no data for developer hiring table because there is no insertion scripts in the data.sql file. So table must be empty. In second case developer must be free for the given date because there is no data in table. I can hire any developer in any date.
To the final steps we will create adapters unit tests.
package com.farukbozan.medium.hiring.developer.adapter;
import com.farukbozan.medium.hiring.developer.entity.Developer;
import com.farukbozan.medium.hiring.developer.enums.DeveloperType;
import com.farukbozan.medium.hiring.developer.mapper.DeveloperMapper;
import com.farukbozan.medium.hiring.developer.model.DeveloperModel;
import com.farukbozan.medium.hiring.developer.repository.DeveloperRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DeveloperDbAdapterTest {
@InjectMocks
private DeveloperDbAdapter developerDbAdapter;
@Mock
private DeveloperRepository developerRepository;
@Mock
private DeveloperMapper developerMapper;
@Test
void should_get_all_developer_model() {
//GIVEN
when(developerRepository.findAll()).thenReturn(createDevelopers());
when(developerMapper.toDeveloperModel(any())).thenReturn(createDeveloperModels());
//WHEN
var allDevelopers = developerDbAdapter.getAllDevelopers();
//THEN
assertEquals(1, allDevelopers.size());
assertEquals(1L, allDevelopers.get(0).getDevId());
assertEquals("Dev1", allDevelopers.get(0).getName());
assertEquals(DeveloperType.PYTHON, allDevelopers.get(0).getType());
}
private List<DeveloperModel> createDeveloperModels() {
var developerModel = DeveloperModel.builder()
.devId(1L)
.name("Dev1")
.type(DeveloperType.PYTHON)
.build();
return List.of(developerModel);
}
private List<Developer> createDevelopers() {
var developer = new Developer();
developer.setDevId(1L);
developer.setName("Dev1");
developer.setType(DeveloperType.PYTHON);
return List.of(developer);
}
}
I use mocking in the infra hexagon unit tests. In this approach, dependencies of adapter are mocked and adapter will use mocking objects. So we need the give when expressions to tell mocking objects what to do when a call occurs. In this case, we managed DeveloperRepository and DeveloperMapper by giving when expressions. During call of DeveloperDbAdapter.getAllDevelopers(), our createDeveloperModels() and createDevelopers() will be executed. So we can only focus on the logic of adapter.
package com.farukbozan.medium.hiring.developer.adapter;
import com.farukbozan.medium.hiring.developer.repository.DeveloperHiringRepository;
import com.farukbozan.medium.hiring.developer.usecase.HiringUseCase;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DeveloperHiringDbAdapterTest {
@InjectMocks
private DeveloperHiringDbAdapter developerHiringDbAdapter;
@Mock
private DeveloperHiringRepository developerHiringRepository;
@Test
void should_exists() {
//GIVEN
when(developerHiringRepository.existsByDeveloperIdAndHiringDate(anyLong(), any())).thenReturn(true);
//WHEN
var result = developerHiringDbAdapter.existsByDeveloperIdAndBetweenStartDateAndEndDate(1L, LocalDate.now());
//THEN
assertTrue(result);
}
@Test
void should_not_exists() {
//GIVEN
when(developerHiringRepository.existsByDeveloperIdAndHiringDate(anyLong(), any())).thenReturn(false);
//WHEN
var result = developerHiringDbAdapter.existsByDeveloperIdAndBetweenStartDateAndEndDate(1L, LocalDate.now());
//THEN
assertFalse(result);
}
@Test
void should_save() {
//GIVEN
var useCase = HiringUseCase.builder().developerId(1L).hiringDate(LocalDate.now()).build();
//WHEN
//THEN
assertDoesNotThrow(() -> developerHiringDbAdapter.hire(useCase));
}
}
We have three cases for DeveloperHiringDbADapter. Two for exists operations of repository and other one is save operation. We do not expect any exception in hire method.
And finally we completed the implementation of unit tests in hexagonal architecture. One of the biggest advantage of hexagonal architecture is separation of core domain hexagon and tech based hexagon. We can focus on just domain by fake port implementations. Also we can focus on just architecture logics by mocking in the infra hexagon.
Thanks for reading…