Method to build unit tests with separate toolchain

Here’s a brief outline of my situation:

  • I’m working on a large Windows Kernel Mode Driver
  • Our unit tests need to run in user mode
  • Our toolchain is setup to support user and kernel mode
  • But we cannot unify the building of the driver and tests because of this difference in toolchain

The goal (which may be impossible) would be to invoke our CMake for the driver as normal and have it also build the tests in the same command. This provides the benefit of ease-of-use for devs, unified compile commands, smooth integration of targets and dependencies.

Setup

The general structure of the project is:

ROOT
│   CMakeLists.txt
├───cmake
│       toolchain.cmake
├───src
│       lib.cpp
│       lib.h
└───tests
        CMakeLists.txt
        test.cpp

Everything is fairly standard. Our toolchain sets up the different compilers, flags, and such, with a switch KERNEL_MODE that determines if links to the KM or UM libraries should be used. The root CMake builds a KM library (part of the overall KMD). The tests CMake builds a UM library and the test EXE.

Of course, this last part is tricky since (as I’ve read on these forums) CMake only supports one toolchain per invocation. As such, I figure I will likely need to invoke the tests as a separate CMake call.

I will attach a sample project for this later.

Goals

  • One build command to rule them all: Devs are already avoidant of tests so we need to make these as easy to use, modify, and build as possible.
    • This means, ONE command to build the library and tests (that is cmake -S ROOT).
    • No need to change LSP configuration to get intellisense on test files.
  • Reduce build times: The primary reason for upgrading to CMake was to reduce build times. Our main library greatly benefitted from this, but our unit tests still run make taking orders of magnitude longer to build then the library itself.
    • This means we’d like to reduce unnecessary configuration steps and reduce recompilation.
  • Clean and maintainable CMake: We should be able to continually add test and library files with ease and not need to add their data to multiple files.
    • This means that we don’t want to introduce intertwining build logic where possible and avoid duplicating data such as source files or headers between CMake scripts.

Perhaps this is all a tall order. I’m an idealist though. Let’s get onto my approach for this problem:

Attempt 1: Flag Mangling

In my first approach, I simply added the test directory as a add_subdirectory and manually edited the flags to change the project to user mode.

# In `tests/CMakeLists.txt`
# We must override the kernel flags and includes for all of the testing targets.
# Due to CMake limiting us to one toolchain, we must be "dirty" and modify `CMAKE_` variables directly.
foreach(LANGUAGE C CXX)
  string(REPLACE "-kernel" "" CMAKE_${LANGUAGE}_FLAGS "${CMAKE_${LANGUAGE}_FLAGS}")
  string(REPLACE "${WDK_INCLUDE}/km/crt" "" CMAKE_${LANGUAGE}_STANDARD_INCLUDE_DIRECTORIES "${CMAKE_${LANGUAGE}_STANDARD_INCLUDE_DIRECTORIES}")
endforeach()

# We will also need to remove the omission of all default libraries when linking.
string(REPLACE "-NODEFAULTLIB" "" CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS}")

# We manually specify the user mode libraries directories.
target_link_directories(pp_utf PRIVATE
  "${SDK_LIB}/um/${BUILD_ARCH}"
  "${SDK_LIB}/ucrt/${BUILD_ARCH}"
  "${VC_ROOT}/lib/${BUILD_ARCH}"
)
target_link_libraries(tests
  kernel32.lib
  user32.lib
  gdi32.lib
  comdlg32.lib
  shell32.lib
  bufferoverflowu.lib
)

Pros

This is (oddly) one of the simpler methods I tried. The root CMake has the least fiddling needed to achieve it since we simply add the tests as a subdirectory. In the tests CMake, once we set this up, we are good to go and can build the rest of the tests like usual. When doing clean builds, all objects we nicely removed.

We have access to all of the targets and definitions from the root CMake. This makes dependency management easier. As well, we can actually link our tests to the KM library (assuming we omit any calls to KM functions) and avoid the need to recompile our code.

Lastly, this gives me a unified compile_commands.json so that our LSP will work without modification for both the main library and tests sources. Previously this has been an issue that we had no LSP help when modifying tests (very cumbersome). Simply pointing our LSP to a “test compile commands” is infeasible because of the size of our library (Clangd will brick our machines trying to re-index the entire project, often taking 30+ minutes).

Cons

