Trochę więcej

o testach w Springu

Perspektywa... ma znaczenie :)

E2E

Integration

Unit

Integration

Spotify: Testing of Microservices Each window worked in isolation

Testy Integracyjne

  • Dokumentacja Springa: [Integration Testing] lets you test the correct wiring of your Spring IoC container contexts
  • + I/O
  • Każdy start kontekstu Springa to już integracja

Testy Integracyjne

Każdy start kontekstu Springa to już integracja

"Ukryta" logika

  • Fallback, @HystrixCommand
  • @Transactional
  • @Cacheable
  • @ConditionalOn...
    • Potencjalnie: ApplicationContextRunner
  • Filter, interceptor, @PreAuthorize, AOP

spring-boot-starter-test


                    testImplementation 'org.springframework.boot:spring-boot-starter-test'
                
  • spring-boot-test, spring-test
  • JUnit (Platform + Jupiter)
  • Mockito
  • AssertJ, Hamcrest
  • Różne JSON-y, XML-e

                    package io.github.mat3e.downloads.limiting;

                    import io.github.mat3e.downloads.limiting.api.AccountId;
                    import io.github.mat3e.downloads.reporting.ReportingFacade;
                    import org.junit.jupiter.api.BeforeEach;
                    import org.junit.jupiter.api.Test;
                    import org.mockito.ArgumentCaptor;

                    import java.time.Clock;
                    import java.util.Optional;

                    import static org.assertj.core.api.Assertions.assertThat;
                    import static org.mockito.Mockito.mock;
                    import static org.mockito.Mockito.verify;
                    import static org.mockito.Mockito.when;

                    class LimitingFacadeTest {
                      private final Clock clock = mock(Clock.class);
                      private final AccountRepository accountRepository = mock(AccountRepository.class);
                      private final AccountSettingRepository accountSettingRepository = mock(AccountSettingRepository.class);
                      private final ReportingFacade reporting = mock(ReportingFacade.class);

                      private LimitingFacade systemUnderTest;

                      @BeforeEach
                      void setUp() {
                        systemUnderTest = new LimitingFacade(clock, accountRepository, accountSettingRepository, reporting);
                      }

                      @Test
                      void newAccount_overrideLimit_createsAccount() {
                        // given
                        var accountId = AccountId.valueOf("1");
                        when(accountSettingRepository.findById(accountId)).thenReturn(Optional.empty());

                        // when
                        systemUnderTest.overrideAccountLimit(accountId, 1);

                        // then
                        var accountSettingCaptor = ArgumentCaptor.forClass(AccountSetting.class);
                        verify(accountSettingRepository).save(accountSettingCaptor.capture());
                        assertThat(accountSettingCaptor.getValue().id()).isEqualTo(accountId);
                        assertThat(accountSettingCaptor.getValue().limit()).isEqualTo(1);
                      }

                      @Test
                      void existingAccount_overrideLimit_updatesAccount() {
                        // given
                        var accountId = AccountId.valueOf("1");
                        var existingAccount = mock(AccountSetting.class);
                        when(accountSettingRepository.findById(accountId)).thenReturn(Optional.of(existingAccount));

                        // when
                        systemUnderTest.overrideAccountLimit(accountId, 1);

                        // then
                        verify(existingAccount).overrideLimit(1);
                        verify(accountSettingRepository).save(existingAccount);
                      }
                    }
                

                    package io.github.mat3e.downloads.limiting;

                    import io.github.mat3e.downloads.limiting.api.AccountId;
                    import io.github.mat3e.downloads.reporting.ReportingFacade;
                    import org.junit.jupiter.api.BeforeEach;
                    import org.junit.jupiter.api.Test;
                    import org.mockito.ArgumentCaptor;

                    import java.time.Clock;
                    import java.util.Optional;

                    import static org.assertj.core.api.Assertions.assertThat;
                    import static org.mockito.Mockito.mock;
                    import static org.mockito.Mockito.verify;
                    import static org.mockito.Mockito.when;

                    class LimitingFacadeTest {
                      private final Clock clock = mock(Clock.class);
                      private final AccountRepository accountRepository = mock(AccountRepository.class);
                      private final AccountSettingRepository accountSettingRepository = mock(AccountSettingRepository.class);
                      private final ReportingFacade reporting = mock(ReportingFacade.class);

                      private final LimitingFacade systemUnderTest =
                          new LimitingFacade(clock, accountRepository, accountSettingRepository, reporting);

                      @Test
                      void newAccount_overrideLimit_createsAccount() {
                        // given
                        var accountId = AccountId.valueOf("1");
                        when(accountSettingRepository.findById(accountId)).thenReturn(Optional.empty());

                        // when
                        systemUnderTest.overrideAccountLimit(accountId, 1);

                        // then
                        var accountSettingCaptor = ArgumentCaptor.forClass(AccountSetting.class);
                        verify(accountSettingRepository).save(accountSettingCaptor.capture());
                        assertThat(accountSettingCaptor.getValue().id()).isEqualTo(accountId);
                        assertThat(accountSettingCaptor.getValue().limit()).isEqualTo(1);
                      }

                      @Test
                      void existingAccount_overrideLimit_updatesAccount() {
                        // given
                        var accountId = AccountId.valueOf("1");
                        var existingAccount = mock(AccountSetting.class);
                        when(accountSettingRepository.findById(accountId)).thenReturn(Optional.of(existingAccount));

                        // when
                        systemUnderTest.overrideAccountLimit(accountId, 1);

                        // then
                        verify(existingAccount).overrideLimit(1);
                        verify(accountSettingRepository).save(existingAccount);
                      }
                    }
                

                    package io.github.mat3e.downloads.limiting;

                    import io.github.mat3e.downloads.limiting.api.AccountId;
                    import io.github.mat3e.downloads.reporting.ReportingFacade;
                    import org.junit.jupiter.api.Test;
                    import org.junit.jupiter.api.extension.ExtendWith;
                    import org.mockito.ArgumentCaptor;
                    import org.mockito.Captor;
                    import org.mockito.InjectMocks;
                    import org.mockito.Mock;
                    import org.mockito.junit.jupiter.MockitoExtension;
                    import java.time.Clock;
                    import java.util.Optional;
                    import static org.assertj.core.api.Assertions.assertThat;
                    import static org.mockito.Mockito.verify;
                    import static org.mockito.Mockito.when;

                    @ExtendWith(MockitoExtension.class)
                    class LimitingFacadeTest {
                      @Mock
                      private Clock clock;
                      @Mock
                      private AccountRepository accountRepository;
                      @Mock
                      private AccountSettingRepository accountSettingRepository;
                      @Mock
                      private ReportingFacade reporting;

                      @InjectMocks
                      private LimitingFacade systemUnderTest;

                      @Captor
                      private ArgumentCaptor<AccountSetting> accountSettingCaptor;

                      @Test
                      void newAccount_overrideLimit_createsAccount() {
                        // given
                        var accountId = AccountId.valueOf("1");
                        when(accountSettingRepository.findById(accountId)).thenReturn(Optional.empty());

                        // when
                        systemUnderTest.overrideAccountLimit(accountId, 1);

                        // then
                        verify(accountSettingRepository).save(accountSettingCaptor.capture());
                        var account = accountSettingCaptor.getValue();
                        assertThat(account.id()).isEqualTo(accountId);
                        assertThat(account.limit()).isEqualTo(1);
                      }

                      @Test
                      void existingAccount_overrideLimit_updatesAccount() {
                        // given
                        var accountId = AccountId.valueOf("1");
                        var existingAccount = new AccountSetting(accountId.getId(), 2, 1);
                        when(accountSettingRepository.findById(accountId)).thenReturn(Optional.of(existingAccount));

                        // when
                        systemUnderTest.overrideAccountLimit(accountId, 1);

                        // then
                        assertThat(existingAccount.limit()).isEqualTo(1);
                        verify(accountSettingRepository).save(existingAccount);
                      }
                    }
                

