Optimizing Spring Integration Tests – 优化Spring集成测试

最后修改: 2018年 7月 18日

中文/混合/英文(键盘快捷键:t)

1. Introduction

1.绪论

In this article, we’ll have a holistic discussion about integration tests using Spring and how to optimize them.

在这篇文章中,我们将对使用Spring的集成测试以及如何优化集成测试进行全面的讨论。

First, we’ll briefly discuss the importance of integration tests and their place in modern Software focusing on the Spring ecosystem.

首先,我们将简要地讨论集成测试的重要性及其在现代软件中的地位,重点是Spring生态系统。

Later, we’ll cover multiple scenarios, focusing on web-apps.

稍后,我们将介绍多种情况,重点是网络应用程序。

Next, we’ll discuss some strategies to improve testing speed, by learning about different approaches that could influence both the way we shape our tests and the way we shape the app itself.

接下来,我们将讨论一些提高测试速度的策略,通过了解不同的方法,可以影响我们塑造测试的方式和塑造应用程序本身的方式。

Before getting started, it is important to keep in mind this is an opinion article based on experience. Some of this things might suit you, some might not.

在开始之前,必须牢记这是一篇基于经验的意见文章。其中有些东西可能适合你,有些可能不适合。

Finally, this article uses Kotlin for the code samples to keep them as concise as possible, but the concepts aren’t specific to this language and code snippets should feel meaningful to Java and Kotlin developers alike.

最后,本文使用Kotlin来编写代码样本,以使其尽可能简洁,但这些概念并不是这种语言所特有的,代码片段应该对Java和Kotlin开发者都有意义。

2. Integration Tests

2.集成测试

Integration tests are a fundamental part of automated test suites. Although they shouldn’t be as numerous as unit tests if we follow a healthy test pyramid. Relying on frameworks such as Spring leave us needing a fair amount of integration testing in order to de-risk certain behaviors of our system.

集成测试是自动化测试套件的基本组成部分。尽管如果我们遵循健康的测试金字塔,它们不应该像单元测试那样多。依靠Spring等框架,我们需要进行相当数量的集成测试,以消除系统的某些行为的风险。

The more we simplify our code by using Spring modules (data, security, social…), the bigger a need for integration tests. This becomes particularly true when we move bits and bobs of our infrastructure into @Configuration classes.

我们通过使用 Spring 模块(数据、安全、社交……)来简化我们的代码,对集成测试的需求就越大。当我们将基础架构的各个部分转移到@Configuration类中时,这一点变得尤为真实。

We shouldn’t “test the framework”, but we should certainly verify the framework is configured to fulfill our needs.

我们不应该 “测试框架”,但我们肯定应该验证框架的配置是否满足我们的需求。

Integration tests help us build confidence but they come at a price:

集成测试帮助我们建立信心,但它们是有代价的。

  • That is a slower execution speed, which means slower builds
  • Also, integration tests imply a broader testing scope which is not ideal in most cases

With this in mind, we’ll try to find some solutions to mitigate the above-mentioned problems.

考虑到这一点,我们将尝试找到一些解决方案来缓解上述问题。

3. Testing Web Apps

3.测试网络应用程序

Spring brings a few options in order to test web applications, and most Spring developers are familiar with them, these are:

为了测试Web应用程序,Spring带来了几个选项,大多数Spring开发者都很熟悉,这些选项是:。

  • MockMvc: Mocks the servlet API, useful for non-reactive web apps
  • TestRestTemplate: Can be used pointing to our app, useful for non-reactive web apps where mocked servlets are not desirable
  • WebTestClient: Is a testing tool for reactive web apps, both with mocked requests/responses or hitting a real server

As we already have articles covering these topics we won’t spend time talking about them.

由于我们已经有涵盖这些主题的文章,我们不会花时间谈论它们。

Feel free to have a look if you’d like to dig deeper.

如果你想深入了解,请随意看看。

4. Optimizing Execution Time

4.优化执行时间

Integration tests are great. They give us a good degree of confidence. Also if implemented appropriately, they can describe the intent of our app in a very clear way, with less mocking and setup noise.

集成测试是伟大的。他们给了我们一个很好的信心。同时,如果实施得当,它们可以以一种非常清晰的方式描述我们应用程序的意图,减少嘲弄和设置的噪音。

However, as our app matures and the development piles up, build time inevitably goes up. As build time increases it might become impractical to keep running all tests every time.

