Unit Tests: Part Three

9 min read
16-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 {
2
3 @Shared NotificationService notificationService = Mock(NotificationService)
4
5 Record record
6
7 def setupSpec() {
8 service.notificationService = notificationService
9 }
10
11 def setup() {
12 record = new Record(
13 // whatever info is required to create the Record
14 )
15
16 record.save()
17 }
18}

And the code to be tested:

1void sendSixtyDayExpirationNotice() {
2 List clientsToEmail = fetchClientsWithExpiringRecords(60, 6, RecordStatus.Expiring)
3
4 clientsToEmail.each { client ->
5 content =
6 """
7 Some content personalized for the client
8 Some list of records with links
9 """
10
11 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)
3
4 clientsToEmail.each { client ->
5 subject = "Hello Client"
6 content =
7 """
8 Some content personalized for the client
9 Some list of records with links
10 """
11
12 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:

  1. Be deterministic. Whatever we're testing should produce the same outcome regardless of order the tests run, time of day, leap years, etc.

  2. 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.

  3. Run quickly. We aren't looking for multi-layered tests with a million assertions. We're looking for something cheap!

  4. 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:

  1. Naming the test
  2. Adding assertions
  3. Providing setup
  4. Firing the method
  5. Filling in the blanks

Naming

1def 'it sends email for records expiring in exactly 60 days'() {
2 // content
3}

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() + 60
3record.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 {
2
3 @Shared NotificationService notificationService = Mock(NotificationService)
4
5 Client client
6 Record record
7
8 def setupSpec() {
9 service.notificationService = notificationService
10 }
11
12 def setup() {
13 client = new Client(
14 // let's pretend we only need an email:
15 email: 'test@email'
16 )
17 client.save()
18
19 record = new Record([
20 // whatever info is required to create the Record
21 client: client
22 ])
23
24 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() > 0
6 }

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 {
2
3 @Shared NotificationService notificationService = Mock(NotificationService)
4
5 Client client
6 Record record
7
8 def setupSpec() {
9 service.notificationService = notificationService
10 }
11
12 def setup() {
13 client = new Client(
14 // let's pretend we only need an email:
15 email: 'test@email'
16 )
17 client.save()
18
19 record = new Record([
20 // whatever info is required to create the Record
21 client: client
22 ])
23
24 record.save()
25 }
26
27 def 'it sends email for records expiring in exactly 60 days'() {
28 given:
29 record.expirationDate = new Date().clearTime() + 60
30 record.save()
31
32 when:
33 service.sendSixtyDayExpirationNotice()
34
35 then:
36 noExceptionThrown()
37 1 * notificationService.email('test@email', _) >> { args ->
38 args[1].subject == 'You have expiring records'
39 args[1].content.size() > 0
40 }
41 }
42
43 def 'it does not send email for records expiring in exactly 59 days'() {
44 given:
45 record.expirationDate = new Date().clearTime() + 59
46 record.save()
47
48 when:
49 service.sendSixtyDayExpirationNotice()
50
51 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