I needn’t say why this is not a great solution; It is incredibly fragile.
If something about the kernel mode configuration changes, our test build breaks (even if it’s switching /kernel to -kernel). As well, since we mangle the flags, we may break any kernel mode building that happens after our test’s configuration (we are one library of the driver with no control over when our library is configured).

Attempt 2: External Project

Here, I replaced the add_subdirectory with a call to ExternalProject_Add.

# In `ROOT/CMakeLists.txt`
include(ExternalProject)
ExternalProject_Add(mre_test
	PREFIX ${TEST_BUILD_DIR}
	SOURCE_DIR ${TEST_DIR}

	CMAKE_GENERATOR ${CMAKE_GENERATOR}
	CMAKE_ARGS
		-DMRE_LIB_DIR=$<TARGET_FILE_DIR:mre_lib>

	DOWNLOAD_COMMAND ""
	INSTALL_COMMAND ""

	DEPENDS mre_lib
	BUILD_ALWAYS ON
)

Pros

This is also a relatively simple method, allowing for the test CMake to be largely standalone with no fancy logic in there.

This correctly tracks and rebuilds when we change either the test’s CMake file or source code.

This might be the canonical way of achieving my goal given the prevalence of the Super Build paradigm for cross compilation (which is essentially what I am doing).

Cons

It does not “feel” nice to have the tests as an external project. We have a separate invocation for configuring the tests and this can only happen at build time for the library. I really wish I could configure the tests at the configure time for the library (a sort of “IS_LOCAL” switch to the external project).

We do not have any access within the tests to the targets defined within the libraries CMake. This prevents easy reuse of, say, an interface library containing include paths for the library. As illustrated in the code snippet above, I have found a workaround to avoid recompilation of the library by passing the directory for the library as a definition. In the tests CMake, I would use this as:

target_link_directories(mre_test PUBLIC ${MRE_LIB_DIR})
target_link_libraries(mre_test PUBLIC mre_lib)

Though I unfortunately had to also manually add some of mre_lib’s link flags to the mre_test target (which is again, fragile). The same is true of the mre_lib header files.

Lastly, even if I pass-through the CMAKE_EXPORT_COMPILE_COMMANDS option, there is no information to compile test files within the compile commands file leading to cumbersome coding. I’m left with two compile commands files that I must switch between causing a large loss of productivity.

Attempt 3: Custom Commands

Here, I used the add_custom_command and add_custom_target to try and build the unit tests.

# In `ROOT/CMakeLists.txt`
add_custom_command(
	OUTPUT
		${TEST_EXE}
	COMMAND
		${CMAKE_COMMAND}
			-G ${CMAKE_GENERATOR}
			-B ${TEST_BUILD_DIR}
			-S ${TEST_DIR}
	COMMAND
		${CMAKE_COMMAND}
			--build ${TEST_BUILD_DIR}
	VERBATIM
	USES_TERMINAL
	COMMENT "-- BUILDING UNIT TESTS --"
)

add_custom_target(test_exe
	ALL
	DEPENDS ${TEST_EXE} mre_lib
)

Pros

Almost nothing. This essentially only works on a clean build. This can link the libraries the same as we did in the external project.

Cons

Does not track changes to the test’s CMake or source files. Cannot produce a unified compile commands. Test configuration only happens at build time for the library.

Attempt 3.5: A Better Custom Command

The above is not really a contender at all. In order to make it any bit usable, we need it to track changes to the test files.

add_custom_command(
	OUTPUT
		${TEST_BUILD_DIR}/CMakeCache.txt
	COMMAND
		${CMAKE_COMMAND}
			-G ${CMAKE_GENERATOR}
			-B ${TEST_BUILD_DIR}
			-S ${TEST_DIR}
			--fresh
	DEPENDS
		${TEST_DIR}/CMakeLists.txt
	VERBATIM
	USES_TERMINAL
	COMMENT "-- GENERATING UNIT TESTS BUILD --"
)

add_custom_target(test_exe_gen
	ALL
	DEPENDS ${TEST_BUILD_DIR}/CMakeCache.txt mre_lib
)

add_custom_command(
	OUTPUT
		${TEST_EXE}
	COMMAND
		${CMAKE_COMMAND}
			--build ${TEST_BUILD_DIR}
	DEPENDS
		test_exe_gen
		${TEST_BUILD_DIR}/CMakeCache.txt
		${TEST_DIR}/test.cpp
	VERBATIM
	USES_TERMINAL
	COMMENT "-- BUILDING UNIT TESTS --"
)

