A Deep Dive into Unit Testing

  1. Business code
  2. Documentation
  3. CI/CD pipeline
  4. Communication rules
  5. Automation tests

What is unit testing

  1. UserService
  2. RoleService
  3. PostService
  4. CommentService
  5. UserRepo
  6. RoleRepo

Test Driven Development

  1. Write a test for the new functionality. It’s going to fail because you haven’t written the required business code yet.
  2. Add the minimum code to implement the feature.
  3. If the test passes, refactor the result and go back to the first step.

The problem of unit definition

  1. User provides post id and comment content.
  2. If the post is absent, an exception is thrown.
  3. If comment content is longer than 300 characters, an exception is thrown.
  4. If all validations pass, the comment should be saved successfully.
public class CommentService {  private final PostRepository postRepository;
private final CommentRepository commentRepository;
// constructor is omitted for brevity public void addComment(long postId, String content) {
if (!postRepository.existsByid(postId)) {
throw new CommentAddingException("No post with id = " + postId);
}
if (content.length() > 300) {
throw new CommentAddingException("Too long comment: " + content.length());
}
commentRepository.save(new Comment(postId, content));
}
}

The Detroit School of TDD

class CommentServiceTest {  @Test
void testWithStubs() {
CommentService service = new CommentService(
new StubPostRepository(),
new StubCommentRepository()
);
}
}

The London School of TDD

class CommentServiceTest {  @Test
void testWithStubs() {
PostRepository postRepository = mock(PostRepository.class);
CommentRepository commentRepository = mock(CommentRepository.class);
CommentService service = new CommentService(
postRepository,
commentRepository
);
}
}

Summary of unit definition

  1. Classes should not break the DI (dependency inversion) principle.
  2. Unit tests should not affect each other.
  3. Unit tests should be deterministic.
  4. Unit tests should not depend on any external state.
  5. Unit tests should run fast.
  6. All Tests Should Run in the CI Environment

Unit test requirements

Classes should not break the DI Principle

public class CommentService {  private final PostRepository postRepository = new PostRepositoryImpl();
private final CommentRepository commentRepository = new CommentRepositoryImpl();

...
}

Unit tests should not affect each other

public class StubCommentRepository implements CommentRepository {  private final List<Comment> comments = new ArrayList<>();  @Override
public void save(Comment comment) {
comments.add(comment);
}
public List<Comment> getSaved() {
return comments;
}
public void deleteSaved() {
comments.clear();
}
}
  1. Make sure that each stub is thread-safe.
  2. Create a new stub/mock for every test case.

Unit tests should be deterministic

class DateUtilTest {  @Test
void shouldBeMorning() {
OffsetDateTime now = OffsetDateTime.now();
assertTrue(DateUtil.isMorning(now));
}
}
  1. Current date time.
  2. System timezone.
  3. Hardware parameters.
  4. Random numbers.

Unit tests should not depend on any external state

class WeatherTest {  @Test
void shouldGetCurrentWeatherStatus() {
String apiRoot = "https://api.openweathermap.org";
Weather weather = new Weather(apiRoot);
WeatherStatus weatherStatus = weather.getCurrentStatus(); assertNotNull(weatherStatus);
}
}

All tests should run in the CI environment

Summary of unit test requirements

  1. A test is excellent code documentation. If you’re unaware of the system’s behaviour, the test can help you to understand the class’s purpose and API.
  2. There is a high chance that you’ll come back to the test later. If it’s poorly written, you’ll have to spend too much time figuring out what it actually does.

The unit testing mindset

Refactoring stability

public class PostDeleteService {  private final UserService userService;
private final PostRepository postRepository;
public void deleteAllArchivedPosts() {
User currentUser = userService.getCurrentUser();
List<Post> posts = postRepository.findByPredicate(
PostPredicate.create()
.archived(true).and()
.createdBy(oneOf(currentUser))
);
postRepository.deleteAll(posts);
}
}
public class PostDeleteServiceTest {
// initialization
@Test
void shouldDeletePostsSuccessfully() {
User mockUser = mock(User.class);
List<Post> mockPosts = mock(List.class);
when(userService.getCurrentUser()).thenReturn(mockUser);
when(postRepository.findByPredicate(
eq(PostPredicate.create()
.archived(true).and()
.createdBy(oneOf(mockUser)))
)).thenReturn(mockPosts);
postDeleteService.deleteAllArchivedPosts(); verify(postRepository, times(1)).deleteAll(mockPosts);
}
}
public class PostDeleteService {  private final UserService userService;
private final PostRepository postRepository;
public void deleteAllArchivedPosts() {
User currentUser = userService.getCurrentUser();
List<Post> posts = postRepository.findByPredicate(
PostPredicate.create()
.archived(true).and()
// replaced 'oneOf' with 'is'
.createdBy(is(currentUser))
);
postRepository.deleteAll(posts);
}
}
public class PostDeleteServiceTest {
// initialization
@Test
void shouldDeletePostsSuccessfully() {
// setup
when(postRepository.findByPredicate(
eq(PostPredicate.create()
.archived(true).and()
// 'oneOf' but not 'is'
.createdBy(oneOf(mockUser)))
)).thenReturn(mockPosts);
// action
}
}
public class PostDeleteServiceTest {
// initialization
@Test
void shouldDeletePostsSuccessfully() {
User currentUser = aUser().name("n1");
User anotherUser = aUser().name("n2");
when(userService.getCurrentUser()).thenReturn(currentUser);
testPostRepository.store(
aPost().withUser(currentUser).archived(true),
aPost().withUser(currentUser).archived(true),
aPost().withUser(anotherUser).archived(true)
);
postDeleteService.deleteAllArchivedPosts(); assertEquals(1, testPostRepository.count());
}
}
  1. There is no PostRepository mocking. We introduced a custom implementation: TestPostRepository. It encapsulates the stored posts and guarantees the correct PostPredicate processing.
  2. Instead of declaring PostRepository returning a list of posts, we put the real objects within TestPostRepository.
  3. We don’t care about which functions have been called. We want to validate the delete operation itself. We know that the storage consists of 2 archived posts of the current user and 1 post of another user. The successful operation process should leave 1 post. That’s why we put assertEquals on posts count.

