Skip to content

junit

Build JUnit XML reports from build events.

JUnit XML schema (consumed by GitLab CI, Jenkins, etc.):

<testsuites>
  <testsuite name="..." tests="N" failures="M" errors="K" time="T">
    <testcase name="..." classname="..." time="T">
      <failure message="..." type="...">details</failure>
      <error message="...">details</error>
      <system-out>log lines</system-out>
      <system-err>error lines</system-err>
    </testcase>
  </testsuite>
</testsuites>

TestSuite

A single testsuite to include in a multi-suite JUnit report.

Attributes:

Name Type Description
name

Value for the <testsuite name="..."> attribute.

builds

Build events (or synthetic events) for this suite.

error_messages

Suite-level <system-err> messages.

elapsed_seconds

Wall-clock duration of the underlying nix process that produced these builds, used for the <testsuite time="..."> attribute when testcase_times is not supplied. None when the caller has no measurement (renders as "0").

testcase_times

Optional per-testcase wall-clock attribution. Keys are BuildEvent.name (the <testcase name="..."> value); values are seconds. When supplied, every <testcase time="..."> is set from this map (entries missing from the map render as "0") and the <testsuite time="..."> becomes the sum of the values — the standard JUnit convention so consumers (GitLab, Jenkins) that compute totals by summing testcase times agree with the suite total.

Source code in src/junix/junit.py
class TestSuite:
    """A single testsuite to include in a multi-suite JUnit report.

    Attributes:
        name: Value for the ``<testsuite name="...">`` attribute.
        builds: Build events (or synthetic events) for this suite.
        error_messages: Suite-level ``<system-err>`` messages.
        elapsed_seconds: Wall-clock duration of the underlying nix process
            that produced these builds, used for the ``<testsuite time="...">``
            attribute when ``testcase_times`` is not supplied. ``None``
            when the caller has no measurement (renders as ``"0"``).
        testcase_times: Optional per-testcase wall-clock attribution.
            Keys are ``BuildEvent.name`` (the ``<testcase name="...">``
            value); values are seconds. When supplied, every
            ``<testcase time="...">`` is set from this map (entries
            missing from the map render as ``"0"``) and the
            ``<testsuite time="...">`` becomes the sum of the values
            — the standard JUnit convention so consumers (GitLab,
            Jenkins) that compute totals by summing testcase times
            agree with the suite total.
    """

    # Tell pytest not to try collecting this as a test class (the name
    # ``TestSuite`` would otherwise match its discovery heuristic).
    __test__ = False

    def __init__(
        self,
        name: str,
        builds: list[BuildEvent],
        error_messages: list[str] | None = None,
        elapsed_seconds: float | None = None,
        testcase_times: dict[str, float] | None = None,
    ) -> None:
        """Initialize a TestSuite.

        Args:
            name: Value for the ``<testsuite name="...">`` attribute.
            builds: Build events for this suite.
            error_messages: Suite-level ``<system-err>`` messages.
            elapsed_seconds: Wall-clock seconds for ``<testsuite time="...">``
                when ``testcase_times`` is not supplied. ``None``
                (default) renders as ``"0"``.
            testcase_times: Per-testcase wall-clock attribution. When
                supplied, ``<testcase time="...">`` is set from this
                map (entries missing from the map render as ``"0"``)
                and ``<testsuite time="...">`` is the sum of the
                values. Takes precedence over ``elapsed_seconds``.
        """
        self.name = name
        self.builds = builds
        self.error_messages = error_messages
        self.elapsed_seconds = elapsed_seconds
        self.testcase_times = testcase_times

__init__(name, builds, error_messages=None, elapsed_seconds=None, testcase_times=None)

Initialize a TestSuite.

Parameters:

Name Type Description Default
name str

Value for the <testsuite name="..."> attribute.

required
builds list[BuildEvent]

Build events for this suite.

required
error_messages list[str] | None

Suite-level <system-err> messages.

None
elapsed_seconds float | None

Wall-clock seconds for <testsuite time="..."> when testcase_times is not supplied. None (default) renders as "0".

