C++Spec 1.0.0
BDD testing for C++
Loading...
Searching...
No Matches
junit_xml.hpp
1#pragma once
2#include <algorithm>
3#include <chrono>
4#include <numeric>
5#include <string>
6
7#ifndef CPPSPEC_SEMIHOSTED
8#include <filesystem>
9#endif
10
11#include "formatters_base.hpp"
12#include "it_base.hpp"
13
14namespace CppSpec::Formatters {
15// JUnit XML header
16constexpr static auto junit_xml_header = R"(<?xml version="1.0" encoding="UTF-8"?>)";
17
18inline std::string encode_xml(const std::string& data) {
19 std::string buffer;
20 for (char c : data) {
21 switch (c) {
22 case '<':
23 buffer += "&lt;";
24 break;
25 case '>':
26 buffer += "&gt;";
27 break;
28 case '&':
29 buffer += "&amp;";
30 break;
31 case '"':
32 buffer += "&quot;";
33 break;
34 case '\'':
35 buffer += "&apos;";
36 break;
37 default:
38 buffer += c;
39 }
40 }
41 return buffer;
42}
43
44// Format a timestamp as local time, as JUnit XML expects (ISO 8601 without a time zone)
45inline std::string format_timestamp(std::chrono::time_point<std::chrono::system_clock> timestamp) {
46#if defined(__APPLE__) || defined(CPPSPEC_SEMIHOSTED)
47 // Cludge because macOS doesn't have std::chrono::current_zone() or std::chrono::zoned_time()
48 std::time_t time_t_timestamp = std::chrono::system_clock::to_time_t(timestamp);
49 std::tm localtime = *std::localtime(&time_t_timestamp);
50 std::ostringstream oss;
51 oss << std::put_time(&localtime, "%Y-%m-%dT%H:%M:%S");
52 return oss.str();
53#else
54 // Use std::chrono::current_zone() and std::chrono::zoned_time() if available (C++20)
55 auto localtime = std::chrono::zoned_time(std::chrono::current_zone(), timestamp).get_local_time();
56 return std::format("{0:%F}T{0:%T}", localtime);
57#endif
58}
59
60namespace JUnitNodes {
61struct Result {
62 enum class Status { Failure, Error, Skipped };
63 Status status = Status::Failure;
64 std::string message;
65 std::string type;
66 std::string text;
67
68 Result(std::string message, std::string type, std::string text, Status status = Status::Failure)
69 : status(status), message(std::move(message)), type(std::move(type)), text(std::move(text)) {}
70
71 [[nodiscard]] std::string status_string() const {
72 switch (status) {
73 case Status::Failure:
74 return "failure";
75 case Status::Error:
76 return "error";
77 case Status::Skipped:
78 return "skipped";
79 }
80 return "failure"; // Default to failure if status is unknown
81 }
82
83 [[nodiscard]] std::string to_xml() const {
84 return std::format(R"( <{} message="{}" type="{}">{}</{}>)", status_string(), encode_xml(message),
85 encode_xml(type), encode_xml(text), status_string());
86 }
87};
88
89struct TestCase {
90 std::string name;
91 std::string classname;
92 std::size_t assertions = 0;
93 std::chrono::duration<double> time;
94 std::list<Result> results;
95 std::string file;
96 std::size_t line;
97
98 [[nodiscard]] std::string to_xml() const {
99 auto start =
100 std::format(R"( <testcase name="{}" classname="{}" assertions="{}" time="{:f}" file="{}" line="{}")",
101 encode_xml(name), encode_xml(classname), assertions, time.count(), file, line);
102 if (results.empty()) {
103 return start + "/>";
104 }
105
106 auto xml_results = results | std::views::transform([](const Result& r) { return r.to_xml(); });
107
108 std::stringstream ss;
109 ss << start << ">" << std::endl;
110
111 ss << std::accumulate(xml_results.begin(), xml_results.end(), std::string{},
112 [](const std::string& acc, const std::string& r) { return acc + "\n" + r; });
113 ss << std::endl;
114 ss << " </testcase>";
115 return ss.str();
116 }
117};
118
119struct TestSuite {
120 size_t id;
121 std::string name;
122 std::chrono::duration<double> time;
123 std::chrono::time_point<std::chrono::system_clock> timestamp;
124 std::size_t tests;
125 std::size_t failures;
126 std::list<TestCase> cases;
127
128 TestSuite(std::string name,
129 std::chrono::duration<double> time,
130 size_t tests,
131 size_t failures,
132 std::chrono::time_point<std::chrono::system_clock> timestamp)
133 : id(get_next_id()), name(std::move(name)), time(time), timestamp(timestamp), tests(tests), failures(failures) {}
134
135 [[nodiscard]] std::string to_xml() const {
136 std::string timestamp_str = format_timestamp(timestamp);
137
138 std::stringstream ss;
139 ss << " "
140 << std::format(R"(<testsuite id="{}" name="{}" time="{:f}" timestamp="{}" tests="{}" failures="{}">)", id,
141 encode_xml(name), time.count(), timestamp_str, tests, failures);
142 ss << std::endl;
143 for (const TestCase& test_case : cases) {
144 ss << test_case.to_xml() << std::endl;
145 }
146 ss << " </testsuite>";
147 return ss.str();
148 }
149
150 static size_t get_next_id() {
151 static std::size_t id_counter = 0;
152 return id_counter++;
153 }
154};
155
157 std::string name;
158 size_t tests = 0;
159 size_t failures = 0;
160 std::chrono::duration<double> time{};
161 std::chrono::time_point<std::chrono::system_clock> timestamp;
162
163 std::list<TestSuite> suites;
164
165 [[nodiscard]] std::string to_xml() const {
166 std::stringstream ss;
167 auto timestamp_str = format_timestamp(timestamp);
168 ss << std::format(R"(<testsuites name="{}" tests="{}" failures="{}" time="{:f}" timestamp="{}">)", encode_xml(name),
169 tests, failures, time.count(), timestamp_str);
170 ss << std::endl;
171 for (const TestSuite& suite : suites) {
172 ss << suite.to_xml() << std::endl;
173 }
174 ss << "</testsuites>" << std::endl;
175 return ss.str();
176 }
177};
178} // namespace JUnitNodes
179
180class JUnitXML : public BaseFormatter {
181 JUnitNodes::TestSuites test_suites;
182
183 public:
184 explicit JUnitXML(std::ostream& out_stream = std::cout, bool color = is_terminal())
185 : BaseFormatter(out_stream, color) {}
186
187 ~JUnitXML() {
188 test_suites.tests =
189 std::accumulate(test_suites.suites.begin(), test_suites.suites.end(), size_t{0},
190 [](size_t sum, const JUnitNodes::TestSuite& suite) { return sum + suite.tests; });
191 test_suites.failures =
192 std::accumulate(test_suites.suites.begin(), test_suites.suites.end(), size_t{0},
193 [](size_t sum, const JUnitNodes::TestSuite& suite) { return sum + suite.failures; });
194 test_suites.time = std::ranges::fold_left(test_suites.suites, std::chrono::duration<double>(0),
195 [](const auto& acc, const auto& suite) { return acc + suite.time; });
196 test_suites.timestamp =
197 test_suites.suites.empty() ? std::chrono::system_clock::now() : test_suites.suites.front().timestamp;
198
199 out_stream << std::fixed; // disable scientific notation
200 // out_stream << std::setprecision(6); // set precision to 6 decimal places
201 out_stream << junit_xml_header << std::endl;
202 out_stream << test_suites.to_xml() << std::endl;
203 out_stream.flush();
204 }
205
206 void format(const Description& description) override {
207 if (test_suites.name.empty()) {
208#ifdef CPPSPEC_SEMIHOSTED
209 std::string file_path = description.get_location().file_name();
210 // remove leading folders
211 auto pos = file_path.find_last_of("/");
212 if (pos != std::string::npos) {
213 file_path = file_path.substr(pos + 1);
214 }
215
216 // remove extension
217 pos = file_path.find_last_of('.');
218 if (pos != std::string::npos) {
219 file_path = file_path.substr(0, pos);
220 }
221
222 test_suites.name = file_path;
223#else
224 std::filesystem::path file_path = description.get_location().file_name();
225 test_suites.name = file_path.stem().string();
226#endif
227 }
228 if (description.has_parent()) {
229 return;
230 }
231 test_suites.suites.emplace_back(description.get_description(), description.get_runtime(), description.num_tests(),
232 description.num_failures(), description.get_start_time());
233 }
234
235 void format(const ItBase& it) override {
236 using namespace std::chrono;
237 std::forward_list<std::string> descriptions;
238
239 descriptions.push_front(it.get_description());
240 for (const auto* parent = it.get_parent_as<Description>(); parent->has_parent();
241 parent = parent->get_parent_as<Description>()) {
242 descriptions.push_front(parent->get_description());
243 }
244
245 std::string description = Util::join(descriptions, " ");
246
247 auto test_case = JUnitNodes::TestCase{
248 .name = description,
249 .classname = "",
250 .assertions = it.get_results().size(),
251 .time = it.get_runtime(),
252 .results = {},
253 .file = it.get_location().file_name(),
254 .line = it.get_location().line(),
255 };
256
257 for (const Result& result : it.get_results()) {
258 if (result.is_success()) {
259 continue;
260 }
261 test_case.results.emplace_back(result.get_location_string() + ": Match failure.", result.get_type(),
262 result.get_message());
263 }
264
265 test_suites.suites.back().cases.push_back(test_case);
266 }
267};
268} // namespace CppSpec::Formatters
Definition description.hpp:22
Base class for it expressions.
Definition it_base.hpp:32
Definition result.hpp:13
bool has_parent() noexcept
Check to see if the Runnable has a parent.
Definition runnable.hpp:56
Definition junit_xml.hpp:61
Definition junit_xml.hpp:119
std::string join(std::ranges::range auto &iterable, const std::string &separator="")
Implode a string.
Definition util.hpp:119