A few words about MVC frameworks

@Service
public class XMLGenerator {
@Autowired
private IDService idService;
public XML generateXML(String rawData) {
// split raw data and traverse each element
for (Element element : splittedElements) {
element.setId(idService.generateId());
}
// processing
return xml;
}
}
@Service
public class XMLGenerator {
@Autowired
private ApplicationContext context;
public XML generateXML(String rawData) {
// Creates new IDService instance
IDService idService = context.getBean(IDService.class);
// split raw data and traverse each element
for (Element element : splittedElements) {
element.setId(idService.generateId());
}
// processing
return xml;
}
}
@Service
public class XMLGenerator {
@Autowired
private IDServiceFactory factory;
public XML generateXML(String rawData) {
IDService idService = factory.getInstance();
// split raw data and traverse each element
for (Element element : splittedElements) {
element.setId(idService.generateId());
}
// processing
return xml;
}
}
  1. Field injection
  2. Setter injection
  3. Constructor injection
@Service
public class XMLGenerator {
private final IDServiceFactory factory; public XMLGenerator(IDServiceFactory factory) {
this.factory = factory;
}
public XML generateXML(String rawData) {
IDService idService = factory.getInstance();
// split raw data and traverse each element
for (Element element : splittedElements) {
element.setId(idService.generateId());
}
// processing
return xml;
}
}

Unit testing mindset summary

  1. Test what the code does but not how it does it.
  2. Code refactoring should not break tests.
  3. Isolate the code from the frameworks’ details.

Best practices

Naming

  1. The type of test is not self-describing (unit, integration, e2e).
  2. You cannot tell which methods are tested.
  1. Specify the particular type of test. This will help us to clarify the test borders. For example, you might have multiple test suites for the same class (PostServiceUnitTest , PostServiceIntegrationTest , PostServiceE2ETest).
  2. Add the name of the method that is being tested. For example, WeatherUnitTest_getCurrentStatus. Or CommentControllerE2ETest_createComment.
  1. Not all classes are solid. Even if you’re the biggest fan of Domain-Driven Design, it is impossible to build every class this way.
  2. Some methods are more complicated than others. You might have 10 test methods just to verify the behaviour of a single class method. If you put all tests inside one test suite, you will make it huge and difficult to maintain.

Assertions

public class PersonServiceTest {
// initialization
@Test
void shouldCreatePersonSuccessfully() {
Person person = personService.createNew("firstName", "lastName");
assertEquals("firstName", person.getFirstName());
assertEquals("lastName", person.getLastName());
}
}
class WeatherTest {
// initialization
@Test
void shouldGetCurrentWeatherStatus() {
LocalDate date = LocalDate.of(2012, 5, 25);
WeatherStatus testWeatherStatus = generateStatus();
tuneWeather(date, testWeatherStatus);
WeatherStatus result = weather.getStatusForDate(date); assertEquals(
testWeatherStatus,
result,
"Unexpected weather status for date " + date
);
assertEquals(
result,
weather.getStatusForDate(date),
"Weather service is not idempotent for date " + date
);
}
}

Error messages