None
testcase_times dict[str, float] | None

Per-testcase wall-clock attribution. When supplied, <testcase time="..."> is set from this map (entries missing from the map render as "0") and <testsuite time="..."> is the sum of the values. Takes precedence over elapsed_seconds.

None
Source code in src/junix/junit.py
def __init__(
    self,
    name: str,
    builds: list[BuildEvent],
    error_messages: list[str] | None = None,
    elapsed_seconds: float | None = None,
    testcase_times: dict[str, float] | None = None,
) -> None:
    """Initialize a TestSuite.

    Args:
        name: Value for the ``<testsuite name="...">`` attribute.
        builds: Build events for this suite.
        error_messages: Suite-level ``<system-err>`` messages.
        elapsed_seconds: Wall-clock seconds for ``<testsuite time="...">``
            when ``testcase_times`` is not supplied. ``None``
            (default) renders as ``"0"``.
        testcase_times: Per-testcase wall-clock attribution. When
            supplied, ``<testcase time="...">`` is set from this
            map (entries missing from the map render as ``"0"``)
            and ``<testsuite time="...">`` is the sum of the
            values. Takes precedence over ``elapsed_seconds``.
    """
    self.name = name
    self.builds = builds
    self.error_messages = error_messages
    self.elapsed_seconds = elapsed_seconds
    self.testcase_times = testcase_times

_build_suite_element(suite)

Build a single <testsuite> element from a TestSuite.

The suite's time attribute is the sum of the per-testcase time values that will be emitted below — the standard JUnit convention. When BuildEvent.duration is None (synthetic / cached entries with no start event), the testcase time is 0 and the suite total excludes it. elapsed_seconds is used as a fallback for callers that have no per-testcase timings.

Parameters:

Name Type Description Default
suite TestSuite

The TestSuite to convert.

required

Returns:

Type Description
Element

An xml.etree.ElementTree.Element for the testsuite.

Source code in src/junix/junit.py
def _build_suite_element(suite: TestSuite) -> ET.Element:
    """Build a single ``<testsuite>`` element from a TestSuite.

    The suite's ``time`` attribute is the sum of the per-testcase
    ``time`` values that will be emitted below — the standard JUnit
    convention. When ``BuildEvent.duration`` is ``None`` (synthetic
    / cached entries with no start event), the testcase time is 0
    and the suite total excludes it. ``elapsed_seconds`` is used as
    a fallback for callers that have no per-testcase timings.

    Args:
        suite: The TestSuite to convert.

    Returns:
        An xml.etree.ElementTree.Element for the testsuite.
    """
    testsuite = ET.Element("testsuite")
    testsuite.set("name", suite.name)

    builds = suite.builds
    total = len(builds)
    failed = sum(1 for b in builds if b.stopped and not b.success)
    incomplete = sum(1 for b in builds if not b.stopped)
    skipped = sum(1 for b in builds if b.cached)
    testsuite.set("tests", str(total))
    testsuite.set("failures", str(failed))
    testsuite.set("errors", str(incomplete))
    testsuite.set("skipped", str(skipped))

    # Per-testcase time comes from the explicit override map when
    # supplied; otherwise it falls back to ``BuildEvent.duration``
    # (the wall-clock measured by ``NixEventHandler`` between the
    # actBuild start and stop events). The suite ``time`` is the
    # sum of per-testcase times — the standard JUnit convention so
    # consumers (GitLab, Jenkins) that compute totals from testcase
    # times agree with the suite value. Synthetic / cached builds
    # with no measured duration contribute 0.
    if suite.testcase_times is None:
        testcase_times = {b.name: (b.duration or 0.0) for b in builds}
    else:
        testcase_times = suite.testcase_times
    testsuite.set("time", _format_seconds(sum(testcase_times.values())))

    _has_system_err = False
    _first_tc: ET.Element | None = None
    for build in sorted(builds, key=lambda b: b.name):
        tc = _build_testcase(
            build,
            suite.error_messages,
            _has_system_err,
            testcase_time=testcase_times.get(build.name, 0.0),
        )
        testsuite.append(tc)
        if _first_tc is None:
            _first_tc = tc
        if tc.find("system-err") is not None:
            _has_system_err = True

    # Fallback: if there are error messages but no failing testcase,
    # attach system-err to the first testcase.
    if suite.error_messages and not _has_system_err and _first_tc is not None:
        _first_tc.append(_make_text_elem("system-err", suite.error_messages))

    return testsuite

