Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable usage of Spring beans in @Shared fields, setupSpec() and cleanupSpec() #76

Closed
leifhanack opened this issue Apr 3, 2015 · 29 comments

Comments

@leifhanack
Copy link

It would be absolutely great, if Spring's TestContext framework can be extended, so that Spring beans can be injected in @shared fields and can be used in setupSpec() and cleanupSpec().

Thanks, Leif

@xdhmoore
Copy link

+1 We are doing this on a project, and as a workaround setting fields in the setup() method. However, some of those fields we want to use in 'where' blocks, so we have a dummy feature at the beginning to initialize the fields so that the next feature has them when the 'where' block is run.

@leifhanack
Copy link
Author

Could you give us a code example please?

@leifhanack
Copy link
Author

Here is what we currently do as workaround:

@ContextConfiguration(classes = MyConfig, initializers = ConfigFileApplicationContextInitializer)
class MyRepositoryTest extends Specification {

  @Autowired
  CouchDbInstance myCouchDbInstance

  @Autowired
  ObjectMapperFactory objectMapperFactory

  MyRepository repository

  def myTempDbName = "mydb-" + new Date().format("yyyy-MM-dd-HH-mm-ss") + (new Random().nextInt(100) + 100)

  @Shared
  int testCount

  def setupDb() {
    if (testCount == 0) {
      repository = new MyRepository(myTempDb())
    }
    testCount++
  }

  def cleanupDb() {
    testCount--
    if (testCount == 0) {
      myCouchDbInstance.deleteDatabase(myTempDbName)
    }
  }

  CouchDbConnector myTempDb() {
    def db = new StdCouchDbConnector(myTempDbName, myCouchDbInstance, objectMapperFactory)
    db.createDatabaseIfNotExists()
    return db
  }

  def "should find entries where .."() {
    setup:
    setupDb()

    when:
    repository.add([someProp: "someVal", ..])

    then:
    repository.getMyFoo("FOO12").size() == ..

    cleanup:
    cleanupDb()
  }

@xdhmoore
Copy link

The following is a simplified version of what we are doing. My comment above refers to our "Dummy" feature below. If that feature isn't present, the createResourceDTO, updateResourceDTO, and testUserId are null in the "Call #path feature". The lack of Spring support in these contexts I think makes it more awkward to use spring-initialized variables in where blocks, because where blocks are run before setup blocks, if my understanding is correct. Our workaround has been to use static variables and initialize them in a setup() method, but also add a dummy feature so that the setup() method gets called before any real where blocks are hit. Let me know if any further clarification would be helpful.

@ContextConfiguration(
initializers = MyCustomApplicationContextInitializer.class,
classes = [ MyCustomTestConfig.class ]
)
@Stepwise
class SPCalendarSpec extends Specification {

   @Shared
   def static ResourceDTO createResourceDTO

   @Shared
   def static ResourceDTO updateResourceDTO

   @Shared
   def static String testUserId

   @Autowired
   public Environment environment

   @Shared
   def static String baseUrl

   def setupSpec() {

      /* 
       * Note: We cannot access Spring from this method, per this quote 
       * from the docs at https://code.google.com/p/spock/wiki/SpringExtension:
       * 
       * "Note: Due to the way Spring's TestContext framework is designed, 
       * @Shared fields cannot currently be injected. This also means that 
       * setupSpec() and cleanupSpec() cannot get access to Spring beans."
       */
   }

   def setup() {

      // Note: according to http://stackoverflow.com/questions/21149157/spock-all-shared-variables-are-null
      // the 'where' block of each feature is run before this method, so any 'where' blocks 
      // using these variables get the values from the invokation of the previous feature.

      baseUrl = buildLocalhostRootBaseURL();

      testUserId = environment.getProperty("ob.user.testUserId")

      createResourceDTO = new ResourceDTO()
      createResourceDTO.setUserId(Integer.parseInt(testUserId))
      createResourceDTO.setResourceName("CalendarName")
      createResourceDTO.setSvcId(1)

      updateResourceDTO = new ResourceDTO()
      updateResourceDTO.setUserId(Integer.parseInt(testUserId))
      updateResourceDTO.setResourceName("CalendarName2")
      updateResourceDTO.setSvcId(1)



      client = new RESTClient(baseUrl)
   }

   def RESTClient client


   def String buildLocalhostRootBaseURL() {
      return "http://" + environment.getProperty("tests.api.localhost") + ":" + environment.getProperty("tests.api.port") + environment.getProperty("tests.api.path") + "/"
   }

   // See comment at top of setup() method
   def "Dummy test to call setup() and populate the variables from Spring"() { }


