- Approved in principle
- Under discussion
- Implementation
- Shipped
Add an environmentVariables section to testconfig.json that lets users declare environment variables to apply to the test host process. This mirrors the <EnvironmentVariables> element of legacy .runsettings, removes the need for users to ship a custom ITestHostEnvironmentVariableProvider, and brings parity with the most-requested missing feature from the issue tracker (#5491).
Today, declaring per-test-project environment variables in Microsoft.Testing.Platform requires writing custom code:
internal sealed class MyEnvProvider : ITestHostEnvironmentVariableProvider
{
// ~50 lines of boilerplate to read appsettings/IConfiguration and call SetVariable.
}
builder.TestHost.AddEnvironmentVariableProvider(_ => new MyEnvProvider(...));This is awkward for three audiences:
- Users migrating from
runsettingslose a feature they relied on (e.g. settingDOTNET_ENVIRONMENT,HEADED, profiler variables, or app-specific knobs for tests). - CI authors want to express test environment in a declarative file checked into source control, not via shell exports that diverge between runners.
- Tooling integrators (IDE Test Explorer,
dotnet test) want a stable place to read the test host environment without parsing source.
A built-in ITestHostEnvironmentVariableProvider (TestConfigurationEnvironmentVariableProvider) is registered automatically by Microsoft.Testing.Platform. The provider is only enabled when the section exists and contains at least one entry, so projects that do not use this feature pay no cost.
When the provider is enabled it opts in to the test host controller process model: the current process becomes the controller, sets the environment variables on ProcessStartInfo, and launches the actual test host as a child process.
Microsoft.Testing.Platform already has the abstractions needed to apply environment variables to the test host: any registered, enabled ITestHostEnvironmentVariableProvider causes TestHostBuilder to take the TestHostControllersTestHost code path. That host:
- Constructs an
EnvironmentVariablesbag and walks each enabled provider'sUpdateAsync. - Calls
ValidateTestHostEnvironmentVariablesAsyncon every provider to give them a chance to reject the final aggregated set. - Copies the result into
ProcessStartInfo.EnvironmentVariables. - Spawns the test host as a child process.
The new provider plugs into step 1.
JsonConfigurationFileParser already flattens the testconfig.json tree into two dictionaries:
_singleValueData["environmentVariables:FOO"] = "bar"_propertyToAllChildren["environmentVariables"] = "{\"FOO\":\"bar\"}"
A new internal method JsonConfigurationProvider.GetSection(string sectionName) enumerates the entries with a strict schema check (see Schema validation). AggregatedConfiguration.GetTestConfigJsonSection exposes this to internal callers.
TestHostBuilder.SetupCommonServicesAsync registers the provider immediately after the configuration is built and inserts it at the front of the controller ordering so later user/VSTest providers can still override:
if (!OperatingSystem.IsBrowser())
{
// Internal API: inserts the provider at the front of the controller ordering so any
// later user-supplied or VSTest-bridge provider registered via the public
// TestHostControllers.AddEnvironmentVariableProvider(...) still wins for the same key.
testHostControllersManager.AddEnvironmentVariableProviderFirst(
sp => new TestConfigurationEnvironmentVariableProvider(sp.GetConfiguration()));
}It is registered first in the controller ordering so any user-supplied or extension-supplied provider runs after it and can override the testconfig.json values. This ordering choice has a concrete consequence — see Precedence.
internal sealed class TestConfigurationEnvironmentVariableProvider : ITestHostEnvironmentVariableProvider
{
public Task<bool> IsEnabledAsync()
{
var entries = configuration.GetTestConfigJsonSection("environmentVariables");
if (entries.Count == 0) return Task.FromResult(false); // section absent or empty
foreach (var entry in entries) ValidateName(entry.Key); // fail fast on malformed names
_entries = entries;
return Task.FromResult(true);
}
public Task UpdateAsync(IEnvironmentVariables environmentVariables)
{
foreach (var entry in _entries)
{
environmentVariables.SetVariable(new EnvironmentVariable(
entry.Key, entry.Value ?? string.Empty, isSecret: false, isLocked: false));
}
return Task.CompletedTask;
}
public Task<ValidationResult> ValidateTestHostEnvironmentVariablesAsync(IReadOnlyEnvironmentVariables _)
=> ValidationResult.ValidTask;
}The environmentVariables section must be a flat JSON object whose values are scalars. Any deviation throws FormatException during the build phase with a message identifying the offending key and the configuration file path. Specifically:
| JSON shape | Behavior |
|---|---|
"environmentVariables": { "FOO": "bar" } |
✅ Sets FOO=bar |
"environmentVariables": { "FOO": "" } |
✅ Sets FOO="" |
"environmentVariables": { "FOO": 42, "BAR": true } |
✅ Coerced to text: FOO=42, BAR=True |
"environmentVariables": { "FOO": null } |
|
"environmentVariables": {} |
✅ No-op; controller process model not triggered |
| (section absent) | ✅ No-op; controller process model not triggered |
"environmentVariables": "oops" |
❌ Throws – section must be a JSON object |
"environmentVariables": [1, 2] |
❌ Throws – section must be a JSON object |
"environmentVariables": { "FOO": { "NESTED": "x" } } |
❌ Throws – nested objects not supported |
"environmentVariables": { "FOO": [1, 2] } |
❌ Throws – nested arrays not supported |
"environmentVariables": { "FOO": {} } |
❌ Throws – nested empty objects not supported |
"environmentVariables": { "": "x" } |
❌ Throws – empty variable names are invalid |
"environmentVariables": { "FOO=BAR": "x" } |
❌ Throws – = is not allowed in names |
Within the testhost controller, providers are applied in registration order (TestHostControllersManager._factoryOrdering) and later providers can overwrite earlier ones unless the earlier entry was marked IsLocked. The built-in TestConfigurationEnvironmentVariableProvider:
- Is registered first (in
SetupCommonServicesAsync, before any user code). - Sets entries with
isLocked: false.
Consequences:
- A user-supplied
ITestHostEnvironmentVariableProviderregistered viabuilder.TestHost.AddEnvironmentVariableProvider(...)runs after the built-in and can override testconfig.json values. - The VSTest-bridge
RunSettingsEnvironmentVariableProvider(which currently setsisLocked: true) wins over testconfig.json when both sources are present.
End-to-end precedence (highest wins):
- CLI options that explicitly set the test host environment (none today, but reserved).
- The system environment inherited by the test host parent process (the controller inherits it; values declared here override the inherited value for the child only).
- User-registered
ITestHostEnvironmentVariableProviderextensions (last-registered wins). - VSTest-bridge runsettings
<EnvironmentVariables>(locked). testconfig.jsonenvironmentVariables(this RFC).launchSettings.jsonenvironmentVariables(in scope for the .NET 10dotnet testintegration, but not implemented by this RFC).
TestHostBuilder.TryBuildTestHostControllersHostAsync returns null when --list-tests is set. As a result, testconfig.json env vars are not applied during discovery — discovery runs in-process. This matches existing runsettings behavior but is a divergence from execution that test framework authors should be aware of: discovery code that branches on DOTNET_ENVIRONMENT will not see the configured value.
Declaring even a single entry in environmentVariables triggers the controller process model: the current process forks a child test host and proxies its lifecycle. We considered a fast-path that sets the variables in-process via Environment.SetEnvironmentVariable when no other extension requires the controller, and rejected it: many runtime knobs (DOTNET_*, profiler/loader variables, ICU/globalization settings) only take effect at process startup. Splitting behavior would create a silent foot-gun where some variables work and others do not. Users that want zero overhead should keep the section absent.
The provider is [UnsupportedOSPlatform("browser")] and is not registered on Wasm targets, mirroring TestHostControllersManager.AddEnvironmentVariableProvider. The section is silently ignored on Wasm because the controller process model does not exist there.
JsonConfigurationFileParser stores keys with StringComparer.OrdinalIgnoreCase. As a result:
{"FOO": "x", "foo": "y"}fails the JSON parse with a duplicate-key error before reaching this provider.- Environment variable names are case-sensitive on Linux/macOS and case-insensitive on Windows. Authors targeting cross-platform should pick a single casing.
A JSON null value is interpreted differently by the two JSON parser implementations the platform ships:
- On .NET (System.Text.Json):
{"FOO": null}results inFOO=""(empty string). - On .NET Framework / .NET Standard (Jsonite):
{"FOO": null}results inFOO=null(the four-character string).
This stems from the underlying parsers and is not unique to this feature. Recommendation: never use JSON null for env var values — use an explicit empty string "" instead.
Values are written to ProcessStartInfo.EnvironmentVariables in plain text and, when trace logging is enabled, may appear in the platform's diagnostic logs (ConfigurationManager traces the full config file content; EnvironmentVariables traces value overrides). Do not put secrets in testconfig.json. For secrets, use existing mechanisms (CI secret stores piped to the system environment, user-secrets, key vaults).
There is no way to unset an inherited environment variable via this section. Setting a key to "" sets it to empty string, which is not the same as removing it. If removal becomes a desired feature in the future, we can adopt a sentinel (null or a documented marker) — but doing so consistently across both JSON parsers will need extra work in the configuration layer.
Discussed in #5491. launchSettings.json is a development-time file; pushing test-only configuration into it ties test execution to a file that is also consulted by dotnet run. Furthermore, launchSettings.json isn't supported by dotnet test until .NET 10. Even when it ships, testconfig.json remains the right place to declare values that apply specifically to test execution (e.g. HEADED=0 in CI but HEADED=1 in launchSettings for local debugging).
We would lose the "no-cost when absent" property and force every MTP run to incur a subprocess. Rejected.
Rejected as described in Cost of opting in — silently dropping runtime knobs that only take effect at startup is worse than uniformly paying the controller cost.
Rejected. IConfigurationProvider is [Experimental("TPEXP")] but still public; adding members to it would be a source-breaking change for third-party providers. The new enumeration lives on the internal JsonConfigurationProvider and is reached via AggregatedConfiguration casting, both of which are internal.
- New JSON section under a previously-undefined key: no existing valid
testconfig.jsonbecomes invalid. - New built-in provider in MTP: registered only when the section exists, so existing projects observe zero behavior change.
- No public API additions; no changes to
PublicAPI.Shipped.txtorUnshipped.txt.
- Should the runsettings (VSTest bridge) provider be loosened to
isLocked: falseso that testconfig.json can also override runsettings in legacy multi-config projects? Not required by this RFC; out of scope. - Should we expose a CLI override (e.g.
--environment KEY=VALUE) that wins over both files? Out of scope; can be added later without breaking changes.
{ "environmentVariables": { "DOTNET_ENVIRONMENT": "Development", "HEADED": "1", "ASPNETCORE_URLS": "http://localhost:5050" } }