Adding doctest to GDExtension for unit testing

Godot Version

4.2.1.stable

Question

How do I set up unit tests for my GDExtension with doctest?

I’m trying to move computationally intensive workloads from GDScript into a GDExtension. As part of this task, I’m trying to add doctest for unit testing. However, I’m missing something, as I keep receiving an error when I execute the tests, and I’m not sure what I’m doing wrong.

I’ve set up a minimally viable project and have the doctest integration in a branch:

How to reproduce

  1. Clone MVP (git clone https://github.com/cyberpuffin-digital/gdexample/)
  2. Checkout doctest branch (cd gdexample; git checkout doctest)
  3. Build godot-cpp and gdextension (make godot_cpp_linux gdextension_linux_debug)
  4. Execute tests (make tests)

Expected results

One test executed and passed: godot::GDExtension::is_prime(int)

Actual results

~/git/gdexample$ make tests
cmake -S ./test -B ./.cmake
-- The CXX compiler identification is GNU 11.4.0
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Found Git: /usr/bin/git (found version "2.34.1") 
-- Godot CPP library found: ~/git/gdexample/godot-cpp/bin/libgodot-cpp.linux.template_debug.x86_64.a
-- GDExample library found: ~/git/gdexample/bin/linux/libgdexample.linux.template_debug.x86_64.so
-- GDExample Include Directories: ~/git/gdexample/test/../src
-- Configuring done
-- Generating done
-- Build files have been written to: ~/git/gdexample/.cmake
cmake --build ./.cmake --verbose
gmake[1]: Entering directory '~/git/gdexample/.cmake'
/usr/bin/cmake -S~/git/gdexample/test -B~/git/gdexample/.cmake --check-build-system CMakeFiles/Makefile.cmake 0
/usr/bin/cmake -E cmake_progress_start ~/git/gdexample/.cmake/CMakeFiles ~/git/gdexample/.cmake//CMakeFiles/progress.marks
/usr/bin/gmake  -f CMakeFiles/Makefile2 all
gmake[2]: Entering directory '~/git/gdexample/.cmake'
/usr/bin/gmake  -f CMakeFiles/doctest.dir/build.make CMakeFiles/doctest.dir/depend
gmake[3]: Entering directory '~/git/gdexample/.cmake'
cd ~/git/gdexample/.cmake && /usr/bin/cmake -E cmake_depends "Unix Makefiles" ~/git/gdexample/test ~/git/gdexample/test ~/git/gdexample/.cmake ~/git/gdexample/.cmake ~/git/gdexample/.cmake/CMakeFiles/doctest.dir/DependInfo.cmake --color=
gmake[3]: Leaving directory '~/git/gdexample/.cmake'
/usr/bin/gmake  -f CMakeFiles/doctest.dir/build.make CMakeFiles/doctest.dir/build
gmake[3]: Entering directory '~/git/gdexample/.cmake'
[ 10%] Creating directories for 'doctest'

...

[ 80%] Completed 'doctest'
/usr/bin/cmake -E make_directory ~/git/gdexample/.cmake/CMakeFiles
/usr/bin/cmake -E touch ~/git/gdexample/.cmake/CMakeFiles/doctest-complete
/usr/bin/cmake -E touch ~/git/gdexample/.cmake/doctest/src/doctest-stamp/doctest-done
gmake[3]: Leaving directory '~/git/gdexample/.cmake'
[ 80%] Built target doctest
/usr/bin/gmake  -f CMakeFiles/gdexample_test.dir/build.make CMakeFiles/gdexample_test.dir/depend
gmake[3]: Entering directory '~/git/gdexample/.cmake'
cd ~/git/gdexample/.cmake && /usr/bin/cmake -E cmake_depends "Unix Makefiles" ~/git/gdexample/test ~/git/gdexample/test ~/git/gdexample/.cmake ~/git/gdexample/.cmake ~/git/gdexample/.cmake/CMakeFiles/gdexample_test.dir/DependInfo.cmake --color=
gmake[3]: Leaving directory '~/git/gdexample/.cmake'
/usr/bin/gmake  -f CMakeFiles/gdexample_test.dir/build.make CMakeFiles/gdexample_test.dir/build
gmake[3]: Entering directory '~/git/gdexample/.cmake'
[ 90%] Building CXX object CMakeFiles/gdexample_test.dir/src/test_gdexample.cpp.o
/usr/bin/c++  -I~/git/gdexample/.cmake/doctest/src/doctest/doctest -isystem ~/git/gdexample/test/../src -isystem ~/git/gdexample/test/../godot-cpp/gdextension -isystem ~/git/gdexample/test/../godot-cpp/gen/include -isystem ~/git/gdexample/test/../godot-cpp/include  -MD -MT CMakeFiles/gdexample_test.dir/src/test_gdexample.cpp.o -MF CMakeFiles/gdexample_test.dir/src/test_gdexample.cpp.o.d -o CMakeFiles/gdexample_test.dir/src/test_gdexample.cpp.o -c ~/git/gdexample/test/src/test_gdexample.cpp
[100%] Linking CXX executable gdexample_test
/usr/bin/cmake -E cmake_link_script CMakeFiles/gdexample_test.dir/link.txt --verbose=1
/usr/bin/c++ CMakeFiles/gdexample_test.dir/src/test_gdexample.cpp.o -o gdexample_test  -Wl,-rpath,~/git/gdexample/bin/linux ~/git/gdexample/bin/linux/libgdexample.linux.template_debug.x86_64.so ~/git/gdexample/godot-cpp/bin/libgodot-cpp.linux.template_debug.x86_64.a 
/usr/bin/ld: CMakeFiles/gdexample_test.dir/src/test_gdexample.cpp.o: in function `DOCTEST_ANON_FUNC_14()':
test_gdexample.cpp:(.text+0x15e2f): undefined reference to `godot::GDExample::GDExample()'
/usr/bin/ld: test_gdexample.cpp:(.text+0x15eb3): undefined reference to `godot::GDExample::is_prime(int)'
/usr/bin/ld: test_gdexample.cpp:(.text+0x15fd0): undefined reference to `godot::GDExample::is_prime(int)'
/usr/bin/ld: test_gdexample.cpp:(.text+0x160ed): undefined reference to `godot::GDExample::is_prime(int)'
/usr/bin/ld: test_gdexample.cpp:(.text+0x1620a): undefined reference to `godot::GDExample::is_prime(int)'
/usr/bin/ld: test_gdexample.cpp:(.text+0x16327): undefined reference to `godot::GDExample::is_prime(int)'
/usr/bin/ld: test_gdexample.cpp:(.text+0x163cf): undefined reference to `godot::GDExample::~GDExample()'
/usr/bin/ld: test_gdexample.cpp:(.text+0x1668b): undefined reference to `godot::GDExample::~GDExample()'
/usr/bin/ld: CMakeFiles/gdexample_test.dir/src/test_gdexample.cpp.o: in function `DOCTEST_ANON_FUNC_16()':
test_gdexample.cpp:(.text+0x16744): undefined reference to `godot::GDExample::is_prime(int)'
/usr/bin/ld: test_gdexample.cpp:(.text+0x16861): undefined reference to `godot::GDExample::is_prime(int)'
/usr/bin/ld: test_gdexample.cpp:(.text+0x1697e): undefined reference to `godot::GDExample::is_prime(int)'
/usr/bin/ld: test_gdexample.cpp:(.text+0x16a9b): undefined reference to `godot::GDExample::is_prime(int)'
/usr/bin/ld: test_gdexample.cpp:(.text+0x16bb8): undefined reference to `godot::GDExample::is_prime(int)'
collect2: error: ld returned 1 exit status
gmake[3]: *** [CMakeFiles/gdexample_test.dir/build.make:99: gdexample_test] Error 1
gmake[3]: Leaving directory '~/git/gdexample/.cmake'
gmake[2]: *** [CMakeFiles/Makefile2:111: CMakeFiles/gdexample_test.dir/all] Error 2
gmake[2]: Leaving directory '~/git/gdexample/.cmake'
gmake[1]: *** [Makefile:91: all] Error 2
gmake[1]: Leaving directory '~/git/gdexample/.cmake'
make: *** [Makefile:83: tests] Error 2

CMakeLists.txt

cmake_minimum_required(VERSION 3.22.1)
project(gdexample_test VERSION 0.0.1 LANGUAGES CXX)

#########################
##### Setup Doctest #####
#########################
# Pull in doctest repository.  Useful for pulling in documentation and updates.
include(ExternalProject)
find_package(Git REQUIRED)

ExternalProject_Add(
    doctest
    PREFIX ${CMAKE_BINARY_DIR}/doctest
    GIT_REPOSITORY https://github.com/doctest/doctest.git
    TIMEOUT 10
    UPDATE_COMMAND ${GIT_EXECUTABLE} pull
    CONFIGURE_COMMAND ""
    BUILD_COMMAND ""
    INSTALL_COMMAND ""
    LOG_DOWNLOAD ON
)

# Expose required variable (DOCTEST_INCLUDE_DIR) to parent scope
ExternalProject_Get_Property(doctest source_dir)
set(DOCTEST_INCLUDE_DIR ${source_dir}/doctest CACHE INTERNAL "Path to include folder for doctest")

# Add doctest globally
include_directories(${DOCTEST_INCLUDE_DIR})

##############################
##### Find library files #####
##############################
set(gdexample_DIR ${CMAKE_SOURCE_DIR}/..)
set(godot_cpp_DIR ${CMAKE_SOURCE_DIR}/../godot-cpp)

if (UNIX)
    if (CMAKE_SIZEOF_VOID_P EQUAL 4)
        find_library(GodotCPP_LIBRARY
            NAMES libgodot-cpp.linux.template_debug.x86_32.a
            PATHS ${godot_cpp_DIR}/bin
        )
        find_library(GDExample_LIBRARY
            NAMES libgdexample.linux.template_debug.x86_32.so
            PATHS ${gdexample_DIR}/bin/linux
        )
    else()
        find_library(GodotCPP_LIBRARY
            NAMES libgodot-cpp.linux.template_debug.x86_64.a
            PATHS ${godot_cpp_DIR}/bin
        )
        find_library(GDExample_LIBRARY
            NAMES libgdexample.linux.template_debug.x86_64.so
            PATHS ${gdexample_DIR}/bin/linux
        )
    endif()
elseif (WIN32)
    if (CMAKE_SIZEOF_VOID_P EQUAL 4)
        find_library(GodotCPP_LIBRARY
            NAMES libgodot-cpp.windows.template_debug.x86_32.a
            PATHS ${godot_cpp_DIR}/bin
        )
        find_library(GDExample_LIBRARY
            NAMES libgdexample.windows.template_debug.x86_32.dll
            PATHS ${gdexample_DIR}/bin/windows
        )
    else()
        find_library(GodotCPP_LIBRARY
            NAMES libgdexample.windows.template_debug.x86_64.a
            PATHS ${godot_cpp_DIR}/bin
        )
        find_library(GDExample_LIBRARY
            NAMES libgdexample.windows.template_debug.x86_64.dll
            PATHS ${gdexample_DIR}/bin/windows
        )
    endif()
else()
    message(FATAL_ERROR "Unsupported platform: ${CMAKE_SYSTEM_NAME}")
endif()

if (GodotCPP_LIBRARY)
    message(STATUS "Godot CPP library found: ${GodotCPP_LIBRARY}")
else()
    message(FATAL_ERROR "Godot CPP library not found.  Build Godot CPP first.")
endif()

if (GDExample_LIBRARY)
    message(STATUS "GDExample library found: ${GDExample_LIBRARY}")
else()
    message(FATAL_ERROR "GDExample library was not found.  Build GDExample first.")
endif()

###########################
##### Setup Godot CPP #####
###########################
add_library(Godot_CPP STATIC IMPORTED)
set_target_properties(Godot_CPP PROPERTIES IMPORTED_LOCATION ${GodotCPP_LIBRARY})
target_include_directories(Godot_CPP
    INTERFACE ${godot_cpp_DIR}/gdextension/
    INTERFACE ${godot_cpp_DIR}/gen/include
    INTERFACE ${godot_cpp_DIR}/include
)

include_directories(${godot_cpp_dir})

###########################
##### Setup GDExample #####
###########################
add_library(GDExample SHARED IMPORTED)
set_target_properties(GDExample PROPERTIES IMPORTED_LOCATION ${GDExample_LIBRARY})
target_include_directories(GDExample INTERFACE ${gdexample_DIR}/src)

add_dependencies(GDExample Godot_CPP)
include_directories(${gdexample_DIR}/src)

get_target_property(GDEXAMPLE_INCLUDE_DIRS GDExample INTERFACE_INCLUDE_DIRECTORIES)
message(STATUS "GDExample Include Directories: ${GDEXAMPLE_INCLUDE_DIRS}")

#############################
##### Build test binary #####
#############################
add_executable(${PROJECT_NAME} ${CMAKE_SOURCE_DIR}/src/test_gdexample.cpp)
target_compile_features(${PROJECT_NAME} PRIVATE cxx_std_17)
target_link_libraries(${PROJECT_NAME}
    PUBLIC GDExample
    PUBLIC Godot_CPP
)

#####################
##### Run tests #####
#####################
add_custom_command(
    TARGET ${PROJECT_NAME}
    POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E echo "Running tests..."
    COMMAND ${CMAKE_CTEST_COMMAND} --build-config $<CONFIG> --output-on-failure -C $<CONFIG>
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
)

Implementation notes

  • Doctest was chosen as the testing framework because (a) Godot engine seems to utilize doctest, and (b) it was a recommendation from the disord channel.
  • The build system used for testing is CMake because (a) it’s the only build system in the doctest documentation, (b) I’m trying to learn the build systems better and I don’t know CMake very well.
  • Tests are in the test subdirectory to keep them separate from the source code. I know doctest.h can be added directly to the code, but if I’m not mistaken, that will execute the test suite each time the extension gets loaded rather than by command.
  • CMake is pulling the doctest repo because (a) the documentation gets copied for offline use and (b) so I don’t have to worry about doctest version tracking yet.

Other notes

  • nm shows the is_prime symbol on the built library:
~/git/gdexample$ nm demo/bin/linux/libgdexample.linux.template_debug.x86_64.so | grep -i is_prime
0000000000007280 t _ZN5godot9GDExample8is_primeEi

Cross-linking for navigation and hopeful collaboration: Unable to test Godot GDExtension -- undefined reference to symbol - Usage - CMake Discourse