cmake with GTest on Windows: build starts test, but shared libs cannot be found

Viewed 451

I have created a project that is split into several directories, each of the directories creating a shared library with add_library(SubdirectoryProject SHARED ${ALL_FILES}) in CMake.

I have a test subdirectory where I use

add_executable(unittest ${ALL_FILES})

# Dependencies
target_link_libraries(unittest
PUBLIC
    GTest::GTest
    Boost::log
    Boost::json
    magic_enum::magic_enum

    SubdirectoryProject
)

find_package(GTest REQUIRED)
enable_testing()
include(GoogleTest)
gtest_discover_tests(unittest)

It all builds fine. However, when building the test, cmake (or GTest) is also trying to run the compiled unittest binary to discover the tests. On Windows, this does not work because the test binary is in its own build folder and the built (shared) DLLs from the other subprojects are in their respective folders. Windows doesn't have rpath. So executing the test binary fails with an error that it cannot find the libraries.

So what to do? Do I really have to manually copy the DLLs prior to the discovering of tests? I feel like there HAS to be a cmake-way of doing this properly and automatically, but I couldn't find anything.

1 Answers

Project layout and files

Here's my MRE project. For convenience, I've published this at https://github.com/alexreinking/so69978314 but everything that's needed is included in the present answer.

This is the directory structure:

$ tree
.
├── CMakeLists.txt
├── include
│   └── myLib.h
├── src
│   └── myLib.cpp
├── test
│   └── test.cpp

3 directories, 4 files

Here are the file contents:

# CMakeLists.txt
cmake_minimum_required(VERSION 3.22)
project(so69978314)

if (PROJECT_IS_TOP_LEVEL)
    include(CTest)
endif ()

option(BUILD_SHARED_LIBS "Build shared libraries instead of static libraries" ON)

find_package(tinyxml2 REQUIRED)

add_library(myLib src/myLib.cpp include/myLib.h)
add_library(myLib::myLib ALIAS myLib)
target_link_libraries(myLib PRIVATE tinyxml2::tinyxml2)
target_include_directories(
    myLib PUBLIC "$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>")
set_target_properties(myLib PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS YES)

if (BUILD_TESTING)
    find_package(GTest REQUIRED)
    include(GoogleTest)

    add_executable(unittest test/test.cpp)
    target_link_libraries(unittest PRIVATE GTest::gtest GTest::gtest_main myLib::myLib)

    if (WIN32)
        add_custom_command(
            TARGET unittest POST_BUILD
            COMMAND ${CMAKE_COMMAND} -E copy_if_different $<TARGET_RUNTIME_DLLS:unittest> $<TARGET_FILE_DIR:unittest>
            COMMAND_EXPAND_LISTS
        )
    endif ()

    gtest_discover_tests(unittest)
endif ()

// include/myLib.h
#ifndef MYLIB_H
#define MYLIB_H

bool validate_xml(const char *xml);

#endif
// src/myLib.cpp
#include "myLib.h"

#include <tinyxml2.h>

bool validate_xml(const char *xml) {
  tinyxml2::XMLDocument doc;
  doc.Parse(xml);

  return doc.ErrorID() == 0;
}
// test/test.cpp
#include <myLib.h>

#include <gtest/gtest.h>

namespace {

TEST(MyLibTest, Success) { ASSERT_TRUE(validate_xml("<element/>")); }
TEST(MyLibTest, Fail) { ASSERT_FALSE(validate_xml("<foo></bar>")); }

} // namespace

Explanation

There are two Windows-specific CMake settings here. The first one may be applied unconditionally:

set_target_properties(myLib PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS YES)

This is for convenience... Linux and macOS shared libraries export all their symbols by default, while Windows does the Right Thingtm and only exposes those that are explicitly exported. I thought that was too much for a StackOverflow answer, so this is a shortcut.

A more principled approach would set:

set(CMAKE_CXX_VISIBILITY_PRESET hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN YES)

and use the GenerateExportHeader module to control symbol visibility.

Now, the real meat that makes testing work for the various shared libraries is this:

if (WIN32)
    add_custom_command(
        TARGET unittest POST_BUILD
        COMMAND ${CMAKE_COMMAND} -E copy_if_different $<TARGET_RUNTIME_DLLS:unittest> $<TARGET_FILE_DIR:unittest>
        COMMAND_EXPAND_LISTS
    )
endif ()

This uses a new feature in CMake 3.21: the $<TARGET_RUNTIME_DLLS:> generator expression. On DLL platforms, it expands to the transitive list of DLLs on which the given target depends. In this case, it will be Google Test's two libraries (gtest.dll, gtest_main.dll), TinyXml2's library (tinyxml2.dll), and the internal myLib.dll.

This is passed to cmake -E copy_if_different to place these DLLs next to the unittest binary. The destination directory is given by $<TARGET_FILE_DIR:unittest>.

The custom command is guarded by if (WIN32) because $<TARGET_RUNTIME_DLLS:unittest> will be empty otherwise. In that case, the cmake -E copy_if_different command won't get enough arguments and will fail at build time.

Finally COMMAND_EXPAND_LISTS makes sure that the semicolon-delimited list returned by $<TARGET_RUNTIME_DLLS:unittest> is split into multiple arguments, rather than being passed as a single argument with escaped semicolons.

To see that this works on Windows, check the output logs of the relevant GitHub Actions workflow: https://github.com/alexreinking/so69978314/runs/6546304512?check_suite_focus=true

Related