头图

[Translation] This article is translated from: Building Reusable Mock Modules with Spring Boot-Reflectoring

Wouldn't it be nice to split the code base into loosely coupled modules, each with a set of specialized responsibilities?

This means that we can easily find each responsibility in the code base to add or modify code. It also means that the code base is easy to grasp, because we only need to load one module into the brain's working memory at a time.

Moreover, since each module has its own API, this means that we can create a reusable simulation for each module. When writing integration tests, we only need to import a simulation module and call its API to start the simulation. We no longer need to know every detail of the class we simulate.

In this article, we will focus on creating such a module, discuss why it is better to simulate a whole module than a single bean, and then introduce a simple but effective way to simulate a complete module for simple test setup using Spring Boot.

Code example

This article attached GitHub on working code examples.

What is a module?

When I talk about "modules" in this article, I mean:

A module is a group of highly cohesive classes with a dedicated API and a set of related responsibilities.

We can combine multiple modules into larger modules, and finally combine them into a complete application.

A module can use another module by calling its API.

You can also call them "components", but in this article, I will stick to "modules".

How to build a module?

When building an application, I recommend thinking about how to modularize the code base in advance. What are the natural boundaries in our code base?

Does our application need to communicate with external systems? This is a natural module boundary. We can build a module whose responsibility is to talk to external systems!

Have we determined the functional "boundary context" of the use cases that belong together? This is another good module boundary. We will build a module to implement the use case in this functional part of the application!

Of course, there are more ways to split an application into modules, and it is often not easy to find the boundaries between them. They may even change over time! What's more important is to have a clear structure in our code base so that we can easily move concepts between modules!

To make the module obvious in our code base, I recommend the following package structure:

  • Each module has its own package
  • Each module package has a api sub-package, which contains all the classes exposed to other modules
  • Each module package has an internal subpackage internal , which contains:

    • All classes that implement the functions exposed by the API
    • A Spring configuration class that provides beans to the Spring application context required to implement the API
  • Just like a Russian doll, the internal sub-package of each module may contain a package with sub-modules, and each sub-module has its own api and internal package
  • Given internal package can only be accessed by the classes in that package.

This makes the code base very clear and easy to navigate. Read more about the structure of this code in my About Clear Architectural Boundaries, or some code in the sample code.

This is a good package structure, but what does it have to do with testing and simulation?

What's wrong with simulating a single bean?

As I said at the beginning, we want to focus on simulating entire modules rather than individual beans. But what's the problem with simulating a single bean in the first place?

Let's take a look at a very common way to create integration tests using Spring Boot.

Suppose we want to write an integration test for a REST controller. The controller should create a repository on GitHub and then send an email to the user.

The integration test might look like this:

@WebMvcTest
class RepositoryControllerTestWithoutModuleMocks {


    @Autowired
    private MockMvc mockMvc;


    @MockBean
    private GitHubMutations gitHubMutations;


    @MockBean
    private GitHubQueries gitHubQueries;


    @MockBean
    private EmailNotificationService emailNotificationService;


  @Test
  void givenRepositoryDoesNotExist_thenRepositoryIsCreatedSuccessfully()
      throws Exception {
    String repositoryUrl = "https://github.com/reflectoring/reflectoring";
   
    given(gitHubQueries.repositoryExists(...)).willReturn(false);
    given(gitHubMutations.createRepository(...)).willReturn(repositoryUrl);
   
    mockMvc.perform(post("/github/repository")
      .param("token", "123")
      .param("repositoryName", "foo")
      .param("organizationName", "bar"))
      .andExpect(status().is(200));
   
    verify(emailNotificationService).sendEmail(...);
    verify(gitHubMutations).createRepository(...);
  }


}

This test actually looks neat, and I have seen (and wrote) many similar tests. But as people say, details determine success or failure.

We use the @WebMvcTest annotation to set up the Spring Boot application context to test the Spring MVC controller. The application context will contain all the beans needed to make the controller work, nothing more.

But our controller needs some extra beans in the application context to work, namely GitHubMutations , GitHubQueries , and EmailNotificationService . Therefore, we add the simulation of these beans to the application context @MockBean

In the test method, we given() statements, and then call the controller endpoint we want to test, and then verify() calls some methods on the simulation.

So, what is wrong with this test? I thought of two main things:

First of all, to set the given() and verify() parts, the test needs to know which methods the controller is calling on the mock bean. This low-level knowledge of implementation details makes the test easy to modify . Every time the implementation details change, we must also update the tests. This dilutes the value of testing and makes maintaining testing a chore rather than "sometimes a routine."

Second, the @MockBean annotation will cause Spring to create a new application context for each test (unless they have exactly the same fields). In code bases with multiple controllers, this will significantly increase test run time.

If we put a little effort into building the modular code base outlined in the previous section, we can solve these two shortcomings by building reusable simulation modules.

Let us understand how to achieve this by looking at a specific example.

