Skip to content

Commit 60a9f66

Browse files
authored
Add test filtering to run_tests tool (#462)
1 parent f671bbc commit 60a9f66

File tree

4 files changed

+139
-9
lines changed

4 files changed

+139
-9
lines changed

MCPForUnity/Editor/Services/ITestRunnerService.cs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,33 @@
44

55
namespace MCPForUnity.Editor.Services
66
{
7+
/// <summary>
8+
/// Options for filtering which tests to run.
9+
/// All properties are optional - null or empty arrays are ignored.
10+
/// </summary>
11+
public class TestFilterOptions
12+
{
13+
/// <summary>
14+
/// Full names of specific tests to run (e.g., "MyNamespace.MyTests.TestMethod").
15+
/// </summary>
16+
public string[] TestNames { get; set; }
17+
18+
/// <summary>
19+
/// Same as TestNames, except it allows for Regex.
20+
/// </summary>
21+
public string[] GroupNames { get; set; }
22+
23+
/// <summary>
24+
/// NUnit category names to filter by (tests marked with [Category] attribute).
25+
/// </summary>
26+
public string[] CategoryNames { get; set; }
27+
28+
/// <summary>
29+
/// Assembly names to filter tests by.
30+
/// </summary>
31+
public string[] AssemblyNames { get; set; }
32+
}
33+
734
/// <summary>
835
/// Provides access to Unity Test Runner data and execution.
936
/// </summary>
@@ -16,8 +43,10 @@ public interface ITestRunnerService
1643
Task<IReadOnlyList<Dictionary<string, string>>> GetTestsAsync(TestMode? mode);
1744

1845
/// <summary>
19-
/// Execute tests for the supplied mode.
46+
/// Execute tests for the supplied mode with optional filtering.
2047
/// </summary>
21-
Task<TestRunResult> RunTestsAsync(TestMode mode);
48+
/// <param name="mode">The test mode (EditMode or PlayMode).</param>
49+
/// <param name="filterOptions">Optional filter options to run specific tests. Pass null to run all tests.</param>
50+
Task<TestRunResult> RunTestsAsync(TestMode mode, TestFilterOptions filterOptions = null);
2251
}
2352
}

MCPForUnity/Editor/Services/TestRunnerService.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public async Task<IReadOnlyList<Dictionary<string, string>>> GetTestsAsync(TestM
5858
}
5959
}
6060

61-
public async Task<TestRunResult> RunTestsAsync(TestMode mode)
61+
public async Task<TestRunResult> RunTestsAsync(TestMode mode, TestFilterOptions filterOptions = null)
6262
{
6363
await _operationLock.WaitAsync().ConfigureAwait(true);
6464
Task<TestRunResult> runTask;
@@ -94,7 +94,14 @@ public async Task<TestRunResult> RunTestsAsync(TestMode mode)
9494
_leafResults.Clear();
9595
_runCompletionSource = new TaskCompletionSource<TestRunResult>(TaskCreationOptions.RunContinuationsAsynchronously);
9696

97-
var filter = new Filter { testMode = mode };
97+
var filter = new Filter
98+
{
99+
testMode = mode,
100+
testNames = filterOptions?.TestNames,
101+
groupNames = filterOptions?.GroupNames,
102+
categoryNames = filterOptions?.CategoryNames,
103+
assemblyNames = filterOptions?.AssemblyNames
104+
};
98105
var settings = new ExecutionSettings(filter);
99106

100107
if (mode == TestMode.PlayMode)

MCPForUnity/Editor/Tools/RunTests.cs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Linq;
23
using System.Threading.Tasks;
34
using MCPForUnity.Editor.Helpers;
45
using MCPForUnity.Editor.Resources.Tests;
@@ -42,11 +43,13 @@ public static async Task<object> HandleCommand(JObject @params)
4243
// Preserve default timeout if parsing fails
4344
}
4445

