Skip to content
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

Abstract away the TestServer dependency from WebApplicationBuilder #60247

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/Hosting/TestHost/src/ITestServer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Net.Http;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;

namespace Microsoft.AspNetCore.TestHost;

/// <summary>
/// A contract for a test server implementation.
/// </summary>
public interface ITestServer : IServer
{
/// <summary>
/// Gets the web host associated with the test server.
/// </summary>
IWebHost Host { get; }

/// <summary>
/// Creates a new <see cref="HttpMessageHandler"/> for processing HTTP requests against the test server.
/// </summary>
/// <returns>A new <see cref="HttpMessageHandler"/> instance.</returns>
HttpMessageHandler CreateHandler();

/// <summary>
/// Creates a new <see cref="HttpClient"/> for processing HTTP requests against the test server.
/// </summary>
/// <returns></returns>
HttpClient CreateClient();
}
4 changes: 4 additions & 0 deletions src/Hosting/TestHost/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
#nullable enable
Microsoft.AspNetCore.TestHost.ITestServer
Microsoft.AspNetCore.TestHost.ITestServer.CreateClient() -> System.Net.Http.HttpClient!
Microsoft.AspNetCore.TestHost.ITestServer.CreateHandler() -> System.Net.Http.HttpMessageHandler!
Microsoft.AspNetCore.TestHost.ITestServer.Host.get -> Microsoft.AspNetCore.Hosting.IWebHost!
2 changes: 1 addition & 1 deletion src/Hosting/TestHost/src/TestServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace Microsoft.AspNetCore.TestHost;
/// <summary>
/// An <see cref="IServer"/> implementation for executing tests.
/// </summary>
public class TestServer : IServer
public class TestServer : ITestServer
{
private readonly IWebHost? _hostInstance;
private bool _disposed;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ protected override IHost CreateHost(IHostBuilder builder)
return result;
}

protected override TestServer CreateServer(IWebHostBuilder builder)
protected override ITestServer CreateTestServer(IWebHostBuilder builder)
{
var result = base.CreateServer(builder);
var result = base.CreateTestServer(builder);
EnsureDatabaseCreated(result.Host.Services);
return result;
}
Expand Down
47 changes: 46 additions & 1 deletion src/Mvc/Mvc.Testing.Tasks/src/GenerateMvcTestManifestTask.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.Serialization.Json;
using System.Text;
using Microsoft.Build.Framework;
Expand Down Expand Up @@ -33,11 +35,14 @@ public override bool Execute()
{
using var fileStream = File.Create(ManifestPath);
var output = new Dictionary<string, string>();
var manifestDirectory = Path.GetDirectoryName(ManifestPath);

foreach (var project in Projects)
{
var contentRoot = project.GetMetadata("ContentRoot");
var assemblyName = project.GetMetadata("Identity");
output[assemblyName] = contentRoot;
var relativeContentRoot = GetRelativePath(manifestDirectory, contentRoot);
output[assemblyName] = relativeContentRoot;
}

var serializer = new DataContractJsonSerializer(typeof(Dictionary<string, string>), new DataContractJsonSerializerSettings
Expand All @@ -49,4 +54,44 @@ public override bool Execute()

return !Log.HasLoggedErrors;
}

private static string GetRelativePath(string? relativeTo, string path)
{
if (string.IsNullOrEmpty(relativeTo))
{
return path;
}

// Ensure the paths are absolute
string absoluteRelativeTo = Path.GetFullPath(relativeTo);
string absolutePath = Path.GetFullPath(path);

// Split the paths into their components
string[] relativeToParts = absoluteRelativeTo.TrimEnd(Path.DirectorySeparatorChar).Split(Path.DirectorySeparatorChar);
string[] pathParts = absolutePath.TrimEnd(Path.DirectorySeparatorChar).Split(Path.DirectorySeparatorChar);

// Find the common base path length
int commonLength = 0;
while (commonLength < relativeToParts.Length && commonLength < pathParts.Length &&
string.Equals(relativeToParts[commonLength], pathParts[commonLength], StringComparison.OrdinalIgnoreCase))
{
commonLength++;
}

// Calculate the number of directories to go up from the relativeTo path
int upDirectories = relativeToParts.Length - commonLength;

// Build the relative path
string relativePath = string.Join(Path.DirectorySeparatorChar.ToString(), new string[upDirectories].Select(_ => "..").ToArray());
if (commonLength < pathParts.Length)
{
if (relativePath.Length > 0)
{
relativePath += Path.DirectorySeparatorChar;
}
relativePath += string.Join(Path.DirectorySeparatorChar.ToString(), pathParts.Skip(commonLength));
}

return relativePath;
}
}
6 changes: 6 additions & 0 deletions src/Mvc/Mvc.Testing/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
#nullable enable
Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<TEntryPoint>.Initialize() -> void
*REMOVED*Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<TEntryPoint>.Server.get -> Microsoft.AspNetCore.TestHost.TestServer!
Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<TEntryPoint>.Server.get -> Microsoft.AspNetCore.TestHost.TestServer?
Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<TEntryPoint>.TestServer.get -> Microsoft.AspNetCore.TestHost.ITestServer?
virtual Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<TEntryPoint>.CreateTestServer(Microsoft.AspNetCore.Hosting.IWebHostBuilder! builder) -> Microsoft.AspNetCore.TestHost.ITestServer!

