Unit Tests: Part Three
9 min read16-Feb-21
In my last post, I scaffolded a unit test file to write some basic tests for a Grails service method.
Background
1class RecordServiceSpec extends Specification implements ServiceUnitTest<RecordService>, DataTest {23 @Shared NotificationService notificationService = Mock(NotificationService)45 Record record67 def setupSpec() {8 service.notificationService = notificationService9 }1011 def setup() {12 record = new Record(13 // whatever info is required to create the Record14 )1516 record.save()17 }18}
And the code to be tested:
1void sendSixtyDayExpirationNotice() {2 List clientsToEmail = fetchClientsWithExpiringRecords(60, 6, RecordStatus.Expiring)34 clientsToEmail.each { client ->5 content =6 """7 Some content personalized for the client8 Some list of records with links9 """1011 notificationService.email(client, content)12 }13}
Moving Forward
Today's post should be a bit lighter, as once we have one test up and running the rest will write themselves.
One quick note: in order to make this test a bit more complicated, I'm going to modify our notificationService
"email" method a little bit. Instead of accepting a client and some content, I'm going to have it accept a client and a map. The map will have fields subject
and content
. This way, we'll get to see how to make assertions about unknown map values!
So now, our updated code to be tested is going to look like this:
1void sendSixtyDayExpirationNotice() {2 List clientsToEmail = fetchClientsWithExpiringRecords(60, 6, RecordStatus.Expiring)34 clientsToEmail.each { client ->5 subject = "Hello Client"6 content =7 """8 Some content personalized for the client9 Some list of records with links10 """1112 notificationService.email(client, [subject: subject, content: content])13 }14}
With that, we can move forward once we decide what specific conditions we'd like to test. Let's lay out the rules for our unit tests again:
Be deterministic. Whatever we're testing should produce the same outcome regardless of order the tests run, time of day, leap years, etc.
Focus on the method's behavior. It's not important to our assertions to know how
clientsToEmail
gets generated. That might affect our setup process, but not whether the test is red or green.Run quickly. We aren't looking for multi-layered tests with a million assertions. We're looking for something cheap!
Use good naming conventions. It must be clear what we're going to assert from the test name — and we must be certain we test what we say.
The Test
So what would a good test look like? I'm going to progress through our first unit test by:
- Naming the test
- Adding assertions
- Providing setup
- Firing the method
- Filling in the blanks
Naming
1def 'it sends email for records expiring in exactly 60 days'() {2 // content3}
It's clear from this test name what we want to check: emails are sending when records are 60 days from expiring. It's also clear what our scope is. From this name, we know we won't be making assertions related to how the list of records is generated nor the inner workings of the email
method.
Asserting
We should probably have exactly one expression in our then
block. I'm not opposed to including noExceptionThrown()
(from Spock's Specification Class), but also wouldn't be upset to see a PR foregoing that assertion. These tests are more about specificity than covering lots of ground — that's where our API and E2E tests should come in.
I imagine the then
block will look something like this:
1then:21 * notificationService.email(foo, bar)3noExceptionThrown()
You'll notice I'm including foos
and bars
in the then
for now — this is just to get the concepts in place before writing the real test.
I'm sure other people approach unit test design differently, but to ensure my test name is connected to my intent (and everything is wired so the assert matches both) this is how I tackle it.
I lay out the name, then what I'd anticipate asserting. If my assertions have to change while I write the test, I know I need to update the test name at the same time. It's less about the content and more about the mental connection I maintain while writing the test.
Setup
So I know what I want to test, and what needs to happen to make my assertion fire. In our case, getting the method to fire is actually really easy.
Our tests are going to hinge on a default Record
getting a new expirationDate
:
1given:2record.expirationDate = new Date().clearTime() + 603record.save()
Easy.
Again, this is kind of the point: we want simple, repeatable tests that leverage all the groundwork we laid in part 2. It should be immediately obvious how this type of test will be easy to copy & paste to check other conditions (59 days, 67 days, etc.).
For our test, we also want to setup a Client
that's linked to the record. This could have happened in Part Two, but I sort of left that as // whatever info is required to create the Record
.
Let's fix that now.
We could build out our Client
in much the same way we injected our NotificationService
. The steps would be almost identical (using the @Shared
decorator and Mock(Client)
). However, I'd like to demonstrate another similar method to setting up the Client
.
1class RecordServiceSpec extends Specification implements ServiceUnitTest<RecordService>, DataTest {23 @Shared NotificationService notificationService = Mock(NotificationService)45 Client client6 Record record78 def setupSpec() {9 service.notificationService = notificationService10 }1112 def setup() {13 client = new Client(14 // let's pretend we only need an email:15 email: 'test@email'16 )17 client.save()1819 record = new Record([20 // whatever info is required to create the Record21 client: client22 ])2324 record.save()25 }26}
Firing the method
Again, this is very straightforward in our case. The important part is actually that this should be very straightforward in all cases.
Good unit tests should not have any major complexity lurking in the when
block!
1when:2service.sendSixtyDayExpirationNotice()
Easy. Again.
There are no gotchas for future developers to trip over when they read this test. We have setup a Record
to expire in 60 days. We have fired a method that should send an expiration notice 60 days out. We are asserting this actually happens.
Filling in the gaps
Last (but definitely not least) there is the matter of replacing my earlier foo
and bar
with real values.
Because of how we set up our Client
, we know the email address we should see passed into the email
method, so we can make that update right away:
1then:21 * notificationService.email('test@email', bar)3noExceptionThrown()
But what about that second parameter?
We changed the email
method to require a map to be passed in, which would include the subject and content for the email. I'm going to show two ways to provide these values for a test.
For the subject, we're going to say we know exactly what copy should be included. Maybe it's something generic like "You have expiring records", or something similarly easy to copy & paste into our test.
However, for the content, we're going to assume it's programatically created and would be a pain to copy and paste here. Let's assume that process of creating the email body is well-tested elsewhere.
What does all this look like together?
1then:2 noExceptionThrown()3 1 * notificationService.email('test@email', _) >> { args ->4 args[1].subject == 'You have expiring records'5 args[1].content.size() > 06 }
With this, we can combine the wildcard character Spock recognizes (_
) with some values we're certain should fit some characteristics.
Maybe our email
method could accept another value into this map, say, signature
. Hypothetically, we could assert that here, too, just by adding to the closure we're passing in.
I think this is a really cool bit of code, because it highlights how we can be hyper-specific where relevant to our test but play fast and loose with wildcards when it's not critical.
The full suite:
1class RecordServiceSpec extends Specification implements ServiceUnitTest<RecordService>, DataTest {23 @Shared NotificationService notificationService = Mock(NotificationService)45 Client client6 Record record78 def setupSpec() {9 service.notificationService = notificationService10 }1112 def setup() {13 client = new Client(14 // let's pretend we only need an email:15 email: 'test@email'16 )17 client.save()1819 record = new Record([20 // whatever info is required to create the Record21 client: client22 ])2324 record.save()25 }2627 def 'it sends email for records expiring in exactly 60 days'() {28 given:29 record.expirationDate = new Date().clearTime() + 6030 record.save()3132 when:33 service.sendSixtyDayExpirationNotice()3435 then:36 noExceptionThrown()37 1 * notificationService.email('test@email', _) >> { args ->38 args[1].subject == 'You have expiring records'39 args[1].content.size() > 040 }41 }4243 def 'it does not send email for records expiring in exactly 59 days'() {44 given:45 record.expirationDate = new Date().clearTime() + 5946 record.save()4748 when:49 service.sendSixtyDayExpirationNotice()5051 then:52 noExceptionThrown()53 0 * notificationService.email('test@email', _)54 }55}
Wrap up
We have a great start to these unit tests. We're a few ctrl-C's and ctrl-V's away from having as many tests hitting the boundaries of the central method as we want.
I'm going to sign off this series with a link dump to a bunch of sites I liked. I would highly recommend perusing some of these if you'd like to write more Grails Unit Tests.
Full disclosure, I'm mostly doing this because I want to have quick access to each of these links later!
Grails Testing:
https://testing.grails.org/latest/api/grails/testing/services/ServiceUnitTest.html
https://testing.grails.org/latest/api/org/grails/testing/GrailsUnitTest.html
https://testing.grails.org/latest/api/grails/testing/gorm/DomainUnitTest.html
https://testing.grails.org/latest/api/grails/testing/gorm/DataTest.html
https://testing.grails.org/latest/guide/index.html
Spock:
http://spockframework.org/spock/docs/1.3/spock_primer.html
http://spockframework.org/spock/docs/1.3/spock_primer.html#_fixture_methods
http://spockframework.org/spock/javadoc/1.0/index.html?spock/lang/Shared.html
http://spockframework.org/spock/docs/1.0/interaction_based_testing.html#_stubbing
http://spockframework.org/spock/javadoc/1.0/spock/lang/Specification.html#noExceptionThrown--
General:
https://martinfowler.com/articles/nonDeterminism.html
https://en.wikipedia.org/wiki/Specification_by_example
https://guides.grails.org/grails-mock-basics/guide/index.html
This is part three of a three part series on unit testing a Grails app.
Previous
Unit Tests: Part Two
Next
SRP and Me