   @Unroll
   def "#method - Call #path using http should produce successful return code"() {
      def response
      when: "rest call"
      if (method == "get" || method == "delete") {
         response = client."${method}"(
               path : path,
               contentType : "application/json"
               )
      } else {
         response = client."${method}"(
               path : path,
               contentType : "application/json",
               body : body
               )
      }

      then: "response 200/201"
      response.status == successStatus

      where:
      method   | body              | path                                | successStatus
      "put"    | createResourceDTO | "resource"                          | 201
      "get"    | null              | "resource/"+testUserId              | 200
      "post"   | updateResourceDTO | "resource"                          | 200
      "delete" | null              | "resource/"+testUserId              | 200
   }

@chrylis
Copy link

chrylis commented May 4, 2015

+1 for this feature. I have some Selenium tests that I'd like to convert to MockMvc tests, but I'm using @Stepwise to break out long interaction scripts into discrete methods, and this won't work with the new Spring HtmlUnit support.

@mallim
Copy link

mallim commented Oct 23, 2015

+1 look forward for this feature

@leifhanack
Copy link
Author

According to @sbrannen (see sbrannen/spring-test-junit5#1) the Spring Testing framework should be flexible enough so that this feature could be implemented by the Spock framework.

@ahansen1
Copy link

Not sure if it's helpful, but thought I'd share in case it was. We are currently using a TestExecutionListener and a base class to allow for having spring beans available in the setupSpec. It has been a while since I looked at it, but if I remember correctly we needed to "tickle" the applicaiton context to make sure Spring loaded it. Not using it for the @shared, so I am not sure if the same applies. Below is an example of the listener and the base class.

Base Class

@ContextConfiguration(locations = "classpath:applicationContext-test.xml")
@TestExecutionListeners(listeners = [SpringContextInitializingTestExecutionListener])
abstract class BaseSpecification extends Specification {
}

Listener

/**
 * A TestExecutionListener that tickles the Spring application context to ensure that it is initialized in the
 * beforeTestClass.  This allows us to have the application context initialize before the setupSpec.
 */
class SpringContextInitializingTestExecutionListener extends AbstractTestExecutionListener {
    @Override
    void beforeTestClass(TestContext testContext) throws Exception {
        testContext.applicationContext
    }
}

@leonard84
Copy link
Member

There is a reason that it is explicitly forbidden to inject into @Shared fields, if you look at the org.springframework.test.context.support.DependencyInjectionTestExecutionListener which does the actual injection you can see that it only supports prepareTestInstance and beforeTestMethod which run in Spocks setup phase. Furthermore, if you are using org.springframework.test.annotation.DirtiesContext#AFTER_EACH_TEST_METHOD then the org.springframework.test.context.support.DirtiesContextTestExecutionListener will destroy the context after each test-iteration which could cause weird behaviour.
Another reason is that @Shared fields are - as the name suggests - shared between instances, this could also cause issues in multi-threaded executions. Another issue is that @Shared beans could be used in where blocks, however the injection happens to late for that.

One goal of spock is simplicity, but allowing @Shared injection is the opposite of that and for this reason it is disallowed.

@leifhanack
Copy link
Author

Thanks @leonard84, so this issue can be closed? Or what thoughts do you have?

@xdhmoore
Copy link

As my example above indicates, when we were having trouble with this our biggest desire was to use Spring beans in where blocks which run before the setup phase, I think. Right now the only way we found seemed kindof hacky--Is there another solution to the example I posted above?

@BorzdeG
Copy link

BorzdeG commented Dec 24, 2015

+1

@jserranoTWS
Copy link

jserranoTWS commented Aug 12, 2016

This workaround works for me:

@ContextConfiguration(classes = MyConfigClass.class)
class MySpec extends Specification {

    @Shared
    private InjectedObject obj;

    def cleanupSpec() {
        obj.deleteAllInBatch()
    }

    @Autowired
    void setInjectedObjectMethod(InjectedObject obj) {
        this.obj = obj
    }

}

@robbertvanwaveren
Copy link

robbertvanwaveren commented Aug 23, 2016

Actually the example above just calls a static method which does not require autowiring nor functions for setupSpec use-cases for non-static methods as autowiring happens after setupSpec.

This is the pattern that I use myself:

    @Shared
    boolean initialized

    @Autowired
    void poorMansSetupSpec(SomeBean someBean) {
        if (!initialized) {
            someBean.doSomething()
            initialized = true
        }
    }

@sskjames
Copy link

Thanks Robbert1 for the simple workaround.
Any workaround for cleanupSpec?

@jserranoTWS
Copy link

@sskjames read my previous comment

@sskjames
Copy link

Thanks @jserranoTWS but is it possible to call an instance method from your version of cleanupSpec?:
obj.someMethod()

@jserranoTWS
Copy link

@sskjames, yes, in my example deleteAllInBatch is an instance method.

@AlexCzar
Copy link

AlexCzar commented Jun 8, 2017

Are there any plans for improving this or is official suggestion to use the "poorMansSetupSpec"?

@mhuelfen
Copy link

Is this feature coming any time soon?

@OsaSoft
Copy link

OsaSoft commented Jan 12, 2018

Just thought Id add my workaround to this problem, using Groovy's dynamic goodness:

	@Autowired
	IDeviceRepository deviceRepository
	@Autowired
	IGroupRepository groupRepository
	@Autowired
	IPlatformRepository platformRepository
	@Autowired
	IUserRepository userRepository

	@Unroll
	def "#repoName Repository is properly injected by Spring container"() {
		given:
			def repo = this."${repoName}Repository"

		when:
			repo.save(instance)
		then:
			repo.findOne(instance.id)

		where:
			repoName   | instance
			"device"   | new Device(token: "token123")
			"group"    | new Group(name: "testGroup")
			"platform" | new Platform(name: "testPlatform")
			"user"     | new User(name: "testUser")
	}

EDIT: made a Gist: https://gist.github.com/OsaSoft/a2e1ea62f5fc58aec3988e2b8af8bda9

@cooniur
Copy link

cooniur commented Jun 26, 2018

@OsaSoft Nice workaround!

@brampat
Copy link

brampat commented Oct 15, 2018

I'm struggling to get an Unroll'd test with Spock using properly injected JpaRepository extended interfaces running.

Either nothing is wired when I use @DataJpaTest resulting in the obvious NPE's or
If I use @RunWith(SpringRunner.class) in my Spec, I will get:
java.lang.Exception: Method $spock_feature_0_0 should have no parameters

Anyone have an idea how this should be set up?

@Chr3is
Copy link

Chr3is commented Dec 12, 2018

This worked for me:

@LocalManagementPort 
int managementPort   
                     
@LocalServerPort     
int serverPort

@Unroll                                                                                                                       
def 'GET on #url should return status=#status'(String url, Closure port, HttpStatus status) { 
    when:                                                                                                                     
    ResponseEntity<String> result = executeRequest(url, port)                                                                 
                                                                                                                              
    then:                                                                                                                     
    result.statusCode == status                                                                                               
                                                                                                                              
    where:                                                                                                                    
    url          | port                        | status                                                
    '/url1'      | { delegate.serverPort }     | HttpStatus.FOUND                                      
    '/url2'      | { delegate.managementPort } | HttpStatus.OK                                                                               
}                                                                                                                             
                                                                                                                              
ResponseEntity<String> executeRequest(String url, Closure port) {                                                             
    port.delegate = this                                                                                                      
    return template.exchange("https://localhost:${port()}$url", HttpMethod.GET, null, String.class)                           
}   `

@StasKolodyuk
Copy link

This is the approach I use:

class AbstractSpringSpec extends Specification {

    @Shared
    Closure cleanupClosure
    @Shared boolean setupSpecDone = false

    def setup() {
        if (!setupSpecDone) {
            setupSpecWithSpring()
            setupSpecDone = true
        }

        cleanupClosure = this.&cleanupSpecWithSpring
    }

    def cleanupSpec() {
        cleanupClosure?.run()
    }

    def setupSpecWithSpring() {
        // override this if Spring Beans are needed in setupSpec
    }

    def cleanupSpecWithSpring() {
        // override this if Spring Beans are needed in cleanupSpec
    }
}

To use in spec:

@SpringBootTest(classes = SampleSpecConfig, webEnvironment = SpringBootTest.WebEnvironment.NONE)
class SampleSpec extends AbstractSpringSpec {
    
    @Autowired
    SampleDao sampleDao
    
    @Shared
    long id

    @Override
    def setupSpecWithSpring() {
        id = sampleDao.insert(new Sample())
    }

    @Override
    def cleanupSpecWithSpring() {
        sampleDao.delete(id)
    }
    
}

@pcimcioch
Copy link

I'm using something similar to @Chr3is workaround:

@LocalManagementPort 
int managementPort   
                     
@LocalServerPort     
int serverPort

@Unroll                                                                                                                       
def 'GET on #url should return status=#status'() { 
    when:                                                                                                                     
    ResponseEntity<String> result = executeRequest(url, port(this))                                                                 
                                                                                                                              
    then:                                                                                                                     
    result.statusCode == status                                                                                               
                                                                                                                              
    where:                                                                                                                    
    url          | port                  | status                                                
    '/url1'      | { it.serverPort }     | HttpStatus.FOUND                                      
    '/url2'      | { it.managementPort } | HttpStatus.OK                                                                               
}                                                                                                                             
                                                                                                                              
ResponseEntity<String> executeRequest(String url, int port) {                                                                                                                                                                  
    return template.exchange("https://localhost:${port}$url", HttpMethod.GET, null, String.class)                           
}

@vfelberg
Copy link

Following works for me:

@LocalServerPort
int localServerPort

@Unroll
def "accessing #path is unauthorized"(){
    when:
    def response = executeRequest("http://localhost:$localServerPort$path")

    then:
    response.statusCode == HttpStatus.UNAUTHORIZED

    where:
    path << ["/path1", "/path2"]
}

No need for closure.

@erdi
Copy link
Member

erdi commented May 3, 2020

For a very long time, I thought that allowing injection into @Shared fields was technically impossible due to some limitations in spring-test's SpringTestContextManager but it turned out not to be the case. After talking this over with @leonard84, it turns out injection into @Shared fields is disabled by design because it's known that it's impossible to support it together with certain spring test features (like org.springframework.test.annotation.DirtiesContext used on feature methods or on spec class with DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD mode or org.springframework.transaction.annotation.Transactional used on specs/features, although to be honest I don't fully grok what the problems is with the latter).

My suggestion would be to, instead of having a blanket ban on injection into @Shared and throwing an exception when that is attempted, to either only throw when features incompatible with injection into shared fields like the ones listed in the previous paragraph are used or to introduce an annotation or configuration option to allow users to specify that they know about the limitations and that they want to use the feature at their own risk.

Why do I personally want to be able to inject into @Shared fields? In my particular case, the configuration classes that I'm using do not specify beans for any production classes, they only specify beans for various fixture classes which allow me to bootstrap data and/or application state needed for my tests. Some of that setup might take a significant amount of time and doing it before every feature method would be wasteful and would unnecessary increase the time needed for my tests to run. Therefore I would like to be able to inject my fixtures into @Shared fields, setup the data/state in setupSpec() and get it restored to the original state at the end of the spec either in cleanupSpec() or by utilising @AutoCleanup annotation on the injected fixture. What's more I'm using neither @DirtiesContext nor @Transactional in my specs so the known limitations do not apply to my use case.

To confirm that injecting into @Shared fields can totally be achieved I will share my current workaround that I put in my project a month ago and which has so far served us well and without any issues. Note that this is a workaround and not a clean, production grade solution - it could be much cleaner if I were able to modify the code of org.spockframework.spring.SpringExtension and org.spockframework.spring.SpringInterceptor. Also, note that because using @Shared and @Inject results in an exception, I had to introduce a custom @InjectShared annotation to mark @Shared fields I'd like to be injected.

class SharedFieldInjectionExtension extends AbstractGlobalExtension {

    @Override
    void visitSpec(SpecInfo spec) {
        spec.addSetupSpecInterceptor(new SharedFieldInjectionInterceptor())
    }
}

class SharedFieldInjectionInterceptor implements IMethodInterceptor {

    @Override
    void intercept(IMethodInvocation invocation) throws Throwable {
        def spec = invocation.spec

        if (isSpringSpec(spec)) {
            def testContextManager = new TestContextManager(spec.reflection)
            def listener = new SharedFieldInjectionTestExecutionListener(spec, invocation.sharedInstance)
            testContextManager.registerTestExecutionListeners(listener)
            testContextManager.beforeTestClass()
        }

        invocation.proceed()
    }

    private boolean isSpringSpec(SpecInfo spec) {
        spec.setupInterceptors.any { it instanceof SpringInterceptor }
    }
}

class SharedFieldInjectionTestExecutionListener extends AbstractTestExecutionListener {

    private final SpecInfo spec
    private final Object sharedSpecInstance

    SharedFieldInjectionTestExecutionListener(SpecInfo spec, Object sharedSpecInstance) {
        this.spec = spec
        this.sharedSpecInstance = sharedSpecInstance
    }

    @Override
    void beforeTestClass(TestContext testContext) throws Exception {
        def fieldsToInject = spec.allFields.findAll(this.&shouldInject)

        fieldsToInject.each { field ->
            def bean = testContext.applicationContext.getBean(field.type as Class<Object>)
            field.writeValue(sharedSpecInstance, bean)
        }
    }

    private boolean shouldInject(FieldInfo field) {
        field.shared && field.isAnnotationPresent(InjectShared)
    }
}

I'd be more than happy to work on a PR which would enable injection into @Shared fields if it's something that's desired and if we can come to an agreement about which strategy with regards to dealing with known limitations we would like to go with.

@leonard84
Copy link
Member

@erdi if you want to create a PR. I was thinking about something like a @EnabledSharedInjection annotation that does the afore mentioned validation and if the SpringExtension sees this annotation it allows @Shared injection.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests