Short tests are better
Ideally, we'd like tests to have 3 lines of code:
- Setup: aka Given or Arrange
- Run: aka When or Act
- Test: aka Then or Assert
But they often require setting up a lot of data before actually executing the function to test. This causes several drawbacks:
- The tests get longer, less clear and more error prone.
- Developers may feel lazy to create new tests if the required effort is too high.
- The explosion in the fan-in of the setup code will slow down potential refactors.
How to reduce the setup phase
In this post I'll present some examples on using the Test Data Builder pattern to mitigate this issue.
The Model
Our toy model represents Circles and Squares which are fully enclosed in a Canvas.
Base.@kwdef struct Canvas
x::Int
y::Int
end
Base.@kwdef struct Position
x::Int
y::Int
end
Base.@kwdef struct Circle
center::Position
radius::Int
end
Base.@kwdef struct Square
center::Position
side::Int
end
The Builder pattern
Using Julia's Base.@kwdef
, you can quickly create a Builder for Position
values.
The builder should ensure that the non-explicitly set fields will get consistent values. In this simple case, you could actually enforce inconsistent instances to define unhappy path tests. However, when available, I prefer Builders to transparently invoke the model integrity tests to ensure that test setups start from consistent data.
const TEST_CANVAS = Canvas(640, 480)
randX(c::Canvas) = rand(1:c.x)
randY(c::Canvas) = rand(1:c.y)
Base.@kwdef mutable struct PositionBuilder
canvas::Canvas = TEST_CANVAS
x::Int = randX(canvas)
y::Int = randY(canvas)
end
Base.rand(pb::PositionBuilder) = Position(; pb.x, pb.y)
Since all data takes default values, the intent of the setup code becomes evident
# testing a position at first column
rand(PositionBuilder(;x=0))
# testing a position at a 1-pixel-sized Canvas
rand(PositionBuilder(;canvas=Canvas(1,1))
Builders are composable
You can define your CircleBuilder
& SquareBuilder
to compose PositionBuilder to create the shape center.
Base.@kwdef mutable struct CircleBuilder
canvas::Canvas = TEST_CANVAS
centerB::PositionBuilder = PositionBuilder(; canvas)
radius::Int = randRadius(centerB, canvas)
end
function randRadius(pb::PositionBuilder, canvas::Canvas)
maxRadius = min(pb.x, canvas.x - pb.x, pb.y, canvas.y - pb.y)
return rand(1:maxRadius)
end
function Base.rand(cb::CircleBuilder)
radius = randRadius(cb.centerB, cb.canvas)
return Circle(; center=rand(cb.centerB), radius)
end
Going farther
However, we suffer several limitations in the current design:
- If we do
CircleBuilder().radius = X
, the assigned value will get lost when creating a Circle. - We need to invoke
radius = randRadius(...)
twice.
To avoid these shortcomings, I tend to use the following design:
Base.@kwdef struct SquareBuilder
canvas::Optional{Canvas} = nothing
center::Optional{PositionBuilder} = nothing
side::Optional{Int} = Nothing
end
canvas!(sb::SquareBuilder) = @something sb.canvas TEST_CANVAS
center!(sb::SquareBuilder) = sb.centerB = @something sb.centerB PositionBuilder(; canvas=canvas!(sb))
side!(sb::SquareBuilder) = sb.side = @something sb.side calculateSide(canvas!(sb), center!(sb))
function Base.rand(sb::SquareBuilder)
center = rand(center!(sb))
side = side!(sb)
return Square(; center, side)
end
Now, all fields are nothing
until the tests assign a specific value, or they are automatically assigned during the construction of the struct.
Top comments (0)