Base.@kwdef breaks in tests only

Julia v1.7.3

I have encountered a problem with using Base.@kwdef that only occurs in tests, not when code is executed normally. An example:

File src/Example.jl:

module Example

Base.@kwdef mutable struct Foo
    bar :: Integer
end

Foo() = Foo(bar=1)

instance = Foo()
print("Instance value is $(instance.bar)")

end

Running this code with julia src/Example.jl results in the expected output: Instance value is 1

Now let’s add a test covering the constructor that takes no argument:

In file test/runtests.jl

using Test
using Example

@testset "default_foo_constructor" begin
    instance = Example.Foo()

    @test typeof(instance) == Example.Foo
    @test instance.bar == 1
end

Running the tests results in an error:

     Testing Example
      Status `userdir\AppData\Local\Temp\jl_ADLTZW\Project.toml`
  [53506a2b] Example v0.1.0 `userdir\Projects\test-julia`
  [8dfed614] Test `@stdlib/Test`
      Status `userdir\AppData\Local\Temp\jl_ADLTZW\Manifest.toml`
  [53506a2b] Example v0.1.0 `userdir\Projects\test-julia`
  [2a0f44e3] Base64 `@stdlib/Base64`
  [b77e0a4c] InteractiveUtils `@stdlib/InteractiveUtils`
  [56ddb016] Logging `@stdlib/Logging`
  [d6f4376e] Markdown `@stdlib/Markdown`
  [9a3f8284] Random `@stdlib/Random`
  [ea8e919c] SHA `@stdlib/SHA`
  [9e88b42a] Serialization `@stdlib/Serialization`
  [8dfed614] Test `@stdlib/Test`
     Testing Running tests...
default_foo_constructor: Error During Test at projectdir\test\runtests.jl:4
  Got exception outside of a @test
  UndefKeywordError: keyword argument bar not assigned
  Stacktrace:
   [1] Example.Foo()
     @ Example .\util.jl:478
   [2] macro expansion
     @ projectdir\test\runtests.jl:5 [inlined]
   [3] macro expansion
     @ userdir\AppData\Local\Programs\Julia-1.7.3\share\julia\stdlib\v1.7\Test\src\Test.jl:1283 [inlined]
   [4] top-level scope
     @ projectdir\test\runtests.jl:5
   [5] include(fname::String)
     @ Base.MainInclude .\client.jl:451
   [6] top-level scope
     @ none:6
   [7] eval
     @ .\boot.jl:373 [inlined]
   [8] exec_options(opts::Base.JLOptions)
     @ Base .\client.jl:268
   [9] _start()
     @ Base .\client.jl:495
Test Summary:           | Error  Total
default_foo_constructor |     1      1
ERROR: LoadError: Some tests did not pass: 0 passed, 0 failed, 1 errored, 0 broken.
in expression starting at projectdir\test\runtests.jl:4
ERROR: Package Example errored during testing

This example is not exactly how we use Base.@kwdef in our project, but I was able to condense the problem down to this example. I’m just a bit confused why this error occurs. Perhaps I’m misunderstanding how constructors in general and Base.@kwdef in particular work, as I do find the concepts involved a bit confusing. We could also fairly easily not use the macro in the first place. It seems a useful tool for developers, but perhaps it would best not to mix keyword and positional constructors.

I believe that’s unfortunately a case of this known issue: https://github.com/JuliaLang/julia/issues/9498
Basically the issue is that if you define

foo(; bar) = bar
foo() = 1

then you will get the expected

julia> foo()
1

However, if you try to reverse the order of definitions in a new julia session:

foo() = 1
foo(; bar) = bar

then you get

julia> foo()
ERROR: UndefKeywordError: keyword argument bar not assigned

In particular, in your program, the order of method definitions for Foo is well-defined, but I don’t think there is any guarantee that the order is preserved post-precompilation, which likely results in the issue you observe when testing it: the Foo() = Foo(bar=1) method gets virtually defined before the main constructor for Foo, which “shadows” it, if that makes sense.
What makes me think precompilation is the issue is that adding a __precompile__(false) statement at the beginning of your module definition removes the error as well, but this statement is not meant to solve this kind of issue at all so I would recommend against using it.

What I can recommend instead is simply adding the default value for bar in your constructor:

module Example

Base.@kwdef mutable struct Foo
    bar::Integer=1 # notice the =1
end

instance = Foo()
print("Instance value is $(instance.bar)")

end

Isn’t this the behavior what you were looking for? Otherwise, I’m afraid the simplest solution is getting rid of the Base.@kwdef and just create one big constructor method taking the relevant keyword arguments and setting the default “by hand”…

Thanks for the reply, it has already helped with our problems. I haven’t fully groked the linked issue yet, but your explanation of the methods getting out of order and shadowing does make sense to me.

Regarding the modified example, that does indeed solve the problem… in the example. The use in our project is more complicated with multiple “non-default” constructor methods, mixed use of defaultable and non-defaultable fields, etc. However I’m confident I can find a solution, especially given that the keyword constructor method generated by Base.@kwdef is mostly convenience for our use case and nothing essential. I’ll consider the problem solved.

1 Like