Test jednostkowy


                    package io.github.mat3e.downloads.limiting;

                    import io.github.mat3e.downloads.limiting.api.AccountId;
                    import io.github.mat3e.downloads.reporting.ReportingFacade;
                    import org.junit.jupiter.api.Test;
                    import org.junit.jupiter.api.extension.ExtendWith;
                    import org.mockito.ArgumentCaptor;
                    import org.mockito.Captor;
                    import org.mockito.InjectMocks;
                    import org.mockito.Mock;
                    import org.mockito.junit.jupiter.MockitoExtension;
                    import java.time.Clock;
                    import java.util.Optional;
                    import static org.assertj.core.api.Assertions.assertThat;
                    import static org.mockito.Mockito.verify;
                    import static org.mockito.Mockito.when;

                    @ExtendWith(MockitoExtension.class)
                    class LimitingFacadeTest {
                      @Mock
                      private Clock clock;
                      @Mock
                      private AccountRepository accountRepository;
                      @Mock
                      private AccountSettingRepository accountSettingRepository;
                      @Mock
                      private ReportingFacade reporting;

                      @InjectMocks
                      private LimitingFacade systemUnderTest;

                      @Captor
                      private ArgumentCaptor<AccountSetting> accountSettingCaptor;

                      @Test
                      void newAccount_overrideLimit_createsAccount() {
                        // given
                        var accountId = AccountId.valueOf("1");
                        when(accountSettingRepository.findById(accountId)).thenReturn(Optional.empty());

                        // when
                        systemUnderTest.overrideAccountLimit(accountId, 1);

                        // then
                        verify(accountSettingRepository).save(accountSettingCaptor.capture());
                        var account = accountSettingCaptor.getValue();
                        assertThat(account.id()).isEqualTo(accountId);
                        assertThat(account.limit()).isEqualTo(1);
                      }

                      @Test
                      void existingAccount_overrideLimit_updatesAccount() {
                        // given
                        var accountId = AccountId.valueOf("1");
                        var existingAccount = new AccountSetting(accountId.getId(), 2, 1);
                        when(accountSettingRepository.findById(accountId)).thenReturn(Optional.of(existingAccount));

                        // when
                        systemUnderTest.overrideAccountLimit(accountId, 1);

                        // then
                        assertThat(existingAccount.limit()).isEqualTo(1);
                        verify(accountSettingRepository).save(existingAccount);
                      }
                    }
                

                    package io.github.mat3e.downloads.limiting;

                    import io.github.mat3e.downloads.limiting.api.AccountId;
                    import io.github.mat3e.downloads.reporting.ReportingFacade;
                    import org.assertj.core.api.BDDAssertions;
                    import org.junit.jupiter.api.Test;
                    import org.junit.jupiter.api.extension.ExtendWith;
                    import org.mockito.ArgumentCaptor;
                    import org.mockito.Captor;
                    import org.mockito.InjectMocks;
                    import org.mockito.Mock;
                    import org.mockito.junit.jupiter.MockitoExtension;
                    import java.time.Clock;
                    import java.util.Optional;
                    import static org.mockito.BDDMockito.given;
                    import static org.mockito.BDDMockito.then;

                    @ExtendWith(MockitoExtension.class)
                    class LimitingFacadeTest {
                      @Mock
                      private Clock clock;
                      @Mock
                      private AccountRepository accountRepository;
                      @Mock
                      private AccountSettingRepository accountSettingRepository;
                      @Mock
                      private ReportingFacade reporting;

                      @InjectMocks
                      private LimitingFacade systemUnderTest;

                      @Captor
                      private ArgumentCaptor<AccountSetting> accountSettingCaptor;

                      @Test
                      void newAccount_overrideLimit_createsAccount() {
                        var accountId = AccountId.valueOf("1");
                        given(accountSettingRepository.findById(accountId)).willReturn(Optional.empty());

                        // when
                        systemUnderTest.overrideAccountLimit(accountId, 1);

                        then(accountSettingRepository).should().save(accountSettingCaptor.capture());
                        var account = accountSettingCaptor.getValue();
                        BDDAssertions.then(account.id()).isEqualTo(accountId);
                        BDDAssertions.then(account.limit()).isEqualTo(1);
                      }

                      @Test
                      void existingAccount_overrideLimit_updatesAccount() {
                        var accountId = AccountId.valueOf("1");
                        var existingAccount = new AccountSetting(accountId.getId(), 2, 1);
                        given(accountSettingRepository.findById(accountId)).willReturn(Optional.of(existingAccount));

                        // when
                        systemUnderTest.overrideAccountLimit(accountId, 1);

                        then(accountSettingRepository).should().save(existingAccount);
                        BDDAssertions.then(existingAccount.limit()).isEqualTo(1);
                      }
                    }
                

                    package io.github.mat3e.downloads.limiting.rest;

                    import com.fasterxml.jackson.databind.JavaType;
                    import com.fasterxml.jackson.databind.ObjectMapper;
                    import com.fasterxml.jackson.databind.type.TypeFactory;
                    import io.github.mat3e.downloads.limiting.LimitingFacade;
                    import io.github.mat3e.downloads.limiting.api.AccountId;
                    import io.github.mat3e.downloads.limiting.api.Asset;
                    import io.github.mat3e.downloads.limiting.api.AssetDeserialization;
                    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.autoconfigure.web.servlet.AutoConfigureMockMvc;
                    import org.springframework.boot.test.context.SpringBootTest;
                    import org.springframework.test.context.junit.jupiter.SpringExtension;
                    import org.springframework.test.web.servlet.MockMvc;
                    import org.springframework.test.web.servlet.ResultActions;

                    import java.util.Collection;

                    import static java.util.stream.Collectors.toUnmodifiableList;
                    import static org.assertj.core.api.BDDAssertions.then;
                    import static org.springframework.http.MediaType.APPLICATION_JSON;
                    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
                    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
                    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
                    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

                    @AutoConfigureMockMvc
                    @SpringBootTest
                    @ExtendWith(SpringExtension.class)
                    class LimitingIntTest {
                      private static final String ACCOUNT_ID = "1";

                      @Autowired
                      private MockMvc mockMvc;

                      @Test
                      void downloadStarted_storesAssetsTillLimit() throws Exception {
                        givenAccountLimit(2);
                        // and
                        httpSuccessfulPostAsset(
                            "{",
                            " \"id\": \"123\",",
                            " \"countryCode\": \"US\"",
                            "}");
                        // and
                        httpSuccessfulPostAsset(
                            "{",
                            " \"id\": \"456\",",
                            " \"countryCode\": \"US\"",
                            "}");

                        // when
                        httpPostAsset(
                            "{",
                            " \"id\": \"789\",",
                            " \"countryCode\": \"US\"",
                            "}"
                        ).andExpect(status().isUnprocessableEntity());

                        then(httpSuccessfulGetAssets()).containsExactly(
                            Asset.withId("123").inCountry("US"),
                            Asset.withId("456").inCountry("US"));

                        // when
                        httpSuccessfulDeleteAsset("123", "US");
                        // and
                        httpSuccessfulPostAsset(
                            "{",
                            " \"id\": \"789\",",
                            " \"countryCode\": \"US\"",
                            "}");

                        then(httpSuccessfulGetAssets()).containsExactly(
                            Asset.withId("456").inCountry("US"),
                            Asset.withId("789").inCountry("US"));
                      }

                      @Test
                      void illegalParams_returnsClientError() throws Exception {
                        // given
                        var validBody = "{ \"id\": \"123\", \"countryCode\": \"US\" }";

                        // expect 404 - no account created, no assets
                        httpGetAssets("lookMaNotExistingId").andExpect(status().isNotFound());
                        httpPostAsset(validBody).andExpect(status().isNotFound());
                        httpDeleteAsset("123", "US").andExpect(status().isNotFound());

                        // expect 400
                        httpPostAssetForAccount("  ", validBody).andExpect(status().isBadRequest());
                        httpPostAsset("{ \"countryCode\": \"US\" }").andExpect(status().isBadRequest());
                        httpPostAsset("{ \"id\": \"123\" }").andExpect(status().isBadRequest());
                        httpDeleteAsset("  ", "OK").andExpect(status().isBadRequest());
                        httpDeleteAsset("123", "  ").andExpect(status().isBadRequest());
                      }

                      private void httpSuccessfulDeleteAsset(String assetId, String countryCode) {
                        try {
                          httpDeleteAsset(assetId, countryCode).andExpect(status().isNoContent());
                        } catch (Exception e) {
                          throw new RuntimeException(e);
                        }
                      }

                      private ResultActions httpDeleteAsset(String assetId, String countryCode) throws Exception {
                        return mockMvc.perform(delete("/api/accounts/{id}/assets/{assetId}", ACCOUNT_ID, assetId)
                            .queryParam("countryCode", countryCode));
                      }

                      private Collection<Asset> httpSuccessfulGetAssets() {
                        try {
                          String jsonResponse = httpGetAssets(ACCOUNT_ID)
                              .andExpect(status().isOk())
                              .andReturn().getResponse().getContentAsString();
                          JavaType returnType =
                              TypeFactory.defaultInstance().constructCollectionType(Collection.class, AssetDeserialization.class);
                          return new ObjectMapper().<Collection<AssetDeserialization>>readValue(jsonResponse, returnType).stream()
                              .map(AssetDeserialization::toApi)
                              .collect(toUnmodifiableList());
                        } catch (Exception e) {
                          throw new RuntimeException(e);
                        }
                      }

                      private ResultActions httpGetAssets(String accountId) throws Exception {
                        return mockMvc.perform(get("/api/accounts/{id}/assets", accountId).contentType(APPLICATION_JSON));
                      }

                      private void httpSuccessfulPostAsset(String... jsonLines) {
                        try {
                          httpPostAsset(jsonLines).andExpect(status().isCreated());
                        } catch (Exception e) {
                          throw new RuntimeException(e);
                        }
                      }

                      private ResultActions httpPostAsset(String... jsonLines) throws Exception {
                        return httpPostAssetForAccount(ACCOUNT_ID, jsonLines);
                      }

                      private ResultActions httpPostAssetForAccount(String accountId, String... jsonLines) throws Exception {
                        return mockMvc.perform(post("/api/accounts/{id}/assets", accountId)
                            .contentType(APPLICATION_JSON)
                            .content(String.join("\n", jsonLines)));
                      }

                      void givenAccountLimit(int limit) {
                        facade.overrideAccountLimit(AccountId.valueOf(ACCOUNT_ID), limit);
                      }

                      @Autowired
                      private LimitingFacade facade;
                    }
                

                    package io.github.mat3e.downloads.limiting.rest;

                    import com.fasterxml.jackson.databind.JavaType;
                    import com.fasterxml.jackson.databind.ObjectMapper;
                    import com.fasterxml.jackson.databind.type.TypeFactory;
                    import io.github.mat3e.downloads.limiting.LimitingFacade;
                    import io.github.mat3e.downloads.limiting.api.AccountId;
                    import io.github.mat3e.downloads.limiting.api.Asset;
                    import io.github.mat3e.downloads.limiting.api.AssetDeserialization;
                    import org.junit.jupiter.api.Test;
                    import org.springframework.beans.factory.annotation.Autowired;
                    import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
                    import org.springframework.boot.test.context.SpringBootTest;
                    import org.springframework.test.web.servlet.MockMvc;
                    import org.springframework.test.web.servlet.ResultActions;

                    import java.util.Collection;

                    import static java.util.stream.Collectors.toUnmodifiableList;
                    import static org.assertj.core.api.BDDAssertions.then;
                    import static org.springframework.http.MediaType.APPLICATION_JSON;
                    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
                    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
                    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
                    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

                    @AutoConfigureMockMvc
                    @SpringBootTest
                    class LimitingIntTest {
                      private static final String ACCOUNT_ID = "1";

                      @Autowired
                      private MockMvc mockMvc;

                      @Test
                      void downloadStarted_storesAssetsTillLimit() throws Exception {
                        givenAccountLimit(2);
                        // and
                        httpSuccessfulPostAsset(
                            "{",
                            " \"id\": \"123\",",
                            " \"countryCode\": \"US\"",
                            "}");
                        // and
                        httpSuccessfulPostAsset(
                            "{",
                            " \"id\": \"456\",",
                            " \"countryCode\": \"US\"",
                            "}");

                        // when
                        httpPostAsset(
                            "{",
                            " \"id\": \"789\",",
                            " \"countryCode\": \"US\"",
                            "}"
                        ).andExpect(status().isUnprocessableEntity());

                        then(httpSuccessfulGetAssets()).containsExactly(
                            Asset.withId("123").inCountry("US"),
                            Asset.withId("456").inCountry("US"));

                        // when
                        httpSuccessfulDeleteAsset("123", "US");
                        // and
                        httpSuccessfulPostAsset(
                            "{",
                            " \"id\": \"789\",",
                            " \"countryCode\": \"US\"",
                            "}");

                        then(httpSuccessfulGetAssets()).containsExactly(
                            Asset.withId("456").inCountry("US"),
                            Asset.withId("789").inCountry("US"));
                      }

                      @Test
                      void illegalParams_returnsClientError() throws Exception {
                        // given
                        var validBody = "{ \"id\": \"123\", \"countryCode\": \"US\" }";

                        // expect 404 - no account created, no assets
                        httpGetAssets("lookMaNotExistingId").andExpect(status().isNotFound());
                        httpPostAsset(validBody).andExpect(status().isNotFound());
                        httpDeleteAsset("123", "US").andExpect(status().isNotFound());

                        // expect 400
                        httpPostAssetForAccount("  ", validBody).andExpect(status().isBadRequest());
                        httpPostAsset("{ \"countryCode\": \"US\" }").andExpect(status().isBadRequest());
                        httpPostAsset("{ \"id\": \"123\" }").andExpect(status().isBadRequest());
                        httpDeleteAsset("  ", "OK").andExpect(status().isBadRequest());
                        httpDeleteAsset("123", "  ").andExpect(status().isBadRequest());
                      }

                      private void httpSuccessfulDeleteAsset(String assetId, String countryCode) {
                        try {
                          httpDeleteAsset(assetId, countryCode).andExpect(status().isNoContent());
                        } catch (Exception e) {
                          throw new RuntimeException(e);
                        }
                      }

                      private ResultActions httpDeleteAsset(String assetId, String countryCode) throws Exception {
                        return mockMvc.perform(delete("/api/accounts/{id}/assets/{assetId}", ACCOUNT_ID, assetId)
                            .queryParam("countryCode", countryCode));
                      }

                      private Collection<Asset> httpSuccessfulGetAssets() {
                        try {
                          String jsonResponse = httpGetAssets(ACCOUNT_ID)
                              .andExpect(status().isOk())
                              .andReturn().getResponse().getContentAsString();
                          JavaType returnType =
                              TypeFactory.defaultInstance().constructCollectionType(Collection.class, AssetDeserialization.class);
                          return new ObjectMapper().<Collection<AssetDeserialization>>readValue(jsonResponse, returnType).stream()
                              .map(AssetDeserialization::toApi)
                              .collect(toUnmodifiableList());
                        } catch (Exception e) {
                          throw new RuntimeException(e);
                        }
                      }

                      private ResultActions httpGetAssets(String accountId) throws Exception {
                        return mockMvc.perform(get("/api/accounts/{id}/assets", accountId).contentType(APPLICATION_JSON));
                      }

                      private void httpSuccessfulPostAsset(String... jsonLines) {
                        try {
                          httpPostAsset(jsonLines).andExpect(status().isCreated());
                        } catch (Exception e) {
                          throw new RuntimeException(e);
                        }
                      }

                      private ResultActions httpPostAsset(String... jsonLines) throws Exception {
                        return httpPostAssetForAccount(ACCOUNT_ID, jsonLines);
                      }

                      private ResultActions httpPostAssetForAccount(String accountId, String... jsonLines) throws Exception {
                        return mockMvc.perform(post("/api/accounts/{id}/assets", accountId)
                            .contentType(APPLICATION_JSON)
                            .content(String.join("\n", jsonLines)));
                      }

                      void givenAccountLimit(int limit) {
                        facade.overrideAccountLimit(AccountId.valueOf(ACCOUNT_ID), limit);
                      }

                      @Autowired
                      private LimitingFacade facade;
                    }
                

                    @Target(ElementType.TYPE)
                    @Retention(RetentionPolicy.RUNTIME)
                    @Documented
                    @Inherited
                    @BootstrapWith(SpringBootTestContextBootstrapper.class)
                    @ExtendWith(SpringExtension.class)
                    public @interface SpringBootTest {
                      /* ... */
                    }
                

                    /**
                     * Annotation for a JPA test that focuses <strong>only</strong> on JPA components.
                     * <p>
                     * Using this annotation will disable full auto-configuration and instead apply only
                     * configuration relevant to JPA tests.
                     * <p>
                     * By default, tests annotated with {@code @DataJpaTest} are transactional and roll back
                     * at the end of each test. They also use an embedded in-memory database (replacing any
                     * explicit or usually auto-configured DataSource).
                     * ...
                     * If you are looking to load your full application configuration, but use an embedded
                     * database, you should consider {@link SpringBootTest @SpringBootTest} combined with
                     * {@link AutoConfigureTestDatabase @AutoConfigureTestDatabase} rather than this
                     * annotation.
                     * ...
                     */
                    @Target(ElementType.TYPE)
                    @Retention(RetentionPolicy.RUNTIME)
                    @Documented
                    @Inherited
                    @BootstrapWith(DataJpaTestContextBootstrapper.class)
                    @ExtendWith(SpringExtension.class)
                    @OverrideAutoConfiguration(enabled = false)
                    @TypeExcludeFilters(DataJpaTypeExcludeFilter.class)
                    @Transactional
                    @AutoConfigureCache
                    @AutoConfigureDataJpa
                    @AutoConfigureTestDatabase
                    @AutoConfigureTestEntityManager
                    @ImportAutoConfiguration
                    public @interface DataJpaTest {
                      /* ... */
                    }
                

                    package io.github.mat3e.downloads.limiting.rest;

                    import com.fasterxml.jackson.databind.JavaType;
                    import com.fasterxml.jackson.databind.ObjectMapper;
                    import com.fasterxml.jackson.databind.type.TypeFactory;
                    import io.github.mat3e.downloads.limiting.LimitingFacade;
                    import io.github.mat3e.downloads.limiting.api.AccountId;
                    import io.github.mat3e.downloads.limiting.api.Asset;
                    import io.github.mat3e.downloads.limiting.api.AssetDeserialization;
                    import org.junit.jupiter.api.Test;
                    import org.springframework.beans.factory.annotation.Autowired;
                    import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
                    import org.springframework.boot.test.context.SpringBootTest;
                    import org.springframework.test.web.servlet.MockMvc;
                    import org.springframework.test.web.servlet.ResultActions;

                    import java.util.Collection;

                    import static java.util.stream.Collectors.toUnmodifiableList;
                    import static org.assertj.core.api.BDDAssertions.then;
                    import static org.springframework.http.MediaType.APPLICATION_JSON;
                    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
                    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
                    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
                    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

                    @AutoConfigureMockMvc
                    @SpringBootTest
                    class LimitingIntTest {
                      private static final String ACCOUNT_ID = "1";

                      @Autowired
                      private MockMvc mockMvc;

                      @Test
                      void downloadStarted_storesAssetsTillLimit() throws Exception {
                        givenAccountLimit(2);
                        // and
                        httpSuccessfulPostAsset(
                            "{",
                            " \"id\": \"123\",",
                            " \"countryCode\": \"US\"",
                            "}");
                        // and
                        httpSuccessfulPostAsset(
                            "{",
                            " \"id\": \"456\",",
                            " \"countryCode\": \"US\"",
                            "}");

                        // when
                        httpPostAsset(
                            "{",
                            " \"id\": \"789\",",
                            " \"countryCode\": \"US\"",
                            "}"
                        ).andExpect(status().isUnprocessableEntity());

                        then(httpSuccessfulGetAssets()).containsExactly(
                            Asset.withId("123").inCountry("US"),
                            Asset.withId("456").inCountry("US"));

                        // when
                        httpSuccessfulDeleteAsset("123", "US");
                        // and
                        httpSuccessfulPostAsset(
                            "{",
                            " \"id\": \"789\",",
                            " \"countryCode\": \"US\"",
                            "}");

                        then(httpSuccessfulGetAssets()).containsExactly(
                            Asset.withId("456").inCountry("US"),
                            Asset.withId("789").inCountry("US"));
                      }

                      @Test
                      void illegalParams_returnsClientError() throws Exception {
                        // given
                        var validBody = "{ \"id\": \"123\", \"countryCode\": \"US\" }";

                        // expect 404 - no account created, no assets
                        httpGetAssets("lookMaNotExistingId").andExpect(status().isNotFound());
                        httpPostAsset(validBody).andExpect(status().isNotFound());
                        httpDeleteAsset("123", "US").andExpect(status().isNotFound());

                        // expect 400
                        httpPostAssetForAccount("  ", validBody).andExpect(status().isBadRequest());
                        httpPostAsset("{ \"countryCode\": \"US\" }").andExpect(status().isBadRequest());
                        httpPostAsset("{ \"id\": \"123\" }").andExpect(status().isBadRequest());
                        httpDeleteAsset("  ", "OK").andExpect(status().isBadRequest());
                        httpDeleteAsset("123", "  ").andExpect(status().isBadRequest());
                      }

                      private void httpSuccessfulDeleteAsset(String assetId, String countryCode) {
                        try {
                          httpDeleteAsset(assetId, countryCode).andExpect(status().isNoContent());
                        } catch (Exception e) {
                          throw new RuntimeException(e);
                        }
                      }

                      private ResultActions httpDeleteAsset(String assetId, String countryCode) throws Exception {
                        return mockMvc.perform(delete("/api/accounts/{id}/assets/{assetId}", ACCOUNT_ID, assetId)
                            .queryParam("countryCode", countryCode));
                      }

                      private Collection<Asset> httpSuccessfulGetAssets() {
                        try {
                          String jsonResponse = httpGetAssets(ACCOUNT_ID)
                              .andExpect(status().isOk())
                              .andReturn().getResponse().getContentAsString();
                          JavaType returnType =
                              TypeFactory.defaultInstance().constructCollectionType(Collection.class, AssetDeserialization.class);
                          return new ObjectMapper().<Collection<AssetDeserialization>>readValue(jsonResponse, returnType).stream()
                              .map(AssetDeserialization::toApi)
                              .collect(toUnmodifiableList());
                        } catch (Exception e) {
                          throw new RuntimeException(e);
                        }
                      }

                      private ResultActions httpGetAssets(String accountId) throws Exception {
                        return mockMvc.perform(get("/api/accounts/{id}/assets", accountId).contentType(APPLICATION_JSON));
                      }

                      private void httpSuccessfulPostAsset(String... jsonLines) {
                        try {
                          httpPostAsset(jsonLines).andExpect(status().isCreated());
                        } catch (Exception e) {
                          throw new RuntimeException(e);
                        }
                      }

                      private ResultActions httpPostAsset(String... jsonLines) throws Exception {
                        return httpPostAssetForAccount(ACCOUNT_ID, jsonLines);
                      }

                      private ResultActions httpPostAssetForAccount(String accountId, String... jsonLines) throws Exception {
                        return mockMvc.perform(post("/api/accounts/{id}/assets", accountId)
                            .contentType(APPLICATION_JSON)
                            .content(String.join("\n", jsonLines)));
                      }

                      void givenAccountLimit(int limit) {
                        facade.overrideAccountLimit(AccountId.valueOf(ACCOUNT_ID), limit);
                      }

                      @Autowired
                      private LimitingFacade facade;
                    }
                

                    package io.github.mat3e.downloads.limiting.rest;

                    import com.fasterxml.jackson.databind.JavaType;
                    import com.fasterxml.jackson.databind.ObjectMapper;
                    import com.fasterxml.jackson.databind.type.TypeFactory;
                    import io.github.mat3e.downloads.limiting.LimitingFacade;
                    import io.github.mat3e.downloads.limiting.api.AccountId;
                    import io.github.mat3e.downloads.limiting.api.Asset;
                    import io.github.mat3e.downloads.limiting.api.AssetDeserialization;
                    import org.junit.jupiter.api.Test;
                    import org.springframework.beans.factory.annotation.Autowired;
                    import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
                    import org.springframework.boot.test.context.SpringBootTest;
                    import org.springframework.test.web.servlet.MockMvc;
                    import org.springframework.test.web.servlet.ResultActions;
                    import org.springframework.transaction.annotation.Transactional;

                    import java.util.Collection;

                    import static java.util.stream.Collectors.toUnmodifiableList;
                    import static org.assertj.core.api.BDDAssertions.then;
                    import static org.springframework.http.MediaType.APPLICATION_JSON;
                    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
                    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
                    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
                    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

                    @Transactional
                    @AutoConfigureMockMvc
                    @SpringBootTest
                    class LimitingIntTest {
                      private static final String ACCOUNT_ID = "1";

                      @Autowired
                      private MockMvc mockMvc;

                      @Test
                      void downloadStarted_storesAssetsTillLimit() throws Exception {
                        givenAccountLimit(2);
                        // and
                        httpSuccessfulPostAsset(
                            "{",
                            " \"id\": \"123\",",
                            " \"countryCode\": \"US\"",
                            "}");
                        // and
                        httpSuccessfulPostAsset(
                            "{",
                            " \"id\": \"456\",",
                            " \"countryCode\": \"US\"",
                            "}");

                        // when
                        httpPostAsset(
                            "{",
                            " \"id\": \"789\",",
                            " \"countryCode\": \"US\"",
                            "}"
                        ).andExpect(status().isUnprocessableEntity());

                        then(httpSuccessfulGetAssets()).containsExactly(
                            Asset.withId("123").inCountry("US"),
                            Asset.withId("456").inCountry("US"));

                        // when
                        httpSuccessfulDeleteAsset("123", "US");
                        // and
                        httpSuccessfulPostAsset(
                            "{",
                            " \"id\": \"789\",",
                            " \"countryCode\": \"US\"",
                            "}");

                        then(httpSuccessfulGetAssets()).containsExactly(
                            Asset.withId("456").inCountry("US"),
                            Asset.withId("789").inCountry("US"));
                      }

                      @Test
                      void illegalParams_returnsClientError() throws Exception {
                        // given
                        var validBody = "{ \"id\": \"123\", \"countryCode\": \"US\" }";

                        // expect 404 - no account created, no assets
                        httpGetAssets("lookMaNotExistingId").andExpect(status().isNotFound());
                        httpPostAsset(validBody).andExpect(status().isNotFound());
                        httpDeleteAsset("123", "US").andExpect(status().isNotFound());

                        // expect 400
                        httpPostAssetForAccount("  ", validBody).andExpect(status().isBadRequest());
                        httpPostAsset("{ \"countryCode\": \"US\" }").andExpect(status().isBadRequest());
                        httpPostAsset("{ \"id\": \"123\" }").andExpect(status().isBadRequest());
                        httpDeleteAsset("  ", "OK").andExpect(status().isBadRequest());
                        httpDeleteAsset("123", "  ").andExpect(status().isBadRequest());
                      }

                      private void httpSuccessfulDeleteAsset(String assetId, String countryCode) {
                        try {
                          httpDeleteAsset(assetId, countryCode).andExpect(status().isNoContent());
                        } catch (Exception e) {
                          throw new RuntimeException(e);
                        }
                      }

                      private ResultActions httpDeleteAsset(String assetId, String countryCode) throws Exception {
                        return mockMvc.perform(delete("/api/accounts/{id}/assets/{assetId}", ACCOUNT_ID, assetId)
                            .queryParam("countryCode", countryCode));
                      }

                      private Collection<Asset> httpSuccessfulGetAssets() {
                        try {
                          String jsonResponse = httpGetAssets(ACCOUNT_ID)
                              .andExpect(status().isOk())
                              .andReturn().getResponse().getContentAsString();
                          JavaType returnType =
                              TypeFactory.defaultInstance().constructCollectionType(Collection.class, AssetDeserialization.class);
                          return new ObjectMapper().<Collection<AssetDeserialization>>readValue(jsonResponse, returnType).stream()
                              .map(AssetDeserialization::toApi)
                              .collect(toUnmodifiableList());
                        } catch (Exception e) {
                          throw new RuntimeException(e);
                        }
                      }

                      private ResultActions httpGetAssets(String accountId) throws Exception {
                        return mockMvc.perform(get("/api/accounts/{id}/assets", accountId).contentType(APPLICATION_JSON));
                      }

                      private void httpSuccessfulPostAsset(String... jsonLines) {
                        try {
                          httpPostAsset(jsonLines).andExpect(status().isCreated());
                        } catch (Exception e) {
                          throw new RuntimeException(e);
                        }
                      }

                      private ResultActions httpPostAsset(String... jsonLines) throws Exception {
                        return httpPostAssetForAccount(ACCOUNT_ID, jsonLines);
                      }

                      private ResultActions httpPostAssetForAccount(String accountId, String... jsonLines) throws Exception {
                        return mockMvc.perform(post("/api/accounts/{id}/assets", accountId)
                            .contentType(APPLICATION_JSON)
                            .content(String.join("\n", jsonLines)));
                      }

                      void givenAccountLimit(int limit) {
                        facade.overrideAccountLimit(AccountId.valueOf(ACCOUNT_ID), limit);
                      }

                      @Autowired
                      private LimitingFacade facade;
                    }
                
