Skip to content

Add better error messages for dotnet test with solution/project/directory/dll arguments in Microsoft.Testing.Platform (MTP) #50108

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Aug 12, 2025
80 changes: 80 additions & 0 deletions src/Cli/dotnet/Commands/Test/TestCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ public static int Run(ParseResult parseResult)
// all parameters before --
args = [.. args.TakeWhile(a => a != "--")];

// Check for common patterns that suggest users need to use specific flags
var validationResult = ValidateArgumentsForRequiredFlags(args);
if (validationResult != 0)
{
return validationResult;
}

// Fix for https://github.com/Microsoft/vstest/issues/1453
// Run dll/exe directly using the VSTestForwardingApp
if (ContainsBuiltTestSources(args))
Expand Down Expand Up @@ -341,6 +348,79 @@ private static Dictionary<string, string> GetUserSpecifiedExplicitMSBuildPropert
}
return globalProperties;
}

/// <summary>
/// Validates that arguments requiring specific flags are used correctly.
/// Provides helpful error messages when users provide file/directory arguments without proper flags.
/// </summary>
/// <returns>0 if validation passes, non-zero error code if validation fails</returns>
private static int ValidateArgumentsForRequiredFlags(string[] args)
{
foreach (string arg in args)
{
if (arg.StartsWith("-"))
{
// Skip options/flags
continue;
}

string? errorMessage = null;
string? suggestedUsage = null;

// Check for .sln files
if (arg.EndsWith(".sln", StringComparison.OrdinalIgnoreCase) && File.Exists(arg))
{
errorMessage = $"Solution file '{arg}' was provided as a positional argument.";
suggestedUsage = $"Consider passing the solution as the default target by running: dotnet test (from the solution directory)\n" +
$"Or use the new Testing Platform with: --solution {arg}\n" +
"To enable Testing Platform, create a 'dotnet.config' file with:\n" +
"[dotnet.test.runner]\n" +
"name = Microsoft.Testing.Platform";
}
// Check for .csproj/.vbproj/.fsproj files
else if ((arg.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase) ||
arg.EndsWith(".vbproj", StringComparison.OrdinalIgnoreCase) ||
arg.EndsWith(".fsproj", StringComparison.OrdinalIgnoreCase)) && File.Exists(arg))
{
errorMessage = $"Project file '{arg}' was provided as a positional argument.";
suggestedUsage = $"Consider passing the project as the default target by running: dotnet test (from the project directory)\n" +
$"Or use the new Testing Platform with: --project {arg}\n" +
"To enable Testing Platform, create a 'dotnet.config' file with:\n" +
"[dotnet.test.runner]\n" +
"name = Microsoft.Testing.Platform";
}
// Check for directories (if they exist)
else if (Directory.Exists(arg))
{
errorMessage = $"Directory '{arg}' was provided as a positional argument.";
suggestedUsage = $"Use the new Testing Platform with: --directory {arg}\n" +
"To enable Testing Platform, create a 'dotnet.config' file with:\n" +
"[dotnet.test.runner]\n" +
"name = Microsoft.Testing.Platform";
}
// Check for .dll or .exe files (but not for VSTest forwarding case)
else if ((arg.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ||
arg.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) &&
File.Exists(arg))
{
errorMessage = $"Test assembly '{arg}' was provided as a positional argument.";
suggestedUsage = $"Use the new Testing Platform with: --test-modules {arg}\n" +
"To enable Testing Platform, create a 'dotnet.config' file with:\n" +
"[dotnet.test.runner]\n" +
"name = Microsoft.Testing.Platform";
}

if (errorMessage != null && suggestedUsage != null)
{
Reporter.Error.WriteLine(errorMessage);
Reporter.Error.WriteLine(suggestedUsage);
Reporter.Error.WriteLine("\nFor more information about the available options, run 'dotnet test --help'.");
return 1;
}
}

return 0;
}
}

public class TerminalLoggerDetector
Expand Down
72 changes: 71 additions & 1 deletion src/Cli/dotnet/Commands/Test/TestingPlatformCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
using System.CommandLine;
using Microsoft.DotNet.Cli.Commands.Test.Terminal;
using Microsoft.DotNet.Cli.Extensions;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.TemplateEngine.Cli.Commands;
using Microsoft.TemplateEngine.Cli.Help;

namespace Microsoft.DotNet.Cli.Commands.Test;