5 changes: 4 additions & 1 deletion src/Mvc/Mvc.Testing/src/Resources.resx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Expand Down Expand Up @@ -120,6 +120,9 @@
<data name="InvalidAssemblyEntryPoint" xml:space="preserve">
<value>The provided Type '{0}' does not belong to an assembly with an entry point. A common cause for this error is providing a Type from a class library.</value>
</data>
<data name="InvalidTestServerConfiguration" xml:space="preserve">
<value>The host doesn't contain a valid ITestServer configuration.</value>
</data>
<data name="MissingBuilderMethod" xml:space="preserve">
<value>No method 'public static {0} CreateHostBuilder(string[] args)' or 'public static {1} CreateWebHostBuilder(string[] args)' found on '{2}'. Alternatively, {3} can be extended and '{4}' or '{5}' can be overridden to provide your own instance.</value>
</data>
Expand Down
86 changes: 65 additions & 21 deletions src/Mvc/Mvc.Testing/src/WebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,16 @@ namespace Microsoft.AspNetCore.Mvc.Testing;
/// </summary>
/// <typeparam name="TEntryPoint">A type in the entry point assembly of the application.
/// Typically the Startup or Program classes can be used.</typeparam>
/// <remarks>The default behavior of the <see cref="CreateTestServer(IWebHostBuilder)"/> implementation
/// creates a new <see cref="TestHost.TestServer"/> instance as an in-memory server to utilize for testing.
/// If the developers wants, they can override the <see cref="CreateTestServer(IWebHostBuilder)"/> method to return a customer <see cref="ITestServer"/> implementation,
/// and provide an adapter over a real web server, if needed. If done so, the <see cref="CreateClient()"/> and similar methods will
/// create <see cref="HttpClient"/> instances configured to interact with the provided test server instead.</remarks>
public partial class WebApplicationFactory<TEntryPoint> : IDisposable, IAsyncDisposable where TEntryPoint : class
{
private bool _disposed;
private bool _disposedAsync;
private TestServer? _server;
private ITestServer? _server;
private IHost? _host;
private Action<IWebHostBuilder> _configuration;
private readonly List<HttpClient> _clients = new();
Expand Down Expand Up @@ -71,11 +76,23 @@ public WebApplicationFactory()
/// <summary>
/// Gets the <see cref="TestServer"/> created by this <see cref="WebApplicationFactory{TEntryPoint}"/>.
/// </summary>
public TestServer Server
[Obsolete("This property is obsolete. Consider utilizing the TestServer property instead.")]
public TestServer? Server
{
get
{
EnsureServer();
return TestServer as TestServer;
}
}

/// <summary>
/// Gets the <see cref="ITestServer"/> instance created by the underyling <see cref="CreateTestServer(IWebHostBuilder)"/> call.
/// </summary>
public ITestServer? TestServer
{
get
{
Initialize();
return _server;
}
}
Expand All @@ -87,7 +104,7 @@ public virtual IServiceProvider Services
{
get
{
EnsureServer();
Initialize();
return _host?.Services ?? _server.Host.Services;
}
}
Expand Down Expand Up @@ -119,7 +136,7 @@ internal virtual WebApplicationFactory<TEntryPoint> WithWebHostBuilderCore(Actio
{
var factory = new DelegatedWebApplicationFactory(
ClientOptions,
CreateServer,
CreateTestServer,
CreateHost,
CreateWebHostBuilder,
CreateHostBuilder,
Expand All @@ -136,8 +153,12 @@ internal virtual WebApplicationFactory<TEntryPoint> WithWebHostBuilderCore(Actio
return factory;
}

/// <summary>
/// Initializes the instance by configurating the host builder.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown if the provided <typeparamref name="TEntryPoint"/> type has no factory method.</exception>
[MemberNotNull(nameof(_server))]
private void EnsureServer()
public void Initialize()
{
if (_server != null)
{
Expand Down Expand Up @@ -197,7 +218,7 @@ private void EnsureServer()
{
SetContentRoot(builder);
_configuration(builder);
_server = CreateServer(builder);
_server = CreateTestServer(builder);
}
}

Expand All @@ -211,7 +232,11 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder)
webHostBuilder.UseTestServer();
});
_host = CreateHost(hostBuilder);
_server = (TestServer)_host.Services.GetRequiredService<IServer>();
_server = _host.Services.GetRequiredService<IServer>() as ITestServer;
if (_server is null)
{
throw new InvalidOperationException(Resources.InvalidTestServerConfiguration);
}
}

private void SetContentRoot(IWebHostBuilder builder)
Expand All @@ -221,17 +246,23 @@ private void SetContentRoot(IWebHostBuilder builder)
return;
}