class WeatherTest {
// initialization
@ParameterizedTest
@MethodSource("weatherDates")
void shouldGetCurrentWeatherStatus(LocalDate date) {
WeatherStatus testWeatherStatus = generateStatus();
tuneWeather(date, testWeatherStatus);
WeatherStatus result = weather.getStatusForDate(date); assertEquals(testWeatherStatus, result);
}
}
expected: <SHINY> but was: <CLOUDY>
Expected :SHINY
Actual :CLOUDY
class WeatherTest {
// initialization
@ParameterizedTest
@MethodSource("weatherDates")
void shouldGetCurrentWeatherStatus(LocalDate date) {
WeatherStatus testWeatherStatus = generateStatus();
tuneWeather(date, testWeatherStatus);
WeatherStatus result = weather.getStatusForDate(date); assertEquals(
testWeatherStatus,
result,
"Unexpected weather status for date " + date
);
}
}
Unexpected weather status for date 2022-03-12 ==> expected: <SHINY> but was: <CLOUDY>
Expected :SHINY
Actual :CLOUDY

Test data initialization

  1. Direct Declaration.
  2. Object Mother Pattern.
  3. Test Data Builder Pattern .

Direct Declaration

public class Post {  private Long id;
private String name;
private User userWhoCreated;
private List<Comment> comments;
// constructor, getters, setters
}
public class PostTest {  @Test
void someTest() {
Post post = new Post(
1,
"Java for beginners",
new User("Jack", "Brown"),
List.of(new Comment(1, "Some comment"))
);
// action...
}
}
  1. Parameter names are not descriptive. You have to check the constructor’s declaration to tell the meaning of each provided value.
  2. Class attributes are not static. What if another field were added? You would have to fix every constructor invocation in every test.
public class PostTest {  @Test
void someTest() {
Post post = new Post();
post.setId(1);
post.setName("Java for beginners");
User user = new User();
user.setFirstName("Jack");
user.setLastName("Brown");
post.setUser(user);
Comment comment = new Comment();
comment.setId(1);
comment.setTitle("Some comment");
post.setComments(List.of(comment));
// action...
}
}
  1. The declaration is too verbose. At first glance, it’s hard to tell what’s going on.
  2. Some parameters might be obligatory. If we added another field to the Post class, it could lead to runtime exceptions due to the object’s inconsistency.

Object Mother Pattern

public class PostFactory {  public static Post createSimplePost() {
// simple post logic
}
public static Post createPostWithUser(User user) {
// simple post logic
}
}

Test Data Builder Pattern

public class PostTest {  @Test
void someTest() {
Post post = aPost()
.id(1)
.name("Java for beginners")
.user(aUser().firstName("Jack").lastName("Brown"))
.comments(List.of(
aComment().id(1).title("Some comment")
))
.build();
// action...
}
}
public class PostTest {  private PostBuilder defaultPost =
aPost().name("post1").comments(List.of(aComment()));
@Test
void someTest() {
Post postWithNoComments = defaultPost.comments(emptyList()).build();
Post postWithDifferentName = defaultPost.name("another name").build();
// action...
}
}

Best practices summary

  1. Naming is important. Test suite names should be declarative enough to understand their purpose.
  2. Do not group assertions that have nothing in common.
  3. Specific error messages are the key to quick bug spotting.
  4. Test data initialization is important. Do not neglect this.

Tools

JUnit

Mockito

public class SomeSuite {  @Test
void someTest() {
// creates a mock for CommentService
CommentService mockService = mock(CommentService.class);
// when mockService.getCommentById(1) is called, new Comment instance is returned
when(mockService.getCommentById(eq(1)))
.thenReturn(new Comment());
// when mockService.getCommentById(2) is called, NoSuchElementException is thrown
when(mockService.getCommentById(eq(2)))
.thenThrow(new NoSuchElementException());
}
}

Spock

def "two plus two should equal four"() {
given:
int left = 2
int right = 2
when:
int result = left + right
then:
result == 4
}

Vavr Test

public class SomeSuite {  @Test
void someTest() {
Arbitrary<Integer> evenNumbers = Arbitrary.integer()
.filter(i -> i > 0)
.filter(i -> i % 2 == 0);
CheckedFunction1<Integer, Boolean> alwaysEven =
i -> isEven(i);
CheckResult result = Property
.def("All numbers must be treated as even ones")
.forAll(evenNumbers)
.suchThat(alwaysEven)
.check();
result.assertIsSatisfied();
}
}

Conclusion

--

--

--

Supporting developers with insights and tutorials on delivering good software. · https://semaphoreci.com

Love podcasts or audiobooks? Learn on the go with our new app.

Recommended from Medium

Create Scalable Business Workflows Using AWS Step Functions

How I Build My Own Amazon S3 Storage

Worthwhile Modern Deployment Strategies — A Full Guide

GraphQL Observability with Go Using Open-source Tools

Leveling up: why developers need to be able to identify technologies with staying power (and how to…

Agile Conversations with Jeffrey Fredrick

A Reality Check About Cloud Native DevOps

Don’t give your API bad error messages

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Semaphore

Semaphore

Supporting developers with insights and tutorials on delivering good software. · https://semaphoreci.com

More from Medium

A Complete Guide to Optimizing Slow Tests

The 5 Golden Rules of Code Reviews

Books for Great Software Architects

4 truths from a Software Architecture guru