⚠️ @Transactional + JPA ⚠️
Spring pitfalls: transactional tests considered harmful
Fun fact: 2011

                    package io.github.mat3e.downloads.limiting.rest;

                    import com.fasterxml.jackson.databind.JavaType;
                    import com.fasterxml.jackson.databind.ObjectMapper;
                    import com.fasterxml.jackson.databind.type.TypeFactory;
                    import io.github.mat3e.downloads.limiting.LimitingFacade;
                    import io.github.mat3e.downloads.limiting.api.AccountId;
                    import io.github.mat3e.downloads.limiting.api.Asset;
                    import io.github.mat3e.downloads.limiting.api.AssetDeserialization;
                    import org.junit.jupiter.api.Test;
                    import org.springframework.beans.factory.annotation.Autowired;
                    import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
                    import org.springframework.boot.test.context.SpringBootTest;
                    import org.springframework.test.web.servlet.MockMvc;
                    import org.springframework.test.web.servlet.ResultActions;
                    import org.springframework.transaction.annotation.Transactional;

                    import java.util.Collection;

                    import static java.util.stream.Collectors.toUnmodifiableList;
                    import static org.assertj.core.api.BDDAssertions.then;
                    import static org.springframework.http.MediaType.APPLICATION_JSON;
                    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
                    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
                    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
                    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

                    @Transactional
                    @AutoConfigureMockMvc
                    @SpringBootTest
                    class LimitingIntTest {
                      private static final String ACCOUNT_ID = "1";

                      @Autowired
                      private MockMvc mockMvc;

                      @Test
                      void downloadStarted_storesAssetsTillLimit() throws Exception {
                        givenAccountLimit(2);
                        // and
                        httpSuccessfulPostAsset(
                            "{",
                            " \"id\": \"123\",",
                            " \"countryCode\": \"US\"",
                            "}");
                        // and
                        httpSuccessfulPostAsset(
                            "{",
                            " \"id\": \"456\",",
                            " \"countryCode\": \"US\"",
                            "}");

                        // when
                        httpPostAsset(
                            "{",
                            " \"id\": \"789\",",
                            " \"countryCode\": \"US\"",
                            "}"
                        ).andExpect(status().isUnprocessableEntity());

                        then(httpSuccessfulGetAssets()).containsExactly(
                            Asset.withId("123").inCountry("US"),
                            Asset.withId("456").inCountry("US"));

                        // when
                        httpSuccessfulDeleteAsset("123", "US");
                        // and
                        httpSuccessfulPostAsset(
                            "{",
                            " \"id\": \"789\",",
                            " \"countryCode\": \"US\"",
                            "}");

                        then(httpSuccessfulGetAssets()).containsExactly(
                            Asset.withId("456").inCountry("US"),
                            Asset.withId("789").inCountry("US"));
                      }

                      @Test
                      void illegalParams_returnsClientError() throws Exception {
                        // given
                        var validBody = "{ \"id\": \"123\", \"countryCode\": \"US\" }";

                        // expect 404 - no account created, no assets
                        httpGetAssets("lookMaNotExistingId").andExpect(status().isNotFound());
                        httpPostAsset(validBody).andExpect(status().isNotFound());
                        httpDeleteAsset("123", "US").andExpect(status().isNotFound());

                        // expect 400
                        httpPostAssetForAccount("  ", validBody).andExpect(status().isBadRequest());
                        httpPostAsset("{ \"countryCode\": \"US\" }").andExpect(status().isBadRequest());
                        httpPostAsset("{ \"id\": \"123\" }").andExpect(status().isBadRequest());
                        httpDeleteAsset("  ", "OK").andExpect(status().isBadRequest());
                        httpDeleteAsset("123", "  ").andExpect(status().isBadRequest());
                      }

                      private void httpSuccessfulDeleteAsset(String assetId, String countryCode) {
                        try {
                          httpDeleteAsset(assetId, countryCode).andExpect(status().isNoContent());
                        } catch (Exception e) {
                          throw new RuntimeException(e);
                        }
                      }

                      private ResultActions httpDeleteAsset(String assetId, String countryCode) throws Exception {
                        return mockMvc.perform(delete("/api/accounts/{id}/assets/{assetId}", ACCOUNT_ID, assetId)
                            .queryParam("countryCode", countryCode));
                      }

                      private Collection<Asset> httpSuccessfulGetAssets() {
                        try {
                          String jsonResponse = httpGetAssets(ACCOUNT_ID)
                              .andExpect(status().isOk())
                              .andReturn().getResponse().getContentAsString();
                          JavaType returnType =
                              TypeFactory.defaultInstance().constructCollectionType(Collection.class, AssetDeserialization.class);
                          return new ObjectMapper().<Collection<AssetDeserialization>>readValue(jsonResponse, returnType).stream()
                              .map(AssetDeserialization::toApi)
                              .collect(toUnmodifiableList());
                        } catch (Exception e) {
                          throw new RuntimeException(e);
                        }
                      }

                      private ResultActions httpGetAssets(String accountId) throws Exception {
                        return mockMvc.perform(get("/api/accounts/{id}/assets", accountId).contentType(APPLICATION_JSON));
                      }

                      private void httpSuccessfulPostAsset(String... jsonLines) {
                        try {
                          httpPostAsset(jsonLines).andExpect(status().isCreated());
                        } catch (Exception e) {
                          throw new RuntimeException(e);
                        }
                      }

                      private ResultActions httpPostAsset(String... jsonLines) throws Exception {
                        return httpPostAssetForAccount(ACCOUNT_ID, jsonLines);
                      }

                      private ResultActions httpPostAssetForAccount(String accountId, String... jsonLines) throws Exception {
                        return mockMvc.perform(post("/api/accounts/{id}/assets", accountId)
                            .contentType(APPLICATION_JSON)
                            .content(String.join("\n", jsonLines)));
                      }

                      void givenAccountLimit(int limit) {
                        facade.overrideAccountLimit(AccountId.valueOf(ACCOUNT_ID), limit);
                      }

                      @Autowired
                      private LimitingFacade facade;
                    }
                

                    package io.github.mat3e.downloads.limiting;

                    import org.junit.jupiter.api.Tag;
                    import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
                    import org.springframework.boot.test.context.SpringBootTest;
                    import org.springframework.transaction.annotation.Transactional;

                    import java.lang.annotation.Documented;
                    import java.lang.annotation.ElementType;
                    import java.lang.annotation.Inherited;
                    import java.lang.annotation.Retention;
                    import java.lang.annotation.RetentionPolicy;
                    import java.lang.annotation.Target;

                    @Tag("integration")
                    @Transactional
                    @AutoConfigureMockMvc
                    @SpringBootTest
                    @Target(ElementType.TYPE)
                    @Retention(RetentionPolicy.RUNTIME)
                    @Documented
                    @Inherited
                    public @interface IntegrationTest {
                    }
                

                    @IntegrationTest
                    class LimitingControllerIntTest {
                        /* ... */
                    }
                

                    package io.github.mat3e.downloads;

                    import io.prometheus.client.CollectorRegistry;
                    import org.springframework.beans.factory.annotation.Autowired;
                    import org.springframework.boot.actuate.autoconfigure.metrics.export.prometheus.PrometheusMetricsExportAutoConfiguration;
                    import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
                    import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
                    import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
                    import org.springframework.boot.test.context.SpringBootTest;
                    import org.springframework.context.annotation.Bean;
                    import org.springframework.context.annotation.Configuration;
                    import org.springframework.test.web.servlet.MockMvc;
                    import org.springframework.transaction.PlatformTransactionManager;
                    import org.springframework.transaction.TransactionDefinition;
                    import org.springframework.transaction.TransactionException;
                    import org.springframework.transaction.TransactionStatus;
                    import org.springframework.transaction.annotation.Transactional;
                    import org.springframework.transaction.support.SimpleTransactionStatus;

                    import javax.annotation.Nonnull;
                    import javax.annotation.Nullable;
                    import java.util.List;

                    @Transactional // => rollback at the end of each test
                    @AutoConfigureMockMvc
                    @SpringBootTest(properties = {
                        "app.repositories.type=in-mem", // not used in prod code anyhow
                        "app.metrics.type=in-mem",
                        "spring.cloud.gcp.core.enabled=false"
                        // ...
                    })
                    class AbstractInMemoryScenario<SELF extends AbstractInMemoryScenario<? extends SELF>> {

                      @Autowired
                      protected MockMvc mockMvc;

                      @SuppressWarnings("unchecked")
                      protected SELF given() {
                        return (SELF) this;
                      }

                      @SuppressWarnings("unchecked")
                      protected SELF when() {
                        return (SELF) this;
                      }

                      @SuppressWarnings("unchecked")
                      protected SELF and() {
                        return (SELF) this;
                      }
                    }

                    @Configuration(proxyBeanMethods = false)
                    @ConditionalOnProperty(name = "app.metrics.type", havingValue = "in-mem")
                    @EnableAutoConfiguration(exclude = PrometheusMetricsExportAutoConfiguration.class)
                    class PrometheusTestConfiguration {

                      @Bean
                      CollectorRegistry collectorRegistry() {
                        return CollectorRegistry.defaultRegistry;
                      }
                    }

                    @Configuration(proxyBeanMethods = false)
                    @ConditionalOnProperty(name = "app.repositories.type", havingValue = "in-mem")
                    class InMemoryRepositoryConfig {

                      /**
                       * Injected instances of Cleanable will be cleaned up after each test.
                       */
                      @Bean
                      PlatformTransactionManager cleaningTransactionManager(List<Cleanable> toClean) {
                        return new PlatformTransactionManager() {
                          @Nonnull
                          @Override
                          public TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException {
                            return new SimpleTransactionStatus();
                          }

                          @Override
                          public void commit(@Nonnull TransactionStatus status) throws TransactionException {
                          }

                          @Override
                          public void rollback(@Nonnull TransactionStatus status) throws TransactionException {
                            toClean.forEach(Cleanable::clean);
                          }
                        };
                      }
                    }
                
