DEV Community

Semyon Kirekov
Semyon Kirekov

Posted on • Edited on

Spring Boot + JPA — Clear Tests

I have written several articles about testing Spring Boot applications. We discussed integration testing with database and Testcontainers library particularly. But today I want to cover an even more important topic. You see, tests are essential to building robust software. But poorly written tests are not the case. They can slow down the development process and make code hard to change and maintain.

When it comes to testing, one has to declare test data. It can be items in the ArrayList, records in the database, or messages in the distributed queue. The behaviour cannot be tested without data.

So, today we're discussing the patterns of creating test rows in the database with Spring Boot + Spring Data + JPA usage.

You can find all code snippets in the repository.

Domain Model

Assuming we're are developing the dev.to like service. People can make posts and leave comments. Here is the database schema.

database schema

And here are the corresponding Hibernate entities.

Post

@Entity
@Table(name = "post")
public class Post {
  @Id
  @GeneratedValue(strategy = IDENTITY)
  private Long id;

  private String name;

  private String content;

  private double rating;

  @OneToMany(fetch = LAZY, mappedBy = "post", cascade = {PERSIST, MERGE})
  private List<Comment> comments;

  @ManyToOne(fetch = LAZY)
  private User author;

  private OffsetDateTime createdAt;
}
Enter fullscreen mode Exit fullscreen mode

Comment

@Entity
@Table(name = "comment")
public class Comment {
  @Id
  @GeneratedValue(strategy = IDENTITY)
  private Long id;

  @ManyToOne(fetch = LAZY)
  private User author;

  private String text;

  private OffsetDateTime createdAt;

  @ManyToOne(fetch = LAZY)
  private Post post;
  // getters, setters
}
Enter fullscreen mode Exit fullscreen mode

User

@Entity
@Table(name = "user_table")
public class User {
  @Id
  @GeneratedValue(strategy = IDENTITY)
  private Long id;

  private String login;

  private String firstName;

  private String lastName;
  // getters, setters
}
Enter fullscreen mode Exit fullscreen mode

Business Case

Suppose we should display top N posts sorted by rating in descending order with comments.

public interface CustomPostRepository {
  List<PostView> findTopPosts(int maxPostsCount);
}

public record PostView(
    Long id,
    String name,
    double rating,
    String userLogin,
    List<CommentView> comments
) {
}
Enter fullscreen mode Exit fullscreen mode

The implementation details have been omitted for brevity because it's not the topic of the article. You can find the complete working solution in the repository.

Time to write some tests. Suppose we have 10 posts. Each post has 2 comments. And we want to retrieve the top 3 posts. That's how the test may look like.

@SpringBootTest
class PostRepositoryTest extends AbstractIntegrationTest {
  @Autowired
  private PostRepository postRepository;
  @Autowired
  private CommentRepository commentRepository;
  @Autowired
  private UserRepository userRepository;

