From f0a61269e8359ce7b132b125b910f1f047b3a9f9 Mon Sep 17 00:00:00 2001
From: Joep Hoffland <joephoffland@gmail.com>
Date: Thu, 22 Dec 2022 15:48:17 +0100
Subject: [PATCH] Improved Cobertura report

---
 src/Report/Cobertura.php                      | 272 +-----------------
 src/Report/Cobertura/CoberturaClass.php       | 164 +++++++++++
 src/Report/Cobertura/CoberturaCoverage.php    | 245 ++++++++++++++++
 src/Report/Cobertura/CoberturaElement.php     |  71 +++++
 src/Report/Cobertura/CoberturaLine.php        |  62 ++++
 src/Report/Cobertura/CoberturaMethod.php      | 104 +++++++
 src/Report/Cobertura/CoberturaPackage.php     |  99 +++++++
 tests/_files/BankAccount-cobertura-line.xml   |  15 +-
 tests/_files/BankAccount-cobertura-path.xml   |  15 +-
 ...lass-with-anonymous-function-cobertura.xml |   9 +-
 .../class-with-outside-function-cobertura.xml |  15 +-
 tests/_files/ignored-lines-cobertura.xml      |  13 +-
 tests/tests/Report/CoberturaTest.php          |  33 ++-
 13 files changed, 807 insertions(+), 310 deletions(-)
 create mode 100644 src/Report/Cobertura/CoberturaClass.php
 create mode 100644 src/Report/Cobertura/CoberturaCoverage.php
 create mode 100644 src/Report/Cobertura/CoberturaElement.php
 create mode 100644 src/Report/Cobertura/CoberturaLine.php
 create mode 100644 src/Report/Cobertura/CoberturaMethod.php
 create mode 100644 src/Report/Cobertura/CoberturaPackage.php

diff --git a/src/Report/Cobertura.php b/src/Report/Cobertura.php
index 0d1dde760..67a050b71 100644
--- a/src/Report/Cobertura.php
+++ b/src/Report/Cobertura.php
@@ -9,18 +9,11 @@
  */
 namespace SebastianBergmann\CodeCoverage\Report;
 
-use function basename;
-use function count;
 use function dirname;
 use function file_put_contents;
-use function preg_match;
-use function range;
-use function str_replace;
-use function time;
-use DOMImplementation;
 use SebastianBergmann\CodeCoverage\CodeCoverage;
 use SebastianBergmann\CodeCoverage\Driver\WriteOperationFailedException;
-use SebastianBergmann\CodeCoverage\Node\File;
+use SebastianBergmann\CodeCoverage\Report\Cobertura\CoberturaCoverage;
 use SebastianBergmann\CodeCoverage\Util\Filesystem;
 
 final class Cobertura