然而,随着我们的应用程序的成熟和开发的堆积,构建时间不可避免地会增加。随着构建时间的增加,每次持续运行所有的测试可能变得不切实际。

Thereafter, impacting our feedback loop and getting on the way of best development practices.

此后,影响了我们的反馈循环,妨碍了最佳发展实践。

Furthermore, integration tests are inherently expensive. Starting up persistence of some sort, sending requests through (even if they never leave localhost), or doing some IO simply takes time.

此外,集成测试本质上是昂贵的。启动某种持久性,发送请求(即使它们从未离开过localhost),或进行一些IO,都需要时间。

It’s paramount to keep an eye on our build time, including test execution. And there are some tricks we can apply in Spring to keep it low.

关注我们的构建时间是最重要的,包括测试执行。我们可以在Spring中应用一些技巧来保持较低的时间。

In the next sections, we’ll cover a few points to help us out optimize our build time as well as some pitfalls that might impact its speed:

在接下来的章节中,我们将介绍几个要点,以帮助我们优化构建时间,以及一些可能影响其速度的陷阱。

  • Using profiles wisely – how profiles impact performance
  • Reconsidering @MockBean – how mocking hits performance
  • Refactoring @MockBean – alternatives to improve performance
  • Thinking carefully about @DirtiesContext – a useful but dangerous annotation and how not to use it
  • Using test slices – a cool tool that can help or get on our way
  • Using class inheritance – a way to organize tests in a safe manner
  • State management – good practices to avoid flakey tests
  • Refactoring into unit tests – the best way to get a solid and snappy build

Let’s get started!

让我们开始吧!

4.1. Using Profiles Wisely

4.1.明智地使用配置文件

Profiles are a pretty neat tool. Namely, simple tags that can enable or disable certain areas of our App. We could even implement feature flags with them!

Profiles是一个相当整洁的工具。即,可以启用或禁用我们的应用程序的某些区域的简单标签。我们甚至可以用它们来实现功能标志!

As our profiles get richer, it’s tempting to swap every now and then in our integration tests. There are convenient tools to do so, like @ActiveProfiles. However, every time we pull a test with a new profile, a new ApplicationContext gets created.

随着我们的配置文件越来越丰富,我们很想在集成测试中时不时地交换一下。有一些方便的工具可以这样做,比如@ActiveProfiles。然而,每次我们使用新的配置文件进行测试时,都会创建一个新的ApplicationContext

Creating application contexts might be snappy with a vanilla spring boot app with nothing in it. Add an ORM and a few modules and it will quickly skyrocket to 7+ seconds.

在一个什么都没有的vanilla spring boot应用中,创建应用上下文可能会很迅速。如果增加一个ORM和一些模块,它就会迅速飙升到7秒以上。

Add a bunch of profiles, and scatter them through a few tests and we’ll quickly get a 60+ seconds build (assuming we run tests as part of our build – and we should).

添加一堆配置文件,并将其分散到几个测试中,我们将很快得到一个60多秒的构建(假设我们运行测试作为我们构建的一部分–我们应该这样做)。

Once we face a complex enough application, fixing this is daunting. However, if we plan carefully in advance, it becomes trivial to keep a sensible build time.

一旦我们面对一个足够复杂的应用程序,解决这个问题是令人生畏的。然而,如果我们事先仔细计划,保持一个合理的构建时间就变得微不足道了。

There are a few tricks we could keep in mind when it comes to profiles in integration tests:

当涉及到集成测试中的配置文件时,有几个技巧我们可以记住。

  • Create an aggregate profile, i.e. test, include all needed profiles within – stick to our test profile everywhere
  • Design our profiles with testability in mind. If we end up having to switch profiles perhaps there is a better way
  • State our test profile in a centralized place – we’ll talk about this later
  • Avoid testing all profiles combinations. Alternatively, we could have an e2e test-suite per environment testing the app with that specific profile-set

4.2. The Problems with @MockBean

4.2.@MockBean的问题

@MockBean is a pretty powerful tool.

@MockBean是一个相当强大的工具。

When we need some Spring magic but want to mock a particular component, @MockBean comes in really handy. But it does so at a price.

当我们需要一些Spring的魔法,但又想模拟一个特定的组件时,@MockBean就会非常方便。但它是有代价的。

Every time @MockBean appears in a class, the ApplicationContext cache gets marked as dirty, hence the runner will clean the cache after the test-class is done. Which again adds an extra bunch of seconds to our build.

