In my current project we use UUID's as id's for all aggregates. The good part of UUID's is that it's guaranteed to be unique. The ugly part is that it looks ... well ... ugly.
Writing tests with lots of UUID's clutters the test codebase and you lose focus on what it is you're testing.
A common use case when writing tests is that I want to assert that some id is the same as some expected id. For instance I want to test a filter - named VeryComplexFilter - that keeps users older than 40 and sorts them by age. The code is written in groovy:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class User {
UUID id
String fullName
int age
}
class VeryComplexFilter {
int ageThreshold = 40
List<User> lifeStartsAt40(List<User> input) {
input.findAll { it.age >= ageThreshold }.sort { it.age }
}
}
I know. It's not the most compelling code, but lets continue for the sake of the example. My unit test creates a list of users and asserts the outcome of my filter. I'm using a data-driven test using spock because I want to test different inputs in the same test. Spock does a great job here.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Unroll
def "from users with age #inputUsersAge, #resultSize users are kept"() {
given:
def underTest = new VeryComplexFilter()
when:
def result = underTest.lifeStartsAt40(input.collect {
new User(id: UUID.fromString(it[0]), age: it[1])
})
then:
result*.id == expectedResult.collect { UUID.fromString(it) }
where:
input || expectedResult
[["40288045-4BA8-CB2E-014B-A8CB2E780000", 35]] || []
[["40288045-4BA8-CB2E-014B-A8CBA1330002", 41]] || ["40288045-4BA8-CB2E-014B-A8CBA1330002"]
[["40288045-4BA8-CB2E-014B-A8CB2E780000", 35],
["40288045-4BA8-CB2E-014B-B0C80EDB0003", 52],
["40402880-454B-A8CB-2E01-4BB0D5051F00", 15],
["40288045-4BA8-CB2E-014B-B0D5CB820005", 79],
["40288045-4BA8-CB2E-014B-A8CBA1330002", 41]] || ["40288045-4BA8-CB2E-014B-A8CBA1330002",
"40288045-4BA8-CB2E-014B-B0C80EDB0003",
"40288045-4BA8-CB2E-014B-B0D5CB820005"]
inputUsersAge = input*.get(1)
resultSize = expectedResult.size()
}
In the where-block, I create the different input lists and define the expected result. Each input list contains pairs of UUID-and-age tuples. In the when-block, each element of the input list is converted into a User object and handed to the filter under test. Finally in the then-block, the id's of the result list is compared against the expected results. When I run the test I get the following output:
You can see that the number of UUID's I have to create, really makes my test ugly. I could pregenerate them and then it would look like this:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
static def uuids = (1..10).collect { UUID.randomUUID().toString() }
@Unroll
def "with predefined uuids, from users with age #inputUsersAge, #resultSize users are kept"() {
given:
def underTest = new VeryComplexFilter()
when:
def result = underTest.lifeStartsAt40(input.collect {
new User(id: UUID.fromString(it[0]), age: it[1])
})
then:
result*.id == expectedResult.collect { UUID.fromString(it) }
where:
input || expectedResult
[[uuids[0], 35]] || []
[[uuids[1], 41]] || [uuids[1]]
[[uuids[0], 35], [uuids[3], 52], [uuids[4], 15], [uuids[5], 79], [uuids[1], 41]] || [uuids[1], uuids[3], uuids[5]]
inputUsersAge = input*.get(1)
resultSize = expectedResult.size()
}
It's the same test but I pregenerate the UUID's in a static list and refer to them from within the where-clause. However, what I really want, is to use some symbolic notation like a string that refers to a UUID. The test than would look much simpler:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Unroll
def "with memoized uuids, from users with age #inputUsersAge, #resultSize users are kept"() {
given:
def underTest = new VeryComplexFilter()
when:
def result = underTest.lifeStartsAt40(input.collect {
def (idChar, age) = it.split('-')
new User(id: uuid.call(idChar), age: age as int)
})
then:
result*.id == expectedResult.collect { uuid.call(it) }
where:
input || expectedResult
["a-35"] || ''
["b-41"] || 'b'
["a-35", "c-52", "d-15", "e-79", "b-41"] || 'bce'
inputUsersAge = input.collect { it.split('-').last() }
resultSize = expectedResult.size()
}
In this where-block the input list contains a lists of strings like 'a-35' that I can refer to in the expectedResults. Now I can specify, for instance, that - provided with a list of users with ages 35, 52, 15, 79 and 41 - the filter must return the 2nd, the 5th and the 4th user. I do this by setting ...
- the input list to "a-35", "c-52", "d-15", "e-79", "b-41" and
- the expectedResult to 'bce'.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Closure<?> uuid = { _ ->
return UUID.randomUUID()
}.memoize() //remember what symbol we mapped to the UUID
I just call a closure named uuid that returns me the same UUID, every time I call it with the same input. Executing uuid.call('a') twice will always give me the same UUID. Executing uuid.call('b') gives me a new UUID.
What if I want to return something else instead of UUID's? I can do the same creating a user:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Closure<?> user = { String idAndAge ->
def (_, age) = idAndAge.split('-')
new User(id: UUID.randomUUID(), age: age as int)
}.memoize() //remember what symbol we mapped to the UUID
@Unroll
def "with memoized users, from users with age #inputUsersAge, #resultSize users are kept"() {
given:
def underTest = new VeryComplexFilter()
when:
def result = underTest.lifeStartsAt40(input.collect { user.call(it) })
then:
result == expectedResult.collect { user.call(it) }
where:
input || expectedResult
["a-35"] || []
["b-41"] || ['b-41']
["a-35", "c-52", "d-15", "e-79", "b-41"] || ['b-41','c-52', 'e-79']
inputUsersAge = input.collect { it.split('-').last() }
resultSize = expectedResult.size()
}
I can generalize this idea of creating something and return the same complex object, every time I call it with the same input parameter. Here's a method creating a complex object:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
def createComplexObject() {
return (1..20).collect { UUID.randomUUID() }.groupBy { it.toString().toList().last() }
}
Here's how I call it and expect to get the same object for the same input value:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
recall('complex', 'a') == recall('complex', 'a')
recall('complex', 'a') != recall('complex', 'b')
recall('complex', 'a') != recall('complex', 'abc')
recall('complex', 'abc') == recall('complex', 'abc')
For this to work I need to be able to say: "This is how you create my complex object":
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
remember('complex', { createComplexObject() })
The above snippet registers a way to create the object and also passes the key that I can use to specify what I want. Once that's done, I can call it, like I did using the uuid closure call.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class RememberTest extends Specification {
def "remembering symbols to object creation"() {
given:
remember('complex', { createComplexObject() })
expect:
id('a') instanceof UUID
id('a') == id('a')
id('a') != id('abc')
user('a-52') instanceof User
user('a-52').age == 52
user('a-52') == user('a-52')
user('a-52') != id('a')
recall('complex', 'a') == recall('complex', 'a')
recall('complex', 'a') != recall('complex', 'b')
recall('complex', 'a') != recall('complex', 'abc')
recall('complex', 'abc') == recall('complex', 'abc')
}
def createComplexObject() {
return (1..20).collect { UUID.randomUUID() }.groupBy { it.toString().toList().last() }
}
}
class TestUtils {
private TestUtils() {}
/**
* knows how to create objects given a creatorId
*/
private static final Map<Object, Closure<?>> creatorRegistry = [
(UUID): { UUID.randomUUID() },
(User): { idAndAge ->
def (_, age) = idAndAge.split('-')
new User(id: UUID.randomUUID(), age: age as int)
}
]
/**
* remembers what object was created given the symbol and the creatorId
*/
private static Closure<?> remembers = { String symbol, Object creatorId ->
def creator = creatorRegistry[creatorId]
assert creator
return creator.call(symbol)
}.memoize() //remember what symbol we mapped to what created object
/**
* Registers a creator for a given creatorId
* @param creatorId
* @param creator
*/
static <T> void remember(def creatorId, Closure<T> creator) {
assert creatorId
assert creator
creatorRegistry << [(creatorId): creator]
}
/**
* Recall the object matching the creatorId with the given symbol
* @param creatorId
* @param symbol
* @return
*/
static <T> T recall(def creatorId, String symbol) {
assert symbol
assert creatorId
return remembers.call(symbol, creatorId) as T
}
/**
* Generates the same UUID for the same input
* @param c
* @return
*/
static UUID id(String c) {
recall(UUID, c)
}
/**
* Generates the same User for the same input
* @param idAndAge
* @return
*/
static User user(String idAndAge) {
recall(User, idAndAge)
}
}
You can find a full gist here.
I don't know how far I'll take this trick in my tests. The fact that you can return the same - potentially complex - object and use some short symbol to reference it, helps a lot when trying to create some clear specification. However, there is some hocus-pocus going on, so it's not something I would apply everywhere... I think.
Greetings
Jan
No comments:
Post a Comment