From 35152d5933ca885aaa3633301d344e7a58b20cd7 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 22 Jun 2017 11:11:27 -0700 Subject: [PATCH] [Fixes #6233] Productionize and harden our functional testing infrastructure --- Mvc.sln | 35 ++++- .../Internal/CultureReplacer.cs | 82 +++++++++++ .../Internal/CultureReplacerMiddleware.cs | 42 ++++++ .../Internal/CultureReplacerStartupFilter.cs | 27 ++++ .../Internal/ReplaceCultureAttribute.cs | 69 +++++++++ ...rosoft.AspNetCore.Mvc.Testing.Xunit.csproj | 25 ++++ .../WebApplicationTestFixture.cs | 126 ++++++++++++++++ ...osoft.AspNetCore.Mvc.Testing.Xunit.targets | 14 ++ .../build}/xunit.runner.json | 0 .../Internal/CookieContainerHandler.cs | 41 ++++++ .../Internal/TestServiceRegistrations.cs | 33 +++++ .../Internal/TestStartup.cs | 55 +++++++ .../Microsoft.AspNetCore.Mvc.Testing.csproj | 24 ++++ .../MvcWebApplicationBuilder.cs | 136 ++++++++++++++++++ .../Microsoft.AspNetCore.Mvc.Testing.targets | 37 +++++ ...soft.AspNetCore.Mvc.FunctionalTests.csproj | 39 +---- .../MvcEncodedTestFixtureOfT.cs | 17 ++- .../MvcSampleFixture.cs | 6 +- .../MvcTestFixture.cs | 59 ++------ 19 files changed, 770 insertions(+), 97 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Internal/CultureReplacer.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Internal/CultureReplacerMiddleware.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Internal/CultureReplacerStartupFilter.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Internal/ReplaceCultureAttribute.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Microsoft.AspNetCore.Mvc.Testing.Xunit.csproj create mode 100644 src/Microsoft.AspNetCore.Mvc.Testing.Xunit/WebApplicationTestFixture.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.Testing.Xunit/build/Microsoft.AspNetCore.Mvc.Testing.Xunit.targets rename {test/Microsoft.AspNetCore.Mvc.FunctionalTests => src/Microsoft.AspNetCore.Mvc.Testing.Xunit/build}/xunit.runner.json (100%) create mode 100644 src/Microsoft.AspNetCore.Mvc.Testing/Internal/CookieContainerHandler.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.Testing/Internal/TestServiceRegistrations.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.Testing/Internal/TestStartup.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.Testing/Microsoft.AspNetCore.Mvc.Testing.csproj create mode 100644 src/Microsoft.AspNetCore.Mvc.Testing/MvcWebApplicationBuilder.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.Testing/build/Microsoft.AspNetCore.Mvc.Testing.targets diff --git a/Mvc.sln b/Mvc.sln index 4aac68236a..0f2d1a146b 100644 --- a/Mvc.sln +++ b/Mvc.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26228.9 +VisualStudioVersion = 15.0.26615.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{DAAE4C74-D06F-4874-A166-33305D2643CE}" EndProject @@ -122,6 +122,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RazorPagesWebSite", "test\W EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Performance", "test\Microsoft.AspNetCore.Mvc.Performance\Microsoft.AspNetCore.Mvc.Performance.csproj", "{F16CEE0D-A28E-43BD-802F-99BAFE4BA7CE}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Testing", "src\Microsoft.AspNetCore.Mvc.Testing\Microsoft.AspNetCore.Mvc.Testing.csproj", "{7500B228-1769-4CFB-A571-3DFAC6678A06}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Mvc.Testing.Xunit", "src\Microsoft.AspNetCore.Mvc.Testing.Xunit\Microsoft.AspNetCore.Mvc.Testing.Xunit.csproj", "{5248D809-E5E5-49FE-B3E8-428D454C63B3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -756,6 +760,30 @@ Global {F16CEE0D-A28E-43BD-802F-99BAFE4BA7CE}.Release|Mixed Platforms.Build.0 = Release|Any CPU {F16CEE0D-A28E-43BD-802F-99BAFE4BA7CE}.Release|x86.ActiveCfg = Release|Any CPU {F16CEE0D-A28E-43BD-802F-99BAFE4BA7CE}.Release|x86.Build.0 = Release|Any CPU + {7500B228-1769-4CFB-A571-3DFAC6678A06}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7500B228-1769-4CFB-A571-3DFAC6678A06}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7500B228-1769-4CFB-A571-3DFAC6678A06}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7500B228-1769-4CFB-A571-3DFAC6678A06}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {7500B228-1769-4CFB-A571-3DFAC6678A06}.Debug|x86.ActiveCfg = Debug|Any CPU + {7500B228-1769-4CFB-A571-3DFAC6678A06}.Debug|x86.Build.0 = Debug|Any CPU + {7500B228-1769-4CFB-A571-3DFAC6678A06}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7500B228-1769-4CFB-A571-3DFAC6678A06}.Release|Any CPU.Build.0 = Release|Any CPU + {7500B228-1769-4CFB-A571-3DFAC6678A06}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {7500B228-1769-4CFB-A571-3DFAC6678A06}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {7500B228-1769-4CFB-A571-3DFAC6678A06}.Release|x86.ActiveCfg = Release|Any CPU + {7500B228-1769-4CFB-A571-3DFAC6678A06}.Release|x86.Build.0 = Release|Any CPU + {5248D809-E5E5-49FE-B3E8-428D454C63B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5248D809-E5E5-49FE-B3E8-428D454C63B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5248D809-E5E5-49FE-B3E8-428D454C63B3}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {5248D809-E5E5-49FE-B3E8-428D454C63B3}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {5248D809-E5E5-49FE-B3E8-428D454C63B3}.Debug|x86.ActiveCfg = Debug|Any CPU + {5248D809-E5E5-49FE-B3E8-428D454C63B3}.Debug|x86.Build.0 = Debug|Any CPU + {5248D809-E5E5-49FE-B3E8-428D454C63B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5248D809-E5E5-49FE-B3E8-428D454C63B3}.Release|Any CPU.Build.0 = Release|Any CPU + {5248D809-E5E5-49FE-B3E8-428D454C63B3}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {5248D809-E5E5-49FE-B3E8-428D454C63B3}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {5248D809-E5E5-49FE-B3E8-428D454C63B3}.Release|x86.ActiveCfg = Release|Any CPU + {5248D809-E5E5-49FE-B3E8-428D454C63B3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -818,5 +846,10 @@ Global {0AB46520-F441-4E01-B444-08F4D23F8B1B} = {3BA657BF-28B1-42DA-B5B0-1C4601FCF7B1} {4BA6EC9A-B6D9-41F2-BFDA-D82B22D80352} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {F16CEE0D-A28E-43BD-802F-99BAFE4BA7CE} = {3BA657BF-28B1-42DA-B5B0-1C4601FCF7B1} + {7500B228-1769-4CFB-A571-3DFAC6678A06} = {32285FA4-6B46-4D6B-A840-2B13E4C8B58E} + {5248D809-E5E5-49FE-B3E8-428D454C63B3} = {32285FA4-6B46-4D6B-A840-2B13E4C8B58E} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {63D344F6-F86D-40E6-85B9-0AABBE338C4A} EndGlobalSection EndGlobal diff --git a/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Internal/CultureReplacer.cs b/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Internal/CultureReplacer.cs new file mode 100644 index 0000000000..75d60d3ace --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Internal/CultureReplacer.cs @@ -0,0 +1,82 @@ +// 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.Globalization; +using System.Threading; + +namespace Microsoft.AspNetCore.Mvc.Testing.Xunit.Internal +{ + internal class CultureReplacer : IDisposable + { + private const string _defaultCultureName = "en-GB"; + private const string _defaultUICultureName = "en-US"; + private static readonly CultureInfo _defaultCulture = new CultureInfo(_defaultCultureName); + private readonly CultureInfo _originalCulture; + private readonly CultureInfo _originalUICulture; + private readonly long _threadId; + + // Culture => Formatting of dates/times/money/etc, defaults to en-GB because en-US is the same as InvariantCulture + // We want to be able to find issues where the InvariantCulture is used, but a specific culture should be. + // + // UICulture => Language + public CultureReplacer(string culture = _defaultCultureName, string uiCulture = _defaultUICultureName) + : this(new CultureInfo(culture), new CultureInfo(uiCulture)) + { + } + + public CultureReplacer(CultureInfo culture, CultureInfo uiCulture) + { + _originalCulture = CultureInfo.CurrentCulture; + _originalUICulture = CultureInfo.CurrentUICulture; + _threadId = Thread.CurrentThread.ManagedThreadId; + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = uiCulture; + } + + /// + /// The name of the culture that is used as the default value for CultureInfo.DefaultThreadCurrentCulture when CultureReplacer is used. + /// + public static string DefaultCultureName + { + get { return _defaultCultureName; } + } + + /// + /// The name of the culture that is used as the default value for [Thread.CurrentThread(NET45)/CultureInfo(K10)].CurrentUICulture when CultureReplacer is used. + /// + public static string DefaultUICultureName + { + get { return _defaultUICultureName; } + } + + /// + /// The culture that is used as the default value for [Thread.CurrentThread(NET45)/CultureInfo(K10)].CurrentCulture when CultureReplacer is used. + /// + public static CultureInfo DefaultCulture + { + get { return _defaultCulture; } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (disposing) + { + if(Thread.CurrentThread.ManagedThreadId != _threadId) + { + throw new InvalidOperationException("The current thread is not the same as the thread " + + "invoking the constructor. This should never happen."); + } + + CultureInfo.CurrentCulture = _originalCulture; + CultureInfo.CurrentUICulture = _originalUICulture; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Internal/CultureReplacerMiddleware.cs b/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Internal/CultureReplacerMiddleware.cs new file mode 100644 index 0000000000..64408bb787 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Internal/CultureReplacerMiddleware.cs @@ -0,0 +1,42 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.Testing.Xunit.Internal +{ + /// + /// A middleware that ensures web sites run in a consistent culture. Currently useful for tests that format dates, + /// times, or numbers. Will be more useful when we have localized resources. + /// + public class CultureReplacerMiddleware + { + // Have no current need to use cultures other than the ReplaceCultureAttribute defaults (en-GB, en-US). + private readonly ReplaceCultureAttribute _replaceCulture = new ReplaceCultureAttribute(); + + private readonly RequestDelegate _next; + + public CultureReplacerMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task Invoke(HttpContext context) + { + // Use ReplaceCultureAttribute to avoid thread consistency checks in CultureReplacer. await doesn't + // necessarily end on the original thread. For this case, problems arise when next middleware throws. Can + // remove the thread consistency checks once culture is (at least for .NET 4.6) handled using + // AsyncLocal. + try + { + _replaceCulture.Before(methodUnderTest: null); + await _next(context); + } + finally + { + _replaceCulture.After(methodUnderTest: null); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Internal/CultureReplacerStartupFilter.cs b/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Internal/CultureReplacerStartupFilter.cs new file mode 100644 index 0000000000..caa71a337f --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Internal/CultureReplacerStartupFilter.cs @@ -0,0 +1,27 @@ +// 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 Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; + +namespace Microsoft.AspNetCore.Mvc.Testing.Xunit.Internal +{ + /// + /// Inserts the at the beginning of the pipeline. + /// + public class CultureReplacerStartupFilter : IStartupFilter + { + /// + public Action Configure(Action next) + { + return AddCulture; + + void AddCulture(IApplicationBuilder builder) + { + builder.UseMiddleware(); + next(builder); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Internal/ReplaceCultureAttribute.cs b/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Internal/ReplaceCultureAttribute.cs new file mode 100644 index 0000000000..c7914127e7 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Internal/ReplaceCultureAttribute.cs @@ -0,0 +1,69 @@ +// 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.Globalization; +using System.Reflection; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Mvc.Testing.Xunit.Internal +{ + /// + /// Replaces the current culture and UI culture for the test. + /// + [AttributeUsage(AttributeTargets.Method)] + internal class ReplaceCultureAttribute : BeforeAfterTestAttribute + { + private const string _defaultCultureName = "en-GB"; + private const string _defaultUICultureName = "en-US"; + private CultureInfo _originalCulture; + private CultureInfo _originalUICulture; + + /// + /// Replaces the current culture and UI culture to en-GB and en-US respectively. + /// + public ReplaceCultureAttribute() : + this(_defaultCultureName, _defaultUICultureName) + { + } + + /// + /// Replaces the current culture and UI culture based on specified values. + /// + public ReplaceCultureAttribute(string currentCulture, string currentUICulture) + { + Culture = new CultureInfo(currentCulture); + UICulture = new CultureInfo(currentUICulture); + } + + /// + /// The for the test. Defaults to en-GB. + /// + /// + /// en-GB is used here as the default because en-US is equivalent to the InvariantCulture. We + /// want to be able to find bugs where we're accidentally relying on the Invariant instead of the + /// user's culture. + /// + public CultureInfo Culture { get; } + + /// + /// The for the test. Defaults to en-US. + /// + public CultureInfo UICulture { get; } + + public override void Before(MethodInfo methodUnderTest) + { + _originalCulture = CultureInfo.CurrentCulture; + _originalUICulture = CultureInfo.CurrentUICulture; + + CultureInfo.CurrentCulture = Culture; + CultureInfo.CurrentUICulture = UICulture; + } + + public override void After(MethodInfo methodUnderTest) + { + CultureInfo.CurrentCulture = _originalCulture; + CultureInfo.CurrentUICulture = _originalUICulture; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Microsoft.AspNetCore.Mvc.Testing.Xunit.csproj b/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Microsoft.AspNetCore.Mvc.Testing.Xunit.csproj new file mode 100644 index 0000000000..bf45538e3f --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/Microsoft.AspNetCore.Mvc.Testing.Xunit.csproj @@ -0,0 +1,25 @@ + + + + + + XUnit convenience fixture for creating functional tests for MVC applications. + netstandard2.0 + $(NoWarn);CS1591 + aspnetcore;aspnetcoremvc;aspnetcoremvctesting + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/WebApplicationTestFixture.cs b/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/WebApplicationTestFixture.cs new file mode 100644 index 0000000000..6ebc54b642 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/WebApplicationTestFixture.cs @@ -0,0 +1,126 @@ +// 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.IO; +using System.Net.Http; +using System.Reflection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Mvc.Testing.Xunit.Internal; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + /// + /// XUnit fixture for bootstrapping an application in memory for functional end to end tests. + /// + /// The applications startup class. + public class WebApplicationTestFixture : IDisposable where TStartup : class + { + private readonly TestServer _server; + + public WebApplicationTestFixture() + : this("src") + { + } + + protected WebApplicationTestFixture(string solutionRelativePath) + : this("*.sln", solutionRelativePath) + { + } + + protected WebApplicationTestFixture(string solutionSearchPattern, string solutionRelativePath) + { + var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly; + + // This step assumes project name = assembly name. + var projectName = startupAssembly.GetName().Name; + var projectPath = Path.Combine(solutionRelativePath, projectName); + var builder = new MvcWebApplicationBuilder() + .UseSolutionRelativeContentRoot(projectPath) + .UseApplicationAssemblies(); + + ConfigureApplication(builder); + + var xunitRunnerJson = new FileInfo(Path.Combine(Directory.GetCurrentDirectory(), "xunit.runner.json")); + if (!xunitRunnerJson.Exists) + { + Console.WriteLine("Can't find xunit.runner.json. " + + "Functional tests require '\"shadowCopy\": false' to work properly. " + + "Make sure your XUnit configuration has that setup."); + } + + var content = JsonConvert.DeserializeObject(File.ReadAllText(xunitRunnerJson.FullName)); + if (!content.TryGetValue("shadowCopy", out var token) || !(bool)token) + { + Console.WriteLine("'shadowCopy' is not set to true on xunit.runner.json. " + + "Functional tests require '\"shadowCopy\": false' to work properly. " + + "Make sure your XUnit configuration has that setup."); + } + + using (new CultureReplacer()) + { + _server = builder.Build(); + } + + Client = _server.CreateClient(); + Client.BaseAddress = new Uri("http://localhost"); + } + + /// + /// Gives a fixture an opportunity to configure the application before it gets built. + /// + /// The for the application. + protected virtual void ConfigureApplication(MvcWebApplicationBuilder builder) + { + builder.ConfigureAfterStartup(s => s.TryAddEnumerable(ServiceDescriptor.Transient())); + } + + public HttpClient Client { get; } + + public HttpClient CreateClient() + { + var client = _server.CreateClient(); + client.BaseAddress = new Uri("http://localhost"); + + return client; + } + + public HttpClient CreateClient(Uri baseAddress, params DelegatingHandler[] handlers) + { + if (handlers.Length == 0) + { + var client = _server.CreateClient(); + client.BaseAddress = baseAddress; + + return client; + } + else + { + + for (var i = handlers.Length - 1; i > 1; i++) + { + handlers[i - 1].InnerHandler = handlers[i]; + } + + var serverHandler = _server.CreateHandler(); + handlers[handlers.Length - 1].InnerHandler = serverHandler; + var client = new HttpClient(handlers[0]); + client.BaseAddress = baseAddress; + + return client; + } + } + + public void Dispose() + { + Client.Dispose(); + _server.Dispose(); + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/build/Microsoft.AspNetCore.Mvc.Testing.Xunit.targets b/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/build/Microsoft.AspNetCore.Mvc.Testing.Xunit.targets new file mode 100644 index 0000000000..2f8351755d --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/build/Microsoft.AspNetCore.Mvc.Testing.Xunit.targets @@ -0,0 +1,14 @@ + + + + + + + + + true + + + + + \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/xunit.runner.json b/src/Microsoft.AspNetCore.Mvc.Testing.Xunit/build/xunit.runner.json similarity index 100% rename from test/Microsoft.AspNetCore.Mvc.FunctionalTests/xunit.runner.json rename to src/Microsoft.AspNetCore.Mvc.Testing.Xunit/build/xunit.runner.json diff --git a/src/Microsoft.AspNetCore.Mvc.Testing/Internal/CookieContainerHandler.cs b/src/Microsoft.AspNetCore.Mvc.Testing/Internal/CookieContainerHandler.cs new file mode 100644 index 0000000000..edcaa857c1 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Testing/Internal/CookieContainerHandler.cs @@ -0,0 +1,41 @@ +// 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.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Mvc.Testing.Internal +{ + /// + /// Delegating handler for managing cookies on functional tests. + /// + public class CookieContainerHandler : DelegatingHandler + { + public CookieContainerHandler(HttpMessageHandler innerHandler) + : base(innerHandler) + { + } + + public CookieContainer Container { get; } = new CookieContainer(); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var cookieHeader = Container.GetCookieHeader(request.RequestUri); + request.Headers.Add("Cookie", cookieHeader); + + var response = await base.SendAsync(request, cancellationToken); + + if (response.Headers.TryGetValues("Set-Cookie", out var setCookieHeaders)) + { + foreach (var header in setCookieHeaders) + { + Container.SetCookies(response.RequestMessage.RequestUri, header); + } + } + + return response; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Testing/Internal/TestServiceRegistrations.cs b/src/Microsoft.AspNetCore.Mvc.Testing/Internal/TestServiceRegistrations.cs new file mode 100644 index 0000000000..018c55bba4 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Testing/Internal/TestServiceRegistrations.cs @@ -0,0 +1,33 @@ +// 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 Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Mvc.Testing.Internal +{ + /// + /// Helper class to orchestrate service registrations in . + /// + public class TestServiceRegistrations + { + public IList> Before { get; set; } = new List>(); + public IList> After { get; set; } = new List>(); + + public void ConfigureServices(IServiceCollection services, Action startupConfigureServices) + { + foreach (var config in Before) + { + config(services); + } + + startupConfigureServices(); + + foreach (var config in After) + { + config(services); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Testing/Internal/TestStartup.cs b/src/Microsoft.AspNetCore.Mvc.Testing/Internal/TestStartup.cs new file mode 100644 index 0000000000..2e9665d4e7 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Testing/Internal/TestStartup.cs @@ -0,0 +1,55 @@ +// 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.Linq; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Mvc.Testing.Internal +{ + /// + /// Fake startup class used in functional tests to decorate the registration of + /// ConfigureServices. + /// + /// The startup class of your application. + public class TestStartup where TStartup : class + { + private readonly IServiceProvider _serviceProvider; + private readonly TestServiceRegistrations _registrations; + private readonly TStartup _instance; + + public TestStartup(IServiceProvider serviceProvider, TestServiceRegistrations registrations) + { + _serviceProvider = serviceProvider; + _registrations = registrations; + _instance = (TStartup)ActivatorUtilities.CreateInstance(serviceProvider, typeof(TStartup)); + } + + public void ConfigureServices(IServiceCollection services) + { + var configureServices = _instance.GetType().GetMethod(nameof(ConfigureServices)); + var parameters = Enumerable.Repeat(services, 1) + .Concat(configureServices + .GetParameters() + .Skip(1) + .Select(p => ActivatorUtilities.GetServiceOrCreateInstance(_serviceProvider, p.ParameterType))) + .ToArray(); + + _registrations.ConfigureServices(services, () => configureServices.Invoke(_instance, parameters)); + } + + public void Configure(IApplicationBuilder applicationBuilder) + { + var configure = _instance.GetType().GetMethod(nameof(Configure)); + var parameters = Enumerable.Repeat(applicationBuilder, 1) + .Concat(configure + .GetParameters() + .Skip(1) + .Select(p => ActivatorUtilities.GetServiceOrCreateInstance(_serviceProvider, p.ParameterType))) + .ToArray(); + + configure.Invoke(_instance, parameters); + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Testing/Microsoft.AspNetCore.Mvc.Testing.csproj b/src/Microsoft.AspNetCore.Mvc.Testing/Microsoft.AspNetCore.Mvc.Testing.csproj new file mode 100644 index 0000000000..6c0201d8c9 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Testing/Microsoft.AspNetCore.Mvc.Testing.csproj @@ -0,0 +1,24 @@ + + + + + + Support for writing functional tests for MVC applications. + netstandard2.0 + $(NoWarn);CS1591 + aspnetcore;aspnetcoremvc;aspnetcoremvctesting + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Testing/MvcWebApplicationBuilder.cs b/src/Microsoft.AspNetCore.Mvc.Testing/MvcWebApplicationBuilder.cs new file mode 100644 index 0000000000..04753a5260 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Testing/MvcWebApplicationBuilder.cs @@ -0,0 +1,136 @@ +// 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.Reflection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.Testing.Internal; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Mvc.Testing +{ + /// + /// Builder API for bootstraping an MVC application for functional tests. + /// + /// The application startup class. + public class MvcWebApplicationBuilder where TStartup : class + { + public string ContentRoot { get; set; } + public IList> ConfigureServicesBeforeStartup { get; set; } = new List>(); + public IList> ConfigureServicesAfterStartup { get; set; } = new List>(); + public List ApplicationAssemblies { get; set; } = new List(); + + /// + /// Configures services before runs. + /// + /// The to configure the services with. + /// An instance of this + public MvcWebApplicationBuilder ConfigureBeforeStartup(Action configure) + { + ConfigureServicesBeforeStartup.Add(configure); + return this; + } + + /// + /// Configures services after runs. + /// + /// The to configure the services with. + /// An instance of this + public MvcWebApplicationBuilder ConfigureAfterStartup(Action configure) + { + ConfigureServicesAfterStartup.Add(configure); + return this; + } + + /// + /// Configures to include the default set + /// of provided by . + /// + /// An instance of this + public MvcWebApplicationBuilder UseApplicationAssemblies() + { + var depsFileName = $"{typeof(TStartup).Assembly.GetName().Name}.deps.json"; + var depsFile = new FileInfo(Path.Combine(Directory.GetCurrentDirectory(), depsFileName)); + if (!depsFile.Exists) + { + throw new InvalidOperationException($"Can't find'{depsFile}'. This file is required for functional tests " + + $"to run properly. There should be a copy of the file on your source project bin folder. If thats not the " + + $"case, make sure that the property PreserveCompilationContext is set to true on your project file. E.g" + + $"'true'."); + } + + ApplicationAssemblies.AddRange(DefaultAssemblyPartDiscoveryProvider + .DiscoverAssemblyParts(typeof(TStartup).Assembly.GetName().Name) + .Select(s => ((AssemblyPart)s).Assembly) + .ToList()); + + return this; + } + + /// + /// Configures the application content root. + /// + /// The glob pattern to use for finding the solution. + /// The relative path to the content root from the solution file. + /// An instance of this + public MvcWebApplicationBuilder UseSolutionRelativeContentRoot( + string solutionRelativePath, + string solutionName = "*.sln") + { + var applicationBasePath = AppContext.BaseDirectory; + + var directoryInfo = new DirectoryInfo(applicationBasePath); + do + { + var solutionPath = Directory.EnumerateFiles(directoryInfo.FullName, solutionName).FirstOrDefault(); + if (solutionPath != null) + { + ContentRoot = Path.GetFullPath(Path.Combine(directoryInfo.FullName, solutionRelativePath)); + return this; + } + + directoryInfo = directoryInfo.Parent; + } + while (directoryInfo.Parent != null); + + throw new Exception($"Solution root could not be located using application root {applicationBasePath}."); + } + + public TestServer Build() + { + var builder = new WebHostBuilder() + .UseStartup>() + // This is necessary so that IHostingEnvironment.ApplicationName has the right + // value and libraries depending on it (to load the dependency context, for example) + // work properly. + .UseSetting(WebHostDefaults.ApplicationKey, typeof(TStartup).Assembly.GetName().Name) + .UseContentRoot(ContentRoot) + .ConfigureServices(InitializeServices); + + return new TestServer(builder); + } + + protected virtual void InitializeServices(IServiceCollection services) + { + // Inject a custom application part manager. Overrides AddMvcCore() because that uses TryAdd(). + var manager = new ApplicationPartManager(); + foreach (var assembly in ApplicationAssemblies) + { + manager.ApplicationParts.Add(new AssemblyPart(assembly)); + } + + services.AddSingleton(manager); + services.AddSingleton(new TestServiceRegistrations + { + Before = ConfigureServicesBeforeStartup, + After = ConfigureServicesAfterStartup + }); + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Testing/build/Microsoft.AspNetCore.Mvc.Testing.targets b/src/Microsoft.AspNetCore.Mvc.Testing/build/Microsoft.AspNetCore.Mvc.Testing.targets new file mode 100644 index 0000000000..b128ceeb70 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Testing/build/Microsoft.AspNetCore.Mvc.Testing.targets @@ -0,0 +1,37 @@ + + + + + + + true + + + + + x86 + + + true + true + + + + + + + + + + \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj index 86ebaa991b..34bc035c86 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj @@ -1,5 +1,7 @@  + + netcoreapp2.0;net461 @@ -11,39 +13,15 @@ $(DefineConstants);__RemoveThisBitTo__GENERATE_BASELINES $(DefineConstants);FUNCTIONAL_TESTS - - true - - - - - x86 - - - true - true - - - + @@ -78,15 +56,4 @@ - - - - - - - - diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/MvcEncodedTestFixtureOfT.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/MvcEncodedTestFixtureOfT.cs index 16d6c494d6..396842ce1b 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/MvcEncodedTestFixtureOfT.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/MvcEncodedTestFixtureOfT.cs @@ -2,19 +2,24 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Text.Encodings.Web; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.WebEncoders.Testing; namespace Microsoft.AspNetCore.Mvc.FunctionalTests { public class MvcEncodedTestFixture : MvcTestFixture + where TStartup : class { - protected override void InitializeServices(IServiceCollection services) + protected override void ConfigureApplication(MvcWebApplicationBuilder builder) { - base.InitializeServices(services); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); + base.ConfigureApplication(builder); + builder.ConfigureBeforeStartup(services => + { + services.TryAddTransient(); + services.TryAddTransient(); + services.TryAddTransient(); + }); } } } diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/MvcSampleFixture.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/MvcSampleFixture.cs index 6a09db1a65..c17a3f504b 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/MvcSampleFixture.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/MvcSampleFixture.cs @@ -4,10 +4,8 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests { public class MvcSampleFixture : MvcTestFixture + where TStartup : class { - public MvcSampleFixture() - : base("samples") - { - } + public MvcSampleFixture() : base("samples") { } } } diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/MvcTestFixture.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/MvcTestFixture.cs index 73658b514c..6fb5c1661e 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/MvcTestFixture.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/MvcTestFixture.cs @@ -1,71 +1,30 @@ // 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.IO; -using System.Net.Http; using System.Reflection; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.ApplicationParts; -using Microsoft.AspNetCore.Mvc.Controllers; -using Microsoft.AspNetCore.Mvc.ViewComponents; -using Microsoft.AspNetCore.TestHost; -using Microsoft.AspNetCore.Testing; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Mvc.Testing; namespace Microsoft.AspNetCore.Mvc.FunctionalTests { - public class MvcTestFixture : IDisposable + public class MvcTestFixture : WebApplicationTestFixture + where TStartup : class { - private readonly TestServer _server; - public MvcTestFixture() - : this(Path.Combine("test", "WebSites")) + : base(Path.Combine("test", "WebSites")) { } protected MvcTestFixture(string solutionRelativePath) + : base(solutionRelativePath) { - // RequestLocalizationOptions saves the current culture when constructed, potentially changing response - // localization i.e. RequestLocalizationMiddleware behavior. Ensure the saved culture - // (DefaultRequestCulture) is consistent regardless of system configuration or personal preferences. - using (new CultureReplacer()) - { - var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly; - var contentRoot = SolutionPathUtility.GetProjectPath(solutionRelativePath, startupAssembly); - - var builder = new WebHostBuilder() - .UseContentRoot(contentRoot) - .ConfigureServices(InitializeServices) - .UseStartup(typeof(TStartup)); - - _server = new TestServer(builder); - } - - Client = _server.CreateClient(); - Client.BaseAddress = new Uri("http://localhost"); } - public HttpClient Client { get; } - - public void Dispose() + protected override void ConfigureApplication(MvcWebApplicationBuilder builder) { - Client.Dispose(); - _server.Dispose(); - } - - protected virtual void InitializeServices(IServiceCollection services) - { - var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly; - - // Inject a custom application part manager. Overrides AddMvcCore() because that uses TryAdd(). - var manager = new ApplicationPartManager(); - manager.ApplicationParts.Add(new AssemblyPart(startupAssembly)); - - manager.FeatureProviders.Add(new ControllerFeatureProvider()); - manager.FeatureProviders.Add(new ViewComponentFeatureProvider()); - - services.AddSingleton(manager); + base.ConfigureApplication(builder); + builder.ApplicationAssemblies.Clear(); + builder.ApplicationAssemblies.Add(typeof(TStartup).GetTypeInfo().Assembly); } } }