Modular Spring Boot application

Well, let's see how to use Spring Boots to implement a reusable simulation module.

This is the folder structure of the sample application. If you want to follow along, you can find the code on

├── github
|   ├── api
|   |  ├── <I> GitHubMutations
|   |  ├── <I> GitHubQueries
|   |  └── <C> GitHubRepository
|   └── internal
|      ├── <C> GitHubModuleConfiguration
|      └── <C> GitHubService
├── mail
|   ├── api
|   |  └── <I> EmailNotificationService
|   └── internal
|      ├── <C> EmailModuleConfiguration
|      ├── <C> EmailNotificationServiceImpl
|      └── <C> MailServer
├── rest
|   └── internal
|       └── <C> RepositoryController
└── <C> DemoApplication

The application has 3 modules:

Let's study each module in more detail.

GitHub modules

github module provides two interfaces ( <I> ) as part of its API:

This is what the interface looks like:

public interface GitHubMutations {


    String createRepository(String token, GitHubRepository repository);


}


public interface GitHubQueries {


    List<String> getOrganisations(String token);


    List<String> getRepositories(String token, String organisation);


    boolean repositoryExists(String token, String repositoryName, String organisation);


}

It also provides the class GitHubRepository for signing these interfaces.

Internally, the github module has the class GitHubService , which implements two interfaces, and the class GitHubModuleConfiguration , which is a Spring configuration and contributes a GitHubService instance to the application context:

@Configuration
class GitHubModuleConfiguration {


    @Bean
    GitHubService gitHubService() {
        return new GitHubService();
    }


}

Since GitHubService implements the github module, this bean is sufficient to make the module's API available to other modules in the same Spring Boot application.

Mail module

The construction of the mail Its API consists of a single interface EmailNotificationService :

public interface EmailNotificationService {


    void sendEmail(String to, String subject, String text);


}

This interface is implemented beanEmailNotificationServiceImpl

Note that I am in mail use module naming convention in github naming conventions used in different modules. github module has an internal class ending *Servicee mail module has a *Service class as part of its API. Although github module does not use ugly *Impl suffix, but mail module uses.

I did this deliberately to make the code more realistic. Have you ever seen a code base (not written by yourself) that uses the same naming convention everywhere? I do not have.

However, if you build modules like we did in this article, it doesn't really matter. Because the ugly *Impl class is hidden behind the module's API.

Internally, the mail module has the EmailModuleConfiguration class, which provides an API implementation for the Spring application context:

@Configuration
class EmailModuleConfiguration {


    @Bean
    EmailNotificationService emailNotificationService() {
        return new EmailNotificationServiceImpl();
    }


}

REST module

rest module consists of a single REST controller:

@RestController
class RepositoryController {


    private final GitHubMutations gitHubMutations;
    private final GitHubQueries gitHubQueries;
    private final EmailNotificationService emailNotificationService;


    // constructor omitted


    @PostMapping("/github/repository")
    ResponseEntity<Void> createGitHubRepository(@RequestParam("token") String token,
            @RequestParam("repositoryName") String repoName, @RequestParam("organizationName") String orgName) {


        if (gitHubQueries.repositoryExists(token, repoName, orgName)) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
        }
        String repoUrl = gitHubMutations.createRepository(token, new GitHubRepository(repoName, orgName));
        emailNotificationService.sendEmail("user@mail.com", "Your new repository",
                "Here's your new repository: " + repoUrl);


        return ResponseEntity.ok().build();
    }


}

The controller calls github module to create a GitHub repository, and then mail module to let users know the new repository.

Simulate GitHub modules
Now, let's see how to build a reusable simulation for the github module. We created a @TestConfiguration class, which provides all the beans of the module API:

@TestConfiguration
public class GitHubModuleMock {


    private final GitHubService gitHubServiceMock = Mockito.mock(GitHubService.class);


    @Bean
    @Primary
    GitHubService gitHubServiceMock() {
        return gitHubServiceMock;
    }


    public void givenCreateRepositoryReturnsUrl(String url) {
        given(gitHubServiceMock.createRepository(any(), any())).willReturn(url);
    }


    public void givenRepositoryExists() {
        given(gitHubServiceMock.repositoryExists(anyString(), anyString(), anyString())).willReturn(true);
    }


    public void givenRepositoryDoesNotExist() {
        given(gitHubServiceMock.repositoryExists(anyString(), anyString(), anyString())).willReturn(false);
    }


    public void assertRepositoryCreated() {
        verify(gitHubServiceMock).createRepository(any(), any());
    }


    public void givenDefaultState(String defaultRepositoryUrl) {
        givenRepositoryDoesNotExist();
        givenCreateRepositoryReturnsUrl(defaultRepositoryUrl);
    }


    public void assertRepositoryNotCreated() {
        verify(gitHubServiceMock, never()).createRepository(any(), any());
    }


}

In addition to providing a mock GitHubService bean, we also added a bunch of given*() and assert*() methods to this class.

