Julia Community 🟣

Daniel Pinyol
Daniel Pinyol

Posted on

Test Data builder in julia

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)