46+
var filterOptions = ParseFilterOptions(@params);
47+
4548
var testService = MCPServiceLocator.Tests;
4649
Task<TestRunResult> runTask;
4750
try
4851
{
49-
runTask = testService.RunTestsAsync(parsedMode.Value);
52+
runTask = testService.RunTestsAsync(parsedMode.Value, filterOptions);
5053
}
5154
catch (Exception ex)
5255
{
@@ -69,5 +72,66 @@ public static async Task<object> HandleCommand(JObject @params)
6972
var data = result.ToSerializable(parsedMode.Value.ToString());
7073
return new SuccessResponse(message, data);
7174
}
75+
76+
private static TestFilterOptions ParseFilterOptions(JObject @params)
77+
{
78+
if (@params == null)
79+
{
80+
return null;
81+
}
82+
83+
var testNames = ParseStringArray(@params, "testNames");
84+
var groupNames = ParseStringArray(@params, "groupNames");
85+
var categoryNames = ParseStringArray(@params, "categoryNames");
86+
var assemblyNames = ParseStringArray(@params, "assemblyNames");
87+
88+
// Return null if no filters specified
89+
if (testNames == null && groupNames == null && categoryNames == null && assemblyNames == null)
90+
{
91+
return null;
92+
}
93+
94+
return new TestFilterOptions
95+
{
96+
TestNames = testNames,
97+
GroupNames = groupNames,
98+
CategoryNames = categoryNames,
99+
AssemblyNames = assemblyNames
100+
};
101+
}
102+
103+
private static string[] ParseStringArray(JObject @params, string key)
104+
{
105+
var token = @params[key];
106+
if (token == null)
107+
{
108+
return null;
109+
}
110+
111+
if (token.Type == JTokenType.String)
112+
{
113+
var value = token.ToString();
114+
return string.IsNullOrWhiteSpace(value) ? null : new[] { value };
115+
}
116+
117+
if (token.Type == JTokenType.Array)
118+
{
119+
var array = token as JArray;
120+
if (array == null || array.Count == 0)
121+
{
122+
return null;
123+
}
124+
125+
var values = array
126+
.Where(t => t.Type == JTokenType.String)
127+
.Select(t => t.ToString())
128+
.Where(s => !string.IsNullOrWhiteSpace(s))
129+
.ToArray();
130+
131+
return values.Length > 0 ? values : null;
132+
}
133+
134+
return null;
135+
}
72136
}
73137
}

Server/src/services/tools/run_tests.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,12 @@ class RunTestsResponse(MCPResponse):
4545
)
4646
async def run_tests(
4747
ctx: Context,
48-
mode: Annotated[Literal["EditMode", "PlayMode"], Field(
49-
description="Unity test mode to run")] = "EditMode",
50-
timeout_seconds: Annotated[int | str, Field(
51-
description="Optional timeout in seconds for the Unity test run (string, e.g. '30')")] | None = None,
48+
mode: Annotated[Literal["EditMode", "PlayMode"], "Unity test mode to run"] = "EditMode",
49+
timeout_seconds: Annotated[int | str, "Optional timeout in seconds for the test run"] | None = None,
50+
test_names: Annotated[list[str] | str, "Full names of specific tests to run (e.g., 'MyNamespace.MyTests.TestMethod')"] | None = None,
51+
group_names: Annotated[list[str] | str, "Same as test_names, except it allows for Regex"] | None = None,
52+
category_names: Annotated[list[str] | str, "NUnit category names to filter by (tests marked with [Category] attribute)"] | None = None,
53+
assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None,
5254
) -> RunTestsResponse:
5355
unity_instance = get_unity_instance_from_context(ctx)
5456

@@ -68,11 +70,39 @@ def _coerce_int(value, default=None):
6870
except Exception:
6971
return default
7072

73+
# Coerce string or list to list of strings
74+
def _coerce_string_list(value) -> list[str] | None:
75+
if value is None:
76+
return None
77+
if isinstance(value, str):
78+
return [value] if value.strip() else None
79+
if isinstance(value, list):
80+
result = [str(v).strip() for v in value if v and str(v).strip()]
81+
return result if result else None
82+
return None
83+
7184
params: dict[str, Any] = {"mode": mode}
7285
ts = _coerce_int(timeout_seconds)
7386
if ts is not None:
7487
params["timeoutSeconds"] = ts
7588

89+
# Add filter parameters if provided
90+
test_names_list = _coerce_string_list(test_names)
91+
if test_names_list:
92+
params["testNames"] = test_names_list
93+
94+
group_names_list = _coerce_string_list(group_names)
95+
if group_names_list:
96+
params["groupNames"] = group_names_list
97+
98+
category_names_list = _coerce_string_list(category_names)
99+
if category_names_list:
100+
params["categoryNames"] = category_names_list
101+
102+
assembly_names_list = _coerce_string_list(assembly_names)
103+
if assembly_names_list:
104+
params["assemblyNames"] = assembly_names_list
105+
76106
response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "run_tests", params)
77107
await ctx.info(f'Response {response}')
78108
return RunTestsResponse(**response) if isinstance(response, dict) else response

0 commit comments

Comments
 (0)