每当@MockBean出现在一个类中时,ApplicationContext缓存就会被标记为脏,因此运行器将在测试类完成后清理缓存。这又给我们的构建增加了一堆额外的时间。

This is a controversial one, but trying to exercise the actual app instead of mocking for this particular scenario could help. Of course, there’s no silver bullet here. Boundaries get blurry when we don’t allow ourselves to mock dependencies.

这是一个有争议的问题,但尝试行使实际的应用程序,而不是为这个特定的场景进行嘲弄,可能会有帮助。当然,这里没有银弹。当我们不允许自己模拟依赖关系的时候,界限就会变得模糊不清。

We might think: Why would we persist when all we want to test is our REST layer? This is a fair point, and there’s always a compromise.

我们可能会想:我们想测试的只是我们的REST层,为什么要坚持呢?这是一个合理的观点,而且总是有一个折中的办法。

However, with a few principles in mind, this might actually can be turned into an advantage that leads to better design of both tests and our app and reduces testing time.

然而,只要记住一些原则,这实际上可能会变成一种优势,导致测试和我们的应用程序的更好设计,并减少测试时间。

4.3. Refactoring @MockBean

4.3.重构@MockBean

In this section, we’ll try to refactor a ‘slow’ test using @MockBean to make it reuse the cached ApplicationContext.

在本节中,我们将尝试使用@MockBean重构一个 “慢速 “测试,使其重新使用缓存的ApplicationContext

Let’s assume we want to test a POST that creates a user. If we were mocking – using @MockBean, we could simply verify that our service has been called with a nicely serialized user.

让我们假设我们想测试一个创建用户的POST。如果我们使用@MockBean进行模拟,我们可以简单地验证我们的服务已经被调用,并有一个很好的序列化的用户。

If we tested our service properly this approach should suffice:

如果我们正确测试了我们的服务,这种方法应该足够了。

class UsersControllerIntegrationTest : AbstractSpringIntegrationTest() {

    @Autowired
    lateinit var mvc: MockMvc
    
    @MockBean
    lateinit var userService: UserService

    @Test
    fun links() {
        mvc.perform(post("/users")
          .contentType(MediaType.APPLICATION_JSON)
          .content("""{ "name":"jose" }"""))
          .andExpect(status().isCreated)
        
        verify(userService).save("jose")
    }
}

interface UserService {
    fun save(name: String)
}

We want to avoid @MockBean though. So we’ll end up persisting the entity (assuming that’s what the service does).

但我们想避免@MockBean。所以我们最终会持久化实体(假设这就是服务的作用)。

The most naive approach here would be to test the side effect: After POSTing, my user is in my DB, in our example, this would use JDBC.

这里最天真的做法是测试副作用。POST后,我的用户在我的数据库中,在我们的例子中,这将使用JDBC。

This, however, violates testing boundaries:

然而,这违反了测试的界限。

@Test
fun links() {
    mvc.perform(post("/users")
      .contentType(MediaType.APPLICATION_JSON)
      .content("""{ "name":"jose" }"""))
      .andExpect(status().isCreated)

    assertThat(
      JdbcTestUtils.countRowsInTable(jdbcTemplate, "users"))
      .isOne()
}

In this particular example we violate testing boundaries because we treat our app as an HTTP black box to send the user, but later we assert using implementation details, that is, our user has been persisted in some DB.

在这个特殊的例子中,我们违反了测试边界,因为我们把我们的应用程序当作一个HTTP黑盒子来发送用户,但后来我们用实现细节来断言,也就是说,我们的用户已经被持久化在某个DB中。

If we exercise our app through HTTP, can we assert the result through HTTP too?

如果我们通过HTTP行使我们的应用程序,我们也可以通过HTTP断言结果吗?

@Test
fun links() {
    mvc.perform(post("/users")
      .contentType(MediaType.APPLICATION_JSON)
      .content("""{ "name":"jose" }"""))
      .andExpect(status().isCreated)

    mvc.perform(get("/users/jose"))
      .andExpect(status().isOk)
}

There are a few advantages if we follow the last approach:

如果我们采用最后一种方法,会有一些好处。

  • Our test will start quicker (arguably, it might take a tiny bit longer to execute though, but it should pay back)
  • Also, our test isn’t aware of side effects not related to HTTP boundaries i.e. DBs
  • Finally, our test expresses with clarity the intent of the system: If you POST, you’ll be able to GET Users

Of course, this might not always be possible for various reasons:

