Skip to content

Commit ad163d6

Browse files
committed
✨ +code coverage
1 parent cf4d62c commit ad163d6

4 files changed

Lines changed: 193 additions & 18 deletions

File tree

CMakeLists.txt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ string(JSON TARGET_NAMESPACE_VALUE ERROR_VARIABLE json_error GET "${METADATA_JSO
55
string(JSON PROJECT_VERSION_VALUE ERROR_VARIABLE json_error GET "${METADATA_JSON}" "version")
66
string(JSON BUILD_CPPSTD ERROR_VARIABLE json_error GET "${METADATA_JSON}" "build_cppstd")
77
string(JSON BUILD_CSTD ERROR_VARIABLE json_error GET "${METADATA_JSON}" "build_cstd")
8+
string(JSON ENABLE_COVERAGE ERROR_VARIABLE json_error GET "${METADATA_JSON}" "activate_code_coverage")
89
string(JSON IS_SHARED ERROR_VARIABLE json_error GET "${METADATA_JSON}" "is_shared")
910
string(JSON STD_MODULES ERROR_VARIABLE json_error GET "${METADATA_JSON}" "std_modules")
1011
set(LIB_NAME "${PROJECT_NAME_VALUE}")
@@ -172,6 +173,30 @@ target_include_directories(${LIB_NAME}_cpp PUBLIC
172173
)
173174

174175

176+
# code coverage options
177+
if(ENABLE_COVERAGE)
178+
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU")
179+
set(COVERAGE_FLAGS "--coverage" "-O0" "-g")
180+
set(LINK_FLAGS "--coverage")
181+
elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
182+
set(COVERAGE_FLAGS "-fprofile-instr-generate" "-fcoverage-mapping" "-O0" "-g")
183+
set(LINK_FLAGS "")
184+
else()
185+
message(FATAL_ERROR "Code coverage not support for compiler: ${CMAKE_CXX_COMPILER_ID}")
186+
endif()
187+
188+
189+
target_compile_options(${LIB_NAME}_c PRIVATE ${COVERAGE_FLAGS})
190+
target_compile_options(${LIB_NAME}_cpp PRIVATE ${COVERAGE_FLAGS})
191+
192+
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU")
193+
target_link_options(${LIB_NAME}_c PRIVATE ${LINK_FLAGS})
194+
target_link_options(${LIB_NAME}_cpp PRIVATE ${LINK_FLAGS})
195+
endif()
196+
197+
endif()
198+
199+
175200
# module features are fully activated in C++23 or greater
176201
if(BUILD_CPPSTD VERSION_GREATER_EQUAL 23)
177202

metadata.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"cmake_version": "4.0.1",
1414
"build_cppstd": "17",
1515
"build_cstd": "11",
16+
"activate_code_coverage": false,
1617
"is_shared": false,
1718
"generate_modules_inplace": false,
1819
"std_modules": "iostream",

test_package/CMakeLists.txt

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,59 @@ function(split_conan_dependency INPUT_STR PACKAGE_NAME TARGETS)
4343
endfunction()
4444

4545

46+
function(apply_unit_coverage U_NAME U_FILE)
47+
find_package(GTest REQUIRED)
48+
add_executable(${U_NAME} ${U_FILE})
49+
target_link_libraries(${U_NAME} PRIVATE ${MAIN_LIB_TARGET} GTest::gtest)
50+
gtest_discover_tests(${U_NAME})
51+
52+
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU")
53+
set(COVERAGE_FLAGS "--coverage" "-O0" "-g")
54+
set(LINK_FLAGS "--coverage")
55+
elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
56+
set(COVERAGE_FLAGS "-fprofile-instr-generate" "-fcoverage-mapping" "-O0" "-g")
57+
set(LINK_FLAGS "")
58+
else()
59+
message(FATAL_ERROR "Code coverage not support for compiler: ${CMAKE_CXX_COMPILER_ID}")
60+
endif()
61+
62+
target_compile_options(${U_NAME} PRIVATE ${COVERAGE_FLAGS})
63+
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU")
64+
target_link_options(${U_NAME} PRIVATE ${LINK_FLAGS})
65+
endif()
66+
endfunction()
67+
68+
69+
function(enable_main_coverage)
70+
find_package(GTest REQUIRED)
71+
include(GoogleTest)
72+
73+
target_link_libraries(main PRIVATE GTest::gtest)
74+
enable_testing()
75+
gtest_discover_tests(main)
76+
77+
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU")
78+
set(COVERAGE_FLAGS "--coverage" "-O0" "-g")
79+
set(LINK_FLAGS "--coverage")
80+
elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
81+
set(COVERAGE_FLAGS "-fprofile-instr-generate" "-fcoverage-mapping" "-O0" "-g")
82+
set(LINK_FLAGS "")
83+
else()
84+
message(FATAL_ERROR "Code coverage not support for compiler: ${CMAKE_CXX_COMPILER_ID}")
85+
endif()
86+
87+
target_compile_options(main PRIVATE ${COVERAGE_FLAGS})
88+
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU")
89+
target_link_options(main PRIVATE ${LINK_FLAGS})
90+
endif()
91+
endfunction()
92+
93+
4694
add_executable(main main.cpp)
4795

96+
if(TRIGGER_TESTS AND ENABLE_COVERAGE)
97+
enable_main_coverage()
98+
endif()
4899

49100
if(NOT CXX_DEPS STREQUAL "[]")
50101
parse_conan_deps(CXX_DEPS PARSED_CXX_DEPS)
@@ -57,19 +108,29 @@ endif()
57108

58109
target_include_directories(main PRIVATE ${${LIB_NAME}_INCLUDE_DIRS})
59110

60-
61111
if (TRIGGER_TESTS)
62112
find_package(${LIB_NAME} REQUIRED)
63113
find_package(GTest REQUIRED)
114+
include(GoogleTest)
64115
enable_testing()
65116

66-
file(GLOB TEST_SOURCES_U "test/unit/*.cpp")
67-
add_executable(unit_tests ${TEST_SOURCES_U})
68-
target_link_libraries(unit_tests PRIVATE ${MAIN_LIB_TARGET} GTest::gtest)
69-
add_test(NAME unit_tests COMMAND unit_tests)
70-
71-
file(GLOB TEST_SOURCES_S "test/stress/*.cpp")
117+
file(GLOB_RECURSE TEST_SOURCES_S "test/stress/*.cpp")
72118
add_executable(stress_tests ${TEST_SOURCES_S})
73119
target_link_libraries(stress_tests PRIVATE ${MAIN_LIB_TARGET} GTest::gtest)
74-
add_test(NAME stress_tests COMMAND stress_tests)
120+
gtest_discover_tests(stress_tests)
121+
122+
file(GLOB_RECURSE TEST_SOURCES_U "test/unit/*.cpp")
123+
if(NOT ENABLE_COVERAGE)
124+
add_executable(unit_tests ${TEST_SOURCES_U})
125+
target_link_libraries(unit_tests PRIVATE ${MAIN_LIB_TARGET} GTest::gtest)
126+
gtest_discover_tests(unit_tests)
127+
else()
128+
foreach(U_SOURCE IN LISTS TEST_SOURCES_U)
129+
get_filename_component(U_NAME ${U_SOURCE} NAME_WE)
130+
string(SUBSTRING ${U_NAME} 0 5 U_NAME_PREFIX)
131+
if(U_NAME_PREFIX STREQUAL "ucov_")
132+
apply_unit_coverage(${U_NAME} ${U_SOURCE})
133+
endif()
134+
endforeach()
135+
endif()
75136
endif ()

test_package/conanfile.py

Lines changed: 98 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
from conan.tools.build import can_run
44
from conan.tools.env import VirtualRunEnv, VirtualBuildEnv
55
from pathlib import Path
6+
import subprocess
67
import shutil
78
import yaml
89
import os
910
sep = os.path.sep
11+
_get_file_name = (lambda x: x.split(sep)[-1])
1012

1113

1214
def _clear_test_build():
@@ -30,6 +32,7 @@ def _recursive_find(root: str, obj_files: list[str]):
3032

3133
def _entry_lists() -> list[str]:
3234
return ['#include <gtest/gtest.h>\n',
35+
'\n',
3336
'\n',
3437
'int main(int argc, char **argv) {\n',
3538
' ::testing::InitGoogleTest(&argc, argv);\n',
@@ -68,6 +71,7 @@ def generate(self):
6871
tc.variables["LIB_NAME"] = lib_name
6972
tc.variables["CXX_DEPS"] = self._get_targets()
7073
tc.variables["TRIGGER_TESTS"] = self.metadata.get('trigger_tests')
74+
tc.variables['ENABLE_COVERAGE'] = self.metadata.get('activate_code_coverage')
7175
tc.variables["MAIN_LIB_TARGET"] = [_a := self.metadata.get('target'),
7276
f'{lib_name}::{lib_name}' if _a == 'auto' else _a][-1]
7377
tc.generate()
@@ -100,10 +104,11 @@ def layout(self):
100104

101105
def configure(self):
102106
supported_compilers = {"gcc", "msvc", "clang", "apple-clang", } # no support for 'Visual Studio' in Conan1.0
103-
if self.settings.compiler.__str__() in supported_compilers:
107+
compiler = getattr(self.settings, 'compiler')
108+
if compiler.__str__() in supported_compilers:
104109
_build_std = self.metadata.get("build_cppstd")
105110
_build_std = "17" if _build_std not in {"17", "20", "23"} else _build_std # fallback
106-
self.settings.compiler.cppstd = _build_std
111+
compiler.cppstd = _build_std
107112

108113
def test(self):
109114

@@ -138,22 +143,105 @@ def test(self):
138143

139144
self._remove_entries()
140145

146+
if self.metadata.get('activate_code_coverage'):
147+
self._code_coverage_auto()
148+
149+
def _code_coverage_auto(self):
150+
compiler = getattr(self.settings, 'compiler').__str__()
151+
if compiler == 'gcc':
152+
self._code_coverage_gcc()
153+
elif compiler == 'clang':
154+
self._code_coverage_clang()
155+
else:
156+
raise NotImplementedError(f'Compiler {compiler} is not supported.')
157+
158+
def _code_coverage_clang(self):
159+
raise NotImplementedError('Clang is under implementation')
160+
161+
def _code_coverage_gcc(self):
162+
163+
# get conan build folder
164+
_name, _ver = [self.metadata.get(_) for _ in ['name', 'version']]
165+
_tmp = subprocess.run(["conan", "list", f"{_name}/{_ver}:*"], capture_output=True, text=True)
166+
_tmp_ref = [str(_).strip() for _ in _tmp.stdout.split('\n')]
167+
_pkg_uid = _tmp_ref[[i for i, _ in enumerate(_tmp_ref) if _ == 'packages'][0] + 1]
168+
_tmp = subprocess.run(["conan", "cache", "path", f"{_name}/{_ver}:{_pkg_uid}"],
169+
capture_output=True, text=True)
170+
_main_pkg_build_fd = sep.join(_tmp.stdout.split(sep)[:-1] + ['b', 'build'])
171+
172+
# collect code coverage files to export/coverage/
173+
_gcda = [str(_) for _ in list(Path(_main_pkg_build_fd).rglob('*.gcda'))]
174+
_gcno = [_[:-4] + 'gcno' for _ in _gcda]
175+
176+
target_folder = self.recipe_folder + sep + 'test' + sep + 'export'
177+
coverage_folder = target_folder + sep + 'coverage'
178+
if not os.path.exists(target_folder):
179+
os.mkdir(target_folder)
180+
else:
181+
if os.path.exists(coverage_folder):
182+
shutil.rmtree(coverage_folder)
183+
os.mkdir(coverage_folder)
184+
185+
for v1, v2 in zip(_gcda, _gcno):
186+
shutil.copy2(v1, coverage_folder + sep + _get_file_name(v1))
187+
shutil.copy2(v2, coverage_folder + sep + _get_file_name(v2))
188+
189+
# auto html report generation
190+
cmd1 = ['lcov', '--directory', coverage_folder, '--capture', '--output-file',
191+
os.path.join(coverage_folder, 'coverage_test.info'), '--rc', 'geninfo_auto_base=1']
192+
subprocess.run(cmd1, check=True)
193+
cmd2 = ['lcov', '--extract', os.path.join(coverage_folder, 'coverage_test.info'),
194+
f'*/.conan2/p/b/{_name[:3]}*', '--output-file',
195+
os.path.join(coverage_folder, 'coverage_test.filtered.info')]
196+
subprocess.run(cmd2, check=True) # hard-coding: your package name len >= 3
197+
cmd3 = ['genhtml', os.path.join(coverage_folder, 'coverage_test.filtered.info'),
198+
'--output-directory', os.path.join(coverage_folder, 'coverage_report')]
199+
subprocess.run(cmd3, check=True)
200+
201+
# remove intermediate files
202+
for _f in os.listdir(coverage_folder):
203+
_full_name = coverage_folder + sep + _f
204+
if not os.path.isdir(_full_name):
205+
os.remove(_full_name)
206+
141207
def _add_entries(self):
142208
if self.metadata.get('trigger_tests'):
209+
143210
_f_stress = self.recipe_folder + sep + 'test' + sep + 'stress'
144-
_f_unit = self.recipe_folder + sep + 'test' + sep + 'unit'
145211
if not os.path.exists(_m := _f_stress + sep + 'main.cpp'):
146212
with open(_m, 'w', encoding='utf-8') as f:
147213
f.write(''.join(_entry_lists()))
148-
if not os.path.exists(_m := _f_unit + sep + 'main.cpp'):
149-
with open(_m, 'w', encoding='utf-8') as f:
150-
f.write(''.join(_entry_lists()))
214+
215+
_f_unit = self.recipe_folder + sep + 'test' + sep + 'unit'
216+
if self.metadata.get('activate_code_coverage'):
217+
_files = [str(_) for _ in list(Path(_f_unit).rglob('*.cpp'))]
218+
_cache = [[_a := _.split(sep), (sep.join(_a[:-1]), _a[-1])][-1] for _ in _files]
219+
for _test_src, _test_ucov in zip(_files, _cache):
220+
with open(_test_src, 'r') as f:
221+
_tmp = f.readlines()
222+
_tmp.extend(_entry_lists()[1:])
223+
with open(_test_ucov[0] + sep + 'ucov_' + _test_ucov[1], 'w', encoding='utf-8') as f:
224+
f.write(''.join(_tmp))
225+
else:
226+
if not os.path.exists(_m := _f_unit + sep + 'main.cpp'):
227+
with open(_m, 'w', encoding='utf-8') as f:
228+
f.write(''.join(_entry_lists()))
151229

152230
def _remove_entries(self):
153-
if os.path.exists(_f1 := self.recipe_folder + sep + 'test' + sep + 'stress' + sep + 'main.cpp'):
154-
os.remove(_f1)
155-
if os.path.exists(_f2 := self.recipe_folder + sep + 'test' + sep + 'unit' + sep + 'main.cpp'):
156-
os.remove(_f2)
231+
if self.metadata.get('trigger_tests'):
232+
233+
if os.path.exists(_f1 := self.recipe_folder + sep + 'test' + sep + 'stress' + sep + 'main.cpp'):
234+
os.remove(_f1)
235+
236+
if self.metadata.get('activate_code_coverage'):
237+
_f_unit = self.recipe_folder + sep + 'test' + sep + 'unit'
238+
_files = [str(_) for _ in list(Path(_f_unit).rglob('*.cpp'))]
239+
for _m in _files:
240+
if _m.split(sep)[-1].startswith('ucov_'):
241+
os.remove(_m)
242+
else:
243+
if os.path.exists(_f3 := self.recipe_folder + sep + 'test' + sep + 'unit' + sep + 'main.cpp'):
244+
os.remove(_f3)
157245

158246

159247
if __name__ == '__main__':

0 commit comments

Comments
 (0)