var fromFile = File.Exists("MvcTestingAppManifest.json");
var contentRoot = fromFile ? GetContentRootFromFile("MvcTestingAppManifest.json") : GetContentRootFromAssembly();

if (contentRoot != null)
string? contentRoot = null;
if (File.Exists("MvcTestingAppManifest.json"))
{
builder.UseContentRoot(contentRoot);
var manifestContentRoot = GetContentRootFromFile("MvcTestingAppManifest.json");
if (manifestContentRoot is not null && Directory.Exists(manifestContentRoot))
{
contentRoot = manifestContentRoot;
}
}
else

contentRoot ??= GetContentRootFromAssembly();
if (contentRoot is null || !Directory.Exists(contentRoot))
{
builder.UseSolutionRelativeContentRoot(typeof(TEntryPoint).Assembly.GetName().Name!);
contentRoot = AppContext.BaseDirectory;
}

builder.UseContentRoot(contentRoot);
}

private static string? GetContentRootFromFile(string file)
Expand Down Expand Up @@ -426,12 +457,25 @@ private static void EnsureDepsFile()
/// <param name="builder">The <see cref="IWebHostBuilder"/> used to
/// create the server.</param>
/// <returns>The <see cref="TestServer"/> with the bootstrapped application.</returns>
protected virtual TestServer CreateServer(IWebHostBuilder builder) => new(builder);
[Obsolete("This method is obsolete. Consider utilizing the CreateTestServer method instead.")]
protected virtual TestServer CreateServer(IWebHostBuilder builder) => new TestServer(builder);

/// <summary>
/// Creates the <see cref="ITestServer"/> with the bootstrapped application in <paramref name="builder"/>.
/// This is only called for applications using <see cref="IWebHostBuilder"/>. Applications based on
/// <see cref="IHostBuilder"/> will use <see cref="CreateHost"/> instead.
/// </summary>
/// <param name="builder">The <see cref="IWebHostBuilder"/> used to
/// create the server.</param>
/// <returns>The <see cref="ITestServer"/> with the bootstrapped application.</returns>
#pragma warning disable CS0618 // Type or member is obsolete
protected virtual ITestServer CreateTestServer(IWebHostBuilder builder) => CreateServer(builder);
#pragma warning restore CS0618 // Type or member is obsolete

