Beyond Traditional Acceptance Tests
Pesticide: A library to write Domain-Driven Tests
I am sorry about it, but I neglected this blog recently, the reason is that I’m currently trying to write a book explaining how to write complete applications in a functional way with Kotlin. This is taking a ridiculous amount of time because I need to explain functional programming principles while also writing a realistic web application (including database, progressive enhancement, auth-auth. etc.). Well, I hope it will be worth the effort. If you are interested you can sign-up for updates here.
As an aside, while working on the book, I have also created an Open Source library to write Domain-Driven Tests called Pesticide.
I did a presentation for the London Java Community explaining what is a DDT and how to use Pesticide.
It seems only fair that I will talk about this also on my own blog. Let’s start with an introduction.
Domain-Driven Tests
The most common automated tests are the so-called Unit-Tests. They are very useful but they work on a single small unit — hence the name — if we want to verify in an automatic way that our application works as a whole we need to write a test with a different level of granularity.
AcceptanceTests are a way to test our full application end-to-end in order to be sure to be on the right track. The traditional way to do that is recording user interactions with our system and replaying them back every time we want to verify that our system is still correct.
Unfortunately, writing tests in this way will make them very hard to understand, because the goal is hidden beyond layers of non-essential details about the user interaction.
Nat Pryce (of GOOS fame) invented the Domain-Driven style of tests when he got tired of tests written in a kind of “click here and then click there” way.
They are also influenced by Serenity and the Screenplay pattern by Anthony Marcano.
The name comes from the concern that tests should be written using business domain terms. DDT is also an apt name since they are quite efficient in killing bugs (like the pesticide).
So where is the innovation? The idea is to have a single interface (the interpreter) for two or more representations (protocols) of our application — for example, DomainOnly
and Http
. This forces us to define a common language that stays independent of the details of each protocol.
We also aim to use the same terms both in our tests and in the conversation with the business people. In this way, we can facilitate communication between people working on the software and the business domain experts. This is known as the ubiquitous language.
After a while, writing DDTs we discovered patterns that helped us to keep the code clean and other things that didn’t work very well. Ultimately, I decided to distill this knowledge and write a library in a more general and polished way and release it as an open-source.
Pesticide is a library to describe our requirements as stories composed by a list of interactions between actors (domain personas) and one or more interpreters of our system.
Running the same test using different implementations of the interpreter we get these benefits:
- Be confident that the functionality works both end-to-end and in the in-memory domain.
- Document our feature using a language close to the business.
- Removing UI or technical details from tests.
- Make sure there is no business logic in the infrastructure layer and there are no infrastructure details in the business logic.
Let’s see now how to write a test with Pesticide.
There are several examples in the GitHub pesticide-example project. We will look at the PetShopDDT
example. Let’s imagine we need to write the RESTful API for a pet shop.
My recommendation is to start writing the DDT directly from the user stories before start coding the application.
Our story will say something like: “As a potential customer, I want to check the price of a pet, in order to buy it.”
Phase 1 — write the DDT
class PetShopDDT : DomainDrivenTest<PetShopInterpreter>(allPetShopInterpreters) {//something will go here}
To make this compile we already have to define the interpreter interface for our domain. Something like this:
interface PetShopInterpreter: DomainInterpreter<DdtProtocol> {
fun populateShop(vararg pets: Pet)
fun askPetPrice(petName: String): Int fun buyPet(petName: String): String
}
You can define all the methods you need here but there are some simple rules:
- It has to be an interface, so you can have multiple implementations, one for each protocol you want to use.
- It has to use only domain concepts, stuff like Json, Http Status, Buttons, etc. shouldn’t be present here.
- The methods should reflect some atomic user interaction, either a question (“ask pet price”) or an action (“buy pet”).
Exact names are not important now because we will change them later as we proceed.
We now have to define the list of protocols on which to run the tests. There are already four defined in Pesticide but you can define your own.
For example, here we want to useDomainOnly
and HttpRest
protocols. This means we need to create two implementations ofPetShopInterpreter
and put them in a collection to be used by our test.
val allPetShopInterpreters = setOf(
DomainOnlyPetShop(),
HttpRestPetshop("localhost", 8082)
)
At this point, we can only define the protocol and the prepare
method for each, leaving all the methods implementations as a TODO()
:
class HttpRestPetshop(val host: String, val port: Int) : PetShopInterpreter {
override val protocol = Http("$host:$port")
override fun prepare(): DomainSetUp = try {
//try to start the local http server
//or to connect with the deployed one
Ready
} catch (t: Throwable) {
NotReady(t.toString())
} override fun askPetPrice(petName: String): Int = TODO()...
class DomainOnlyPetShop() : PetShopInterpreter {
override val protocol = DomainOnly
override fun prepare(): DomainSetUp = Ready override fun askPetPrice(petName: String): Int = TODO()...
To continue we need to define at least an actor. Actors represent the user of the system inside the DDT.
Let’s start defining a customer of our shop.
data class PetBuyer(override val name: String):
DdtActor<PetShopInterpreter>() {
fun `check that the price of $ is $`(pet: String, price: Int) =
step(petName, expectedPrice) {
val price = askPetPrice(pet)
expectThat(price).isEqualTo(expectedPrice)
}
A few things worth noting here:
- The actor class must have a field called name. This name will work as a reference for the actor, so we cannot have two actors with the same name in the same test.
- The actor must inherit from
DdtActor
orDdtActorWithContext
with the correctDomainInterpreter.
- A method in the actor should be created using the
step
function. This is important because the method will become a dynamic test. - All the `$` sign in the method name will be replaced with the step parameters to create the test names. In this way, the tests can indicate the exact context they are testing.
- The main responsibility of the actor’s step is to keep all the expectations (or assertions) together but hidden so that they will not clutter the actual test.
Now we can put the first test together:
class PetShopDDT : DomainDrivenTest<PetShopInterpreter>(allPetShopInterpreters) {
val mary by NamedActor(::PetBuyer) @DDT
fun `mary buys a lamb`() = ddtScenario {
setting {
//set up here
} atRise play(
mary.`check that the price of $ is $`("lamb", 64)
).wip(LocalDate.of(2020, 6, 7), "Working on it")
}
}
A few notes on this code:
- The test class need to inherit from
DomainDrivenTest
and specify which interpreters should run (allPetShopInterpreters
). - The actor(s) can be created with the
NamedActor
delegator. - The single tests need to be marked with
DDT
annotation orTestFactory
, since they will generate multiple tests. - Each test is generated with a
ddtScenario
which takes care of generating all the tests for Junit5. - The test DSL is
setting … atRise play(steps)
where the steps are the actors' interactions and setting part is optional to put the system in a given state. - It is possible to specify a work-in-progress modifier that will ignore failing tests until the due date with the
wip
extension.
We run the test as usual from our ide or using Gradle from command line. This is how it looks in IntelliJ:
Tests are in gray because they are marked as work in progress, and the date is not expired yet.
Note how each step becomes a test and it is running the same test twice using Http and DomainOnly protocols. Also, the dollar signs in the method names are replaced with actual values.
Phase 2—Write the walking skeleton
Now that we have the test in place, we should use it to guide the development of our application.
A good practice is to start from the Http interpreter and then modeling our domain interface. In this way, we avoid the risk of wasting time on a domain interface that doesn’t fit well with our web server.
To test the Http server we need an Http client. So we can put one inside the HttpInterpreter
.
val client = JettyClient()
We also need to start our local server at the beginning of the tests
class HttpRestPetshop(val host: String, val port: Int) {
...
override fun prepare(): DomainSetUp = try {
if (host == "localhost" && !started) {
started = true
println("Pets example started listening on port $port")
val server = PetShopHandler(PetShopHub())
.asServer(Jetty(port)).start()
registerShutdownHook {
server.stop()
}
}
Ready
} catch (t: Throwable) {
NotReady(t.toString())
}
We do this with the override of the prepare
method. At some point maybe this code will be generic enough to be part of Pesticide
itself… but for the moment you need to write something like this yourself.
Note also that we start the local server only if the specified host for our tests is “localhost”. If we want, we can also use the same test on our cloud environment.
At this point, we need to implement our askPetPrice
method using the Http client to send the request and then parse the response to return the price. Something like:
override fun askPetPrice(petName: String): Int? {
val req = Request(GET, uri("pets/${petName}"))
val resp = client(req)
expectThat(resp.status).isEqualTo(OK)
val pet = klaxon.parse<Pet>(resp.bodyString())
return pet?.price
}
The only assertions in the HttpInterpreter
are the technical ones, like checking the response code in this case. All domain assertions should stay in the actors’ steps.
Ok now our test asks for the rest call, but it will get an error because there is no server code yet.
So we go to the server and we will implement our route:
class PetShopHandler(val hub: PetShopHub): HttpHandler {
val klaxon = Klaxon() //json parser/serializer
override fun invoke(request: Request) = petShopRoutes(request) val petShopRoutes: HttpHandler = routes(
"/pets/{name}" bind GET to ::petDetails
)
fun petDetails(request: Request): Response =
request.path("name")
?.let(hub::getByName)
?.let(::toJson)
?.let(Response(Status.OK)::body)
?: Response(Status.BAD_REQUEST)
private fun toJson(it: Pet) = klaxon.toJsonString(it)
}
In Http4k everything is a function. We attach our routes to simple functions of type (Request) -> Response
. To keep everything in a nice functional style, the function itself — petDetails
in this case— consists of a chain of simpler functions.
The actual domain is represented here by the hub
field. Whilst writing the PetShopHandler
we “discover” the methods that PetShopHub
must expose — getByName
in this case.
To keep everything simple in this example, we use null
to capture all the errors. So we avoid completely exceptions and instead we just return an error page if any step gives us a null.
Incidentally, in case you are interested, my book will analyze and explain in detail this style.
Phase 3—Write the domain
So now our Http DDT fails on the PetShopHub method, which is not implemented yet.
At this point, we put the Http DDT away and we concentrate on the DomainOnly DDT, because we need to write the domain now.
As we put the Http client inside the HttpInterpreter
, we put an instance of our hub inside our DomainOnlyInterpreter
, and we use it to in this way:
class DomainOnlyPetShop() : PetShopInterpreter {private val hub = PetShopHub()...
override fun askPetPrice(petName: String): Int? =
hub.getByName(petName)?.price
And now we can create our domain:
class PetShopHub() {
private val pets: AtomicReference<List<Pet>> = AtomicReference(emptyList())
fun getByName(petName: String): Pet? = pets.get().firstOrNull { it.name == petName }}
We start using a list in memory to store the pets. If this were a real project, at some point later we will inject a function to read and write from a database or some other form of persistence.
If we run our DDT now, we can see they are still failing because the shop is empty. We need to add a setup in our test and populate the shop:
class PetShopDDT ... val lamb = Pet("lamb", 64)
val hamster = Pet("hamster", 128) @DDT
fun `mary buys a lamb`() = ddtScenario {
setting {
populateShop(lamb, hamster)
} atRise play(
mary.`check that the price of $ is $`("lamb", 64),
)
}
we also need to implement the method on the interpreter interface:
interface PetShopInterpreter : DomainInterpreter<DdtProtocol> { fun populateShop(vararg pets: Pet)
And the HttpRestPetshop
:
class HttpRestPetshop ...override fun populateShop(vararg pets: Pet) =
pets.forEach {
val resp = client(addPetRequest(it))
expectThat(resp.status).isEqualTo(ACCEPTED)
}
And the DomainOnlyPetShop
:
class DomainOnlyPetShop() : PetShopInterpreter { override fun populateShop(vararg pets: Pet) =
pets.forEach {
hub.addPet(it)
}
...
And finally, the DDT will pass on both protocols!
We can remove the wip
flag and we can run the tests again:
Phase 4 — Add new steps to the test
We can now go on adding new steps to the tests, and new features to our application until we consider the scenario completed:
class PetShopDDT : DomainDrivenTest<PetShopInterpreter>(allPetShopInterpreters) {
val mary by NamedActor(::PetBuyer) val lamb = Pet("lamb", 64)
val hamster = Pet("hamster", 128) @DDT
fun `mary buys a lamb`() = ddtScenario {
setting {
populateShop(lamb, hamster)
} atRise play(
mary.`check that the price of $ is $`("lamb", 64),
mary.`check that the price of $ is $`("hamster", 128),
mary.`put $ into the cart`("lamb"),
mary.`checkout with pets $`("lamb")
)
}
}
Running now the test, we can see how everything is green:
We can also run the test from the command line with ./gradlew test
:
PetShopDDT > DomainOnly - Setting up the scenario PASSEDPetShopDDT > DomainOnly - Mary check that the price of lamb is 64 PASSEDPetShopDDT > DomainOnly - Mary check that the price of hamster is 128 PASSEDPetShopDDT > DomainOnly - Mary put lamb into the cart PASSEDPetShopDDT > DomainOnly - Mary check that there are no more lamb for sale PASSEDPetShopDDT > DomainOnly - Mary checkout with pets lamb PASSEDPetShopDDT > Http localhost:8082 - Setting up the scenario PASSEDPetShopDDT > Http localhost:8082 - Mary check that the price of lamb is 64 PASSEDPetShopDDT > Http localhost:8082 - Mary check that the price of hamster is 128 PASSEDPetShopDDT > Http localhost:8082 - Mary put lamb into the cart PASSEDPetShopDDT > Http localhost:8082 - Mary check that there are no more lamb for sale PASSEDPetShopDDT > Http localhost:8082 - Mary checkout with pets lamb PASSED
It should be all downhill from here. You can find the full code for all PetShop tests here:
There is only another interesting feature worth mentioning here—storing and retrieving new data during the test.
For example, a common pattern for e-commerce sites is creating a virtual cart or basket to put your articles. If the site doesn’t require registration, it will generate a unique id for the cart, that the user has to remember until the checkout.
So far so good. Now the problem is that we cannot anticipate what the cart id will be, so how can we write assertions about it?
Let’s look at the step when Mary is putting her lamb in the cart:
fun `put $ into the cart`(petName: String) =
step(petName) { cxt ->
expectThat(cxt.getOrNull()).isNull()
val cartId = createNewCart() ?: fail("No CartId")
addToCart(cartId, petName)
cxt.store(cartId)
}
Here we get the cartId
from our response and we store it in the test context — see the line in bold.
fun `checkout with pets $`(vararg pets: String) =
step(pets.asList().joinToString(",")) { ctx ->
val cartId = ctx.get()
val cart = askCartStatus(cartId)
val petList = cart?.pets?.map(Pet::name).orEmpty()
expectThat(petList).containsExactly(pets.toList())
checkOut(cartId)
}
In the next step, we can retrieve the current value of cartId
and use it to call the checkout.
I think this is enough for a first look at how to use Pesticide, there are other possibilities like testing java application, testing javascript pages, testing microservices, testing legacy applications, and so on. Some of these are already covered by the examples, and some will be in the near future.
Let’s conclude with the big question:
Is it safe to use Pesticide in my project?
Well, it’s your call but please consider that:
- We are using it in a big project and it is the result of several years of experience.
- It is test code — it will not put at risk your application.
- It is open-source, relatively small and test covered — even if I abandon the project you can still use it and expand it.
- It’s not a mature product, so there may be rough edges and there may be changes of API, although not in version 1.x.
That’s all folks! I really hope the Pesticide library can be useful for other people's projects as well.
I’m more than happy to help and discuss any questions about Pesticide on this blog, or on Twitter, or on Github.
—
If you are interested in more similar posts please follow me here or on my twitter account @ramtop