问题
There's a discusson here about testing and singletons... but that is about Java patterns.
My question is specifically about the Groovy @Singleton
(annotation) way of implementing this pattern.
This seems like another bit of Groovy Goodness. But I have a bit of a problem when testing (with Spock) using a class which has this annotation.
If any of the state of this instance changes during a test (from the pristine, just-constructed state), as far as my experiments indicate this will then carry through to the next test... I tested MySingletonClass.instance
's hashCode()
with several tests and they all came back the same. Perhaps this isn't surprising.
But ... wouldn't it be better if Spock could (using the kind of uber-Groovy magic I can only speculate on) somehow reset the class between tests? I.e. by creating a new instance?
There is an obvious workaround: to incorporate a reset
method into each @Singleton
class where its state might change during a test. And then call that reset
method in setup()
... in fact I use a common Specification
subclass, CommonProjectSpec
, from which all my real Specification
s subclass... so that would be simple enough to implement.
But it seems a bit inelegant. Is there any other option? Should I maybe submit this as a Spock suggested enhancement?
PS it also turns out you can't then make a Spy
of this class (or a GroovySpy
). But you can make a Mock
of it:
ConsoleHandler mockCH = Mock( ConsoleHandler ){
getDriver() >> ltfm
}
GroovyMock( ConsoleHandler, global: true )
ConsoleHandler.instance = mockCH
... yes, the "global" GroovyMock
here actually has the ability to "tame" the static instance
field so that it meekly accepts a Mock
cuckoo in the nest.
回答1:
So basically you want to test that a singleton is not a singleton. This strikes me as rather odd. But anyway, I am regarding this question rather as a puzzle which I am going to solve for its own sake because it is a nice challenge. (Don't do this at home, kids!)
Groovy singleton:
package de.scrum_master.stackoverflow
@Singleton
class Highlander {
def count = 0
def fight() {
println "There can be only one!"
count++
doSomething()
}
def doSomething() {
println "Doing something"
}
}
Singleton helper class:
package de.scrum_master.stackoverflow
import java.lang.reflect.Field
import java.lang.reflect.Modifier
class GroovySingletonTool<T> {
private Class<T> clazz
GroovySingletonTool(Class<T> clazz) {
this.clazz = clazz
}
void setSingleton(T instance) {
// Make 'instance' field non-final
Field field = clazz.getDeclaredField("instance")
field.modifiers &= ~Modifier.FINAL
// Only works if singleton instance was unset before
field.set(clazz.instance, instance)
}
void unsetSingleton() {
setSingleton(null)
}
void reinitialiseSingleton() {
// Unset singleton instance, otherwise subsequent constructor call will fail
unsetSingleton()
setSingleton(clazz.newInstance())
}
}
Spock test:
This test shows how to
- re-instantiate a Groovy singleton before feature method execution
- use a
Stub()
for a Groovy singleton - use a
Mock()
for a Groovy singleton - use a
Spy()
for a Groovy singleton (needs Objenesis)
package de.scrum_master.stackoverflow
import org.junit.Rule
import org.junit.rules.TestName
import spock.lang.Specification
import spock.lang.Unroll
class HighlanderTest extends Specification {
def singletonTool = new GroovySingletonTool<Highlander>(Highlander)
@Rule
TestName gebReportingSpecTestName
def setup() {
println "\n--- $gebReportingSpecTestName.methodName ---"
}
@Unroll
def "Highlander fight no. #fightNo"() {
given:
singletonTool.reinitialiseSingleton()
def highlander = Highlander.instance
when:
highlander.fight()
then:
highlander.count == 1
where:
fightNo << [1, 2, 3]
}
@Unroll
def "Highlander stub fight no. #fightNo"() {
given:
Highlander highlanderStub = Stub() {
fight() >> { println "I am a stub" }
}
singletonTool.setSingleton(highlanderStub)
def highlander = Highlander.instance
when:
highlander.fight()
then:
highlander == highlanderStub
where:
fightNo << [1, 2, 3]
}
@Unroll
def "Highlander mock fight no. #fightNo"() {
given:
Highlander highlanderMock = Mock() {
fight() >> { println "I am just mocking you" }
}
singletonTool.setSingleton(highlanderMock)
def highlander = Highlander.instance
when:
highlander.fight()
then:
highlander == highlanderMock
0 * highlander.doSomething()
where:
fightNo << [1, 2, 3]
}
@Unroll
def "Highlander spy fight no. #fightNo"() {
given:
// Unset not necessary because Objenesis creates object without constructor call
// singletonTool.unsetSingleton()
Highlander highlanderSpy = Spy(useObjenesis: true)
// Spy's member is not initialised by Objenesis
highlanderSpy.count = 0
singletonTool.setSingleton(highlanderSpy)
def highlander = Highlander.instance
when:
highlander.fight()
then:
highlander == highlanderSpy
highlander.count == 1
1 * highlander.doSomething() >> { println "I spy" }
where:
fightNo << [1, 2, 3]
}
}
Console log:
--- Highlander fight no. 1 ---
There can be only one!
Doing something
--- Highlander fight no. 2 ---
There can be only one!
Doing something
--- Highlander fight no. 3 ---
There can be only one!
Doing something
--- Highlander stub fight no. 1 ---
I am a stub
--- Highlander stub fight no. 2 ---
I am a stub
--- Highlander stub fight no. 3 ---
I am a stub
--- Highlander mock fight no. 1 ---
I am just mocking you
--- Highlander mock fight no. 2 ---
I am just mocking you
--- Highlander mock fight no. 3 ---
I am just mocking you
--- Highlander spy fight no. 1 ---
There can be only one!
I spy
--- Highlander spy fight no. 2 ---
There can be only one!
I spy
--- Highlander spy fight no. 3 ---
There can be only one!
I spy
回答2:
Unfortunately I ran into big problems with Kriegax's otherwise helpful solution.
I've done quite a bit of experimentation and have been unable to explain where the problem comes from. Although there is a possible clue here. (Incidentally I did try this idea of applying the change of modifier immediately after setting the new instance to be the singleton instance... it did not solve the problem).
In a typical situation I find I may have a Specification
with maybe 15 features (tests). Running these on their own works fine: the MySingleton.instance
field is set first to null
and then to a new instance of MySingleton
.
But then when I try to run this with another xxx.groovy file with another Specification
, it will work OK for about 8 features ... but then I add a new feature (i.e. I'm basically uncommenting existing features as I go) suddenly the problem crops up: MySingleton.instance
can be set to null
... but refuses point blank to be set to a new instance. I even tried a for
loop with a Thread.sleep()
to see if trying multiple times might solve it.
Naturally I then had a look at the offending feature which had just been added: but there was nothing there that I hadn't done in other features. Worse, far worse, follows: I then find that these results are not consistent: sometimes the "offending" new feature, once uncommented, does NOT then trigger the failure of Field.set( ... )
in the other .groovy file. By the way, no Exception
is thrown by Field.set( ... )
during any of this.
It should be noted, en passant, that field.modifiers &= ~Modifier.FINAL
is said to be "a hack", as described here, for example, with many caveats about its use.
I've therefore reluctantly come to the conclusion that if you want to have one or more singleton classes with Groovy you either have to have a reset
method, which can be guaranteed to return the instance to a pristine (newly constructed) state, or you have to abandon use of the @Singleton
annotation (i.e. if you are keen to construct a new instance with each feature).
来源:https://stackoverflow.com/questions/49602657/groovy-singleton-and-testing-issue-with-spock