/// <summary>
/// Creates the <see cref="IHost"/> with the bootstrapped application in <paramref name="builder"/>.
/// This is only called for applications using <see cref="IHostBuilder"/>. Applications based on
/// <see cref="IWebHostBuilder"/> will use <see cref="CreateServer"/> instead.
/// <see cref="IWebHostBuilder"/> will use <see cref="CreateTestServer"/> instead.
/// </summary>
/// <param name="builder">The <see cref="IHostBuilder"/> used to create the host.</param>
/// <returns>The <see cref="IHost"/> with the bootstrapped application.</returns>
Expand Down Expand Up @@ -476,7 +520,7 @@ public HttpClient CreateClient(WebApplicationFactoryClientOptions options) =>
/// <returns>The <see cref="HttpClient"/>.</returns>
public HttpClient CreateDefaultClient(params DelegatingHandler[] handlers)
{
EnsureServer();
Initialize();

HttpClient client;
if (handlers == null || handlers.Length == 0)
Expand Down Expand Up @@ -606,7 +650,7 @@ public virtual async ValueTask DisposeAsync()

private sealed class DelegatedWebApplicationFactory : WebApplicationFactory<TEntryPoint>
{
private readonly Func<IWebHostBuilder, TestServer> _createServer;
private readonly Func<IWebHostBuilder, ITestServer> _createServer;
private readonly Func<IHostBuilder, IHost> _createHost;
private readonly Func<IWebHostBuilder?> _createWebHostBuilder;
private readonly Func<IHostBuilder?> _createHostBuilder;
Expand All @@ -615,7 +659,7 @@ private sealed class DelegatedWebApplicationFactory : WebApplicationFactory<TEnt

public DelegatedWebApplicationFactory(
WebApplicationFactoryClientOptions options,
Func<IWebHostBuilder, TestServer> createServer,
Func<IWebHostBuilder, ITestServer> createServer,
Func<IHostBuilder, IHost> createHost,
Func<IWebHostBuilder?> createWebHostBuilder,
Func<IHostBuilder?> createHostBuilder,
Expand All @@ -633,7 +677,7 @@ public DelegatedWebApplicationFactory(
_configuration = configureWebHost;
}

protected override TestServer CreateServer(IWebHostBuilder builder) => _createServer(builder);
protected override ITestServer CreateTestServer(IWebHostBuilder builder) => _createServer(builder);

protected override IHost CreateHost(IHostBuilder builder) => _createHost(builder);

Expand Down
2 changes: 1 addition & 1 deletion src/Mvc/test/Mvc.FunctionalTests/ApiExplorerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1573,7 +1573,7 @@ public async Task ApiExplorer_LogsInvokedDescriptionProvidersOnStartup()
[Fact]
public void ApiExplorer_BuildsMetadataForActionWithTypedResult()
{
var apiDescCollectionProvider = Factory.Server.Services.GetService<IApiDescriptionGroupCollectionProvider>();
var apiDescCollectionProvider = Factory.Services.GetService<IApiDescriptionGroupCollectionProvider>();
var testGroupName = nameof(ApiExplorerWithTypedResultController).Replace("Controller", string.Empty);
var group = apiDescCollectionProvider.ApiDescriptionGroups.Items.Where(i => i.GroupName == testGroupName).SingleOrDefault();
Assert.NotNull(group);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ private HttpClient CreateClient(

// We configure the inner handler with a handler to this TestServer instance so that calls to the
// server can get routed properly.
loopHandler.InnerHandler = fixture.Server.CreateHandler();
loopHandler.InnerHandler = fixture.TestServer.CreateHandler();

void ConfigureTestWeatherForecastService(IServiceCollection services) =>
// We configure the test service here with an HttpClient that uses this loopback handler to talk
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,15 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
});
}

protected override TestServer CreateServer(IWebHostBuilder builder)
protected override ITestServer CreateTestServer(IWebHostBuilder builder)
{
var originalCulture = CultureInfo.CurrentCulture;
var originalUICulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("en-GB");
CultureInfo.CurrentUICulture = new CultureInfo("en-US");
return base.CreateServer(builder);
return base.CreateTestServer(builder);
}
finally
{
Expand Down
Loading
Loading