@WebMvcTest
@SpringJUnitWebConfig
@DataLdapTest
@DataMongoTest
@SpringJUnitConfig
@SpringBootTest
@JsonTest
@DataJdbcTest
@DataJpaTest
@DataRedisTest
@WebMvcTest
@SpringJUnitWebConfig
@SpringJUnitConfig
@DataLdapTest
@DataMongoTest
@SpringBootTest
@JsonTest
@DataJdbcTest
@DataJpaTest
@DataRedisTest
@SpringBootTest
  • Reguła kciuka: korzystać tylko z tego ;)
  • Optymalizacja testów pod cache'owanie i reużywanie kontekstów
  • Klasa bazowa, wspólna adnotacja, interfejs...

ne context to rule them all

Na pewno tylko jeden kontekst?

                    logging:
                      level:
                        org.springframework.test.context.cache: DEBUG
                

Ile kontekstów?


                    ...
                    19:41:08.116 [Test worker] DEBUG org.springframework.test.context.support.AbstractDirtiesContextTestExecutionListener - Before test class: context [DefaultTestContext@5eefa415 testClass = LimitingControllerIntTest, testInstance = [null], testMethod = [null], testException = [null], mergedContextConfiguration = [WebMergedContextConfiguration@181d7f28 testClass = LimitingControllerIntTest, locations = '{}', classes = '{class io.github.mat3e.downloads.DownloadsApplication}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@630cb4a4, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@4b3fa0b3, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@5357c287, [ImportsContextCustomizer@78d50a3c key = [org.springframework.boot.test.autoconfigure.web.servlet.MockMvcAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebClientAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebDriverAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcSecurityConfiguration, org.springframework.boot.test.autoconfigure.web.reactive.WebTestClientAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@5b080f3a, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@6f603e89, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@64df9a61, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@379614be], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.springframework.test.context.web.ServletTestExecutionListener.activateListener' -> true]], class annotated with @DirtiesContext [false] with mode [null].

                      .   ____          _            __ _ _
                     /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
                    ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
                     \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
                      '  |____| .__|_| |_|_| |_\__, | / / / /
                     =========|_|==============|___/=/_/_/_/
                     :: Spring Boot ::               (v2.7.15)

                    2023-10-01 19:41:08.270  INFO 74526 --- [    Test worker] i.g.m.d.l.r.LimitingControllerIntTest    : Starting LimitingControllerIntTest using Java 17.0.7 on CA042772 with PID 74526 (started by chrzonsm0401 in /Users/chrzonsm0401/IdeaProjects/downloads-service/core)
                    2023-10-01 19:41:08.271  INFO 74526 --- [    Test worker] i.g.m.d.l.r.LimitingControllerIntTest    : No active profile set, falling back to 1 default profile: "default"
                    ...
                    2023-10-01 19:41:09.606  INFO 74526 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Completed initialization in 0 ms
                    2023-10-01 19:41:09.622  INFO 74526 --- [    Test worker] i.g.m.d.l.r.LimitingControllerIntTest    : Started LimitingControllerIntTest in 1.484 seconds (JVM running for 2.016)
                    2023-10-01 19:41:09.639  INFO 74526 --- [    Test worker] o.s.t.c.transaction.TransactionContext   : Began transaction (1) for test context [DefaultTestContext@5eefa415 testClass = LimitingControllerIntTest, testInstance = io.github.mat3e.downloads.limiting.rest.LimitingControllerIntTest@7adff6cb, testMethod = illegalParams_returnsClientError@LimitingControllerIntTest, testException = [null], mergedContextConfiguration = [WebMergedContextConfiguration@181d7f28 testClass = LimitingControllerIntTest, locations = '{}', classes = '{class io.github.mat3e.downloads.DownloadsApplication}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@630cb4a4, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@4b3fa0b3, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@5357c287, [ImportsContextCustomizer@78d50a3c key = [org.springframework.boot.test.autoconfigure.web.servlet.MockMvcAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebClientAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebDriverAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcSecurityConfiguration, org.springframework.boot.test.autoconfigure.web.reactive.WebTestClientAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@5b080f3a, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@6f603e89, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@64df9a61, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@379614be], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.springframework.test.context.web.ServletTestExecutionListener.activateListener' -> true, 'org.springframework.test.context.web.ServletTestExecutionListener.populatedRequestContextHolder' -> true, 'org.springframework.test.context.web.ServletTestExecutionListener.resetRequestContextHolder' -> true, 'org.springframework.test.context.event.ApplicationEventsTestExecutionListener.recordApplicationEvents' -> false]]; transaction manager [org.springframework.jdbc.support.JdbcTransactionManager@13ebccd]; rollback [true]
                    Field error in object 'asset' on field 'id': rejected value [  ]; codes [NotBlank.asset.id,NotBlank.id,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [asset.id,id]; arguments []; default message [id]]; default message [must not be blank]
                    ...
                