@@ -30,268 +23,7 @@ final class Cobertura
      */
     public function process(CodeCoverage $coverage, ?string $target = null): string
     {
-        $time = (string) time();
-
-        $report = $coverage->getReport();
-
-        $implementation = new DOMImplementation;
-
-        $documentType = $implementation->createDocumentType(
-            'coverage',
-            '',
-            'http://cobertura.sourceforge.net/xml/coverage-04.dtd'
-        );
-
-        $document               = $implementation->createDocument('', '', $documentType);
-        $document->xmlVersion   = '1.0';
-        $document->encoding     = 'UTF-8';
-        $document->formatOutput = true;
-
-        $coverageElement = $document->createElement('coverage');
-
-        $linesValid   = $report->numberOfExecutableLines();
-        $linesCovered = $report->numberOfExecutedLines();
-        $lineRate     = $linesValid === 0 ? 0 : ($linesCovered / $linesValid);
-        $coverageElement->setAttribute('line-rate', (string) $lineRate);
-
-        $branchesValid   = $report->numberOfExecutableBranches();
-        $branchesCovered = $report->numberOfExecutedBranches();
-        $branchRate      = $branchesValid === 0 ? 0 : ($branchesCovered / $branchesValid);
-        $coverageElement->setAttribute('branch-rate', (string) $branchRate);
-
-        $coverageElement->setAttribute('lines-covered', (string) $report->numberOfExecutedLines());
-        $coverageElement->setAttribute('lines-valid', (string) $report->numberOfExecutableLines());
-        $coverageElement->setAttribute('branches-covered', (string) $report->numberOfExecutedBranches());
-        $coverageElement->setAttribute('branches-valid', (string) $report->numberOfExecutableBranches());
-        $coverageElement->setAttribute('complexity', '');
-        $coverageElement->setAttribute('version', '0.4');
-        $coverageElement->setAttribute('timestamp', $time);
-
-        $document->appendChild($coverageElement);
-
-        $sourcesElement = $document->createElement('sources');
-        $coverageElement->appendChild($sourcesElement);
-
-        $sourceElement = $document->createElement('source', $report->pathAsString());
-        $sourcesElement->appendChild($sourceElement);
-
-        $packagesElement = $document->createElement('packages');
-        $coverageElement->appendChild($packagesElement);
-
-        $complexity = 0;
-
-        foreach ($report as $item) {
-            if (!$item instanceof File) {
-                continue;
-            }
-
-            $packageElement    = $document->createElement('package');
-            $packageComplexity = 0;
-
-            $packageElement->setAttribute('name', str_replace($report->pathAsString() . DIRECTORY_SEPARATOR, '', $item->pathAsString()));
-
-            $linesValid   = $item->numberOfExecutableLines();
-            $linesCovered = $item->numberOfExecutedLines();
-            $lineRate     = $linesValid === 0 ? 0 : ($linesCovered / $linesValid);
-
-            $packageElement->setAttribute('line-rate', (string) $lineRate);
-
-            $branchesValid   = $item->numberOfExecutableBranches();
-            $branchesCovered = $item->numberOfExecutedBranches();
-            $branchRate      = $branchesValid === 0 ? 0 : ($branchesCovered / $branchesValid);
-
-            $packageElement->setAttribute('branch-rate', (string) $branchRate);
-
-            $packageElement->setAttribute('complexity', '');
-            $packagesElement->appendChild($packageElement);
-
-            $classesElement = $document->createElement('classes');
-
-            $packageElement->appendChild($classesElement);
-
-            $classes      = $item->classesAndTraits();
-            $coverageData = $item->lineCoverageData();
-
-            foreach ($classes as $className => $class) {
-                $complexity += $class['ccn'];
-                $packageComplexity += $class['ccn'];
-
-                if (!empty($class['package']['namespace'])) {
-                    $className = $class['package']['namespace'] . '\\' . $className;
-                }
-
-                $linesValid   = $class['executableLines'];
-                $linesCovered = $class['executedLines'];
-                $lineRate     = $linesValid === 0 ? 0 : ($linesCovered / $linesValid);
-
-                $branchesValid   = $class['executableBranches'];
-                $branchesCovered = $class['executedBranches'];
-                $branchRate      = $branchesValid === 0 ? 0 : ($branchesCovered / $branchesValid);
-
-                $classElement = $document->createElement('class');
-
-                $classElement->setAttribute('name', $className);
-                $classElement->setAttribute('filename', str_replace($report->pathAsString() . DIRECTORY_SEPARATOR, '', $item->pathAsString()));
-                $classElement->setAttribute('line-rate', (string) $lineRate);
-                $classElement->setAttribute('branch-rate', (string) $branchRate);
-                $classElement->setAttribute('complexity', (string) $class['ccn']);
-
-                $classesElement->appendChild($classElement);
-
-                $methodsElement = $document->createElement('methods');
-
-                $classElement->appendChild($methodsElement);
-
-                $classLinesElement = $document->createElement('lines');
-
-                $classElement->appendChild($classLinesElement);
-
-                foreach ($class['methods'] as $methodName => $method) {
-                    if ($method['executableLines'] === 0) {
-                        continue;
-                    }
-
-                    preg_match("/\((.*?)\)/", $method['signature'], $signature);
-
-                    $linesValid   = $method['executableLines'];
-                    $linesCovered = $method['executedLines'];
-                    $lineRate     = $linesValid === 0 ? 0 : ($linesCovered / $linesValid);
-
-                    $branchesValid   = $method['executableBranches'];
-                    $branchesCovered = $method['executedBranches'];
-                    $branchRate      = $branchesValid === 0 ? 0 : ($branchesCovered / $branchesValid);
-
-                    $methodElement = $document->createElement('method');
-
-                    $methodElement->setAttribute('name', $methodName);
-                    $methodElement->setAttribute('signature', $signature[1]);
-                    $methodElement->setAttribute('line-rate', (string) $lineRate);
-                    $methodElement->setAttribute('branch-rate', (string) $branchRate);
-                    $methodElement->setAttribute('complexity', (string) $method['ccn']);
-
-                    $methodLinesElement = $document->createElement('lines');
-
-                    $methodElement->appendChild($methodLinesElement);
-
-                    foreach (range($method['startLine'], $method['endLine']) as $line) {
-                        if (!isset($coverageData[$line]) || $coverageData[$line] === null) {
-                            continue;
-                        }
-                        $methodLineElement = $document->createElement('line');
-
-                        $methodLineElement->setAttribute('number', (string) $line);
-                        $methodLineElement->setAttribute('hits', (string) count($coverageData[$line]));
-
-                        $methodLinesElement->appendChild($methodLineElement);
-
-                        $classLineElement = $methodLineElement->cloneNode();
-
-                        $classLinesElement->appendChild($classLineElement);
-                    }
-
-                    $methodsElement->appendChild($methodElement);
-                }
-            }
-
-            if ($report->numberOfFunctions() === 0) {
-                $packageElement->setAttribute('complexity', (string) $packageComplexity);
-
-                continue;
-            }
-
-            $functionsComplexity      = 0;
-            $functionsLinesValid      = 0;
-            $functionsLinesCovered    = 0;
-            $functionsBranchesValid   = 0;
-            $functionsBranchesCovered = 0;
-
-            $classElement = $document->createElement('class');
-            $classElement->setAttribute('name', basename($item->pathAsString()));
-            $classElement->setAttribute('filename', str_replace($report->pathAsString() . DIRECTORY_SEPARATOR, '', $item->pathAsString()));
-
-            $methodsElement = $document->createElement('methods');
-
-            $classElement->appendChild($methodsElement);
-
-            $classLinesElement = $document->createElement('lines');
-
-            $classElement->appendChild($classLinesElement);
-
-            $functions = $report->functions();
-
-            foreach ($functions as $functionName => $function) {
-                if ($function['executableLines'] === 0) {
-                    continue;
-                }
-
-                $complexity += $function['ccn'];
-                $packageComplexity += $function['ccn'];
-                $functionsComplexity += $function['ccn'];
-
-                $linesValid   = $function['executableLines'];
-                $linesCovered = $function['executedLines'];
-                $lineRate     = $linesValid === 0 ? 0 : ($linesCovered / $linesValid);
-
-                $functionsLinesValid += $linesValid;
-                $functionsLinesCovered += $linesCovered;
-
-                $branchesValid   = $function['executableBranches'];
-                $branchesCovered = $function['executedBranches'];
-                $branchRate      = $branchesValid === 0 ? 0 : ($branchesCovered / $branchesValid);
-
-                $functionsBranchesValid += $branchesValid;
-                $functionsBranchesCovered += $branchesValid;
-
-                $methodElement = $document->createElement('method');
-
-                $methodElement->setAttribute('name', $functionName);
-                $methodElement->setAttribute('signature', $function['signature']);
-                $methodElement->setAttribute('line-rate', (string) $lineRate);
-                $methodElement->setAttribute('branch-rate', (string) $branchRate);
-                $methodElement->setAttribute('complexity', (string) $function['ccn']);
-
-                $methodLinesElement = $document->createElement('lines');
-
-                $methodElement->appendChild($methodLinesElement);
-
-                foreach (range($function['startLine'], $function['endLine']) as $line) {
-                    if (!isset($coverageData[$line]) || $coverageData[$line] === null) {
-                        continue;
-                    }
-                    $methodLineElement = $document->createElement('line');
-
-                    $methodLineElement->setAttribute('number', (string) $line);
-                    $methodLineElement->setAttribute('hits', (string) count($coverageData[$line]));
-
-                    $methodLinesElement->appendChild($methodLineElement);
-
-                    $classLineElement = $methodLineElement->cloneNode();
-
-                    $classLinesElement->appendChild($classLineElement);
-                }
-
-                $methodsElement->appendChild($methodElement);
-            }
-
-            $packageElement->setAttribute('complexity', (string) $packageComplexity);
-
-            if ($functionsLinesValid === 0) {
-                continue;
-            }
-
-            $lineRate   = $functionsLinesCovered / $functionsLinesValid;
-            $branchRate = $functionsBranchesValid === 0 ? 0 : ($functionsBranchesCovered / $functionsBranchesValid);
-
-            $classElement->setAttribute('line-rate', (string) $lineRate);
-            $classElement->setAttribute('branch-rate', (string) $branchRate);
-            $classElement->setAttribute('complexity', (string) $functionsComplexity);
-
-            $classesElement->appendChild($classElement);
-        }
-
-        $coverageElement->setAttribute('complexity', (string) $complexity);
-
-        $buffer = $document->saveXML();
+        $buffer = CoberturaCoverage::create($coverage->getReport())->generateDocument()->saveXML();
 
         if ($target !== null) {
             Filesystem::createDirectory(dirname($target));
diff --git a/src/Report/Cobertura/CoberturaClass.php b/src/Report/Cobertura/CoberturaClass.php
new file mode 100644
index 000000000..c08fc8eab
--- /dev/null
+++ b/src/Report/Cobertura/CoberturaClass.php
@@ -0,0 +1,164 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of phpunit/php-code-coverage.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\CodeCoverage\Report\Cobertura;
+
+use function array_merge;
+use function range;
+use DOMDocument;
+use DOMElement;
+
+class CoberturaClass extends CoberturaElement
+{
+    /** @var CoberturaMethod[] */
+    private $methods = [];
+
+    /** @var CoberturaLine[] */
+    private $lines = [];
+
+    /**
+     * @var string
+     */
+    private $name;
+
+    /**
+     * @var string
+     */
+    private $filename;
+
+    /**
+     * @var float
+     */
+    private $complexity;
+
+    public static function create(string $className, string $relativeFilePath, array $classData, array $lineCoverageData): self
+    {
+        if (!empty($classData['package']['namespace'])) {
+            $className = $classData['package']['namespace'] . '\\' . $className;
+        }
+
+        $class = new self(
+            $className,
+            $relativeFilePath,
+            $classData['executableLines'],
+            $classData['executedLines'],
+            $classData['executableBranches'],
+            $classData['executedBranches'],
+            $classData['ccn']
+        );
+
+        $endLine = $classData['startLine'];
+
+        foreach ($classData['methods'] as $methodName => $methodData) {
+            $method = CoberturaMethod::create($methodName, $methodData, $lineCoverageData);
+
+            if (null !== $method) {
+                $class->methods[] = $method;
+            }
+
+            if ($methodData['endLine'] > $endLine) {
+                $endLine = $methodData['endLine'];
+            }
+        }
+
+        /** @var int $lineNumber */
+        foreach (range($classData['startLine'], $endLine) as $lineNumber) {
+            $line = CoberturaLine::create($lineNumber, $lineCoverageData);
+
+            if (null !== $line) {
+                $class->lines[] = $line;
+            }
+        }
+
+        return $class;
+    }
+
+    public static function createForFunctions(
+        string $className,
+        string $relativeFilePath,
+        int $linesValid,
+        int $linesCovered,
+        int $branchesValid,
+        int $branchesCovered,
+        float $complexity,
+        array $functions
+    ): self {
+        $class = new self(
+            $className,
+            $relativeFilePath,
+            $linesValid,
+            $linesCovered,
+            $branchesValid,
+            $branchesCovered,
+            $complexity
+        );
+
+        $class->methods = $functions;
+
+        foreach ($class->methods as $method) {
+            $class->lines = array_merge($class->lines, $method->getLines());
+        }
+
+        return $class;
+    }
+
+    private function __construct(
+        string $name,
+        string $filename,
+        int $linesValid,
+        int $linesCovered,
+        int $branchesValid,
+        int $branchesCovered,
+        float $complexity
+    ) {
+        $this->name       = $name;
+        $this->filename   = $filename;
+        $this->complexity = $complexity;
+        parent::__construct($linesValid, $linesCovered, $branchesValid, $branchesCovered);
+    }
+
+    public function wrap(DOMDocument $document): DOMElement
+    {
+        $classElement = $document->createElement('class');
+
+        $classElement->setAttribute('name', $this->name);
+        $classElement->setAttribute('filename', $this->filename);
+        $classElement->setAttribute('line-rate', (string) $this->lineRate());
+        $classElement->setAttribute('branch-rate', (string) $this->branchRate());
+        $classElement->setAttribute('complexity', (string) $this->complexity);
+
+        $methodsElement = $document->createElement('methods');
+
+        foreach ($this->methods as $method) {
+            $methodsElement->appendChild($method->wrap($document));
+        }
+
+        $classElement->appendChild($methodsElement);
+
+        $linesElement = $document->createElement('lines');
+
+        foreach ($this->lines as $line) {
+            $linesElement->appendChild($line->wrap($document));
+        }
+
+        $classElement->appendChild($linesElement);
+
+        return $classElement;
+    }
+
+    public function getComplexity(): float
+    {
+        return $this->complexity;
+    }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+}
diff --git a/src/Report/Cobertura/CoberturaCoverage.php b/src/Report/Cobertura/CoberturaCoverage.php
new file mode 100644
index 000000000..10d839ccb
--- /dev/null
+++ b/src/Report/Cobertura/CoberturaCoverage.php
@@ -0,0 +1,245 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of phpunit/php-code-coverage.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\CodeCoverage\Report\Cobertura;
+
+use function array_reduce;
+use function basename;
+use function count;
+use function date;
+use function getcwd;
+use function in_array;
+use function sprintf;
+use function str_replace;
+use function time;
+use Composer\InstalledVersions;
+use DOMDocument;
+use DOMElement;
+use DOMImplementation;
+use SebastianBergmann\CodeCoverage\Node\AbstractNode;
+use SebastianBergmann\CodeCoverage\Node\Directory;
+use SebastianBergmann\CodeCoverage\Node\File;
+
+class CoberturaCoverage extends CoberturaElement
+{
+    private const FUNCTIONS_PACKAGE = '_functions';
+
+    /** @var string[] */
+    private $sources = [];
+
+    /** @var array<string, CoberturaPackage> */
+    private $packages = [];
+
+    /**
+     * @var int
+     */
+    private $timestamp;
+
+    public static function create(Directory $report): self
+    {
+        $coverage = new self(
+            time(),
+            $report->numberOfExecutableLines(),
+            $report->numberOfExecutedLines(),
+            $report->numberOfExecutableBranches(),
+            $report->numberOfExecutedBranches()
+        );
+
+        foreach ($report as $item) {
+            if (!$item instanceof File) {
+                continue;
+            }
+
+            $coverage->processFile($item);
+        }
+
+        return $coverage;
+    }
+
+    private function __construct(
+        int $timestamp,
+        int $linesValid,
+        int $linesCovered,
+        int $branchesValid,
+        int $branchesCovered
+    ) {
+        $this->timestamp = $timestamp;
+        parent::__construct($linesValid, $linesCovered, $branchesValid, $branchesCovered);
+    }
+
+    public function generateDocument(): DOMDocument
+    {
+        $implementation = new DOMImplementation;
+
+        $documentType = $implementation->createDocumentType(
+            'coverage',
+            '',
+            'http://cobertura.sourceforge.net/xml/coverage-04.dtd'
+        );
+
+        $document               = $implementation->createDocument('', '', $documentType);
+        $document->xmlVersion   = '1.0';
+        $document->encoding     = 'UTF-8';
+        $document->formatOutput = true;
+
+        $comment = $document->createComment(sprintf(
+            'Cobertura coverage report generated by the PHP library "phpunit/php-code-coverage" on %s.',
+            date('c', $this->timestamp),
+        ));
+        $document->appendChild($comment);
+
+        $coverageElement = $document->createElement('coverage');
+        $coverageElement->setAttribute('line-rate', (string) $this->lineRate());
+        $coverageElement->setAttribute('branch-rate', (string) $this->branchRate());
+        $coverageElement->setAttribute('lines-covered', (string) $this->linesCovered);
+        $coverageElement->setAttribute('lines-valid', (string) $this->linesValid);
+        $coverageElement->setAttribute('branches-covered', (string) $this->branchesCovered);
+        $coverageElement->setAttribute('branches-valid', (string) $this->branchesValid);
+        $coverageElement->setAttribute('complexity', (string) $this->complexity());
+        $coverageElement->setAttribute('version', '0.4');
+        $coverageElement->setAttribute('timestamp', (string) $this->timestamp);
+
+        $coverageElement->appendChild($this->wrapSources($document));
+
+        $packagesElement = $document->createElement('packages');
+
+        foreach ($this->packages as $package) {
+            $packagesElement->appendChild($package->wrap($document));
+        }
+
+        $coverageElement->appendChild($packagesElement);
+
+        $document->appendChild($coverageElement);
+
+        return $document;
+    }
+
+    private function processFile(File $file): void
+    {
+        $this->addSource($this->relativePath($this->fileRoot($file)->pathAsString()));
+
+        $lineCoverageData = $file->lineCoverageData();
+
+        foreach ($file->classesAndTraits() as $className => $classData) {
+            $class = CoberturaClass::create(
+                $className,
+                $this->relativePath($file->pathAsString()),
+                $classData,
+                $lineCoverageData
+            );
+
+            $packageName = CoberturaPackage::packageName($class->getName());
+
+            if (!isset($this->packages[$packageName])) {
+                $this->packages[$packageName] = new CoberturaPackage($packageName);
+            }
+
+            $this->packages[$packageName]->addClass($class);
+        }
+
+        $this->processFunctions($file);
+    }
+
+    private function processFunctions(File $file): void
+    {
+        $lineCoverageData = $file->lineCoverageData();
+
+        $functions       = [];
+        $classComplexity = 0;
+
+        foreach ($file->functions() as $functionName => $functionData) {
+            $method = CoberturaMethod::create($functionName, $functionData, $lineCoverageData);
+
+            if (null !== $method) {
+                $functions[$functionName] = $method;
+                $classComplexity += $functionData['ccn'];
+            }
+        }
+
+        if (count($functions) > 0) {
+            $classCoverageData = array_reduce($functions, static function (array $data, CoberturaMethod $function)
+            {
+                $data['linesValid'] += $function->getLinesValid();
+                $data['linesCovered'] += $function->getLinesCovered();
+                $data['branchesValid'] += $function->getBranchesValid();
+                $data['branchesCovered'] += $function->getBranchesCovered();
+
+                return $data;
+            }, ['linesValid' => 0, 'linesCovered' => 0, 'branchesValid' => 0, 'branchesCovered' => 0]);
+
+            $relativeFilePath = $this->relativePath($file->pathAsString());
+
+            $class = CoberturaClass::createForFunctions(
+                self::FUNCTIONS_PACKAGE . '\\' . basename($relativeFilePath),
+                $relativeFilePath,
+                $classCoverageData['linesValid'],
+                $classCoverageData['linesCovered'],
+                $classCoverageData['branchesValid'],
+                $classCoverageData['branchesCovered'],
+                $classComplexity,
+                $functions
+            );
+
+            if (!isset($this->packages[self::FUNCTIONS_PACKAGE])) {
+                $this->packages[self::FUNCTIONS_PACKAGE] = new CoberturaPackage(self::FUNCTIONS_PACKAGE);
+            }
+
+            $this->packages[self::FUNCTIONS_PACKAGE]->addClass($class);
+        }
+    }
+
+    private function addSource(string $source): void
+    {
+        if (!in_array($source, $this->sources, true)) {
+            $this->sources[] = $source;
+        }
+    }
+
+    private function fileRoot(File $file): AbstractNode
+    {
+        $root = $file;
+
+        while (true) {
+            if ($root->parent() === null) {
+                return $root;
+            }
+
+            /** @var AbstractNode $root */
+            $root = $root->parent();
+        }
+    }
+
+    private function relativePath(string $path): string
+    {
+        return str_replace(getcwd() . DIRECTORY_SEPARATOR, '', $path);
+    }
+
+    private function wrapSources(DOMDocument $document): DOMElement
+    {
+        $sourcesElement = $document->createElement('sources');
+
+        foreach ($this->sources as $source) {
+            $sourcesElement->appendChild($document->createElement('source', $source));
+        }
+
+        return $sourcesElement;
+    }
+
+    private function complexity(): float
+    {
+        return array_reduce(
+            $this->packages,
+            static function (float $complexity, CoberturaPackage $package)
+            {
+                return $complexity + $package->complexity();
+            },
+            0
+        );
+    }
+}
diff --git a/src/Report/Cobertura/CoberturaElement.php b/src/Report/Cobertura/CoberturaElement.php
new file mode 100644
index 000000000..72c0f473a
--- /dev/null
+++ b/src/Report/Cobertura/CoberturaElement.php
@@ -0,0 +1,71 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of phpunit/php-code-coverage.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\CodeCoverage\Report\Cobertura;
+
+abstract class CoberturaElement
+{
+    /**
+     * @var int
+     */
+    protected $linesValid;
+
+    /**
+     * @var int
+     */
+    protected $linesCovered;
+
+    /**
+     * @var int
+     */
+    protected $branchesValid;
+
+    /**
+     * @var int
+     */
+    protected $branchesCovered;
+
+    public function __construct(int $linesValid, int $linesCovered, int $branchesValid, int $branchesCovered)
+    {
+        $this->linesValid      = $linesValid;
+        $this->linesCovered    = $linesCovered;
+        $this->branchesValid   = $branchesValid;
+        $this->branchesCovered = $branchesCovered;
+    }
+
+    public function getLinesValid(): int
+    {
+        return $this->linesValid;
+    }
+
+    public function getLinesCovered(): int
+    {
+        return $this->linesCovered;
+    }
+
+    public function getBranchesValid(): int
+    {
+        return $this->branchesValid;
+    }
+
+    public function getBranchesCovered(): int
+    {
+        return $this->branchesCovered;
+    }
+
+    protected function lineRate(): float
+    {
+        return $this->linesValid === 0 ? 0 : $this->linesCovered / $this->linesValid;
+    }
+
+    protected function branchRate(): float
+    {
+        return $this->branchesValid === 0 ? 0 : $this->branchesCovered / $this->branchesValid;
+    }
+}
diff --git a/src/Report/Cobertura/CoberturaLine.php b/src/Report/Cobertura/CoberturaLine.php
new file mode 100644
index 000000000..bc31c02bf
--- /dev/null
+++ b/src/Report/Cobertura/CoberturaLine.php
@@ -0,0 +1,62 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of phpunit/php-code-coverage.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\CodeCoverage\Report\Cobertura;
+
+use function count;
+use DOMDocument;
+use DOMElement;
+
+class CoberturaLine
+{
+    /**
+     * @var int
+     */
+    private $number;
+
+    /**
+     * @var int
+     */
+    private $hits;
+
+    /**
+     * @var null|bool
+     */
+    private $branch;
+
+    public static function create(int $lineNumber, array $lineCoverageData): ?self
+    {
+        if (!isset($lineCoverageData[$lineNumber])) {
+            return null;
+        }
+
+        return new self($lineNumber, count($lineCoverageData[$lineNumber]));
+    }
+
+    private function __construct(int $number, int $hits, ?bool $branch = null)
+    {
+        $this->number = $number;
+        $this->hits   = $hits;
+        $this->branch = $branch;
+    }
+
+    public function wrap(DOMDocument $document): DOMElement
+    {
+        $element = $document->createElement('line');
+
+        $element->setAttribute('number', (string) $this->number);
+        $element->setAttribute('hits', (string) $this->hits);
+
+        if (null !== $this->branch) {
+            $element->setAttribute('branch', $this->branch ? 'true' : 'false');
+        }
+
+        return $element;
+    }
+}
diff --git a/src/Report/Cobertura/CoberturaMethod.php b/src/Report/Cobertura/CoberturaMethod.php
new file mode 100644
index 000000000..cc0acfaf4
--- /dev/null
+++ b/src/Report/Cobertura/CoberturaMethod.php
@@ -0,0 +1,104 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of phpunit/php-code-coverage.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\CodeCoverage\Report\Cobertura;
+
+use function range;
+use DOMDocument;
+use DOMElement;
+
+class CoberturaMethod extends CoberturaElement
+{
+    /** @var CoberturaLine[] */
+    private $lines = [];
+
+    /**
+     * @var string
+     */
+    private $name;
+
+    /**
+     * @var string
+     */
+    private $signature;
+
+    /**
+     * @var float
+     */
+    private $complexity;
+
+    public static function create(string $name, array $methodData, array $lineCoverageData): ?self
+    {
+        if ($methodData['executableLines'] === 0) {
+            return null;
+        }
+
+        $method = new self(
+            $name,
+            $methodData['signature'],
+            $methodData['executableLines'],
+            $methodData['executedLines'],
+            $methodData['executableBranches'],
+            $methodData['executedBranches'],
+            $methodData['ccn']
+        );
+
+        /** @var int $lineNumber */
+        foreach (range($methodData['startLine'], $methodData['endLine']) as $lineNumber) {
+            $line = CoberturaLine::create($lineNumber, $lineCoverageData);
+
+            if (null !== $line) {
+                $method->lines[] = $line;
+            }
+        }
+
+        return $method;
+    }
+
+    private function __construct(
+        string $name,
+        string $signature,
+        int $linesValid,
+        int $linesCovered,
+        int $branchesValid,
+        int $branchesCovered,
+        float $complexity
+    ) {
+        $this->name       = $name;
+        $this->signature  = $signature;
+        $this->complexity = $complexity;
+        parent::__construct($linesValid, $linesCovered, $branchesValid, $branchesCovered);
+    }
+
+    public function wrap(DOMDocument $document): DOMElement
+    {
+        $methodElement = $document->createElement('method');
+
+        $methodElement->setAttribute('name', $this->name);
+        $methodElement->setAttribute('signature', $this->signature);
+        $methodElement->setAttribute('line-rate', (string) $this->lineRate());
+        $methodElement->setAttribute('branch-rate', (string) $this->branchRate());
+        $methodElement->setAttribute('complexity', (string) $this->complexity);
+
+        $linesElement = $document->createElement('lines');
+
+        foreach ($this->lines as $line) {
+            $linesElement->appendChild($line->wrap($document));
+        }
+
+        $methodElement->appendChild($linesElement);
+
+        return $methodElement;
+    }
+
+    public function getLines(): array
+    {
+        return $this->lines;
+    }
+}
diff --git a/src/Report/Cobertura/CoberturaPackage.php b/src/Report/Cobertura/CoberturaPackage.php
new file mode 100644
index 000000000..c204ded8a
--- /dev/null
+++ b/src/Report/Cobertura/CoberturaPackage.php
@@ -0,0 +1,99 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of phpunit/php-code-coverage.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\CodeCoverage\Report\Cobertura;
+
+use function array_reduce;
+use function explode;
+use DOMDocument;
+use DOMElement;
+
+class CoberturaPackage
+{
+    /** @var CoberturaClass[] */
+    private $classes = [];
+
+    /**
+     * @var string
+     */
+    private $name;
+
+    public static function packageName(string $className): string
+    {
+        return explode('\\', $className)[0];
+    }
+
+    public function __construct(string $name)
+    {
+        $this->name = $name;
+    }
+
+    public function addClass(CoberturaClass $class): void
+    {
+        $this->classes[] = $class;
+    }
+
+    public function wrap(DOMDocument $document): DOMElement
+    {
+        $packageElement = $document->createElement('package');
+
+        $packageElement->setAttribute('name', $this->name);
+        $packageElement->setAttribute('line-rate', (string) $this->lineRate());
+        $packageElement->setAttribute('branch-rate', (string) $this->branchRate());
+        $packageElement->setAttribute('complexity', (string) $this->complexity());
+
+        $classesElement = $document->createElement('classes');
+
+        foreach ($this->classes as $class) {
+            $classesElement->appendChild($class->wrap($document));
+        }
+
+        $packageElement->appendChild($classesElement);
+
+        return $packageElement;
+    }
+
+    public function complexity(): float
+    {
+        return array_reduce(
+            $this->classes,
+            static function (float $complexity, CoberturaClass $class)
+            {
+                return $complexity + $class->getComplexity();
+            },
+            0
+        );
+    }
+
+    private function lineRate(): float
+    {
+        $linesData = array_reduce($this->classes, static function (array $data, CoberturaClass $class)
+        {
+            $data['valid'] += $class->getLinesValid();
+            $data['covered'] += $class->getLinesCovered();
+
+            return $data;
+        }, ['valid' => 0, 'covered' => 0]);
+
+        return $linesData['valid'] === 0 ? 0 : $linesData['covered'] / $linesData['valid'];
+    }
+
+    private function branchRate(): float
+    {
+        $branchesData = array_reduce($this->classes, static function (array $data, CoberturaClass $class)
+        {
+            $data['valid'] += $class->getBranchesValid();
+            $data['covered'] += $class->getBranchesCovered();
+
+            return $data;
+        }, ['valid' => 0, 'covered' => 0]);
+
+        return $branchesData['valid'] === 0 ? 0 : $branchesData['covered'] / $branchesData['valid'];
+    }
+}
diff --git a/tests/_files/BankAccount-cobertura-line.xml b/tests/_files/BankAccount-cobertura-line.xml
index 75401a6cb..85dcfaa8c 100644
--- a/tests/_files/BankAccount-cobertura-line.xml
+++ b/tests/_files/BankAccount-cobertura-line.xml
@@ -1,33 +1,34 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">
+<!--Cobertura coverage report generated by the PHP library "phpunit/php-code-coverage" on %s.-->
 <coverage line-rate="0.625" branch-rate="0" lines-covered="5" lines-valid="8" branches-covered="0" branches-valid="0" complexity="5" version="0.4" timestamp="%i">
   <sources>
-    <source>%s</source>
+    <source>tests%e_files</source>
   </sources>
   <packages>
-    <package name="BankAccount.php" line-rate="0.625" branch-rate="0" complexity="5">
+    <package name="BankAccount" line-rate="0.625" branch-rate="0" complexity="5">
       <classes>
-        <class name="BankAccount" filename="BankAccount.php" line-rate="0.625" branch-rate="0" complexity="5">
+        <class name="BankAccount" filename="tests%e_files%eBankAccount.php" line-rate="0.625" branch-rate="0" complexity="5">
           <methods>
-            <method name="getBalance" signature="" line-rate="1" branch-rate="0" complexity="1">
+            <method name="getBalance" signature="getBalance()" line-rate="1" branch-rate="0" complexity="1">
               <lines>
                 <line number="8" hits="2"/>
               </lines>
             </method>
-            <method name="setBalance" signature="$balance" line-rate="0" branch-rate="0" complexity="2">
+            <method name="setBalance" signature="setBalance($balance)" line-rate="0" branch-rate="0" complexity="2">
               <lines>
                 <line number="13" hits="0"/>
                 <line number="14" hits="0"/>
                 <line number="16" hits="0"/>
               </lines>
             </method>
-            <method name="depositMoney" signature="$balance" line-rate="1" branch-rate="0" complexity="1">
+            <method name="depositMoney" signature="depositMoney($balance)" line-rate="1" branch-rate="0" complexity="1">
               <lines>
                 <line number="22" hits="2"/>
                 <line number="24" hits="1"/>
               </lines>
             </method>
-            <method name="withdrawMoney" signature="$balance" line-rate="1" branch-rate="0" complexity="1">
+            <method name="withdrawMoney" signature="withdrawMoney($balance)" line-rate="1" branch-rate="0" complexity="1">
               <lines>
                 <line number="29" hits="2"/>
                 <line number="31" hits="1"/>
diff --git a/tests/_files/BankAccount-cobertura-path.xml b/tests/_files/BankAccount-cobertura-path.xml
index 9ce9efe6e..fecd9e63b 100644
--- a/tests/_files/BankAccount-cobertura-path.xml
+++ b/tests/_files/BankAccount-cobertura-path.xml
@@ -1,33 +1,34 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">
+<!--Cobertura coverage report generated by the PHP library "phpunit/php-code-coverage" on %s.-->
 <coverage line-rate="0.625" branch-rate="0.42857142857143" lines-covered="5" lines-valid="8" branches-covered="3" branches-valid="7" complexity="5" version="0.4" timestamp="%i">
   <sources>
-    <source>%s</source>
+    <source>tests%e_files</source>
   </sources>
   <packages>
-    <package name="BankAccount.php" line-rate="0.625" branch-rate="0.42857142857143" complexity="5">
+    <package name="BankAccount" line-rate="0.625" branch-rate="0.42857142857143" complexity="5">
       <classes>
-        <class name="BankAccount" filename="BankAccount.php" line-rate="0.625" branch-rate="0.42857142857143" complexity="5">
+        <class name="BankAccount" filename="tests%e_files%eBankAccount.php" line-rate="0.625" branch-rate="0.42857142857143" complexity="5">
           <methods>
-            <method name="getBalance" signature="" line-rate="1" branch-rate="1" complexity="1">
+            <method name="getBalance" signature="getBalance()" line-rate="1" branch-rate="1" complexity="1">
               <lines>
                 <line number="8" hits="2"/>
               </lines>
             </method>
-            <method name="setBalance" signature="$balance" line-rate="0" branch-rate="0" complexity="2">
+            <method name="setBalance" signature="setBalance($balance)" line-rate="0" branch-rate="0" complexity="2">
               <lines>
                 <line number="13" hits="0"/>
                 <line number="14" hits="0"/>
                 <line number="16" hits="0"/>
               </lines>
             </method>
-            <method name="depositMoney" signature="$balance" line-rate="1" branch-rate="1" complexity="1">
+            <method name="depositMoney" signature="depositMoney($balance)" line-rate="1" branch-rate="1" complexity="1">
               <lines>
                 <line number="22" hits="2"/>
                 <line number="24" hits="1"/>
               </lines>
             </method>
-            <method name="withdrawMoney" signature="$balance" line-rate="1" branch-rate="1" complexity="1">
+            <method name="withdrawMoney" signature="withdrawMoney($balance)" line-rate="1" branch-rate="1" complexity="1">
               <lines>
                 <line number="29" hits="2"/>
                 <line number="31" hits="1"/>
diff --git a/tests/_files/class-with-anonymous-function-cobertura.xml b/tests/_files/class-with-anonymous-function-cobertura.xml
index 3beb9d9ff..0de293cee 100644
--- a/tests/_files/class-with-anonymous-function-cobertura.xml
+++ b/tests/_files/class-with-anonymous-function-cobertura.xml
@@ -1,15 +1,16 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">
+<!--Cobertura coverage report generated by the PHP library "phpunit/php-code-coverage" on %s.-->
 <coverage line-rate="1" branch-rate="0" lines-covered="8" lines-valid="8" branches-covered="0" branches-valid="0" complexity="1" version="0.4" timestamp="%i">
   <sources>
-    <source>%s</source>
+    <source>tests%e_files</source>
   </sources>
   <packages>
-    <package name="source_with_class_and_anonymous_function.php" line-rate="1" branch-rate="0" complexity="1">
+    <package name="CoveredClassWithAnonymousFunctionInStaticMethod" line-rate="1" branch-rate="0" complexity="1">
       <classes>
-        <class name="CoveredClassWithAnonymousFunctionInStaticMethod" filename="source_with_class_and_anonymous_function.php" line-rate="1" branch-rate="0" complexity="1">
+        <class name="CoveredClassWithAnonymousFunctionInStaticMethod" filename="tests%e_files%esource_with_class_and_anonymous_function.php" line-rate="1" branch-rate="0" complexity="1">
           <methods>
-            <method name="runAnonymous" signature="" line-rate="1" branch-rate="0" complexity="1">
+            <method name="runAnonymous" signature="runAnonymous()" line-rate="1" branch-rate="0" complexity="1">
               <lines>
                 <line number="7" hits="1"/>
                 <line number="9" hits="1"/>
diff --git a/tests/_files/class-with-outside-function-cobertura.xml b/tests/_files/class-with-outside-function-cobertura.xml
index fe6c005fc..fe66f3ae9 100644
--- a/tests/_files/class-with-outside-function-cobertura.xml
+++ b/tests/_files/class-with-outside-function-cobertura.xml
@@ -1,15 +1,16 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">
+<!--Cobertura coverage report generated by the PHP library "phpunit/php-code-coverage" on %s.-->
 <coverage line-rate="0.75" branch-rate="0" lines-covered="3" lines-valid="4" branches-covered="0" branches-valid="0" complexity="3" version="0.4" timestamp="%i">
   <sources>
-    <source>%s</source>
+    <source>tests%e_files</source>
   </sources>
   <packages>
-    <package name="source_with_class_and_outside_function.php" line-rate="0.75" branch-rate="0" complexity="3">
+    <package name="ClassInFileWithOutsideFunction" line-rate="1" branch-rate="0" complexity="1">
       <classes>
-        <class name="ClassInFileWithOutsideFunction" filename="source_with_class_and_outside_function.php" line-rate="1" branch-rate="0" complexity="1">
+        <class name="ClassInFileWithOutsideFunction" filename="tests%e_files%esource_with_class_and_outside_function.php" line-rate="1" branch-rate="0" complexity="1">
           <methods>
-            <method name="classMethod" signature="" line-rate="1" branch-rate="0" complexity="1">
+            <method name="classMethod" signature="classMethod(): string" line-rate="1" branch-rate="0" complexity="1">
               <lines>
                 <line number="6" hits="1"/>
               </lines>
@@ -19,7 +20,11 @@
             <line number="6" hits="1"/>
           </lines>
         </class>
-        <class name="source_with_class_and_outside_function.php" filename="source_with_class_and_outside_function.php" line-rate="0.66666666666667" branch-rate="0" complexity="2">
+      </classes>
+    </package>
+    <package name="_functions" line-rate="0.66666666666667" branch-rate="0" complexity="2">
+      <classes>
+        <class name="_functions\source_with_class_and_outside_function.php" filename="tests%e_files%esource_with_class_and_outside_function.php" line-rate="0.66666666666667" branch-rate="0" complexity="2">
           <methods>
             <method name="outsideFunction" signature="outsideFunction(bool $test): int" line-rate="0.66666666666667" branch-rate="0" complexity="2">
               <lines>
diff --git a/tests/_files/ignored-lines-cobertura.xml b/tests/_files/ignored-lines-cobertura.xml
index 2650a3dde..81427d442 100644
--- a/tests/_files/ignored-lines-cobertura.xml
+++ b/tests/_files/ignored-lines-cobertura.xml
@@ -1,17 +1,22 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">
+<!--Cobertura coverage report generated by the PHP library "phpunit/php-code-coverage" on %s.-->
 <coverage line-rate="1" branch-rate="0" lines-covered="1" lines-valid="1" branches-covered="0" branches-valid="0" complexity="2" version="0.4" timestamp="%i">
   <sources>
-    <source>%s</source>
+    <source>tests%e_files</source>
   </sources>
   <packages>
-    <package name="source_with_ignore.php" line-rate="1" branch-rate="0" complexity="2">
+    <package name="Foo" line-rate="0" branch-rate="0" complexity="1">
       <classes>
-        <class name="Foo" filename="source_with_ignore.php" line-rate="0" branch-rate="0" complexity="1">
+        <class name="Foo" filename="tests%e_files%esource_with_ignore.php" line-rate="0" branch-rate="0" complexity="1">
           <methods/>
           <lines/>
         </class>
-        <class name="Bar" filename="source_with_ignore.php" line-rate="0" branch-rate="0" complexity="1">
+      </classes>
+    </package>
+    <package name="Bar" line-rate="0" branch-rate="0" complexity="1">
+      <classes>
+        <class name="Bar" filename="tests%e_files%esource_with_ignore.php" line-rate="0" branch-rate="0" complexity="1">
           <methods/>
           <lines/>
         </class>
diff --git a/tests/tests/Report/CoberturaTest.php b/tests/tests/Report/CoberturaTest.php
index 05163ed2d..131ef5a76 100644
--- a/tests/tests/Report/CoberturaTest.php
+++ b/tests/tests/Report/CoberturaTest.php
@@ -9,60 +9,67 @@
  */
 namespace SebastianBergmann\CodeCoverage\Report;
 
+use DOMDocument;
 use SebastianBergmann\CodeCoverage\TestCase;
 
-/**
- * @covers \SebastianBergmann\CodeCoverage\Report\Cobertura
- */
 final class CoberturaTest extends TestCase
 {
     public function testLineCoverageForBankAccountTest(): void
     {
-        $cobertura = new Cobertura;
+        $report = (new Cobertura)->process($this->getLineCoverageForBankAccount(), null);
 
         $this->assertStringMatchesFormatFile(
             TEST_FILES_PATH . 'BankAccount-cobertura-line.xml',
-            $cobertura->process($this->getLineCoverageForBankAccount(), null)
+            $report
         );
+
+        $this->validateReport($report);
     }
 
     public function testPathCoverageForBankAccountTest(): void
     {
-        $cobertura = new Cobertura;
+        $report = (new Cobertura)->process($this->getPathCoverageForBankAccount(), null);
 
         $this->assertStringMatchesFormatFile(
             TEST_FILES_PATH . 'BankAccount-cobertura-path.xml',
-            $cobertura->process($this->getPathCoverageForBankAccount(), null)
+            $report
         );
     }
 
     public function testCoberturaForFileWithIgnoredLines(): void
     {
-        $cobertura = new Cobertura;
+        $report = (new Cobertura)->process($this->getCoverageForFileWithIgnoredLines());
 
         $this->assertStringMatchesFormatFile(
             TEST_FILES_PATH . 'ignored-lines-cobertura.xml',
-            $cobertura->process($this->getCoverageForFileWithIgnoredLines())
+            $report
         );
     }
 
     public function testCoberturaForClassWithAnonymousFunction(): void
     {
-        $cobertura = new Cobertura;
+        $report = (new Cobertura)->process($this->getCoverageForClassWithAnonymousFunction());
 
         $this->assertStringMatchesFormatFile(
             TEST_FILES_PATH . 'class-with-anonymous-function-cobertura.xml',
-            $cobertura->process($this->getCoverageForClassWithAnonymousFunction())
+            $report
         );
     }
 
     public function testCoberturaForClassAndOutsideFunction(): void
     {
-        $cobertura = new Cobertura;
+        $report = (new Cobertura)->process($this->getCoverageForClassWithOutsideFunction());
 
         $this->assertStringMatchesFormatFile(
             TEST_FILES_PATH . 'class-with-outside-function-cobertura.xml',
-            $cobertura->process($this->getCoverageForClassWithOutsideFunction())
+            $report
         );
     }
+
+    private function validateReport(string $coberturaReport): void
+    {
+        $document = (new DOMDocument);
+        $this->assertTrue($document->loadXML($coberturaReport));
+        $this->assertTrue(@$document->validate());
+    }
 }