The given given*() method allows us to set the simulation to the desired state, while the verify*() method allows us to check whether the interaction with the simulation occurs after running the test.

@Primary annotation ensures that if both simulated and real beans are loaded into the application context, the simulation takes precedence.

Simulate Email module

We built a very similar analog configuration for the mail

@TestConfiguration
public class EmailModuleMock {


    private final EmailNotificationService emailNotificationServiceMock = Mockito.mock(EmailNotificationService.class);


    @Bean
    @Primary
    EmailNotificationService emailNotificationServiceMock() {
        return emailNotificationServiceMock;
    }


    public void givenSendMailSucceeds() {
        // nothing to do, the mock will simply return
    }


    public void givenSendMailThrowsError() {
        doThrow(new RuntimeException("error when sending mail")).when(emailNotificationServiceMock)
                .sendEmail(anyString(), anyString(), anyString());
    }


    public void assertSentMailContains(String repositoryUrl) {
        verify(emailNotificationServiceMock).sendEmail(anyString(), anyString(), contains(repositoryUrl));
    }


    public void assertNoMailSent() {
        verify(emailNotificationServiceMock, never()).sendEmail(anyString(), anyString(), anyString());
    }


}

Use mock modules in testing

Now, with the analog modules, we can use them in the integration test of the controller:

@WebMvcTest
@Import({ GitHubModuleMock.class, EmailModuleMock.class })
class RepositoryControllerTest {


    @Autowired
    private MockMvc mockMvc;


    @Autowired
    private EmailModuleMock emailModuleMock;


    @Autowired
    private GitHubModuleMock gitHubModuleMock;


    @Test
    void givenRepositoryDoesNotExist_thenRepositoryIsCreatedSuccessfully() throws Exception {


        String repositoryUrl = "https://github.com/reflectoring/reflectoring.github.io";


        gitHubModuleMock.givenDefaultState(repositoryUrl);
        emailModuleMock.givenSendMailSucceeds();


        mockMvc.perform(post("/github/repository").param("token", "123").param("repositoryName", "foo")
                .param("organizationName", "bar")).andExpect(status().is(200));


        emailModuleMock.assertSentMailContains(repositoryUrl);
        gitHubModuleMock.assertRepositoryCreated();
    }


    @Test
    void givenRepositoryExists_thenReturnsBadRequest() throws Exception {


        String repositoryUrl = "https://github.com/reflectoring/reflectoring.github.io";


        gitHubModuleMock.givenDefaultState(repositoryUrl);
        gitHubModuleMock.givenRepositoryExists();
        emailModuleMock.givenSendMailSucceeds();


        mockMvc.perform(post("/github/repository").param("token", "123").param("repositoryName", "foo")
                .param("organizationName", "bar")).andExpect(status().is(400));


        emailModuleMock.assertNoMailSent();
        gitHubModuleMock.assertRepositoryNotCreated();
    }


}

We use the @Import annotation to import the simulation into the application context.

Please note that the @WebMvcTest annotation will also cause the actual module to be loaded into the application context. This is why we use the @Primary annotation on the simulation, so that the simulation takes precedence.

How to deal with abnormally behaving modules?

The module may try to connect to some external services and behave abnormally during startup. For example, the mail module may create an SMTP connection pool at startup. This will naturally fail when there is no SMTP server available. This means that when we load the module in the integration test, the startup of the Spring context will fail.
In order to make the module perform better during testing, we can introduce a configuration attribute mail.enabled . Then, we use @ConditionalOnProperty annotate the configuration class of the module to tell Spring if the property is set to false , not to load this configuration.
Now, during the test, only the simulation module is loaded.

We are not simulating a specific method call in the test, but calling the prepared given*() method on the simulation module. This means that the test no longer requires internal knowledge of the class called by the test object .

After executing the code, we can use the prepared verify*() method to verify whether the repository has been created or the mail has been sent. Similarly, do not know the specific underlying method call.

If we need the github or mail module in another controller, we can use the same analog module in the test of this controller.

If we later decide to build another integration that uses the real version of some modules but the simulated version of other modules, we only need to use a few @Import annotations to build the application context we need.

This is the whole idea of the module: we can use real module A and module B simulations, and we still have a working application that can run tests.

The simulation module is the center of our simulation behavior in this module. They can convert high-level simulation expectations such as "ensure that repositories can be created" into low-level calls to API bean simulations.

in conclusion

By consciously understanding what is part of the module API and what is not, we can build an appropriate modular code base with almost no unnecessary dependencies introduced.

Since we know what is part of the API and what is not, we can build a dedicated simulation for each module's API. We don't care about the internals, we are just simulating the API.

The simulation module can provide APIs to simulate certain states and verify certain interactions. By using the API of a mock module instead of mocking each individual method call, our integration tests become more flexible to adapt to changes.


信码由缰
65 声望8 粉丝

“码”界老兵,分享程序人生。