Skip to content

Commit de57780

Browse files
committed
Add --coverlet-file-prefix option for unique report files
Introduce --coverlet-file-prefix to allow custom prefixes for coverage report filenames, preventing overwrites in multi-project test runs. Updated documentation, command-line parsing, configuration, and report generation logic to support the new option. Added comprehensive unit tests for filename generation and option handling. Improves usability in CI and multi-project scenarios.
1 parent b17f544 commit de57780

File tree

9 files changed

+320
-3
lines changed

9 files changed

+320
-3
lines changed

Documentation/Coverlet.MTP.Integration.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ dotnet exec <test-assembly.dll> --help
7878
| :------- | :------------ |
7979
| `--coverlet` | Enable code coverage data collection. |
8080
| `--coverlet-output-format <format>` | Output format(s) for coverage report. Supported formats: `json`, `lcov`, `opencover`, `cobertura`, `teamcity`. Can be specified multiple times. (default: `json`, `cobertura`) |
81+
| `--coverlet-file-prefix <prefix>` | Prefix for coverage report filenames to prevent overwrites when multiple test projects write to the same directory. When specified, files are named `<prefix>.coverage.<extension>` instead of `coverage.<extension>`. (default: `none`) |
8182
| `--coverlet-include <filter>` | Include assemblies matching filters (e.g., `[Assembly]Type`). Can be specified multiple times. (default: `none`) |
8283
| `--coverlet-include-directory <path>` | Include additional directories for sources. Can be specified multiple times. (default: `none`) |
8384
| `--coverlet-exclude <filter>` | Exclude assemblies matching filters (e.g., `[Assembly]Type`). Can be specified multiple times. User-specified filters are merged with defaults. (default: `[coverlet.*]*`, `[xunit.*]*`, `[NUnit3.*]*`, `[nunit.*]*`, `[Microsoft.Testing.*]*`, `[Microsoft.Testplatform.*]*`, `[Microsoft.VisualStudio.TestPlatform.*]*`) |
@@ -192,6 +193,14 @@ dotnet exec TestProject.dll --coverlet --coverlet-exclude "[.Tests]" --coverlet-
192193
dotnet exec TestProject.dll --coverlet --coverlet-exclude-by-attribute "Obsolete" --coverlet-exclude-by-attribute "GeneratedCode"
193194
```
194195

196+
**Use file prefix to prevent overwrites in multi-project solutions:**
197+
198+
```bash
199+
dotnet exec TestProject.dll --coverlet --coverlet-file-prefix "MyProject.UnitTests"
200+
```
201+
202+
This generates files named `MyProject.UnitTests.coverage.json` and `MyProject.UnitTests.coverage.cobertura.xml` instead of overwriting the default `coverage.json` and `coverage.cobertura.xml`.
203+
195204
## Coverage Output
196205

197206
Coverlet can generate coverage results in multiple formats:

src/coverlet.MTP/Collector/CollectorExtension.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ Task ITestHostProcessLifetimeHandler.BeforeTestHostProcessStartAsync(Cancellatio
125125
_configuration.SingleHit = config.UseSingleHit;
126126
_configuration.SkipAutoProps = config.SkipAutoProps;
127127
_configuration.formats = config.GetOutputFormats();
128+
_configuration.FilePrefix = config.GetFilePrefix();
128129
_configuration.UseSourceLink = false;
129130

130131
_logger.LogVerbose($"Test module path: {_testModulePath}");
@@ -403,7 +404,8 @@ internal List<string> GenerateCoverageReportFiles(
403404
ISourceRootTranslator sourceRootTranslator,
404405
IFileSystem fileSystem,
405406
string outputDirectory,
406-
string[] formats)
407+
string[] formats,
408+
string? filePrefix = null)
407409
{
408410
var generatedReports = new List<string>();
409411

@@ -420,7 +422,9 @@ internal List<string> GenerateCoverageReportFiles(
420422
}
421423
else
422424
{
423-
string filename = $"coverage.{reporter.Extension}";
425+
string filename = string.IsNullOrEmpty(filePrefix)
426+
? $"coverage.{reporter.Extension}"
427+
: $"{filePrefix}.coverage.{reporter.Extension}";
424428
string reportPath = Path.Combine(outputDirectory, filename);
425429
fileSystem.WriteAllText(reportPath, reporter.Report(result, sourceRootTranslator));
426430
generatedReports.Add(reportPath);
@@ -483,7 +487,8 @@ private async Task GenerateReportsAsync(CoverageResult result, CancellationToken
483487
sourceRootTranslator,
484488
fileSystem,
485489
outputDirectory,
486-
_configuration.formats);
490+
_configuration.formats,
491+
_configuration.FilePrefix);
487492

488493
// Display results
489494
await DisplayGeneratedReportsAsync(generatedReports, cancellation);

src/coverlet.MTP/CommandLine/CoverletCommandLineOptionDefinitions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public static IReadOnlyCollection<CommandLineOption> GetAllOptions()
1919
[
2020
new CommandLineOption(CoverletOptionNames.Coverage, "Enable code coverage data collection.", ArgumentArity.Zero, isHidden: false),
2121
new CommandLineOption(CoverletOptionNames.Formats, "Output format(s) for coverage report (json, lcov, opencover, cobertura).", ArgumentArity.OneOrMore, isHidden: false),
22+
new CommandLineOption(CoverletOptionNames.FilePrefix, "Prefix for coverage report filenames to prevent overwrites when multiple test projects write to the same directory.", ArgumentArity.ExactlyOne, isHidden: false),
2223
new CommandLineOption(CoverletOptionNames.Include, "Include assemblies matching filters (e.g., [Assembly]Type).", ArgumentArity.OneOrMore, isHidden: false),
2324
new CommandLineOption(CoverletOptionNames.IncludeDirectory, "Include additional directories for instrumentation.", ArgumentArity.OneOrMore, isHidden: false),
2425
new CommandLineOption(CoverletOptionNames.Exclude, "Exclude assemblies matching filters (e.g., [Assembly]Type).", ArgumentArity.OneOrMore, isHidden: false),

src/coverlet.MTP/CommandLine/CoverletOptionNames.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ internal static class CoverletOptionNames
77
{
88
public const string Coverage = "coverlet";
99
public const string Formats = "coverlet-output-format";
10+
public const string FilePrefix = "coverlet-file-prefix";
1011
public const string Include = "coverlet-include";
1112
public const string Exclude = "coverlet-exclude";
1213
public const string ExcludeByFile = "coverlet-exclude-by-file";

src/coverlet.MTP/Configuration/CoverageConfiguration.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,22 @@ public string[] GetDoesNotReturnAttributes()
161161
return [];
162162
}
163163

164+
/// <summary>
165+
/// Gets the file prefix for coverage report filenames.
166+
/// </summary>
167+
public string? GetFilePrefix()
168+
{
169+
if (_commandLineOptions.TryGetOptionArgumentList(
170+
CoverletOptionNames.FilePrefix,
171+
out string[]? prefix) && prefix.Length > 0)
172+
{
173+
LogOptionValue(CoverletOptionNames.FilePrefix, prefix, isExplicit: true);
174+
return prefix[0];
175+
}
176+
177+
return null;
178+
}
179+
164180
/// <summary>
165181
/// Gets the test assembly path.
166182
/// </summary>

src/coverlet.MTP/Configuration/CoverletExtensionConfiguration.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ internal class CoverletExtensionConfiguration
1010
{
1111
public string[] formats { get; set; } = ["json"];
1212
/// <summary>
13+
/// Optional prefix for coverage report filenames to prevent overwrites.
14+
/// </summary>
15+
public string? FilePrefix { get; set; }
16+
/// <summary>
1317
/// Test module
1418
/// </summary>
1519
public string? TestModule { get; set; }

test/coverlet.MTP.tests/Collector/CollectorExtensionTests.GenerateReports.cs

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,197 @@ public void GenerateCoverageReportFilesWithJsonFormatCreatesJsonReport()
297297
Times.Once);
298298
}
299299

300+
[Fact]
301+
public void GenerateCoverageReportFilesWithFilePrefixCreatesReportWithPrefix()
302+
{
303+
// Arrange
304+
var mockFileSystem = new Mock<IFileSystem>();
305+
var mockSourceRootTranslator = new Mock<ISourceRootTranslator>();
306+
var mockReporterFactory = new Mock<IReporterFactory>();
307+
var mockReporter = new Mock<IReporter>();
308+
309+
// Setup mocks
310+
mockReporter.Setup(x => x.OutputType).Returns(ReporterOutputType.File);
311+
mockReporter.Setup(x => x.Extension).Returns("json");
312+
mockReporter.Setup(x => x.Report(It.IsAny<CoverageResult>(), It.IsAny<ISourceRootTranslator>()))
313+
.Returns("{\"coverage\":\"data\"}");
314+
315+
mockReporterFactory.Setup(x => x.CreateReporter("json")).Returns(mockReporter.Object);
316+
317+
var collector = CreateCollectorForTesting(fileSystem: mockFileSystem.Object);
318+
collector.ReporterFactoryOverride = mockReporterFactory.Object;
319+
320+
var coverageResult = CreateTestCoverageResult();
321+
string outputDirectory = "/fake/reports";
322+
string[] formats = ["json"];
323+
string filePrefix = "MyProject";
324+
325+
// Act
326+
List<string> generatedReports = collector.GenerateCoverageReportFiles(
327+
coverageResult,
328+
mockSourceRootTranslator.Object,
329+
mockFileSystem.Object,
330+
outputDirectory,
331+
formats,
332+
filePrefix);
333+
334+
// Assert
335+
Assert.Single(generatedReports);
336+
Assert.EndsWith("MyProject.coverage.json", generatedReports[0]);
337+
338+
mockFileSystem.Verify(
339+
x => x.WriteAllText(
340+
It.Is<string>(path => path.EndsWith("MyProject.coverage.json")),
341+
It.Is<string>(content => content.Contains("coverage"))),
342+
Times.Once);
343+
}
344+
345+
[Fact]
346+
public void GenerateCoverageReportFilesWithNullFilePrefixCreatesReportWithoutPrefix()
347+
{
348+
// Arrange
349+
var mockFileSystem = new Mock<IFileSystem>();
350+
var mockSourceRootTranslator = new Mock<ISourceRootTranslator>();
351+
var mockReporterFactory = new Mock<IReporterFactory>();
352+
var mockReporter = new Mock<IReporter>();
353+
354+
// Setup mocks
355+
mockReporter.Setup(x => x.OutputType).Returns(ReporterOutputType.File);
356+
mockReporter.Setup(x => x.Extension).Returns("cobertura.xml");
357+
mockReporter.Setup(x => x.Report(It.IsAny<CoverageResult>(), It.IsAny<ISourceRootTranslator>()))
358+
.Returns("<coverage />");
359+
360+
mockReporterFactory.Setup(x => x.CreateReporter("cobertura")).Returns(mockReporter.Object);
361+
362+
var collector = CreateCollectorForTesting(fileSystem: mockFileSystem.Object);
363+
collector.ReporterFactoryOverride = mockReporterFactory.Object;
364+
365+
var coverageResult = CreateTestCoverageResult();
366+
string outputDirectory = "/fake/reports";
367+
string[] formats = ["cobertura"];
368+
369+
// Act
370+
List<string> generatedReports = collector.GenerateCoverageReportFiles(
371+
coverageResult,
372+
mockSourceRootTranslator.Object,
373+
mockFileSystem.Object,
374+
outputDirectory,
375+
formats,
376+
filePrefix: null);
377+
378+
// Assert
379+
Assert.Single(generatedReports);
380+
Assert.EndsWith("coverage.cobertura.xml", generatedReports[0]);
381+
382+
mockFileSystem.Verify(
383+
x => x.WriteAllText(
384+
It.Is<string>(path => path.EndsWith("coverage.cobertura.xml")),
385+
It.IsAny<string>()),
386+
Times.Once);
387+
}
388+
389+
[Fact]
390+
public void GenerateCoverageReportFilesWithEmptyFilePrefixCreatesReportWithoutPrefix()
391+
{
392+
// Arrange
393+
var mockFileSystem = new Mock<IFileSystem>();
394+
var mockSourceRootTranslator = new Mock<ISourceRootTranslator>();
395+
var mockReporterFactory = new Mock<IReporterFactory>();
396+
var mockReporter = new Mock<IReporter>();
397+
398+
// Setup mocks
399+
mockReporter.Setup(x => x.OutputType).Returns(ReporterOutputType.File);
400+
mockReporter.Setup(x => x.Extension).Returns("lcov.info");
401+
mockReporter.Setup(x => x.Report(It.IsAny<CoverageResult>(), It.IsAny<ISourceRootTranslator>()))
402+
.Returns("TN:coverage");
403+
404+
mockReporterFactory.Setup(x => x.CreateReporter("lcov")).Returns(mockReporter.Object);
405+
406+
var collector = CreateCollectorForTesting(fileSystem: mockFileSystem.Object);
407+
collector.ReporterFactoryOverride = mockReporterFactory.Object;
408+
409+
var coverageResult = CreateTestCoverageResult();
410+
string outputDirectory = "/fake/reports";
411+
string[] formats = ["lcov"];
412+
413+
// Act
414+
List<string> generatedReports = collector.GenerateCoverageReportFiles(
415+
coverageResult,
416+
mockSourceRootTranslator.Object,
417+
mockFileSystem.Object,
418+
outputDirectory,
419+
formats,
420+
filePrefix: "");
421+
422+
// Assert
423+
Assert.Single(generatedReports);
424+
Assert.EndsWith("coverage.lcov.info", generatedReports[0]);
425+
426+
mockFileSystem.Verify(
427+
x => x.WriteAllText(
428+
It.Is<string>(path => path.EndsWith("coverage.lcov.info")),
429+
It.IsAny<string>()),
430+
Times.Once);
431+
}
432+
433+
[Fact]
434+
public void GenerateCoverageReportFilesWithFilePrefixAndMultipleFormatsCreatesAllReportsWithPrefix()
435+
{
436+
// Arrange
437+
var mockFileSystem = new Mock<IFileSystem>();
438+
var mockSourceRootTranslator = new Mock<ISourceRootTranslator>();
439+
var mockReporterFactory = new Mock<IReporterFactory>();
440+
441+
var mockJsonReporter = new Mock<IReporter>();
442+
mockJsonReporter.Setup(x => x.OutputType).Returns(ReporterOutputType.File);
443+
mockJsonReporter.Setup(x => x.Extension).Returns("json");
444+
mockJsonReporter.Setup(x => x.Report(It.IsAny<CoverageResult>(), It.IsAny<ISourceRootTranslator>()))
445+
.Returns("{\"coverage\":\"data\"}");
446+
447+
var mockCoberturaReporter = new Mock<IReporter>();
448+
mockCoberturaReporter.Setup(x => x.OutputType).Returns(ReporterOutputType.File);
449+
mockCoberturaReporter.Setup(x => x.Extension).Returns("cobertura.xml");
450+
mockCoberturaReporter.Setup(x => x.Report(It.IsAny<CoverageResult>(), It.IsAny<ISourceRootTranslator>()))
451+
.Returns("<coverage />");
452+
453+
mockReporterFactory.Setup(x => x.CreateReporter("json")).Returns(mockJsonReporter.Object);
454+
mockReporterFactory.Setup(x => x.CreateReporter("cobertura")).Returns(mockCoberturaReporter.Object);
455+
456+
var collector = CreateCollectorForTesting(fileSystem: mockFileSystem.Object);
457+
collector.ReporterFactoryOverride = mockReporterFactory.Object;
458+
459+
var coverageResult = CreateTestCoverageResult();
460+
string outputDirectory = "/fake/reports";
461+
string[] formats = ["json", "cobertura"];
462+
string filePrefix = "UnitTests";
463+
464+
// Act
465+
List<string> generatedReports = collector.GenerateCoverageReportFiles(
466+
coverageResult,
467+
mockSourceRootTranslator.Object,
468+
mockFileSystem.Object,
469+
outputDirectory,
470+
formats,
471+
filePrefix);
472+
473+
// Assert
474+
Assert.Equal(2, generatedReports.Count);
475+
Assert.Contains(generatedReports, r => r.EndsWith("UnitTests.coverage.json"));
476+
Assert.Contains(generatedReports, r => r.EndsWith("UnitTests.coverage.cobertura.xml"));
477+
478+
mockFileSystem.Verify(
479+
x => x.WriteAllText(
480+
It.Is<string>(path => path.EndsWith("UnitTests.coverage.json")),
481+
It.IsAny<string>()),
482+
Times.Once);
483+
484+
mockFileSystem.Verify(
485+
x => x.WriteAllText(
486+
It.Is<string>(path => path.EndsWith("UnitTests.coverage.cobertura.xml")),
487+
It.IsAny<string>()),
488+
Times.Once);
489+
}
490+
300491
/// <summary>
301492
/// Creates a CollectorExtension instance for testing with minimal setup.
302493
/// </summary>

test/coverlet.MTP.tests/CommandLine/CoverletMTPCommandLineTests.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,17 @@ public async Task IsValidForFlagOptions(string optionName)
8282
Assert.True(string.IsNullOrEmpty(result.ErrorMessage));
8383
}
8484

85+
[Fact]
86+
public async Task IsValidForFilePrefixWithValue()
87+
{
88+
CommandLineOption option = _provider.GetCommandLineOptions().First(x => x.Name == CoverletOptionNames.FilePrefix);
89+
90+
var result = await _provider.ValidateOptionArgumentsAsync(option, ["MyProject"]);
91+
92+
Assert.True(result.IsValid);
93+
Assert.True(string.IsNullOrEmpty(result.ErrorMessage));
94+
}
95+
8596
[Fact]
8697
public void GetCommandLineOptionsReturnsAllExpectedOptions()
8798
{
@@ -91,6 +102,7 @@ public void GetCommandLineOptionsReturnsAllExpectedOptions()
91102
{
92103
CoverletOptionNames.Coverage,
93104
CoverletOptionNames.Formats,
105+
CoverletOptionNames.FilePrefix,
94106
CoverletOptionNames.Include,
95107
CoverletOptionNames.IncludeDirectory,
96108
CoverletOptionNames.Exclude,

0 commit comments

Comments
 (0)