add_custom_target(test_exe
	ALL
	DEPENDS ${TEST_EXE}
)

Pros

This will track the changes to the CMake and source files for tests correctly (given that all source files are listed). The --fresh is needed to ensure the cache file is up-to-date (which means it’s largely a waste).

This has a nice feel to it. It splits the configuration and building steps cleanly apart. But that’s not worth much.

Cons

We have all the same cons of the external project. Additionally, it is intractable to reference every source file for the tests in any sizable project and would simply require large duplication of code. There is some possibility to have a TEST_SOURCES variable setup in an .cmake file and include it in the root and test CMake, but we’d still be tracking this list of dependencies twice.

An Aside For Split Compile Commands Files

I had experimented with writing a small function to merge the disjoint root and test compile_commands.json using jq. This was made difficult since the file itself is not created until the generation phase and so I cannot directly call a custom command on the files (they don’t exist at configuration time). Something like this in native CMake would be great as I know there would be nuance to the problem (such as my case where we would have the main library files repeated, once for KM and once for UM).

The Easy Out

Make 'em separate!

Sure, I could and I can’t deny that. But my point here is to illustrate a use case and some reasoning why I wouldn’t want to take that route. I think there are benefits, practical and aesthetic, to having a unified build. Otherwise, I wouldn’t have gone through all of this effort to create this post and do this background research.

Conclusion

After listing these options, it seems obvious that the external project method is the best and perhaps canonical answer. However, it is still unsatisfactory given that we are ‘mis-using’ it for an internal project. The fragmented compile commands, clunky use of targets from the parent project, and disjoint configuration steps stick out to me like a sore thumb.

I would like to hear the community and maintainer’s take on my situation and what could be done to improve it. What have I missed? Am I running a fools errand? How has anyone resolved a similar situation?

I hope you can appreciate the effort I am putting into this issue and I appreciate anyone who can give me a lead on this.

Future Work

Considering that CMake has this intrinsic “single-toolchain” setup, I wonder what the maintainers and community would think of built in support for a feature such as above (an InternalProject_Add if you will). One where we may share information on targets, configuration time, and compile commands as such. Even just an implementation of one piece of this system would make the above approaches better. I think simply being able to support multiple toolchains sounds like the “right option.”

If anyone knows what such a programming effort may entail, feel free to let me know. I have never looked into CMake’s source code and so I don’t have such a gauge. However, I am willing to give my time and effort for such a task if it is deemed acceptable and plausible.


Again, thank you for your time and effort reading my post.

Example Project

Here is a reference to a sample project in which you can play with the different approaches to this problem. Use the -DAPP_X=ON and -DBUILD_TESTS=ON flag at configuration to try them out. The toolchain is omitted, as it is specific to my work environment.

DerekCresswell/CMake-Unified-Tests-Example

Seems to me that attempts 3 and 3.5 are reimplementing what ExternalProject already does, but not as well. ExternalProject is a module that uses add_custom_command() and add_custom_target() to do its work.

The first Con listed for ExternalProject is vague. If the complaint is that Configuration for all projects do not happen at “the same time”, then you are working against a useful item that is part of ExternalProject.

The reason you want the configures to happen at different times is so that Project A can generate files (i.e the Generate step of Configuration) that can then be consumed by Project B.

The second Con listed, I would say, seems incorrect because the targets for the library were not exported and included in the test project. Refer to the export command.

Creates a file <filename> that may be included by outside projects to import targets named by <target>... from the current project's build tree. This is useful during cross-compiling to build utility executables that can run on the host platform in one project and then import them into another project being compiled for the target platform.

If I remember correctly the export command creates the files during generation so it could then be included in the test project when the test project does its own configuration and generation.

This is quite helpful. I’ve done a lot of CMake but honestly never needed to do much beyond the very basics for exports. I frankly didn’t realize this was a possibility.

You’re right that 3, 3.5 are shoddy attempts. I included them for posterities sake. It was partially just experimenting, as I have used custom target before, but was less familiar with external projects.

With my first con for external projects, I still think it’s valid, if only aesthetically. It feels odd to have the tests, which are local, to need to go through this sort of process.

I will go and play around with the exports and report back. Cheers.