478 lines
20 KiB
C#
478 lines
20 KiB
C#
// Copyright (c) .NET Foundation. All rights reserved.
|
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Reflection;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.TestHost;
|
|
using Microsoft.Extensions.DependencyModel;
|
|
|
|
namespace Microsoft.AspNetCore.Mvc.Testing
|
|
{
|
|
/// <summary>
|
|
/// Factory for bootstrapping an application in memory for functional end to end tests.
|
|
/// </summary>
|
|
/// <typeparam name="TEntryPoint">A type in the entry point assembly of the application.
|
|
/// Typically the Startup or Program classes can be used.</typeparam>
|
|
public class WebApplicationFactory<TEntryPoint> : IDisposable where TEntryPoint : class
|
|
{
|
|
private bool _disposed;
|
|
private TestServer _server;
|
|
private Action<IWebHostBuilder> _configuration;
|
|
private IList<HttpClient> _clients = new List<HttpClient>();
|
|
private List<WebApplicationFactory<TEntryPoint>> _derivedFactories =
|
|
new List<WebApplicationFactory<TEntryPoint>>();
|
|
|
|
/// <summary>
|
|
/// <para>
|
|
/// Creates an instance of <see cref="WebApplicationFactory{TEntryPoint}"/>. This factory can be used to
|
|
/// create a <see cref="TestServer"/> instance using the MVC application defined by <typeparamref name="TEntryPoint"/>
|
|
/// and one or more <see cref="HttpClient"/> instances used to send <see cref="HttpRequestMessage"/> to the <see cref="TestServer"/>.
|
|
/// The <see cref="WebApplicationFactory{TEntryPoint}"/> will find the entry point class of <typeparamref name="TEntryPoint"/>
|
|
/// assembly and initialize the application by calling <c>IWebHostBuilder CreateWebHostBuilder(string [] args)</c>
|
|
/// on <typeparamref name="TEntryPoint"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// This constructor will infer the application content root path by searching for a
|
|
/// <see cref="WebApplicationFactoryContentRootAttribute"/> on the assembly containing the functional tests with
|
|
/// a key equal to the <typeparamref name="TEntryPoint"/> assembly <see cref="Assembly.FullName"/>.
|
|
/// In case an attribute with the right key can't be found, <see cref="WebApplicationFactory{TEntryPoint}"/>
|
|
/// will fall back to searching for a solution file (*.sln) and then appending <typeparamref name="TEntryPoint"/> assembly name
|
|
/// to the solution directory. The application root directory will be used to discover views and content files.
|
|
/// </para>
|
|
/// <para>
|
|
/// The application assemblies will be loaded from the dependency context of the assembly containing
|
|
/// <typeparamref name="TEntryPoint" />. This means that project dependencies of the assembly containing
|
|
/// <typeparamref name="TEntryPoint" /> will be loaded as application assemblies.
|
|
/// </para>
|
|
/// </summary>
|
|
public WebApplicationFactory()
|
|
{
|
|
_configuration = ConfigureWebHost;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finalizes an instance of the <see cref="WebApplicationFactory{TEntryPoint}"/> class.
|
|
/// </summary>
|
|
~WebApplicationFactory()
|
|
{
|
|
Dispose(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the <see cref="TestServer"/> created by this <see cref="WebApplicationFactory{TEntryPoint}"/>.
|
|
/// </summary>
|
|
public TestServer Server => _server;
|
|
|
|
/// <summary>
|
|
/// Gets the <see cref="IReadOnlyList{WebApplicationFactory}"/> of factories created from this factory
|
|
/// by further customizing the <see cref="IWebHostBuilder"/> when calling
|
|
/// <see cref="WebApplicationFactory{TEntryPoint}.WithWebHostBuilder(Action{IWebHostBuilder})"/>.
|
|
/// </summary>
|
|
public IReadOnlyList<WebApplicationFactory<TEntryPoint>> Factories => _derivedFactories.AsReadOnly();
|
|
|
|
/// <summary>
|
|
/// Gets the <see cref="WebApplicationFactoryClientOptions"/> used by <see cref="CreateClient()"/>.
|
|
/// </summary>
|
|
public WebApplicationFactoryClientOptions ClientOptions { get; private set; } = new WebApplicationFactoryClientOptions();
|
|
|
|
/// <summary>
|
|
/// Creates a new <see cref="WebApplicationFactory{TEntryPoint}"/> with a <see cref="IWebHostBuilder"/>
|
|
/// that is further customized by <paramref name="configuration"/>.
|
|
/// </summary>
|
|
/// <param name="configuration">
|
|
/// An <see cref="Action{IWebHostBuilder}"/> to configure the <see cref="IWebHostBuilder"/>.
|
|
/// </param>
|
|
/// <returns>A new <see cref="WebApplicationFactory{TEntryPoint}"/>.</returns>
|
|
public WebApplicationFactory<TEntryPoint> WithWebHostBuilder(Action<IWebHostBuilder> configuration) =>
|
|
WithWebHostBuilderCore(configuration);
|
|
|
|
internal virtual WebApplicationFactory<TEntryPoint> WithWebHostBuilderCore(Action<IWebHostBuilder> configuration)
|
|
{
|
|
var factory = new DelegatedWebApplicationFactory(
|
|
ClientOptions,
|
|
CreateServer,
|
|
CreateWebHostBuilder,
|
|
GetTestAssemblies,
|
|
ConfigureClient,
|
|
builder =>
|
|
{
|
|
_configuration(builder);
|
|
configuration(builder);
|
|
});
|
|
|
|
_derivedFactories.Add(factory);
|
|
|
|
return factory;
|
|
}
|
|
|
|
private void EnsureServer()
|
|
{
|
|
if (_server != null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
EnsureDepsFile();
|
|
|
|
|
|
var builder = CreateWebHostBuilder();
|
|
SetContentRoot(builder);
|
|
_configuration(builder);
|
|
_server = CreateServer(builder);
|
|
}
|
|
|
|
private void SetContentRoot(IWebHostBuilder builder)
|
|
{
|
|
if (SetContentRootFromSetting(builder))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var metadataAttributes = GetContentRootMetadataAttributes(
|
|
typeof(TEntryPoint).Assembly.FullName,
|
|
typeof(TEntryPoint).Assembly.GetName().Name);
|
|
|
|
string contentRoot = null;
|
|
for (var i = 0; i < metadataAttributes.Length; i++)
|
|
{
|
|
var contentRootAttribute = metadataAttributes[i];
|
|
var contentRootCandidate = Path.Combine(
|
|
AppContext.BaseDirectory,
|
|
contentRootAttribute.ContentRootPath);
|
|
|
|
var contentRootMarker = Path.Combine(
|
|
contentRootCandidate,
|
|
Path.GetFileName(contentRootAttribute.ContentRootTest));
|
|
|
|
if (File.Exists(contentRootMarker))
|
|
{
|
|
contentRoot = contentRootCandidate;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (contentRoot != null)
|
|
{
|
|
builder.UseContentRoot(contentRoot);
|
|
}
|
|
else
|
|
{
|
|
builder.UseSolutionRelativeContentRoot(typeof(TEntryPoint).Assembly.GetName().Name);
|
|
}
|
|
}
|
|
|
|
private static bool SetContentRootFromSetting(IWebHostBuilder builder)
|
|
{
|
|
// Attempt to look for TEST_CONTENTROOT_APPNAME in settings. This should result in looking for
|
|
// ASPNETCORE_TEST_CONTENTROOT_APPNAME environment variable.
|
|
var assemblyName = typeof(TEntryPoint).Assembly.GetName().Name;
|
|
var settingSuffix = assemblyName.ToUpperInvariant().Replace(".", "_");
|
|
var settingName = $"TEST_CONTENTROOT_{settingSuffix}";
|
|
|
|
var settingValue = builder.GetSetting(settingName);
|
|
if (settingValue == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
builder.UseContentRoot(settingValue);
|
|
return true;
|
|
}
|
|
|
|
private WebApplicationFactoryContentRootAttribute[] GetContentRootMetadataAttributes(
|
|
string tEntryPointAssemblyFullName,
|
|
string tEntryPointAssemblyName)
|
|
{
|
|
var testAssembly = GetTestAssemblies();
|
|
var metadataAttributes = testAssembly
|
|
.SelectMany(a => a.GetCustomAttributes<WebApplicationFactoryContentRootAttribute>())
|
|
.Where(a => string.Equals(a.Key, tEntryPointAssemblyFullName, StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(a.Key, tEntryPointAssemblyName, StringComparison.OrdinalIgnoreCase))
|
|
.OrderBy(a => a.Priority)
|
|
.ToArray();
|
|
|
|
return metadataAttributes;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the assemblies containing the functional tests. The
|
|
/// <see cref="WebApplicationFactoryContentRootAttribute"/> applied to these
|
|
/// assemblies defines the content root to use for the given
|
|
/// <typeparamref name="TEntryPoint"/>.
|
|
/// </summary>
|
|
/// <returns>The list of <see cref="Assembly"/> containing tests.</returns>
|
|
protected virtual IEnumerable<Assembly> GetTestAssemblies()
|
|
{
|
|
try
|
|
{
|
|
// The default dependency context will be populated in .net core applications.
|
|
var context = DependencyContext.Default;
|
|
if (context == null || context.CompileLibraries.Count == 0)
|
|
{
|
|
// The app domain friendly name will be populated in full framework.
|
|
return new[] { Assembly.Load(AppDomain.CurrentDomain.FriendlyName) };
|
|
}
|
|
|
|
var runtimeProjectLibraries = context.RuntimeLibraries
|
|
.ToDictionary(r => r.Name, r => r, StringComparer.Ordinal);
|
|
|
|
// Find the list of projects
|
|
var projects = context.CompileLibraries.Where(l => l.Type == "project");
|
|
|
|
var entryPointAssemblyName = typeof(TEntryPoint).Assembly.GetName().Name;
|
|
|
|
// Find the list of projects referencing TEntryPoint.
|
|
var candidates = context.CompileLibraries
|
|
.Where(library => library.Dependencies.Any(d => string.Equals(d.Name, entryPointAssemblyName, StringComparison.Ordinal)));
|
|
|
|
var testAssemblies = new List<Assembly>();
|
|
foreach (var candidate in candidates)
|
|
{
|
|
if (runtimeProjectLibraries.TryGetValue(candidate.Name, out var runtimeLibrary))
|
|
{
|
|
var runtimeAssemblies = runtimeLibrary.GetDefaultAssemblyNames(context);
|
|
testAssemblies.AddRange(runtimeAssemblies.Select(Assembly.Load));
|
|
}
|
|
}
|
|
|
|
return testAssemblies;
|
|
}
|
|
catch (Exception)
|
|
{
|
|
}
|
|
|
|
return Array.Empty<Assembly>();
|
|
}
|
|
|
|
private void EnsureDepsFile()
|
|
{
|
|
var depsFileName = $"{typeof(TEntryPoint).Assembly.GetName().Name}.deps.json";
|
|
var depsFile = new FileInfo(Path.Combine(AppContext.BaseDirectory, depsFileName));
|
|
if (!depsFile.Exists)
|
|
{
|
|
throw new InvalidOperationException(Resources.FormatMissingDepsFile(
|
|
depsFile.FullName,
|
|
Path.GetFileName(depsFile.FullName)));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a <see cref="IWebHostBuilder"/> used to set up <see cref="TestServer"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The default implementation of this method looks for a <c>public static IWebHostBuilder CreateDefaultBuilder(string[] args)</c>
|
|
/// method defined on the entry point of the assembly of <typeparamref name="TEntryPoint" /> and invokes it passing an empty string
|
|
/// array as arguments.
|
|
/// </remarks>
|
|
/// <returns>A <see cref="IWebHostBuilder"/> instance.</returns>
|
|
protected virtual IWebHostBuilder CreateWebHostBuilder()
|
|
{
|
|
var builder = WebHostBuilderFactory.CreateFromTypesAssemblyEntryPoint<TEntryPoint>(Array.Empty<string>());
|
|
if (builder == null)
|
|
{
|
|
throw new InvalidOperationException(Resources.FormatMissingCreateWebHostBuilderMethod(
|
|
nameof(IWebHostBuilder),
|
|
typeof(TEntryPoint).Assembly.EntryPoint.DeclaringType.FullName,
|
|
typeof(WebApplicationFactory<TEntryPoint>).Name,
|
|
nameof(CreateWebHostBuilder)));
|
|
}
|
|
else
|
|
{
|
|
return builder.UseEnvironment("Development");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates the <see cref="TestServer"/> with the bootstrapped application in <paramref name="builder"/>.
|
|
/// </summary>
|
|
/// <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 TestServer(builder);
|
|
|
|
/// <summary>
|
|
/// Gives a fixture an opportunity to configure the application before it gets built.
|
|
/// </summary>
|
|
/// <param name="builder">The <see cref="IWebHostBuilder"/> for the application.</param>
|
|
protected virtual void ConfigureWebHost(IWebHostBuilder builder)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates an instance of <see cref="HttpClient"/> that automatically follows
|
|
/// redirects and handles cookies.
|
|
/// </summary>
|
|
/// <returns>The <see cref="HttpClient"/>.</returns>
|
|
public HttpClient CreateClient() =>
|
|
CreateClient(ClientOptions);
|
|
|
|
/// <summary>
|
|
/// Creates an instance of <see cref="HttpClient"/> that automatically follows
|
|
/// redirects and handles cookies.
|
|
/// </summary>
|
|
/// <returns>The <see cref="HttpClient"/>.</returns>
|
|
public HttpClient CreateClient(WebApplicationFactoryClientOptions options) =>
|
|
CreateDefaultClient(options.BaseAddress, options.CreateHandlers());
|
|
|
|
/// <summary>
|
|
/// Creates a new instance of an <see cref="HttpClient"/> that can be used to
|
|
/// send <see cref="HttpRequestMessage"/> to the server. The base address of the <see cref="HttpClient"/>
|
|
/// instance will be set to <c>http://localhost</c>.
|
|
/// </summary>
|
|
/// <param name="handlers">A list of <see cref="DelegatingHandler"/> instances to set up on the
|
|
/// <see cref="HttpClient"/>.</param>
|
|
/// <returns>The <see cref="HttpClient"/>.</returns>
|
|
public HttpClient CreateDefaultClient(params DelegatingHandler[] handlers)
|
|
{
|
|
EnsureServer();
|
|
|
|
HttpClient client;
|
|
if (handlers == null || handlers.Length == 0)
|
|
{
|
|
client = _server.CreateClient();
|
|
}
|
|
else
|
|
{
|
|
for (var i = handlers.Length - 1; i > 0; i--)
|
|
{
|
|
handlers[i - 1].InnerHandler = handlers[i];
|
|
}
|
|
|
|
var serverHandler = _server.CreateHandler();
|
|
handlers[handlers.Length - 1].InnerHandler = serverHandler;
|
|
|
|
client = new HttpClient(handlers[0]);
|
|
}
|
|
|
|
_clients.Add(client);
|
|
|
|
ConfigureClient(client);
|
|
|
|
return client;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configures <see cref="HttpClient"/> instances created by this <see cref="WebApplicationFactory{TEntryPoint}"/>.
|
|
/// </summary>
|
|
/// <param name="client">The <see cref="HttpClient"/> instance getting configured.</param>
|
|
protected virtual void ConfigureClient(HttpClient client)
|
|
{
|
|
if (client == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(client));
|
|
}
|
|
|
|
client.BaseAddress = new Uri("http://localhost");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new instance of an <see cref="HttpClient"/> that can be used to
|
|
/// send <see cref="HttpRequestMessage"/> to the server.
|
|
/// </summary>
|
|
/// <param name="baseAddress">The base address of the <see cref="HttpClient"/> instance.</param>
|
|
/// <param name="handlers">A list of <see cref="DelegatingHandler"/> instances to set up on the
|
|
/// <see cref="HttpClient"/>.</param>
|
|
/// <returns>The <see cref="HttpClient"/>.</returns>
|
|
public HttpClient CreateDefaultClient(Uri baseAddress, params DelegatingHandler[] handlers)
|
|
{
|
|
var client = CreateDefaultClient(handlers);
|
|
client.BaseAddress = baseAddress;
|
|
|
|
return client;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
Dispose(true);
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
|
|
/// </summary>
|
|
/// <param name="disposing">
|
|
/// <see langword="true" /> to release both managed and unmanaged resources;
|
|
/// <see langword="false" /> to release only unmanaged resources.
|
|
/// </param>
|
|
protected virtual void Dispose(bool disposing)
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (disposing)
|
|
{
|
|
foreach (var client in _clients)
|
|
{
|
|
client.Dispose();
|
|
}
|
|
|
|
foreach (var factory in _derivedFactories)
|
|
{
|
|
factory.Dispose();
|
|
}
|
|
|
|
_server?.Dispose();
|
|
}
|
|
|
|
_disposed = true;
|
|
}
|
|
|
|
private class DelegatedWebApplicationFactory : WebApplicationFactory<TEntryPoint>
|
|
{
|
|
private readonly Func<IWebHostBuilder, TestServer> _createServer;
|
|
private readonly Func<IWebHostBuilder> _createWebHostBuilder;
|
|
private readonly Func<IEnumerable<Assembly>> _getTestAssemblies;
|
|
private readonly Action<HttpClient> _configureClient;
|
|
|
|
public DelegatedWebApplicationFactory(
|
|
WebApplicationFactoryClientOptions options,
|
|
Func<IWebHostBuilder, TestServer> createServer,
|
|
Func<IWebHostBuilder> createWebHostBuilder,
|
|
Func<IEnumerable<Assembly>> getTestAssemblies,
|
|
Action<HttpClient> configureClient,
|
|
Action<IWebHostBuilder> configureWebHost)
|
|
{
|
|
ClientOptions = new WebApplicationFactoryClientOptions(options);
|
|
_createServer = createServer;
|
|
_createWebHostBuilder = createWebHostBuilder;
|
|
_getTestAssemblies = getTestAssemblies;
|
|
_configureClient = configureClient;
|
|
_configuration = configureWebHost;
|
|
}
|
|
|
|
protected override TestServer CreateServer(IWebHostBuilder builder) => _createServer(builder);
|
|
|
|
protected override IWebHostBuilder CreateWebHostBuilder() => _createWebHostBuilder();
|
|
|
|
protected override IEnumerable<Assembly> GetTestAssemblies() => _getTestAssemblies();
|
|
|
|
protected override void ConfigureWebHost(IWebHostBuilder builder) => _configuration(builder);
|
|
|
|
protected override void ConfigureClient(HttpClient client) => _configureClient(client);
|
|
|
|
internal override WebApplicationFactory<TEntryPoint> WithWebHostBuilderCore(Action<IWebHostBuilder> configuration)
|
|
{
|
|
return new DelegatedWebApplicationFactory(
|
|
ClientOptions,
|
|
_createServer,
|
|
_createWebHostBuilder,
|
|
_getTestAssemblies,
|
|
_configureClient,
|
|
builder =>
|
|
{
|
|
_configuration(builder);
|
|
configuration(builder);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|