diff --git a/.gitignore b/.gitignore index b6d6629..7d2d440 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .gradle /build/ !gradle/wrapper/gradle-wrapper.jar +out ### NPM ### /npm-debug.log diff --git a/README.md b/README.md index 820a249..2b2a697 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ +**This is a Spock 1.2 update of the original code from Derek Eskens @snekse** + +Original blog post here: https://objectpartners.com/2017/04/18/spring-integration-testing-with-spock-mocks/ + +Original source code here: https://github.com/snekse/spring-spock-integration-testing + **How to inject Spock mocks into Spring integration tests** This project is intended to be used as en example guide to illustrate how you can use Spock with Spring (and Spring Boot) with a mix of Spring configuration and Spock mocks. @@ -10,5 +16,6 @@ Thankfully Spock 1.1 introduced the `DetachedMockFactory`. This, combined with t The heart of this example lives in our `PersonControllerIntTest`. The `PersonControllerIntTest` spins up a Spring context so we can make a `MockMvc` call to a REST endpoint which pulls data from an h2 database via a Spring Data repo, but the "Rank" data we would normally get from an external service has been mocked. -Testing provided by Travis CI -[![Build Status](https://travis-ci.org/snekse/spring-spock-integration-testing.svg?branch=master)](https://travis-ci.org/snekse/spring-spock-integration-testing) \ No newline at end of file +Updates: +* Spock 1.2 provides new @SpringBean, @SpringSpy, and @UnwrapAopProxy annotations to make injecting mocks even easier. See `PersonControllerIntSpock12Test` +* Mocking Spring proxied objects, like @Validated or @Repository, requires unwrapping the proxy to use the mock objects diff --git a/build.gradle b/build.gradle index c1cc889..3fd9e9b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ buildscript { ext { - springBootVersion = '1.5.2.RELEASE' + springBootVersion = '1.5.13.RELEASE' } repositories { mavenCentral() @@ -21,6 +21,11 @@ sourceCompatibility = 1.8 repositories { jcenter() + + maven { + //Required until Spock 1.2 is released out of snapshot + url 'https://oss.sonatype.org/content/repositories/snapshots/' + } } @@ -30,12 +35,12 @@ dependencies { compile('org.codehaus.groovy:groovy') runtime('com.h2database:h2') - def spockVersion = '1.1-groovy-2.4-rc-4' + def spockVersion = '1.2-groovy-2.4-SNAPSHOT' testCompile("org.springframework.boot:spring-boot-starter-test") testCompile("org.spockframework:spock-core:$spockVersion") testCompile("org.spockframework:spock-spring:$spockVersion") testCompile("com.blogspot.toomuchcoding:spock-subjects-collaborators-extension:1.2.1") // needed for mocking in Spock - testCompile("cglib:cglib-nodep:2.2") + testCompile("cglib:cglib-nodep:3.2.1") } diff --git a/src/main/groovy/com/objectpartners/eskens/controllers/PersonController.groovy b/src/main/groovy/com/objectpartners/eskens/controllers/PersonController.groovy index de0b443..03bbca4 100644 --- a/src/main/groovy/com/objectpartners/eskens/controllers/PersonController.groovy +++ b/src/main/groovy/com/objectpartners/eskens/controllers/PersonController.groovy @@ -1,7 +1,7 @@ package com.objectpartners.eskens.controllers +import com.objectpartners.eskens.model.Rank import com.objectpartners.eskens.services.PersonService -import com.objectpartners.eskens.services.Rank import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping @@ -24,4 +24,11 @@ class PersonController { Rank rank = personService.getRank(id) return "$name ~ $rank.classification:Level $rank.level" } + + @GetMapping(path = '{id}/validatedRank') + String getNameAndValidatedRank(@PathVariable(name = 'id') Long id ) { + def name = personService.getAddressToForPersonId(id) + Rank rank = personService.getValidatedRank(id) + return "$name ~ $rank.classification:Level $rank.level" + } } diff --git a/src/main/groovy/com/objectpartners/eskens/model/Rank.groovy b/src/main/groovy/com/objectpartners/eskens/model/Rank.groovy new file mode 100644 index 0000000..55f4d7a --- /dev/null +++ b/src/main/groovy/com/objectpartners/eskens/model/Rank.groovy @@ -0,0 +1,6 @@ +package com.objectpartners.eskens.model + +class Rank { + int level + String classification +} diff --git a/src/main/groovy/com/objectpartners/eskens/services/ExternalRankingService.groovy b/src/main/groovy/com/objectpartners/eskens/services/ExternalRankingService.groovy index 7922e30..1db619b 100644 --- a/src/main/groovy/com/objectpartners/eskens/services/ExternalRankingService.groovy +++ b/src/main/groovy/com/objectpartners/eskens/services/ExternalRankingService.groovy @@ -1,6 +1,7 @@ package com.objectpartners.eskens.services import com.objectpartners.eskens.entities.Person +import com.objectpartners.eskens.model.Rank import org.springframework.stereotype.Service /** @@ -15,8 +16,3 @@ class ExternalRankingService { throw new RuntimeException('This feature is not yet implemented.') } } - -class Rank { - int level - String classification -} diff --git a/src/main/groovy/com/objectpartners/eskens/services/PersonService.groovy b/src/main/groovy/com/objectpartners/eskens/services/PersonService.groovy index b61edc9..6518fd6 100644 --- a/src/main/groovy/com/objectpartners/eskens/services/PersonService.groovy +++ b/src/main/groovy/com/objectpartners/eskens/services/PersonService.groovy @@ -1,6 +1,7 @@ package com.objectpartners.eskens.services import com.objectpartners.eskens.entities.Person +import com.objectpartners.eskens.model.Rank import com.objectpartners.eskens.repos.PersonRepo import org.springframework.stereotype.Service @@ -11,9 +12,12 @@ class PersonService { final ExternalRankingService externalRankingService - PersonService(PersonRepo pr, ExternalRankingService ers) { + final ValidatedExternalRankingService validatedExternalRankingService + + PersonService(PersonRepo pr, ExternalRankingService ers, ValidatedExternalRankingService vers) { this.personRepo = pr this.externalRankingService = ers + this.validatedExternalRankingService = vers } String getAddressToForPersonId(Long personId) { @@ -25,6 +29,10 @@ class PersonService { externalRankingService.getRank(getPerson(personId)) } + Rank getValidatedRank(Long personId) { + validatedExternalRankingService.getRank(getPerson(personId)) + } + private Person getPerson(Long personId) { personRepo.findOne(personId) } diff --git a/src/main/groovy/com/objectpartners/eskens/services/ValidatedExternalRankingService.groovy b/src/main/groovy/com/objectpartners/eskens/services/ValidatedExternalRankingService.groovy new file mode 100644 index 0000000..1ee036c --- /dev/null +++ b/src/main/groovy/com/objectpartners/eskens/services/ValidatedExternalRankingService.groovy @@ -0,0 +1,20 @@ +package com.objectpartners.eskens.services + +import com.objectpartners.eskens.entities.Person +import com.objectpartners.eskens.model.Rank +import org.springframework.stereotype.Service +import org.springframework.validation.annotation.Validated + +/** + * This class is @Validated, which makes it a spring proxied service + * so unwrapping is necessary to use a Mock + */ +@Service +@Validated +class ValidatedExternalRankingService { + + @SuppressWarnings("GrMethodMayBeStatic") + Rank getRank(Person person) { + throw new RuntimeException('This feature is not yet implemented.') + } +} diff --git a/src/test/groovy/com/objectpartners/eskens/config/IntegrationTestMockingConfig.groovy b/src/test/groovy/com/objectpartners/eskens/config/IntegrationTestMockingConfig.groovy index 7ba31ef..7eb346a 100644 --- a/src/test/groovy/com/objectpartners/eskens/config/IntegrationTestMockingConfig.groovy +++ b/src/test/groovy/com/objectpartners/eskens/config/IntegrationTestMockingConfig.groovy @@ -1,6 +1,7 @@ package com.objectpartners.eskens.config import com.objectpartners.eskens.services.ExternalRankingService +import com.objectpartners.eskens.services.ValidatedExternalRankingService import org.springframework.boot.test.context.TestConfiguration import org.springframework.context.annotation.Bean import spock.mock.DetachedMockFactory @@ -18,4 +19,9 @@ class IntegrationTestMockingConfig { ExternalRankingService externalRankingService() { factory.Mock(ExternalRankingService) } + + @Bean + ValidatedExternalRankingService validatedExternalRankingService() { + factory.Mock(ValidatedExternalRankingService) + } } diff --git a/src/test/groovy/com/objectpartners/eskens/controllers/PersonControllerIntSpock12Test.groovy b/src/test/groovy/com/objectpartners/eskens/controllers/PersonControllerIntSpock12Test.groovy new file mode 100644 index 0000000..03a2eb5 --- /dev/null +++ b/src/test/groovy/com/objectpartners/eskens/controllers/PersonControllerIntSpock12Test.groovy @@ -0,0 +1,83 @@ +package com.objectpartners.eskens.controllers + +import com.objectpartners.eskens.model.Rank +import com.objectpartners.eskens.services.ExternalRankingService +import com.objectpartners.eskens.services.ValidatedExternalRankingService +import org.spockframework.spring.SpringBean +import org.spockframework.spring.SpringSpy +import org.spockframework.spring.UnwrapAopProxy +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.MvcResult +import spock.lang.Specification + +import static org.springframework.http.MediaType.APPLICATION_JSON +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +/** + * An integration test illustrating how to wire everything w/ Spring, + * but replace certain components with Spock mocks + * + * Uses Spock 1.2 annotations of @SpringBean, @SpringSpy, and @UnwrapAopProxy + */ +@SpringBootTest +@AutoConfigureMockMvc +class PersonControllerIntSpock12Test extends Specification { + + @Autowired MockMvc mvc + + /** + * SpringBean will put the mock into the spring context + */ + @SpringBean + ExternalRankingService externalRankingService = Mock() + + /** + * SpringSpy will wrap the Spring injected Service with a Spy + * UnwrapAopProxy will remove the cglib @Validated proxy annotated inside ExternalRankingService + * However it will not use a cached test config, so many tests could be slow. + * see PersonControllerIntTest for how to use the spring cached context config + */ + @SpringSpy + @UnwrapAopProxy + ValidatedExternalRankingService validatedExternalRankingService + + def "GetRank"() { + when: 'Calling getRank for a known seed data entity' + MvcResult mvcResult = mvc.perform(get("/persons/1/rank").contentType(APPLICATION_JSON)) + .andExpect(status().is2xxSuccessful()).andReturn() + + then: 'we define the mock for JUST the external service' + 1 * externalRankingService.getRank(_) >> { + new Rank(level: 1, classification: 'Captain') + } + noExceptionThrown() + + when: 'inspecting the contents' + def resultingJson = mvcResult.response.contentAsString + + then: 'the result contains a mix of mocked service data and actual wired component data' + resultingJson == 'Capt James Kirk ~ Captain:Level 1' + } + + def "GetValidatedRank"() { + when: 'Calling getRank for a known seed data entity' + MvcResult mvcResult = mvc.perform(get("/persons/1/validatedRank").contentType(APPLICATION_JSON)) + .andExpect(status().is2xxSuccessful()).andReturn() + + then: 'we define the mock for the external service' + 1 * validatedExternalRankingService.getRank(_) >> { + new Rank(level: 1, classification: 'Captain') + } + noExceptionThrown() + + when: 'inspecting the contents' + def resultingJson = mvcResult.response.contentAsString + + then: 'the result contains a mix of mocked service data and actual wired component data' + resultingJson == 'Capt James Kirk ~ Captain:Level 1' + } +} diff --git a/src/test/groovy/com/objectpartners/eskens/controllers/PersonControllerIntTest.groovy b/src/test/groovy/com/objectpartners/eskens/controllers/PersonControllerIntTest.groovy index 0242b4c..0b35a3d 100644 --- a/src/test/groovy/com/objectpartners/eskens/controllers/PersonControllerIntTest.groovy +++ b/src/test/groovy/com/objectpartners/eskens/controllers/PersonControllerIntTest.groovy @@ -1,13 +1,14 @@ package com.objectpartners.eskens.controllers import com.objectpartners.eskens.config.IntegrationTestMockingConfig -import com.objectpartners.eskens.entities.Person +import com.objectpartners.eskens.model.Rank import com.objectpartners.eskens.services.ExternalRankingService -import com.objectpartners.eskens.services.Rank +import com.objectpartners.eskens.services.ValidatedExternalRankingService 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.context.annotation.Import +import org.springframework.test.util.AopTestUtils import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MvcResult import spock.lang.Specification @@ -33,6 +34,20 @@ class PersonControllerIntTest extends Specification { */ @Autowired ExternalRankingService externalRankingServiceMock + @Autowired + ValidatedExternalRankingService proxiedValidatedExternalRankingService + + ValidatedExternalRankingService validatedExternalRankingService + + void setup() { + /** + * the Validated proxied ValidatedExternalRankingService must be unwrapped to use the Mock + * + * see PersonControllerIntTest to see how to make this easier with @SpringSpy @UnwrapAopProxy + */ + validatedExternalRankingService = AopTestUtils.getUltimateTargetObject(proxiedValidatedExternalRankingService) + } + def "GetRank"() { when: 'Calling getRank for a known seed data entity' MvcResult mvcResult = mvc.perform(get("/persons/1/rank").contentType(APPLICATION_JSON)) @@ -51,11 +66,34 @@ class PersonControllerIntTest extends Specification { resultingJson == 'Capt James Kirk ~ Captain:Level 1' } + def "GetValidatedRank"() { + when: 'Calling getRank for a known seed data entity' + MvcResult mvcResult = mvc.perform(get("/persons/1/validatedRank").contentType(APPLICATION_JSON)) + .andExpect(status().is2xxSuccessful()).andReturn() + + then: 'we define the mock for the external service' + 1 * validatedExternalRankingService.getRank(_) >> { + new Rank(level: 1, classification: 'Captain') + } + noExceptionThrown() + + when: 'inspecting the contents' + def resultingJson = mvcResult.response.contentAsString + + then: 'the result contains a mix of mocked service data and actual wired component data' + resultingJson == 'Capt James Kirk ~ Captain:Level 1' + } + /* We could define our test configuration here, but if we have multiple integration tests and we want to mock the same things, then it's better to share the configuration for context caching, thus the import of IntegrationTestMockingConfig + + + If you are using Spock 1.2, see PersonControllerInSpock12Test for usage of @SpringBean annotation + + Otherwise use the below code for Spock <= 1.1 */ /* @@ -67,6 +105,11 @@ class PersonControllerIntTest extends Specification { ExternalRankingService externalRankingService() { factory.Mock(ExternalRankingService) } + + @Bean + ValidatedExternalRankingService validatedExternalRankingService() { + factory.Mock(ValidatedExternalRankingService) + } } */ } diff --git a/src/test/groovy/com/objectpartners/eskens/services/PersonServiceTest.groovy b/src/test/groovy/com/objectpartners/eskens/services/PersonServiceTest.groovy index c2645bb..34c3d41 100644 --- a/src/test/groovy/com/objectpartners/eskens/services/PersonServiceTest.groovy +++ b/src/test/groovy/com/objectpartners/eskens/services/PersonServiceTest.groovy @@ -3,6 +3,7 @@ package com.objectpartners.eskens.services import com.blogspot.toomuchcoding.spock.subjcollabs.Collaborator import com.blogspot.toomuchcoding.spock.subjcollabs.Subject import com.objectpartners.eskens.entities.Person +import com.objectpartners.eskens.model.Rank import com.objectpartners.eskens.repos.PersonRepo import spock.lang.Specification