当然,由于各种原因,这可能不总是可能的。

  • We might not have the ‘side-effect’ endpoint: An option here is to consider creating ‘testing endpoints’
  • Complexity is too high to hit the entire app: An option here is to consider slices (we’ll talk about them later)

4.4. Thinking Carefully About @DirtiesContext

4.4.仔细思考@DirtiesContext的问题

Sometimes, we might need to modify the ApplicationContext in our tests. For this scenario, @DirtiesContext delivers exactly that functionality.

有时,我们可能需要在测试中修改ApplicationContext。对于这种情况,@DirtiesContext正好提供了这种功能。

For the same reasons exposed above, @DirtiesContext is an extremely expensive resource when it comes to execution time, and as such, we should be careful.

基于上述同样的原因,当涉及到执行时间时,@DirtiesContext是一个极其昂贵的资源,因此,我们应该小心。

Some misuses of @DirtiesContext include application cache reset or in memory DB resets. There are better ways to handle these scenarios in integration tests, and we’ll cover some in further sections.

@DirtiesContext的一些误用包括应用程序缓存重置或内存中DB重置。在集成测试中,有更好的方法来处理这些场景,我们将在进一步的章节中介绍一些。

4.5. Using Test Slices

4.5.使用测试切片

Test Slices are a Spring Boot feature introduced in the 1.4. The idea is fairly simple, Spring will create a reduced application context for a specific slice of your app.

测试片是Spring Boot在1.4版本中引入的一项功能。这个想法相当简单,Spring将为你的应用程序的特定片断创建一个缩小的应用程序上下文。

Also, the framework will take care of configuring the very minimum.

另外,该框架将负责配置最起码的东西。

There are a sensible number of slices available out of the box in Spring Boot and we can create our own too:

在Spring Boot中,有许多开箱即用的切片,我们也可以创建自己的切片。

  • @JsonTest: Registers JSON relevant components
  • @DataJpaTest: Registers JPA beans, including the ORM available
  • @JdbcTest: Useful for raw JDBC tests, takes care of the data source and in memory DBs without ORM frills
  • @DataMongoTest: Tries to provide an in-memory mongo testing setup
  • @WebMvcTest: A mock MVC testing slice without the rest of the app
  • … (we can check the source to find them all)

This particular feature if used wisely can help us build narrow tests without such a big penalty in terms of performance particularly for small/medium sized apps.

如果明智地使用这一特殊功能,可以帮助我们建立狭窄的测试,而不会在性能方面受到很大的影响,特别是对于小型/中型的应用程序。

However, if our application keeps growing it also piles up as it creates one (small) application context per slice.

然而,如果我们的应用程序不断增长,它也会堆积起来,因为它在每个片断创建一个(小)应用程序上下文。

4.6. Using Class Inheritance

4.6.使用类的继承性

Using a single AbstractSpringIntegrationTest class as the parent of all our integration tests is a simple, powerful and pragmatic way of keeping the build fast.

使用一个AbstractSpringIntegrationTest类作为我们所有集成测试的父类,是保持构建快速的简单、强大和务实的方法。

If we provide a solid setup, our team will simply extend it, knowing that everything ‘just works’. This way we can worry less about managing state or configuring the framework and focus on the problem at hand.

如果我们提供了一个坚实的设置,我们的团队将简单地扩展它,因为我们知道一切都 “刚刚好”。这样,我们可以减少对管理状态或配置框架的担心,而专注于手头的问题。

We could set all the test requirements there:

我们可以在那里设置所有的测试要求。

  • The Spring runner – or preferably rules, in case we need other runners later
  • profiles – ideally our aggregate test profile
  • initial config – setting the state of our application

Let’s have a look at a simple base class that takes care of the previous points:

让我们来看看一个简单的基类,它照顾到了前面的几点。

@SpringBootTest
@ActiveProfiles("test")
abstract class AbstractSpringIntegrationTest {

    @Rule
    @JvmField
    val springMethodRule = SpringMethodRule()

    companion object {
        @ClassRule
        @JvmField
        val SPRING_CLASS_RULE = SpringClassRule()
    }
}

4.7. State Management

4.7.国家管理

It’s important to remember where ‘unit’ in Unit Test comes from. Simply put, it means we can run a single test (or a subset) at any point getting consistent results.

重要的是要记住单元测试中的’单元’是怎么来的。简单地说,它意味着我们可以在任何时候运行一个测试(或一个子集),获得一致的结果。

Hence, the state should be clean and known before every test starts.

