A Java developer's guide to Spock
Derek Eskens
@snekse
Dev 15+ years, OPI 3+ years
Java, JS, Groovy
Star Wars > Star Trek
'Foodie for Life' is tattooed across my tummy
We now have stakeholders asking for testing metrics
and faster deployment of features
Headline
Learn how ___ deploys to production ___ times a day
Testing now takes less code and less time.
class CrewRecruiterServiceSpockMockitoTest
extends Specification implements CommonCrew {
//Inject Mockito mocks into your Spock tests 😱
@InjectMocks CrewRecruiterService crewRecruiterService
@Mock PayrollService payrollService
def setup() {
MockitoAnnotations.initMocks(this);
}
void "SelectForBudget can be tested with Mockito mocks"() {
Mockito.when(payrollService.getSalary(any(CrewMember)))
.thenReturn(100000L)
when:
List crew = crewRecruiterService.selectForBudget(200000L, POOL)
then: "Use 3rd party fluent assertion library"
// AKA: crew == [KIRK, SPOCK]
assertThat(crew).containsExactly(KIRK, SPOCK).inOrder();
}
}
@Test
public void selectForBudget() throws Exception {
// Mock $100k salary for Kirk & Spock
when(payrollService.getSalary(KIRK)).thenReturn(100000L);
when(payrollService.getSalary(SPOCK)).thenReturn(100000L);
// When selecting crew on a $200k budget
List<CrewMember> crew = crewRecruiterService.selectForBudget(200000L, POOL);
// Then Kirk and Spock are the only 2 members selected, and returned in that order.
Assert.assertEquals(new ArrayList<>(asList(KIRK, SPOCK)), crew);
//create inOrder object passing any mocks that need to be verified in order
InOrder inOrder = inOrder(payrollService);
inOrder.verify(payrollService).getSalary(argThat(arg -> arg == CommonCrew.KIRK));
inOrder.verify(payrollService).getSalary(argThat(arg -> arg == CommonCrew.SPOCK));
inOrder.verifyNoMoreInteractions();
}
void "SelectForBudget spends budget in crew priority order /
and avoids unnecessary calls to payroll service"() {
when:
def crew = crewRecruiterService.selectForBudget(200000L, POOL)
then:
1 * payrollService.getSalary(KIRK) >> 100000 // first add Kirk
then:
1 * payrollService.getSalary(SPOCK) >> 100000 // add Spock after Kirk
then:
0 * payrollService.getSalary(_)
and:
crew == [KIRK, SPOCK]
}
void "SelectForBudget spends budget in crew priority order /
and avoids unnecessary calls to payroll service"() {
when: 'We have enough to pay for Kirk and Spock'
def crew = crewRecruiterService.selectForBudget(200000L, POOL)
then: 'We only make our expensive external system salary calls in the order we expect'
1 * payrollService.getSalary(KIRK) >> 100000 // first add Kirk
then: 'add Spock'
1 * payrollService.getSalary(SPOCK) >> 100000 // // add Spock after Kirk
then: 'no more calls to that service since we have depleted our budget'
0 * payrollService.getSalary(_)
and: 'our crew consists of just Kirk and Spock'
crew == [KIRK, SPOCK]
}
def "set course test"() {
Route routeFromEarthToAndoria = new Route([
"Hang a left at Mars",
"Turn right at Alpha Centauri A",
"Gravity assist from Midos V"])
when:
Route results = navigationSystem.setCourse("Andoria")
then:
// ↓ verify this mock called exactly 1 time w/ these params
1 * mockCAS.findRoute("Earth", "Andoria") >> routeFromEarthToAndoria
// ↑ (then return) pojo route
results.directions.first() == "Hang a left at Mars"
}
@Unroll("A budget of #salaryCap pays for #expectedCrew")
void "SelectForBudget adds crew in priority order until budget depleted"() {
when: 'selecting crew at various salary cap levels'
def crew = crewRecruiterService.selectForBudget(salaryCap, POOL)
then: 'we get back crew we can afford in priority order'
crew == expectedCrew
where:
salaryCap || expectedCrew
0 || []
100000 || [KIRK]
299999 || [KIRK, SPOCK]
MAX_VALUE || POOL
}
Closure filterCrewByLevel = { List crew, int rank ->
crew.findResults { it.rank.commandLevel < rank }
}
// ...
1 * mockService.findOfficers(crewMembers) >> {
filterCrewByLevel(it, 5)
}
def KIRK = new CrewMember(firstName: "James", lastName: "Kirk")
Range oneToTen = (1..10) // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
def crewNames = ["Kirk", "Spock", "Ensign Redshirt"] // ArrayList
// Transform a collection
List crew = crewNames.collect { String lastName ->
long yos = getRandomFromRange(oneToTen) //calling outer scope refs
new CrewMember(lastName: lastName, yearsOfService: yos)
}
// IT, " vs ', interpolation of strings, elvis, null-safe
crew.each { println "${it.firstName?:''} $it.lastName - $it.rank?.salary" }
assert KIRK != SPOCK // == is .equals, === for references
http://groovy-lang.org/differences.html
http://groovy-lang.org/syntax.html
@Subject Starship starship = new Starship()
@Collaborator PayrollService payrollService = Mock()
@Shared def POOL = ["Kirk", "Spock", "Ensign Redshirt"].asImmutable()
@Collaborator def crewManager = Spy(CrewManager, constructorArgs: [POOL])
def "A very descriptive name for our test"() {
def currentCrew = ['RebelSpy'] + POOL /* given: */
expect:
currentCrew.size() == 4
when:
def result = starship.findIntruders()
then:
(1..4) * payrollService.getSalary(_) //default returns falsey
1 * crewManager.getExpectedCrew(starship) //concrete call
(1.._) * crewManager.isSpy(starship, _ as CrewMember) >> { ship, cm ->
return ship.name == 'Enterprise' && cm.lastName.contains('Spy')
}
result.lastName == 'RebelSpy'
}
class MyFirstSpockSpec extends Specification {
StringUtil stringUtil = new StringUtil()
// optional setup / teardown methods
def setup() {} // run before every feature method
def cleanup() {} // run after every feature method
def setupSpec() {} // run before the first feature method
def cleanupSpec() {} // run after the last feature method
void testContains() {
expect:
stringUtil.contains("Spock", "S")
}
}
class SetupMethodsAreNotNeededSpec extends MyBaseSpecification {
// Setup methods aren't really needed
StringUtil stringUtilOne = new StringUtil()
@Shared NumberUtil numberUtilOne = new NumberUtil()
/* ---------------- But if you really want them... ----------------*/
StringUtil stringUtilTwo
@Shared NumberUtil numberUtilTwo
def setup() {
//super.setup() //calling super for Fixture methods isn't needed
stringUtilTwo = new StringUtil()
}
def setupSpec() {
numberUtilTwo = new NumberUtil() //instance fields must be @Shared
}
}
void testSpockIsCool() {
// The setup: or given: labels are optional
def crew = [KIRK, SPOCK, SCOTTY]
expect: "the obvious answer from a fanboy"
SPOCK == fanboy.whoIsTheCoolest(crew)
when: "Spock isn't an option"
fanFav = fanboy.whoIsTheCoolest([KIRK, SCOTTY])
then: "Kung Fu is cool"
fanFav == KIRK
and: "for visual separation"
fanFav.name.contains('James')
cleanup: "optional clean up"
SPOCK.mindMeld(fanboy)
}
void "expect assertions to be truthy"() {
expect:
SPOCK.fullName == "S'chn T'gai Spock"
SPOCK.isCool() && ! SPOCK.isFunny()
SPOCK.rank > 0
SPOCK.salary // Truthy
when:
SPOCK.tellJoke()
then:
thrown(IllogicalRequestException)
when:
SPOCK.writeTests()
then:
notThrown(IllogicalRequestException)
noExceptionThrown() // Rarely used
}
void "Registering a crew member is done completely"() {
when:
def crewMember = ship.getCrewMemberById(1)
then:
with(crewMember) {
firstName == 'James'
lastName == 'Kirk'
rank.title == 'Captain'
}
}
void "getCrew returns the expected size of valid crew members"() {
when:
def crew = ship.getCrew()
then:
crew.size() == 12
// This would not tell you _which_ crewMember failed the assertion
crew.every { crewMember.fullName?:'' != '' }
crew.each { assertIsValidCrewMember(it) }
}
void assertIsValidCrewMember(crewMember) {
assert crewMember.fullName?:'' != ''
}
Condition not satisfied:
crew.collect { it.fullName }.join(';') == [KIRK, RED_SHIRT].collect { it.fullName }.join(';')
| | | | | | | |
| [James Kirk] James Kirk| Kirk Doe [James Kirk, John Doe] James Kirk;John Doe
[Kirk] false
9 differences (52% similarity)
James Kirk(---------)
James Kirk(;John Doe)
at com.objectpartners.eskens.spock.services.CrewRecruiterServiceSpockTest.
Crew has a red shirt(CrewRecruiterServiceSpockTest.groovy:76)
com.objectpartners.eskens.spock.services.CrewRecruiterServiceSpockTest > Crew has a red shirt FAILED
org.spockframework.runtime.SpockComparisonFailure at CrewRecruiterServiceSpockTest.groovy:76
1 test completed, 1 failed
:test FAILED
@Unroll("A budget of #salaryCap pays for #expectedCrew")
void "SelectForBudget adds crew in priority order until budget depleted"() {
given: 'a flat rate of 100,000 per member'
payrollService.getSalary(_) >> 100000
when: 'selecting crew at various salary cap levels'
def crew = crewRecruiterService.selectForBudget(salaryCap, POOL)
then: 'we get back crew we can afford in priority order'
crew == expectedCrew
where: 'input of salaryCap should produce output of expectedCrew'
salaryCap || expectedCrew
0 || []
100000 || [KIRK]
299999 || [KIRK, SPOCK]
MAX_VALUE || POOL
}
@Unroll("Scotty, warp #warpSpeed")
void "Engines don't blow up if we give it a bad command"() {
when: 'we tell the ship to warp at various level'
ship.controls.command('warp', warpSpeed)
then: 'It never explodes'
noExceptionThrown()
where:
warpSpeed || _
null || _
0 || _
1 || _
MAX_VALUE || _
'Bajillion' || _
}
@Shared CrewManager crewMgr = new CrewManagerTestingUtil()
static final ENTERPRISE = "USS Enterprise - NCC-1701"
@Unroll("#crewMember should be assigned to the #expectedShip")
void "Using Shared and statics"() {
expect:
// ...
where: "props defined outside test must be static"
crewMember || expectedShip
crewMgr.get('Spock') || ENTERPRISE
crewMgr.get('Kirk') || ENTERPRISE
crewMgr.get('Data') || 'USS Enterprise - NCC-1701-C'
}
given: 'an initial speed'
ship.controls.command('warp', start)
assert getCurrentWarpSpeed() == start
when:
ship.controls.command("${multiplier} our velocity")
then:
getCurrentWarpSpeed() == finalSpeed
where: 'we reference our start value to calculate the final value'
start | multiplier || finalSpeed
1 | 'Double' || start * 2
4 | 'Triple' || start * 3
7 | 'Quadruple' || 12 //Max warp factor is 12
expect: 'an initial speed'
sendCommand('warp', start)
expect:
sendCommand("${command} our velocity").getCurrentWarpSpeed() == finalSpeed
where: 'we reference our start value to calculate the final value'
start | command | multiplier
1 | 'Double' | 2
7 | 'Quadruple' | 4
finalSpeed = [(start * multiplier), 12].max() //Max warp factor is 12
def "spock can maths"() {
expect:
[a,b].sum(0) == c
where:
a << [2, 3, 4]
b << (2..6).step(2)
c << "4,7,10".split(',')
}
where:
[lastName, rank, yos] << excel.get(1, ['B','C','J'])
when:
command.call(start)
then:
getCurrentWarpSpeed() == finalSpeed
where: 'fun with closures'
maxAwareCaptain = { speed ->
KIRK.say( speed > 12 ? 'Sulu, WHAT ARE YOU DOING! Slow Down!'
: 'Steady as she goes, Mr Sulu')
}
start | command || finalSpeed
1 | { ship.command('warp 2') } || 2
6 | { KIRK.say('Warp factor one, Mr. Sulu.') } || 1
12 | maxAwareCaptain || 12
13 | maxAwareCaptain || 12
BridgeInterface bridgeInterface = Mock()
def foodReplicator = Mock(FoodReplicator)
Beamer beamer = Mock(Beamer)
PayrollService payrollService = Spy()
def engineControl = Spy(EngineControl)
def crewManager = Spy(CrewManager, constructorArgs: [POOL])
CrewMember SPOCK = h2.getCrewMember( [ lastName: 'Spock' ])
def spockSpy = Spy(SPOCK) //New feature!
Ship ship = new UssEnterprise()
CrewManager crewManager = Mock()
def setup() {
ship.crewManager = crewManager
}
CrewManager crewManager = Mock()
Ship ship = new UssEnterprise(crewManager: crewManager)
@Subject Ship ship = new UssEnterprise()
@Collaborator CrewManager crewManager = Mock()
// http://bit.ly/spock-subjects-collaborators-extension
when: 'a crew member asks for cereal'
computer.command('I want some cereal')
then: """
expect FoodReplicator called once
with a quantity param of 1
and cereal type of 'Sugar Smacks'
"""
1 * foodReplicator.make(1, 'Sugar Smacks') /*
| | | |
| | | |
| | | argument constraints
| | method constraint
| target constraint
cardinality
*/
/** Taken (almost) directly from the docs **/
1 * computer.command("Lights") // exactly one call
0 * computer.command("Lights") // zero calls
(1..3) * computer.command("Lights") // between one and three calls (inclusive)
(1.._) * computer.command("Lights") // at least one call
(_..3) * computer.command("Lights") // at most three calls
/*
^^ Groovy Ranges at work
*/
_ * computer.command("Lights") // any number of calls, including zero
// (rarely needed; see 'Strict Mocking')
computer.command("Lights") // cardinality is optional, same as: _ *
_ * _.getStatus() // Match any getStatus() call for all mocks/spies
_ * tribble._ // Match any method call on a tribble
_ * targetingSystem./Fire.*/ // Match all firing requests via regex
_ * _._ // Match any method call on any mock/spy
(..) * .(*_) // Highly illogical
/** Taken (almost) directly from the docs **/
1 * computer.command("lights") // an argument that is equal to the String "lights"
1 * computer.command(!"lights") // an argument that is unequal to the String "lights"
1 * computer.command() // the empty argument list (would never match in our example)
1 * computer.command(_) // any single argument (including null)
1 * computer.command(*_) // any argument list (including the empty argument list)
1 * computer.command(!null) // any non-null argument
1 * computer.command(_ as String) // any non-null argument that is-a String
1 * computer.command({ it.length() > 3 }) // an argument that satisfies the given predicate
// (here: message length is greater than 3)
1 * foodReplicator.make( {it > 0}, !null, _ ) // Multiple arguments
Ensure only expected calls are executed
then: 'expect to make a banana and fail if anything else is made'
1 * foodReplicator.make(1, 'Banana')
0 * foodReplicator.make(*_)
when: 'Kirk pushes the big red button'
def response = bigRedButton.push()
then: 'Klingons have fired on use and disabled out weapons'
targetingSystem.fireAllTheWeapons() >> { throw new WeaponsMalfunction('') }
then: 'the response confirms the targeting failed'
response == "I'm sorry, Captain. I'm afraid I can't do that."
when: 'Kirk pushes the big red button'
response = bigRedButton.push()
then: 'but since he reprogrammed the system'
targetingSystem.fireAllTheWeapons() >> new TargetingResponse()
then: 'the response indicates targeting worked'
response == "Targeting confirmed."
computer.request('What is 1 + 1') >> "2"
computer.request('What is 1 + 2') >> "3"
computer.request("Do the thing") >> "Okay" >> "No"
computer.request("Tribble count") >>> ["2", "3", "4", "Many"]
foodReplicator.make(*_) >> {args -> "Food: $args[1]; Qty: $args[0]" }
foodReplicator.make(*_) >> {qty, food -> "Making $qty "+ pluralize(qty, food)}
// Complex mocking
foodReplicator.make(*_) >> {int qty, String food ->
if (food == 'Cereal') {
return 'Sorry, we are out of sugar smacks'
}
return "Making $qty "+ pluralize(qty, food)
}
// Forcing exceptions
foodReplicator.make( {it <= 0} , _ ) >> { throw new InvalidQuantity(it[0]) }
def payrollService = Spy(PayrollService) {
// Mock the pay for every crew member the same amount
_ * getSalaryFor(_ as CrewMember) >> 50_000
}
when:
def amountPaid = accountingService.payCrew()
then: """
We confirm we ask for getTotalCrewPayroll for our crew,
but let the impl do it's thing. When the impl calls
getSalaryFor, that's when our stubbing comes in
"""
1 * payrollService.getTotalCrewPayroll(CREW)
amountPaid == 600_000 // assuming crew of 12
def alienLibrary = Spy(AlienLibrary)
when:
def result = comms.sendBeacon('Hailing Borg Ship')
then: "We call the actual implementation _after_ we mess with it"
alienLibrary.receive(_) >> { msg ->
def alteredOutgoingMsg = filterNSFW(msg)
def alienResp = callRealMethodWithArgs(alteredOutgoingMsg)
return translateResponse(alienResp)
}
result == "Test Often and Prosper"
Provides Spring integration so beans can be injected into integration specs
Or borrow ones like the great subject/collaborator extension
@SpringBootTest
class CrewMemberIntegrationTest extends Specification {
@Autowire MockMvc mvc
@Autowire CrewRepo crewRepo
def jsonSlurper = new JsonSlurper()
void 'can fetch crew member by ID'() {
def rank = new Rank(level: 5, title: 'Grand Admiral')
def khan = new CrewMember(lastName:'Khan', rank: rank )
CrewMember khan = crewRepo.saveAndFlush(khan)
when:
def data = jsonSlurper.parseText(getJson(mvc, "/crew/${khan.id}"))
then:
data.id != null
data.lastName == 'Khan'
data.rank.title == 'Grand Admiral'
}
}
@SpringBootTest
class CrewServiceIntegrationTest extends Specification {
@Autowired CrewService crewService
@Autowired ExternalSalaryService mockSalaryService
// test, tests, tests
/** Override Spring's beans with Spock mocks **/
@TestConfiguration
static class Config {
private DetachedMockFactory factory = new DetachedMockFactory()
@Bean
ExternalSalaryService externalSalaryService() {
factory.Mock(ExternalSalaryService)
}
}
}
@Autowired CrewService crewService // class under test
@Autowired ExternalSalaryService mockSalaryService // Mocked Bean
void "crew service delegates to ExternalSalaryService"() {
when: 'we have 5 crew members in H2'
def amt = crewService.getTotalCrewCost()
then: 'use the mocked spring bean in an integration test 😃'
mockSalaryService.getSalaryFor(_) >> 100000
amt == 500000
}
Blog post with more information
Coming soon...
@SpringBean & @SpringSpy
@AutoCleanup def fio = new FileIOService()
@Timeout(5)
@Use(DiscoMixin)
@Issue('BUG-1701')
void "is Spock cool"() {
expect: SPOCK.dance() == "Doin' the Hustle"
}
@Ignore("Optional description")
@IgnoreRest //Only run tests annotated w/ this
@IgnoreIf({ System.getProperty("os.name").contains("borg") })
@Requires({ os.holodeck })
@PendingFeature
def "Failing tests marked as skipped"() { expect: false }
@PendingFeature // Failing Test! No longer pending :-)
def "marks data driven feature where all iterations pass as _failed!_"() {
expect: test
where: test << [true, true, true]
}
@Rule
public Retry retry = new Retry(3);
void "taking kobayashi maru test"() {
expect:
kobayashiMaru.takeTest().results == 'passed'
}
void "access network filesystem"() {
when:
federationCommand.requestFiles('Kobayashi Maru')
then:
noExceptionThrown()
}
3rd party FoodReplicator is creating new instances of ReplicatedFood
You need to mock what ReplicatedFood#tastesLike() returns
We can't mock or spy FoodReplicator
def anyFood = GroovyMock(ReplicatedFood, global: true)
when:
def courses = foodReplicator.request("A 5 course meal")
then: 'call our mocked static method once'
1 * ReplicatedFood.getStaticMenuOptions() >> ['Chicken', 'Sugar Smacks', 'Soup']
and: 'Every food item created is a mock, so override tastesLike'
5 * anyFood.tastesLike() >> ['Soup', 'Chicken']
and: 'We should know what our dinner will taste like'
courses*.tastesLike() == ['Soup', 'Chicken', 'Chicken', 'Chicken', 'Chicken']
def "@PendingFeature marks data driven feature where all iterations pass as failed"() {
when:
runner.runWithImports("""
class Foo extends Specification {
@PendingFeature
def bar() {
expect: test
where:
test << [true, true, true]
}
}
""")
then:
AssertionError e = thrown(AssertionError)
e.message == "Feature is marked with @PendingFeature but passes unexpectedly"
}