_build_testcase(build, error_messages, has_system_err, testcase_time=None)

Build a single <testcase> element.

Parameters:

Name Type Description Default
build BuildEvent

The build event to convert.

required
error_messages list[str] | None

Suite-level error messages (attached to first failure).

required
has_system_err bool

Whether system-err has already been emitted.

required
testcase_time float | None

Per-testcase wall-clock attribution in seconds. None renders as "0" (the protocol can't say per- build time, so without an explicit attribution we admit the unknown).

None

Returns:

Type Description
Element

An xml.etree.ElementTree.Element for the testcase.

Source code in src/junix/junit.py
def _build_testcase(
    build: BuildEvent,
    error_messages: list[str] | None,
    has_system_err: bool,
    testcase_time: float | None = None,
) -> ET.Element:
    """Build a single ``<testcase>`` element.

    Args:
        build: The build event to convert.
        error_messages: Suite-level error messages (attached to first failure).
        has_system_err: Whether system-err has already been emitted.
        testcase_time: Per-testcase wall-clock attribution in seconds.
            ``None`` renders as ``"0"`` (the protocol can't say per-
            build time, so without an explicit attribution we admit
            the unknown).

    Returns:
        An xml.etree.ElementTree.Element for the testcase.
    """
    tc = ET.Element("testcase")
    tc.set("name", build.name)
    tc.set("classname", build.drv_path)
    tc.set("time", _format_seconds(testcase_time or 0.0))

    if not build.stopped:
        error_el = ET.SubElement(tc, "error")
        error_el.set("message", "Build did not complete")
        error_el.set("type", "incomplete")
        if build.log_lines:
            tc.append(_make_text_elem("system-err", build.log_lines))
    elif not build.success:
        failure_el = ET.SubElement(tc, "failure")
        if build.failed_dep_drv_path:
            # Dep-failed target: not built because some other
            # derivation failed.  Use a distinct ``type`` so CI
            # viewers can group dep-failures separately, and
            # include the failing dep's drv path in the message
            # so users can click through to the root cause.
            failure_el.set("type", "dependency")
            failure_el.set(
                "message",
                f"Dependency failed: {build.failed_dep_drv_path}",
            )
        else:
            failure_el.set("message", "Build failed")
            failure_el.set("type", "failure")
        if build.log_lines:
            tc.append(_make_text_elem("system-out", build.log_lines))
        if error_messages and not has_system_err:
            tc.append(_make_text_elem("system-err", error_messages))
    elif build.cached:
        skip_el = ET.SubElement(tc, "skipped")
        skip_el.set("message", "Cached / substituted")
        skip_el.set("type", "cached")
    else:
        if build.log_lines:
            tc.append(_make_text_elem("system-out", build.log_lines))

    return tc

_format_seconds(seconds)

Format a duration in seconds for a JUnit time attribute.

Whole numbers are rendered without a trailing .0 ("0", "7") so the output stays clean for the common case. Fractional values keep up to three significant decimals ("7.5", "0.123") — enough precision for build timings without trailing-zero noise. Sub- millisecond values collapse to "0" rather than the empty string, so the time attribute is always present and parsable.

Parameters:

Name Type Description Default
seconds float

Duration in seconds.

required

Returns:

Type Description
str

A short, human-readable string suitable for the time attribute.

Source code in src/junix/junit.py
def _format_seconds(seconds: float) -> str:
    """Format a duration in seconds for a JUnit ``time`` attribute.

    Whole numbers are rendered without a trailing ``.0`` (``"0"``, ``"7"``)
    so the output stays clean for the common case. Fractional values keep
    up to three significant decimals (``"7.5"``, ``"0.123"``) — enough
    precision for build timings without trailing-zero noise.  Sub-
    millisecond values collapse to ``"0"`` rather than the empty
    string, so the ``time`` attribute is always present and parsable.

    Args:
        seconds: Duration in seconds.

    Returns:
        A short, human-readable string suitable for the ``time`` attribute.
    """
    if seconds < 1e-6:
        return "0"
    if seconds == int(seconds):
        return str(int(seconds))
    return f"{seconds:.3f}".rstrip("0").rstrip(".")

_make_text_elem(tag, lines)

Create a text element with joined lines content.

Parameters:

Name Type Description Default
tag str

XML tag name (e.g. "system-out").

required
lines list[str]

Lines of text to join with newlines.

required

Returns:

Type Description
Element

An xml.etree.ElementTree.Element with the joined text.

Source code in src/junix/junit.py
def _make_text_elem(tag: str, lines: list[str]) -> ET.Element:
    """Create a text element with joined lines content.

    Args:
        tag: XML tag name (e.g. ``"system-out"``).
        lines: Lines of text to join with newlines.

    Returns:
        An xml.etree.ElementTree.Element with the joined text.
    """
    e = ET.Element(tag)
    e.text = "\n".join(lines) + "\n" if lines else ""
    return e

report_to_xml(builds, suite_name='nix-build', error_messages=None, elapsed_seconds=None, testcase_times=None)

Convert build events into a JUnit XML string (single suite).

Parameters:

Name Type Description Default
builds list[BuildEvent]

Collected build events from NixEventHandler.

required
suite_name str

Value for the <testsuite name="..."> attribute.

'nix-build'
error_messages list[str] | None

Additional error messages (from the Nix logger) to include in the suite-level <system-err>.

None
elapsed_seconds float | None

Wall-clock seconds for <testsuite time="..."> when testcase_times is not supplied.

None
testcase_times dict[str, float] | None

Per-testcase wall-clock attribution. Takes precedence over elapsed_seconds when both are supplied.

None

Returns:

Type Description
str

A JUnit XML string.

Source code in src/junix/junit.py
def report_to_xml(
    builds: list[BuildEvent],
    suite_name: str = "nix-build",
    error_messages: list[str] | None = None,
    elapsed_seconds: float | None = None,
    testcase_times: dict[str, float] | None = None,
) -> str:
    """Convert build events into a JUnit XML string (single suite).

    Args:
        builds: Collected build events from NixEventHandler.
        suite_name: Value for the ``<testsuite name="...">`` attribute.
        error_messages: Additional error messages (from the Nix logger)
            to include in the suite-level ``<system-err>``.
        elapsed_seconds: Wall-clock seconds for ``<testsuite time="...">``
            when ``testcase_times`` is not supplied.
        testcase_times: Per-testcase wall-clock attribution. Takes
            precedence over ``elapsed_seconds`` when both are supplied.

    Returns:
        A JUnit XML string.
    """
    return report_to_xml_multi(
        [
            TestSuite(
                name=suite_name,
                builds=builds,
                error_messages=error_messages,
                elapsed_seconds=elapsed_seconds,
                testcase_times=testcase_times,
            ),
        ]
    )

report_to_xml_multi(suites)

Convert multiple TestSuites into a single JUnit XML string.

Parameters:

Name Type Description Default
suites list[TestSuite]

List of TestSuite objects to include.

required

Returns:

Type Description
str

A JUnit XML string with one <testsuites> root and multiple

str

<testsuite> children.

Source code in src/junix/junit.py
def report_to_xml_multi(suites: list[TestSuite]) -> str:
    """Convert multiple TestSuites into a single JUnit XML string.

    Args:
        suites: List of TestSuite objects to include.

    Returns:
        A JUnit XML string with one ``<testsuites>`` root and multiple
        ``<testsuite>`` children.
    """
    root = ET.Element("testsuites")
    for suite in suites:
        root.append(_build_suite_element(suite))
    ET.indent(root, space="  ")
    body: str = ET.tostring(root, encoding="utf-8", xml_declaration=True).decode(
        "utf-8"
    )
    return body + "\n"