因此,在每次测试开始之前,状态应该是干净的,而且是已知的。

In other words, the result of a test should be consistent regardless of whether it is executed in isolation or together with other tests.

换句话说,一个测试的结果应该是一致的,无论它是单独执行还是与其他测试一起执行。

This idea applies just the same to integration tests. We need to ensure our app has a known (and repeatable) state before starting a new test. The more components we reuse to speed things up (app context, DBs, queues, files…), the more chances to get state pollution.

这个想法也同样适用于集成测试。在开始新的测试之前,我们需要确保我们的应用程序有一个已知(和可重复)的状态。我们重复使用的组件越多,以加快事情的进展(应用程序上下文、数据库、队列、文件……),得到状态污染的机会就越多。

Assuming we went all in with class inheritance, now, we have a central place to manage state.

假设我们全部采用了类的继承,现在,我们有了一个管理状态的中心场所。

Let’s enhance our abstract class to make sure our app is in a known state before running tests.

让我们加强我们的抽象类,以确保我们的应用程序在运行测试之前处于一个已知的状态。

In our example, we’ll assume there are several repositories (from various data sources), and a Wiremock server:

在我们的例子中,我们将假设有几个存储库(来自不同的数据源),以及一个Wiremock服务器。

@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureWireMock(port = 8666)
@AutoConfigureMockMvc
abstract class AbstractSpringIntegrationTest {

    //... spring rules are configured here, skipped for clarity

    @Autowired
    protected lateinit var wireMockServer: WireMockServer

    @Autowired
    lateinit var jdbcTemplate: JdbcTemplate

    @Autowired
    lateinit var repos: Set<MongoRepository<*, *>>

    @Autowired
    lateinit var cacheManager: CacheManager

    @Before
    fun resetState() {
        cleanAllDatabases()
        cleanAllCaches()
        resetWiremockStatus()
    }

    fun cleanAllDatabases() {
        JdbcTestUtils.deleteFromTables(jdbcTemplate, "table1", "table2")
        jdbcTemplate.update("ALTER TABLE table1 ALTER COLUMN id RESTART WITH 1")
        repos.forEach { it.deleteAll() }
    }

    fun cleanAllCaches() {
        cacheManager.cacheNames
          .map { cacheManager.getCache(it) }
          .filterNotNull()
          .forEach { it.clear() }
    }

    fun resetWiremockStatus() {
        wireMockServer.resetAll()
        // set default requests if any
    }
}

4.8. Refactoring into Unit Tests

4.8.重构为单元测试

This is probably one of the most important points. We’ll find ourselves over and over with some integration tests that are actually exercising some high-level policy of our app.

这可能是最重要的一点。我们会发现自己一遍又一遍地进行一些集成测试,这些测试实际上是在行使我们应用程序的一些高级策略。

Whenever we find some integration tests testing a bunch of cases of core business logic, it’s time to rethink our approach and break them down into unit tests.

每当我们发现一些集成测试测试了一堆核心业务逻辑的案例时,就应该重新思考我们的方法,将它们分解成单元测试。

A possible pattern here to accomplish this successfully could be:

在这里,成功完成这一任务的可能模式可以是。

  • Identify integration tests that are testing multiple scenarios of core business logic
  • Duplicate the suite, and refactor the copy into unit Tests – at this stage, we might need to break down the production code too to make it testable
  • Get all tests green
  • Leave a happy path sample that is remarkable enough in the integration suite – we might need to refactor or join and reshape a few
  • Remove the remaining integration Tests

Michael Feathers covers many techniques to achieve this and more in Working Effectively with Legacy Code.

Michael Feathers在《有效处理遗留代码》一书中介绍了许多实现这一目标的技术,还有更多。

5. Summary

5.摘要

In this article, we had an introduction to Integration tests with a focus on Spring.

在这篇文章中,我们对集成测试进行了介绍,重点介绍了Spring。

First, we talked about the importance of integration tests and why they are particularly relevant in Spring applications.

首先,我们谈到了集成测试的重要性,以及为什么它们在Spring应用程序中特别相关。

After that, we summarized some tools that might come in handy for certain types of Integration tests in Web Apps.

之后,我们总结了一些工具,这些工具对于Web App中某些类型的集成测试可能会很有用。

Finally, we went through a list of potential issues that slow down our test execution time, as well as tricks to improve it.

最后,我们经历了一个减缓我们测试执行时间的潜在问题清单,以及改善它的技巧。