  @Test
  @DisplayName("Should return top 3 posts")
  void shouldReturnTop3Posts() {
    final var user = userRepository.save(new User("login", "", ""));
    for (int i = 0; i < 10; i++) {
      final var post = postRepository.save(
          new Post("name" + i, "content", i, user, OffsetDateTime.now())
      );
      commentRepository.saveAll(
          List.of(
              new Comment(user, "comment1", OffsetDateTime.now(), post),
              new Comment(user, "comment2", OffsetDateTime.now(), post)
          )
      );
    }

    final var res = postRepository.findTopPosts(3);

    assertEquals(3, res.size(), "Unexpected posts count");
    assertEquals(
        res,
        res.stream()
            .sorted(comparing(PostView::rating, reverseOrder()))
            .collect(Collectors.toList()),
        "Posts should be sorted in by rating in descending order"
    );
    assertTrue(
        res.stream()
            .allMatch(post -> post.comments().size() == 2),
        "Each post should have 2 comments"
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

test results

This test passes. It works correctly. Anyway, some problems should be mentioned.

  1. The "given" phase is way too complex
  2. The test has too many dependencies
  3. Assertions are vague and unclear

Before performing the query we need to insert some records into the database. In this case, entities are being instantiated via constructors. Seems like a good design choice. All the required attributes can be passed as parameters. If another field appears later, it can be included in the constructor as well. So, we can guarantee that all non-null fields are present.

Well, the devil is in the details.

  1. The test code becomes too coupled with the constructor definition. You see, there can be plenty of tests that need to create posts, comments, and users. If any of these entities' constructors get enhanced with another attribute, all parts of the code that instantiate objects directly won't compile.

  2. Not all the entities' fields are necessary in every test case. For example, while testing the findTopPosts method we're only interested in the rating value of the Post entity.

  3. The arguments are not self-explanatory. Post entity constructor has 5 positional arguments. Every time you look through the test suite you have to stop and analyze the target values that will be assigned.

  4. The test case is imperative rather than declarative. Why is that a problem? Because tests are the API contracts. Well written tests act as perfect class documentation.

We can come around with Object Mother Pattern. A simple factory with multiple creating policies. That is how we can instantiate new Post objects.

public class PostTestFactory {
  public static Post createPost(double rating, User author) {
    final var post = new Post();
    post.setName("");
    post.setContent("");
    post.setRating(rating);
    post.setAuthor(author);
    post.setCreatedAt(OffsetDateTime.now());
    return post;
  }
}
Enter fullscreen mode Exit fullscreen mode

Similar factories can be created for all entities. So, now the test looks like this.

@Test
@DisplayName("Should return top 3 posts")
void shouldReturnTop3Posts() {
  final var user = userRepository.save(createUser("login"));
  for (int i = 0; i < 10; i++) {
    final var post = postRepository.save(
        createPost(i, user)
    );
    commentRepository.saveAll(
        List.of(
            createComment(user, post),
            createComment(user, post)
        )
    );
  }

  final var res = postRepository.findTopPosts(3);

  assertEquals(3, res.size(), "Unexpected posts count");
  assertEquals(
      res,
      res.stream()
          .sorted(comparing(PostView::rating, reverseOrder()))
          .collect(Collectors.toList()),
      "Posts should be sorted in by rating in descending order"
  );
  assertTrue(
      res.stream()
          .allMatch(post -> post.comments().size() == 2),
      "Each post should have 2 comments"
  );
}
Enter fullscreen mode Exit fullscreen mode

test results

Indeed, it's easier to understand the flow. And it can work for simple scenarios. But let's assume that there are several cases of creating new posts. That would require adding many overloading methods.

public class PostTestFactory {
  public static Post createPost(User author) {
    ...
  }

  public static Post createPost(double rating, User author) {
    ...
  }

  public static Post createPost(String name, User author) {
    ...
  }

  public static Post createPost(String name, String content, User author) {
    ...
  }

  public static Post createPost(String name, String content, double rating, User author) {
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

What if the Post entity had a greater amount of fields? What if some of these attributes were optional? Imagine how many createPost declarations would we need to cover all these cases.

The Object Mother Pattern partially solves the problem of arguments' names by reducing its number. Anyway, the solution is far from perfect.

Test Data Builder

Test Data Builder is an alternative for the Object Mother pattern. It's a typical GoF pattern. But we're enhancing it a bit for our test cases.

Take a look at PostTestBuilder.

public interface Builder<T> {
  T build();
}

public class PostTestBuilder implements Builder<Post> {
  private String name = "post_name";
  private String content = "post_content";
  private double rating = 0;
  private List<Builder<Comment>> comments = new ArrayList<>();
  private Builder<User> author;
  private OffsetDateTime createdAt = OffsetDateTime.now();

  public static PostTestBuilder aPost() {
    return new PostTestBuilder();
  }

  private PostTestBuilder(PostTestBuilder builder) {
    this.name = builder.name;
    this.content = builder.content;
    this.rating = builder.rating;
    this.comments = new ArrayList<>(builder.comments);
    this.author = builder.author;
    this.createdAt = builder.createdAt;
  }

  private PostTestBuilder() {
  }

  public PostTestBuilder withName(String name) {
    final var copy = new PostTestBuilder(this);
    copy.name = name;
    return copy;
  }

  // other "with" methods skipped for brevity

  @Override
  public Post build() {
    final var post = new Post();
    post.setName(name);
    post.setContent(content);
    post.setRating(rating);
    post.setComments(
        comments.stream()
            .map(Builder::build)
            .peek(c -> c.setPost(post))
            .collect(toList())
    );
    post.setAuthor(author.build());
    post.setCreatedAt(createdAt);
    return post;
  }
}
Enter fullscreen mode Exit fullscreen mode

It looks like a regular builder with a few slight differences.

Firstly, the class implements the Builder interface that provides the single build method. You may think that it's an overcomplication but soon you'll realise its benefits.

Secondly, the relational attributes. The builder holds Builder<User> instead of User itself (and so for other relations). It helps to make the client code less verbose.

And finally, mutators (withName, withContent, etc.) return a new builder instead of changing the state of the current one. It's a useful feature when we want to create many similar objects that only differ by specific arguments.

var post = aPost().withRating(5).withCreatedAt(date);
var progPost = post.withName("Spring Boot Features").build();
var financePost = post.withName("Stocks for beginners").build();
Enter fullscreen mode Exit fullscreen mode

The rewritten test looks like this.

@Test
@DisplayName("Should return top 3 posts")
void shouldReturnTop3Posts() {
  final var user = aUser().withLogin("login");
  final var comment = aComment().withAuthor(user);
  for (int i = 0; i < 10; i++) {
    postRepository.save(
        aPost()
            .withRating(i)
            .withAuthor(user)
            .withComments(List.of(
                comment.withText("comment1"),
                comment.withText("comment2")
            ))
            .build()
    );
  }
  // when, then
}
Enter fullscreen mode Exit fullscreen mode

Have you noticed how cleaner the test looks? We're creating 10 posts with a rating of i. Each post has 2 comments. Amazing!

But sadly, it does not work.

org.hibernate.TransientPropertyValueException: Not-null property references a transient value - transient instance must be saved before current operation
Enter fullscreen mode Exit fullscreen mode

Post.author save is not propagated. It can be fixed by CascadeType.PERSIST option usage. But perhaps we do not need to cascade the author persistence in the application. So, changing it just to make the test "green" is the wrong path.

Persisted Wrappers

The Post entity and all of its relations (author and comments) are being created on the build method invocation. So, we need to persist child objects when the corresponding builder processes the instantiation. Do you remember the additional Builder interface? It will help us now.

public class TestDBFacade {
  @Autowired
  private TestEntityManager testEntityManager;
  @Autowired
  private TransactionTemplate transactionTemplate;

  public <T> Builder<T> persisted(Builder<T> builder) {
    return () -> transactionTemplate.execute(status -> {
      final var entity = builder.build();
      testEntityManager.persistAndFlush(entity);
      return entity;
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The persisted callback returns the same builder that will persist the resulting entity prematurely.

Now we need to inject TestDBFacade into our test suite. This could be done by @ContextConfiguration annotation usage.

@SpringBootTest
@AutoConfigureTestEntityManager
@ContextConfiguration(classes = TestDBFacade.Config.class)
class PostRepositoryTestDataBuilder extends AbstractIntegrationTest {
  @Autowired
  private PostRepository postRepository;
  @Autowired
  private TestDBFacade db;
  ...
}
Enter fullscreen mode Exit fullscreen mode

We can do this even better by declaring custom annotation.

@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest
@AutoConfigureTestEntityManager
@ContextConfiguration(classes = TestDBFacade.Config.class)
public @interface DBTest {
}
Enter fullscreen mode Exit fullscreen mode

So, that's the resulting test.

@Test
@DisplayName("Should return top 3 posts")
void shouldReturnTop3Posts() {
  final var user = db.persisted(aUser().withLogin("login"));
  final var comment = aComment().withAuthor(user);
  for (int i = 0; i < 10; i++) {
    postRepository.save(
        aPost()
            .withRating(i)
            .withAuthor(user)
            .withComments(List.of(
                comment.withText("comment1"),
                comment.withText("comment2")
            ))
            .build()
    );
  // when, then
}
Enter fullscreen mode Exit fullscreen mode

We do not have to wrap comments with persisted callback because Post.comments relation is already marked with the CascadeType.PERSIST attribute.

Now it works.

test result

I think you've already guessed that not everything is tuned properly. If you turn on the SQL logging, you can notice it.

insert into user_table (first_name, last_name, login) values (?, ?, ?)
insert into user_table (first_name, last_name, login) values (?, ?, ?)
insert into user_table (first_name, last_name, login) values (?, ?, ?)
insert into post (author_id, content, created_at, name, rating) values (?, ?, ?, ?, ?)
insert into comment (author_id, created_at, post_id, text) values (?, ?, ?, ?)
insert into comment (author_id, created_at, post_id, text) values (?, ?, ?, ?)
Enter fullscreen mode Exit fullscreen mode

We expected to have a single user. But in reality, a new one was created for each post and comment. It means that instead, we have 30 users.

Thankfully, there is a workaround.

public <T> Builder<T> persistedOnce(Builder<T> builder) {
  return new Builder<T>() {
    private T entity;

    @Override
    public T build() {
      if (entity == null) {
        entity = persisted(builder).build();
      }
      return entity;
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

The persistence is being processed only on the first call. Latter invocations return the saved entity.

This method is not thread-safe. Nevertheless, it's acceptable in most cases. Because usually, each test scenario is an independent operation that runs sequentially.

All we have to do is to change persisted with persistedOnce usage.

@Test
@DisplayName("Should return top 3 posts")
void shouldReturnTop3Posts() {
  final var user = db.persistedOnce(aUser().withLogin("login"));
  final var comment = aComment().withAuthor(user);
  for (int i = 0; i < 10; i++) {
    postRepository.save(
        aPost()
            .withRating(i)
            .withAuthor(user)
            .withComments(List.of(
                comment.withText("comment1"),
                comment.withText("comment2")
            ))
            .build()
    );
  // when, then
}
Enter fullscreen mode Exit fullscreen mode

Now there is only one persisted user.

insert into user_table (first_name, last_name, login) values (?, ?, ?)
insert into post (author_id, content, created_at, name, rating) values (?, ?, ?, ?, ?)
insert into comment (author_id, created_at, post_id, text) values (?, ?, ?, ?)
insert into comment (author_id, created_at, post_id, text) values (?, ?, ?, ?)
Enter fullscreen mode Exit fullscreen mode

Another thing to refactor is PostRepository usage. We do not have to add additional dependencies on specific Spring Data repositories. We can add the save method directly to TestDBFacade.

public <T> T save(T entity) {
  return transactionTemplate.execute(status -> {
    testEntityManager.persistAndFlush(entity);
    return entity;
  });
}

@Test
@DisplayName("Should return top 3 posts")
void shouldReturnTop3Posts() {
  final var user = db.persistedOnce(aUser().withLogin("login"));
  final var comment = aComment().withAuthor(user);
  for (int i = 0; i < 10; i++) {
    db.save(
        aPost()
            .withRating(i)
            .withAuthor(user)
            .withComments(List.of(
                comment.withText("comment1"),
                comment.withText("comment2")
            ))
            .build()
    );
  // when, then
}
Enter fullscreen mode Exit fullscreen mode

Clear Assertions

The last thing that I want to discuss is assertions usage. Take a look at the current test again.

assertEquals(3, res.size(), "Unexpected posts count");
assertEquals(
    res,
    res.stream()
       .sorted(comparing(PostView::rating, reverseOrder()))
       .collect(Collectors.toList()),
    "Posts should be sorted in by rating in descending order"
);
assertTrue(
    res.stream()
       .allMatch(post -> post.comments().size() == 2),
    "Each post should have 2 comments"
);
Enter fullscreen mode Exit fullscreen mode

The size assertion is OK. And what about the sorting and the comments count check? They are not transparent. When you look through the code, you should pay extra attention to figure out the purpose of these assertions.

Anyway, that is not the only problem. What if they failed? We would get something like this in the log.

Posts should be sorted in by rating in descending order
Expected : [posts list]
Actual   : [posts list]

Each post should have 2 comments
Expected : true
Actual   : false 
Enter fullscreen mode Exit fullscreen mode

Not so descriptive. But there is a better alternative.

Hamcrest

Hamcrest is an assertion library that helps to build a declarative pipeline of matchers. For example, that's how posts count can be validated.

assertThat(res, hasSize(3));
Enter fullscreen mode Exit fullscreen mode

Not so different from the initial attempt. What about two others? Well, these assertions are domain-oriented. Hamcrest does not provide utilities for specific business cases. But what makes Hamrest powerful are custom matchers. Here is one for comments count validation.

private static Matcher<List<PostView>> hasComments(int count) {
    return new TypeSafeMatcher<>() {
      @Override
      protected boolean matchesSafely(List<PostView> item) {
        return item.stream()
            .allMatch(post -> post.comments().size() == count);
      }

      @Override
      public void describeTo(Description description) {
        description.appendText(count + " comments in each post");
      }

      @Override
      protected void describeMismatchSafely(List<PostView> item, Description mismatchDescription) {
        mismatchDescription.appendText(
            item.stream()
                .map(postView -> format(
                    "PostView[%d] with %d comments",
                    postView.id(),
                    postView.comments().size()
                ))
                .collect(joining(" ; "))
        );
      }
    };
  }
Enter fullscreen mode Exit fullscreen mode

The matchesSafely method executes the assertion itself. In this case, we check that the sorted is equal to the initial one.
The describeTo method assigns a label to the assertion.
And the describeMismatchSafely logs the output in case of test failure.

The assertion usage is straightforward.

assertThat(res, hasComments(2));
Enter fullscreen mode Exit fullscreen mode

Suppose that received posts do not have the expected count of comments. Here is failed test output.

Expected: 2 comments in each post
     but: PostView[10] with 3 comments ; PostView[9] with 3 comments ; PostView[8] with 3 comments
Enter fullscreen mode Exit fullscreen mode

The intent is obvious now. We expected that every post would have 2 comments but it was 3. Much more expressive than simple expected true but was false.

Time to write the matcher for posts sorting order validation.

private static Matcher<List<PostView>> sortedByRatingDesc() {
    return new TypeSafeMatcher<>() {
      @Override
      protected boolean matchesSafely(List<PostView> item) {
        return item.equals(
            item.stream()
                .sorted(comparing(PostView::rating, reverseOrder()))
                .collect(toList())
        );
      }

      @Override
      public void describeTo(Description description) {
        description.appendText("sorted posts by rating in desc order");
      }

      @Override
      protected void describeMismatchSafely(List<PostView> item, Description mismatchDescription) {
        mismatchDescription.appendText(
            item.stream()
                .map(PostView::rating)
                .collect(toList())
                .toString()
        );
      }
    };
  }
Enter fullscreen mode Exit fullscreen mode

And here is the assertion.

assertThat(res, is(sortedByRatingDesc()));
Enter fullscreen mode Exit fullscreen mode

Matchers.is is a decorator that makes tests look more expressive. Though it can be omitted.

And here is failed test output.

Expected: is sorted posts by rating in desc order
     but: [7.0, 8.0, 9.0]
Enter fullscreen mode Exit fullscreen mode

Again, the user intent is obvious. We expected to receive [9, 8, 7] but got [7, 8, 9].

Summary

We did some refactoring. Let's compare the initial attempt and the final version.

The First Attempt

@SpringBootTest
class PostRepositoryTest extends AbstractIntegrationTest {
  @Autowired
  private PostRepository postRepository;
  @Autowired
  private CommentRepository commentRepository;
  @Autowired
  private UserRepository userRepository;

  @Test
  @DisplayName("Should return top 3 posts")
  void shouldReturnTop3Posts() {
    final var user = userRepository.save(new User("login", "", ""));
    for (int i = 0; i < 10; i++) {
      final var post = postRepository.save(
          new Post("name" + i, "content", i, user, OffsetDateTime.now())
      );
      commentRepository.saveAll(
          List.of(
              new Comment(user, "comment1", OffsetDateTime.now(), post),
              new Comment(user, "comment2", OffsetDateTime.now(), post)
          )
      );
    }

    final var res = postRepository.findTopPosts(3);

    assertEquals(3, res.size(), "Unexpected posts count");
    assertEquals(
        res,
        res.stream()
            .sorted(comparing(PostView::rating, reverseOrder()))
            .collect(Collectors.toList()),
        "Posts should be sorted in by rating in descending order"
    );
    assertTrue(
        res.stream()
            .allMatch(post -> post.comments().size() == 2),
        "Each post should have 2 comments"
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The Final Version

@DBTest
class PostRepositoryTestDataBuilderWithClearAssertions extends AbstractIntegrationTest {
  @Autowired
  private PostRepository postRepository;
  @Autowired
  private TestDBFacade db;

  @Test
  @DisplayName("Should return top 3 posts")
  void shouldReturnTop3Posts() {
    final var user = db.persistedOnce(aUser().withLogin("login"));
    final var comment = aComment().withAuthor(user);
    for (int i = 0; i < 10; i++) {
      db.save(
          aPost()
              .withRating(i)
              .withAuthor(user)
              .withComments(List.of(
                  comment.withText("comment1"),
                  comment.withText("comment2")
              ))
              .build()
      );
    }

    final var res = postRepository.findTopPosts(3);

    assertThat(res, hasSize(3));
    assertThat(res, is(sortedByRatingDesc()));
    assertThat(res, hasComments(2));
  }
}
Enter fullscreen mode Exit fullscreen mode

A first option is a group of commands. It's hard to understand what exactly are we testing. Whilst the second approach tells us much more about the behaviour. It's more like documentation that describes the contract of the class.

Conclusion

Once I heard a wise statement.

Tests are parts of the code that do not have tests.

It's crucial to test business logic. But it's even more important to make sure that tests won't become a maintainability burden.

That's all I wanted to tell you about writing clear tests. If you have any questions, please, leave your comments down below. Thanks for reading!

Resources

  1. Spring Boot Testing — Data and Services
  2. Spring Boot Testing — Testcontainers and Flyway
  3. Repository with code examples
  4. Object Mother Pattern
  5. Test Data Builder
  6. Builder Pattern
  7. Hamcrest

Top comments (0)