internal partial class TestingPlatformCommand : Command, ICustomHelp
internal partial class TestingPlatformCommand : System.CommandLine.Command, ICustomHelp
{
private MSBuildHandler _msBuildHandler;
private TerminalTestReporter _output;
Expand Down Expand Up @@ -46,6 +47,13 @@ private int RunInternal(ParseResult parseResult)
{
ValidationUtility.ValidateMutuallyExclusiveOptions(parseResult);

// Validate arguments for required flags
var validationResult = ValidateArgumentsForRequiredFlags(parseResult);
if (validationResult != 0)
{
return validationResult;
}

PrepareEnvironment(parseResult, out TestOptions testOptions, out int degreeOfParallelism);

InitializeOutput(degreeOfParallelism, parseResult, testOptions.IsHelp);
Expand Down Expand Up @@ -205,4 +213,66 @@ private void CleanUp()
{
_eventHandlers?.Dispose();
}

/// <summary>
/// Validates that arguments requiring specific flags are used correctly for Microsoft Testing Platform.
/// Provides helpful error messages when users provide file/directory arguments without proper flags.
/// </summary>
/// <returns>0 if validation passes, non-zero error code if validation fails</returns>
private static int ValidateArgumentsForRequiredFlags(ParseResult parseResult)
{
// Check unmatched tokens for file/directory arguments that should use flags
var unmatchedTokens = parseResult.UnmatchedTokens.ToList();

foreach (string token in unmatchedTokens)
{
if (token.StartsWith("-"))
{
// Skip options/flags
continue;
}

string errorMessage = null;
string suggestedUsage = null;

// Check for .sln files
if (token.EndsWith(".sln", StringComparison.OrdinalIgnoreCase) && File.Exists(token))
{
errorMessage = $"Solution file '{token}' was provided as a positional argument.";
suggestedUsage = $"Use the --solution flag: dotnet test --solution {token}";
}
// Check for .csproj/.vbproj/.fsproj files
else if ((token.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase) ||
token.EndsWith(".vbproj", StringComparison.OrdinalIgnoreCase) ||
token.EndsWith(".fsproj", StringComparison.OrdinalIgnoreCase)) && File.Exists(token))
{
errorMessage = $"Project file '{token}' was provided as a positional argument.";
suggestedUsage = $"Use the --project flag: dotnet test --project {token}";
}
// Check for directories (if they exist)
else if (Directory.Exists(token))
{
errorMessage = $"Directory '{token}' was provided as a positional argument.";
suggestedUsage = $"Use the --directory flag: dotnet test --directory {token}";
}
// Check for .dll or .exe files
else if ((token.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ||
token.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) &&
File.Exists(token))
{
errorMessage = $"Test assembly '{token}' was provided as a positional argument.";
suggestedUsage = $"Use the --test-modules flag: dotnet test --test-modules {token}";
}

if (errorMessage != null && suggestedUsage != null)
{
Reporter.Error.WriteLine(errorMessage);
Reporter.Error.WriteLine(suggestedUsage);
Reporter.Error.WriteLine("\nFor more information about the available options, run 'dotnet test --help'.");
return 1;
}
}

return 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

using System.CommandLine;
using Microsoft.DotNet.Cli.Commands.Test;
using Microsoft.DotNet.Cli;

namespace Microsoft.DotNet.Cli.Test.Tests
{
Expand Down
175 changes: 175 additions & 0 deletions test/dotnet.Tests/CommandTests/Test/TestCommandValidationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.DotNet.Tools.Test.Utilities;

namespace Microsoft.DotNet.Cli.Test.Tests
{
public class TestCommandValidationTests : SdkTest
{
public TestCommandValidationTests(ITestOutputHelper log) : base(log)
{
}

[Theory]
[InlineData("MySolution.sln", "Solution file 'MySolution.sln' was provided as a positional argument.")]
[InlineData("MyProject.csproj", "Project file 'MyProject.csproj' was provided as a positional argument.")]
[InlineData("MyProject.vbproj", "Project file 'MyProject.vbproj' was provided as a positional argument.")]
[InlineData("MyProject.fsproj", "Project file 'MyProject.fsproj' was provided as a positional argument.")]
public void TestCommandShouldValidateFileArgumentsAndProvideHelpfulMessages(string filename, string expectedErrorStart)
{
var testDir = _testAssetsManager.CreateTestDirectory();

// Create the test file
var testFilePath = Path.Combine(testDir.Path, filename);
File.WriteAllText(testFilePath, "dummy content");

var result = new DotnetTestCommand(Log, disableNewOutput: true)
.WithWorkingDirectory(testDir.Path)
.Execute(filename);

result.ExitCode.Should().Be(1);
result.StdErr.Should().Contain(expectedErrorStart);
result.StdErr.Should().Contain("Testing Platform");
result.StdErr.Should().Contain("dotnet.config");
}

[Fact]
public void TestCommandShouldValidateDirectoryArgumentAndProvideHelpfulMessage()
{
var testDir = _testAssetsManager.CreateTestDirectory();
var subDir = Path.Combine(testDir.Path, "test_directory");
Directory.CreateDirectory(subDir);

var result = new DotnetTestCommand(Log, disableNewOutput: true)
.WithWorkingDirectory(testDir.Path)
.Execute("test_directory");

result.ExitCode.Should().Be(1);
result.StdErr.Should().Contain("Directory 'test_directory' was provided as a positional argument.");
result.StdErr.Should().Contain("--directory test_directory");
result.StdErr.Should().Contain("Testing Platform");
}

[Fact]
public void TestCommandShouldValidateDllArgumentAndProvideHelpfulMessage()
{
var testDir = _testAssetsManager.CreateTestDirectory();

// Create a dummy dll file
var dllPath = Path.Combine(testDir.Path, "test.dll");
File.WriteAllText(dllPath, "dummy dll content");

var result = new DotnetTestCommand(Log, disableNewOutput: true)
.WithWorkingDirectory(testDir.Path)
.Execute("test.dll");

result.ExitCode.Should().Be(1);
result.StdErr.Should().Contain("Test assembly 'test.dll' was provided as a positional argument.");
result.StdErr.Should().Contain("--test-modules test.dll");
result.StdErr.Should().Contain("Testing Platform");
}

[Fact]
public void TestCommandShouldAllowNormalOptionsWithoutValidation()
{
var testDir = _testAssetsManager.CreateTestDirectory();

// Test that normal options like --help still work without triggering validation
var result = new DotnetTestCommand(Log, disableNewOutput: true)
.Execute("--help");

result.ExitCode.Should().Be(0);
result.StdOut.Should().Contain("Usage:");
result.StdOut.Should().Contain("dotnet test");
}

[Fact]
public void TestCommandShouldNotValidateNonExistentFiles()
{
var testDir = _testAssetsManager.CreateTestDirectory();

// Test that non-existent files with project extensions don't trigger validation
var result = new DotnetTestCommand(Log, disableNewOutput: true)
.WithWorkingDirectory(testDir.Path)
.Execute("NonExistent.csproj");

// This should pass through to MSBuild and give a different error, not our validation error
result.ExitCode.Should().Be(1);
result.StdErr.Should().NotContain("was provided as a positional argument");
result.StdErr.Should().NotContain("Testing Platform");
}

[Theory]
[InlineData("MySolution.sln", "Solution file 'MySolution.sln' was provided as a positional argument.", "--solution MySolution.sln")]
[InlineData("MyProject.csproj", "Project file 'MyProject.csproj' was provided as a positional argument.", "--project MyProject.csproj")]
[InlineData("MyProject.vbproj", "Project file 'MyProject.vbproj' was provided as a positional argument.", "--project MyProject.vbproj")]
[InlineData("MyProject.fsproj", "Project file 'MyProject.fsproj' was provided as a positional argument.", "--project MyProject.fsproj")]
public void TestCommandWithMTPShouldValidateFileArgumentsAndProvideDirectGuidance(string filename, string expectedErrorStart, string expectedSuggestion)
{
var testDir = _testAssetsManager.CreateTestDirectory();

// Create dotnet.config to enable MTP
var configPath = Path.Combine(testDir.Path, "dotnet.config");
File.WriteAllText(configPath, "[dotnet.test.runner]\nname = Microsoft.Testing.Platform");

// Create the test file
var testFilePath = Path.Combine(testDir.Path, filename);
File.WriteAllText(testFilePath, "dummy content");

var result = new DotnetTestCommand(Log, disableNewOutput: true)
.WithWorkingDirectory(testDir.Path)
.Execute(filename);

result.ExitCode.Should().Be(1);
result.StdErr.Should().Contain(expectedErrorStart);
result.StdErr.Should().Contain(expectedSuggestion);
result.StdErr.Should().NotContain("dotnet.config"); // MTP is already enabled, so no need to suggest enabling it
}

[Fact]
public void TestCommandWithMTPShouldValidateDirectoryArgumentAndProvideDirectGuidance()
{
var testDir = _testAssetsManager.CreateTestDirectory();

// Create dotnet.config to enable MTP
var configPath = Path.Combine(testDir.Path, "dotnet.config");
File.WriteAllText(configPath, "[dotnet.test.runner]\nname = Microsoft.Testing.Platform");

var subDir = Path.Combine(testDir.Path, "test_directory");
Directory.CreateDirectory(subDir);

var result = new DotnetTestCommand(Log, disableNewOutput: true)
.WithWorkingDirectory(testDir.Path)
.Execute("test_directory");

result.ExitCode.Should().Be(1);
result.StdErr.Should().Contain("Directory 'test_directory' was provided as a positional argument.");
result.StdErr.Should().Contain("--directory test_directory");
result.StdErr.Should().NotContain("dotnet.config"); // MTP is already enabled
}

[Fact]
public void TestCommandWithMTPShouldValidateDllArgumentAndProvideDirectGuidance()
{
var testDir = _testAssetsManager.CreateTestDirectory();

// Create dotnet.config to enable MTP
var configPath = Path.Combine(testDir.Path, "dotnet.config");
File.WriteAllText(configPath, "[dotnet.test.runner]\nname = Microsoft.Testing.Platform");

// Create a dummy dll file
var dllPath = Path.Combine(testDir.Path, "test.dll");
File.WriteAllText(dllPath, "dummy dll content");

var result = new DotnetTestCommand(Log, disableNewOutput: true)
.WithWorkingDirectory(testDir.Path)
.Execute("test.dll");

result.ExitCode.Should().Be(1);
result.StdErr.Should().Contain("Test assembly 'test.dll' was provided as a positional argument.");
result.StdErr.Should().Contain("--test-modules test.dll");
result.StdErr.Should().NotContain("dotnet.config"); // MTP is already enabled
}
}
}
Loading