Ile kontekstów?


                    ...
                    19:41:08.116 [Test worker] DEBUG org.springframework.test.context.support.AbstractDirtiesContextTestExecutionListener - Before test class: context [DefaultTestContext@5eefa415 testClass = LimitingControllerIntTest, testInstance = [null], testMethod = [null], testException = [null], mergedContextConfiguration = [WebMergedContextConfiguration@181d7f28 testClass = LimitingControllerIntTest, locations = '{}', classes = '{class io.github.mat3e.downloads.DownloadsApplication}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@630cb4a4, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@4b3fa0b3, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@5357c287, [ImportsContextCustomizer@78d50a3c key = [org.springframework.boot.test.autoconfigure.web.servlet.MockMvcAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebClientAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebDriverAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcSecurityConfiguration, org.springframework.boot.test.autoconfigure.web.reactive.WebTestClientAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@5b080f3a, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@6f603e89, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@64df9a61, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@379614be], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.springframework.test.context.web.ServletTestExecutionListener.activateListener' -> true]], class annotated with @DirtiesContext [false] with mode [null].

                      .   ____          _            __ _ _
                     /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
                    ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
                     \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
                      '  |____| .__|_| |_|_| |_\__, | / / / /
                     =========|_|==============|___/=/_/_/_/
                     :: Spring Boot ::               (v2.7.15)

                    2023-10-01 19:41:08.270  INFO 74526 --- [    Test worker] i.g.m.d.l.r.LimitingControllerIntTest    : Starting LimitingControllerIntTest using Java 17.0.7 on CA042772 with PID 74526 (started by chrzonsm0401 in /Users/chrzonsm0401/IdeaProjects/downloads-service/core)
                    2023-10-01 19:41:08.271  INFO 74526 --- [    Test worker] i.g.m.d.l.r.LimitingControllerIntTest    : No active profile set, falling back to 1 default profile: "default"
                    ...
                    2023-10-01 19:41:09.606  INFO 74526 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Completed initialization in 0 ms
                    2023-10-01 19:41:09.622  INFO 74526 --- [    Test worker] i.g.m.d.l.r.LimitingControllerIntTest    : Started LimitingControllerIntTest in 1.484 seconds (JVM running for 2.016)
                    2023-10-01 19:41:09.624 DEBUG 74715 --- [    Test worker] c.DefaultCacheAwareContextLoaderDelegate : Storing ApplicationContext [1779219567] in cache under key [[WebMergedContextConfiguration@181d7f28 testClass = LimitingControllerIntTest, locations = '{}', classes = '{class io.github.mat3e.downloads.DownloadsApplication}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@630cb4a4, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@4b3fa0b3, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@5357c287, [ImportsContextCustomizer@78d50a3c key = [org.springframework.boot.test.autoconfigure.web.servlet.MockMvcAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebClientAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebDriverAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcSecurityConfiguration, org.springframework.boot.test.autoconfigure.web.reactive.WebTestClientAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@5b080f3a, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@6f603e89, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@64df9a61, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@379614be], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]]]
                    2023-10-01 19:41:09.624 DEBUG 74715 --- [    Test worker] org.springframework.test.context.cache   : Spring test ApplicationContext cache statistics: [DefaultContextCache@10fc1a22 size = 1, maxSize = 32, parentContextCount = 0, hitCount = 0, missCount = 1]
                    2023-10-01 19:41:09.630 DEBUG 74715 --- [    Test worker] c.DefaultCacheAwareContextLoaderDelegate : Retrieved ApplicationContext [1779219567] from cache with key [[WebMergedContextConfiguration@181d7f28 testClass = LimitingControllerIntTest, locations = '{}', classes = '{class io.github.mat3e.downloads.DownloadsApplication}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@630cb4a4, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@4b3fa0b3, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@5357c287, [ImportsContextCustomizer@78d50a3c key = [org.springframework.boot.test.autoconfigure.web.servlet.MockMvcAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebClientAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebDriverAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcSecurityConfiguration, org.springframework.boot.test.autoconfigure.web.reactive.WebTestClientAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@5b080f3a, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@6f603e89, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@64df9a61, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@379614be], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]]]
                    2023-10-01 19:41:09.630 DEBUG 74715 --- [    Test worker] org.springframework.test.context.cache   : Spring test ApplicationContext cache statistics: [DefaultContextCache@10fc1a22 size = 1, maxSize = 32, parentContextCount = 0, hitCount = 1, missCount = 1]
                    2023-10-01 19:41:09.632 DEBUG 74715 --- [    Test worker] c.DefaultCacheAwareContextLoaderDelegate : Retrieved ApplicationContext [1779219567] from cache with key [[WebMergedContextConfiguration@181d7f28 testClass = LimitingControllerIntTest, locations = '{}', classes = '{class io.github.mat3e.downloads.DownloadsApplication}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@630cb4a4, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@4b3fa0b3, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@5357c287, [ImportsContextCustomizer@78d50a3c key = [org.springframework.boot.test.autoconfigure.web.servlet.MockMvcAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebClientAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebDriverAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcSecurityConfiguration, org.springframework.boot.test.autoconfigure.web.reactive.WebTestClientAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@5b080f3a, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@6f603e89, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@64df9a61, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@379614be], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]]]
                    2023-10-01 19:41:09.632 DEBUG 74715 --- [    Test worker] org.springframework.test.context.cache   : Spring test ApplicationContext cache statistics: [DefaultContextCache@10fc1a22 size = 1, maxSize = 32, parentContextCount = 0, hitCount = 2, missCount = 1]
                    2023-10-01 19:41:09.636 DEBUG 74715 --- [    Test worker] c.DefaultCacheAwareContextLoaderDelegate : Retrieved ApplicationContext [1779219567] from cache with key [[WebMergedContextConfiguration@181d7f28 testClass = LimitingControllerIntTest, locations = '{}', classes = '{class io.github.mat3e.downloads.DownloadsApplication}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@630cb4a4, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@4b3fa0b3, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@5357c287, [ImportsContextCustomizer@78d50a3c key = [org.springframework.boot.test.autoconfigure.web.servlet.MockMvcAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebClientAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebDriverAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcSecurityConfiguration, org.springframework.boot.test.autoconfigure.web.reactive.WebTestClientAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@5b080f3a, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@6f603e89, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@64df9a61, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@379614be], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]]]
                    2023-10-01 19:41:09.636 DEBUG 74715 --- [    Test worker] org.springframework.test.context.cache   : Spring test ApplicationContext cache statistics: [DefaultContextCache@10fc1a22 size = 1, maxSize = 32, parentContextCount = 0, hitCount = 3, missCount = 1]
                    2023-10-01 19:41:09.639  INFO 74526 --- [    Test worker] o.s.t.c.transaction.TransactionContext   : Began transaction (1) for test context [DefaultTestContext@5eefa415 testClass = LimitingControllerIntTest, testInstance = io.github.mat3e.downloads.limiting.rest.LimitingControllerIntTest@7adff6cb, testMethod = illegalParams_returnsClientError@LimitingControllerIntTest, testException = [null], mergedContextConfiguration = [WebMergedContextConfiguration@181d7f28 testClass = LimitingControllerIntTest, locations = '{}', classes = '{class io.github.mat3e.downloads.DownloadsApplication}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@630cb4a4, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@4b3fa0b3, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@5357c287, [ImportsContextCustomizer@78d50a3c key = [org.springframework.boot.test.autoconfigure.web.servlet.MockMvcAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebClientAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebDriverAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcSecurityConfiguration, org.springframework.boot.test.autoconfigure.web.reactive.WebTestClientAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@5b080f3a, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@6f603e89, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@64df9a61, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@379614be], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.springframework.test.context.web.ServletTestExecutionListener.activateListener' -> true, 'org.springframework.test.context.web.ServletTestExecutionListener.populatedRequestContextHolder' -> true, 'org.springframework.test.context.web.ServletTestExecutionListener.resetRequestContextHolder' -> true, 'org.springframework.test.context.event.ApplicationEventsTestExecutionListener.recordApplicationEvents' -> false]]; transaction manager [org.springframework.jdbc.support.JdbcTransactionManager@13ebccd]; rollback [true]
                    2023-10-01 19:41:09.640 DEBUG 74715 --- [    Test worker] c.DefaultCacheAwareContextLoaderDelegate : Retrieved ApplicationContext [1779219567] from cache with key [[WebMergedContextConfiguration@181d7f28 testClass = LimitingControllerIntTest, locations = '{}', classes = '{class io.github.mat3e.downloads.DownloadsApplication}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@630cb4a4, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@4b3fa0b3, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@5357c287, [ImportsContextCustomizer@78d50a3c key = [org.springframework.boot.test.autoconfigure.web.servlet.MockMvcAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebClientAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebDriverAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcSecurityConfiguration, org.springframework.boot.test.autoconfigure.web.reactive.WebTestClientAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@5b080f3a, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@6f603e89, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@64df9a61, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@379614be], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]]]
                    2023-10-01 19:41:09.640 DEBUG 74715 --- [    Test worker] org.springframework.test.context.cache   : Spring test ApplicationContext cache statistics: [DefaultContextCache@10fc1a22 size = 1, maxSize = 32, parentContextCount = 0, hitCount = 4, missCount = 1]
                    2023-10-01 19:41:09.641 DEBUG 74715 --- [    Test worker] c.DefaultCacheAwareContextLoaderDelegate : Retrieved ApplicationContext [1779219567] from cache with key [[WebMergedContextConfiguration@181d7f28 testClass = LimitingControllerIntTest, locations = '{}', classes = '{class io.github.mat3e.downloads.DownloadsApplication}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@630cb4a4, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@4b3fa0b3, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@5357c287, [ImportsContextCustomizer@78d50a3c key = [org.springframework.boot.test.autoconfigure.web.servlet.MockMvcAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebClientAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebDriverAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcSecurityConfiguration, org.springframework.boot.test.autoconfigure.web.reactive.WebTestClientAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@5b080f3a, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@6f603e89, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@64df9a61, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@379614be], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]]]
                    2023-10-01 19:41:09.640 DEBUG 74715 --- [    Test worker] org.springframework.test.context.cache   : Spring test ApplicationContext cache statistics: [DefaultContextCache@10fc1a22 size = 1, maxSize = 32, parentContextCount = 0, hitCount = 5, missCount = 1]
                    2023-10-01 19:41:09.806 DEBUG 74715 --- [    Test worker] c.DefaultCacheAwareContextLoaderDelegate : Retrieved ApplicationContext [1779219567] from cache with key [[WebMergedContextConfiguration@181d7f28 testClass = LimitingControllerIntTest, locations = '{}', classes = '{class io.github.mat3e.downloads.DownloadsApplication}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@630cb4a4, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@4b3fa0b3, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@5357c287, [ImportsContextCustomizer@78d50a3c key = [org.springframework.boot.test.autoconfigure.web.servlet.MockMvcAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebClientAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcWebDriverAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration, org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration, org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcSecurityConfiguration, org.springframework.boot.test.autoconfigure.web.reactive.WebTestClientAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@5b080f3a, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@6f603e89, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@64df9a61, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@379614be], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]]]
                    2023-10-01 19:41:09.806 DEBUG 74715 --- [    Test worker] org.springframework.test.context.cache   : Spring test ApplicationContext cache statistics: [DefaultContextCache@10fc1a22 size = 1, maxSize = 32, parentContextCount = 0, hitCount = 6, missCount = 1]
                    Field error in object 'asset' on field 'id': rejected value [  ]; codes [NotBlank.asset.id,NotBlank.id,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [asset.id,id]; arguments []; default message [id]]; default message [must not be blank]
                    ...
                

Ile kontekstów - log


                    Spring test ApplicationContext cache statistics:
                    [DefaultContextCache@10fc1a22
                        size = 1,
                        maxSize = 32,
                        parentContextCount = 0,
                        hitCount = 0,
                        missCount = 1
                    ]
                

Szczegóły implementacyjne :)


                    // SpringExtension
                    public static ApplicationContext getApplicationContext(ExtensionContext context) {
                      return getTestContextManager(context).getTestContext().getApplicationContext();
                      // DefaultTestContext -> this.cacheAwareContextLoaderDelegate.loadContext(...)
                    }

                    // DefaultCacheAwareContextLoaderDelegate
                    static final ContextCache defaultContextCache = new DefaultContextCache();

                    // DefaultContextCache
                    private final Map<MergedContextConfiguration, ApplicationContext> contextMap =
                        Collections.synchronizedMap(new LruCache(32, 0.75f));
                

MergedContextConfiguration

  • @TestPropertySource
  • @DynamicPropertySource
  • @ActiveProfiles
  • @ContextConfiguration
  • @MockBean
  • @SpyBean
  • @DirtiesContext

Schodzenie z kontekstów

  1. grep po testach - @MockBean, @SpyBean, etc.
    • Może niepotrzebne/nieużywane?
  2. grep po propsach (@TestPropertySource itp.)
    • Używać domyślnych properties'ów (np. z application.properties/yml) gdzie się da
  3. Dalsza edukacja zespołu + code review

Współdzielona konfiguracja - propozycja


                    
                    @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
                    @ActiveProfiles("integration-test")
                    @ContextConfiguration(
                            initializers = EmbeddedRedisConnectionInitializer.class,
                            classes = {
                                CollectorRegistryTestConfiguration.class
                            })
                    public abstract class IntegrationTest {

                    }

                    // in test
                    class OrderIntegrationTest extends IntegrationTest {

                    }
                
zysk ▶
współdzielenie kontekstów
pracochłonność ▶

Więcej o współdzieleniu

Dokręcanie śrubek

@Lazy

  • Zwykle smrodek w kodzie - cykliczne zależności
  • Ale w testach?

                    spring:
                      main:
                        lazy-initialization: true
                
Konsultanci go nienawidzą, odkrył sekret szybszego uruchamiania testów 😉

Zady i walety

  • Szybsze wstawanie kontekstu (2m => 20s)
  • Dużo mniejsze zużycie pamięci (nie wszystkie beany są potrzebne w każdym teście)
  • @Conditional nie zadziała, jeśli Bean nie jest zawołany
  • Niektóre elementy inicjalizowane jako static muszą być rejestrowane jako Bean -> Spring Security
zysk ▶
współdzielenie kontekstów
@Lazy
pracochłonność ▶

Wyłączenie zbędnych beanów

Czyli których?
debug: true

                    ============================
                    CONDITIONS EVALUATION REPORT
                    ============================


                    Positive matches:
                    -----------------

                       AopAutoConfiguration matched:
                          - @ConditionalOnClass found required classes 'org.springframework.context.annotation.EnableAspectJAutoProxy', 'org.aspectj.lang.annotation.Aspect', 'org.aspectj.lang.reflect.Advice', 'org.aspectj.weaver.AnnotatedElement' (OnClassCondition)
                          - @ConditionalOnProperty (spring.aop.auto=true) matched (OnPropertyCondition)

                    ...
                
GET /actuator/beans

Usuwanie zbędnych beanów

  • Klasy z metodą @Scheduled / Workery
  • Niepotrzebne konfiguracje (np.: Swagger)
  • Ograniczanie zasobów (np.: zmniejszenie puli wątków PubSub)
zysk ▶
współdzielenie kontekstów
@Lazy
wyłączenie beanów
pracochłonność ▶

Alternatywne podejście (2024)

spring-startup-analyzer
java -javaagent:./spring-startup-analyzer/lib/spring-profiler-agent.jar \
     -jar project-demo.jar

IO

  • Wolne - szczególnie przez sieć
  • Lokalne instancje praktycznie zawsze szybsze
  • Lokalne współdzielone instancje? Trochę ryzykowne
    • Ale jeszcze szybsze

Co może pójść nie tak? ;)

Ryzyko niedeterministycznych zachowań

"U nas działa"

  • Redis na dev ⇒ embedded
  • 27 ⇒ 16min
    • Zysk ~40%
  • Zero JedisException i zerwanych połączeń
zysk ▶
współdzielenie kontekstów
@Lazy
wyłączenie beanów
wspólne I/O
pracochłonność ▶

Testcontainers

Zrównoleglanie

Procesy Wątki
Klasy Klasy lub metody
OutOfMemoryError (za dużo wątków) JVM na koniec świata i jeszcze dalej
Odpalenie wszystkiego równolegle, ciężko kontrolować W(y)łączanie z użyciem @Isolated@Execution

    Synchronizacje


                    # src/integration/resources/junit-platform.properties

                    junit.jupiter.execution.parallel.enabled = true
                    junit.jupiter.execution.parallel.mode.default = same_thread
                    junit.jupiter.execution.parallel.mode.classes.default = concurrent
                

                    @Execution(ExecutionMode.CONCURRENT)
                    class ConcurrentTest extends IntegrationTest { // ...
                

                    @Isolated
                    // @ResourceLock("org.junit.platform.engine.support.hierarchical.ExclusiveResource.GLOBAL_KEY")
                    class IsolatedTest extends IntegrationTest { // ...
                 

Kto nie ryzykuje...

  • Wspólne "zasoby"
    • Pola static
    • Singletony
  • Problematyczna inicjalizacja
    • Np. nowy użytkownik, jego konfiguracja
    • ⇒ lepiej korzystać z unikalnych danych w obrębie klasy/metody/jednostki
  • Zrównoleglanie tylko długo trwających testów
    • Z doświadczenia - więcej niż 1s
zysk ▶
współdzielenie kontekstów
@Lazy
wyłączenie beanów
wspólne I/O
zrównoleglanie runnerem
pracochłonność ▶

Zrównoleglanie przez "rurociąg"

⚠️ locki Gradle'a ⚠️
=> kompilować wspólne źródła wczesniej (:compileIntegrationClasses)

                stage('Tests') {
                  parallel {
                    stage('Unit tests') {
                      steps {
                        sh "./gradlew test"
                      }
                    }
                    stage('Integration tests') {
                      steps {
                        sh "./gradlew integrationTest"
                      }
                    }
                    stage('Integration tests intl') {
                      steps {
                        sh "./gradlew integrationTestIntl"
                      }
                    }
                    // ...
                  }
                }
            
zysk ▶
współdzielenie kontekstów
@Lazy
wyłączenie beanów
wspólne I/O
zrównoleglanie runnerem
zrównoleglanie w CI
pracochłonność ▶

Dziękujemy za uwagę!