Writing Parameterized Tests With Spock Framework
After we have finished this lesson, we:
- Understand why we should write parameterized tests.
- Can write parameterized tests that use a fixed data set.
- Know how we can write parameterized tests that use a dynamic data set.
This lesson assumes that:
Watch the Lesson
The text version of this lesson is given the following:
Why Should We Write Parameterized Tests?
Let’s assume that we have to specify the expected behavior of the max(int a, int b)
method of the Math
class. Its Javadoc states that it:
Returns the greater of two int values. That is, the result is the argument closer to the value of Integer.MAX_VALUE. If the arguments have the same value, the result is that same value.
Because the max()
method has no side effects, we should use the expect block when we specify the expected behavior of the max()
method. Let’s create a simple expect block that specifies two assertions.
After we have created the expect block, the source code of our specification class looks as follows:
@Category(UnitTest.class) class MathSpec extends Specification { def 'Get the bigger number'() { expect: 'Should return the bigger number' Math.max(1, 0) == 1 Math.max(2, 3) == 3 } }
Even though this expect block doesn’t look too bad, we should use this approach only for writing simple tests because this approach has four potential drawbacks:
- It’s not easy to fetch the test data from an external resource such as a file.
- If we want to add new assertions, we have to add new lines to our expect block. In other words, we add duplicate code to our expect block and duplicate code makes our test hard to maintain. We can solve this problem by moving the duplicate code to a helper method. Unfortunately, this makes our feature method messy and hard to read.
- If our test case fails, it might not be easy to identify the input that failed our test.
- Running the same code (assertions) multiple times doesn’t benefit from the same isolation as running separate feature methods. This means that the specification’s fixture and the feature method’s fixture are initialized only once. In other words, the
setup()
andcleanup()
methods are invoked only once (before and after the feature method), and thesetup
andcleanup
blocks are run only once.
Luckily, there is a better way. If we want to run the same code multiple times by using different inputs and expected results, we can solve these problems by using the data-driven testing support of Spock framework.
Let’s start by rewriting our expect block.
Rewriting the Expect Block
When we want to write data-driven tests with Spock Framework, we have to write an expect block which specifies the stimulus and the expected response by using so called data variables. In our case, we have to replace the hard-coded int
values with data variables a
, b
, and c
.
After we have done this, the source code of our specification class looks as follows:
@Category(UnitTest.class) class MathSpec extends Specification { def 'Get the bigger number'() { expect: 'Should return the bigger number' Math.max(a, b) == c } }
As we can see, the data variables a
and b
contain the method parameters that are passed to the max()
method, and the data variable c
contains the expected value that should be returned by the max()
method.
After we have modified the expect block of our feature method to use data variables, we have to provide the input data for our feature method. However, before we can do it, we have to take a quick look at a concept called an iteration.
Introduction to Iterations
When we configure the input data of a feature method, we also specify how many times our feature method is invoked. Each invocation is called an iteration, and each iteration benefits from the same isolation as a “normal” feature method.
In other words, the specification’s fixture and the feature method’s fixture are initialized before each iteration, and the cleanup()
method and the cleanup
block are run after each iteration.
If we think about our original specification class, it should be clear that we have to run our feature method twice. These iterations are described in the following:
The first iteration verifies that the max()
method returns one when it is invoked by using the method parameters: one and zero. The “source code” of this iteration looks as follows:
@Category(UnitTest.class) class MathSpec extends Specification { def 'Get the bigger number'() { expect: 'Should return the bigger number' Math.max(1, 0) == 1 } }
The second iteration ensures that the max()
method returns three when it is invoked by using the method parameters: two and three. The “source code” of this iteration looks as follows:
@Category(UnitTest.class) class MathSpec extends Specification { def 'Get the bigger number'() { expect: 'Should return the bigger number' Math.max(2, 3) == 3 } }
Also, it is important to understand that if an iteration fails, the remaining iterations will be run and all failures will be reported.
We are now ready to provide input data for our feature method. Let’s find out how we can do it.
Providing Input Data
We can provide input data for a feature method by using either data tables or data pipes in the where block of the feature method. Let’s start by finding out how we can use data tables.
Providing Input Data With Data Tables
When we want to provide input data by using data tables, we have to create a where block that contains the data table which specifies the input data of a feature method. We can create a data table by following these rules:
- The first line of the data table declares the data variables.
- The next table rows are called data rows. These data rows contain the values of the data variables that are passed to our feature method, and one data row species the input data of a single iteration.
- The different column values of a table row are separated by using the pipe character (‘|’).
Let’s create a data table that specifies the input data of our feature method. We can create this data table by following these steps:
- Declare the data variables
a
,b
, andc
. - Declare the input data of the first iteration.
- Declare the input data of the second iteration.
After we have created our data table, the source code of our specification class looks as follows:
@Category(UnitTest.class) class MathSpec extends Specification { def 'Get the bigger number'() { expect: 'Should return the bigger number' Math.max(a, b) == c where: a | b | c 1 | 0 | 1 2 | 3 | 3 } }
Even though our new where block looks clean, we can make it a bit better by separating the input values and the expected output values with the double pipe symbol (‘||’). After we have done this, the source code of our specification class looks as follows:
@Category(UnitTest.class) class MathSpec extends Specification { def 'Get the bigger number'() { expect: 'Should return the bigger number' Math.max(a, b) == c where: a | b || c 1 | 0 || 1 2 | 3 || 3 } }
Data tables must have at least two columns. If you want to create a data table that has only one column, you have to use the following format:
a | _ 1 | _ 2 | _
Additional Reading:
Data tables allow us to configure the input data of a feature method in an easy and readable way. However, they are not very useful if we have to use dynamic data sets. If this is the case, we have to provide the input data by using data pipes.
Providing Input Data With Data Pipes
A data pipe creates a connection between a data variable and a data provider which provides the input data for the data variable. Before we can use data providers, we have to know these three things:
- A data provider provides one value per iteration.
- Any object that can be iterated by using the Groovy programming language can be used as a data provider.
- The data provider doesn’t have to contain the input data. We can also implement data providers which fetch data from external resources or generate random data.
Our next step is to add a where block to our feature method and connect our data variables to our data providers. We can implement our where block by following these steps:
- Connect the data variable
a
to a data provider that contains theint
values: one and two. - Connect the data variable
b
to a data provider that contains theint
values: zero and three. - Connect the data variable
c
to a data provider that contains theint
values: one and three.
After we have created the where block, the source code of our specification class looks as follows:
@Category(UnitTest.class) class MathSpec extends Specification { def 'Get the bigger number'() { expect: 'Should return the bigger number' Math.max(a, b) == c where: a << [1, 2] b << [0, 3] c << [1, 3] } }
Additional Reading:
We can now write parameterized tests with Spock Framework and provide input data for our feature methods by using both data tables and data pipes.
Let’s summarize what we learned from this lesson.
Summary
This lesson has taught us four things:
- We should write parameterized tests if we want to run the same code multiple times by using different inputs and expected results.
- Each iteration (method invocation) benefits from the same isolation as a “normal” feature method.
- We should use data tables if our feature method uses a fixed data set.
- We should use data pipes if our feature method uses a dynamic data set.