-
Notifications
You must be signed in to change notification settings - Fork 10.3k
Improve automated browser testing with real server #4892
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
Comments
As this is not a priority for 2.2 for now, moving to the backlog. |
@mkArtakMSFT just to clarify the concrete change we should make in 2.2: We should make it so that it's possible to boot up the WebApplicationFactory (or another derived type) without a test server. It makes functional testing of your application absolutely trivial and the changes required to do this should be small. /cc @javiercn |
I'm not sure I follow. How would these changes make it easier to use something like puppeteer and chrome headless/selenium for automated browser testing. What is a real server? |
@steveoh the current set up allows for very convenient Unit Testing by spinning up the App/WebHost and talking to it 'in memory." So no HTTP, no security issue, you're basically talking HTTP without actually putting bytes on the wire (or localhost). Super useful. But if you want to use Automated Browser Testing and have it driven by a Unit Test Framework, you are hampered by the concrete TestServer class and have to do a little dance (above) to fake it out and start up the WebHost yourself. I'd propose no regular person could/would figure that out. David is saying that if WebApplicationFactory could be started without the fake TestServer we could easily and cleanly do Unit Testing AND Browser Automation Testing with the piece we have here. |
This is why we love Scott! Always the advocate for the mainstream developers out there wanting to use MS tools, but stymied by various obscure limitations. Thank you sir! |
The provided workaround has problems. The problems start with the fact that now we have 2 hosts. One from the TestServer, and another built to work with http. And Because of these problems we cannot easily interact with the real host, the one being tested. It is very common that I change some backend service and configure it before a test is run. Suppose I need to access some service that is not callable during development, only production. I replace that service when I configure the services collection with a fake, and then configure it to respond the way I want it to respond. I can't do that through The resulting code I have works, but it is ugly as hell, it is an ugly ugly hack. I hope we can move this forward and do not require |
One way I solved this is to stop using Refactored public static Task<int> Main(string[] args)
{
return RunServer(args);
}
public static async Task<int> RunServer(string[] args,
CancellationToken cancellationToken = default)
{
...
CreateWebHostBuilder()
.Build()
.RunAsync(cancellationToken)
} So, my unit test fixtures new up a Make real HTTP calls like normal. When your done and your unit test completes, call One down side is you need to copy YMMV. 🤔 ✨ "Do you ponder the manner of things... yeah yeah... like glitter and gold..." |
Hello everyone, this issue is still unresolved and seems to keep being postponed. Just so we know the planning, are you considering resolving this for ASP.NET Core 3.0? If not, do you have a workaround that does not incur on the problems I mentioned earlier (#4892 (comment))? |
This would be very welcome. The workaround mentioned here is great, except when you have html files and what not that you would also need to copy over. |
WebApplicationFactory is designed for a very specific in-memory scenario. Having it start Kestrel instead and wire up HttpClient to match is a fairly different feature set. We actually do have components that do this in Microsoft.AspNetCore.Server.IntegrationTesting but we've never cleaned them up for broader usage. It might make more sense to leave WebApplicationFactory for in-memory and improve the IntegrationTesting components for this other scenario. |
I'm fine with that, as long as we have a good end to end testing story. |
Thanks guys for this discussion and specially to @bchavez My test class: public class SelenuimSampleTests : IDisposable
{
private const string TestLocalHostUrl = "http://localhost:8080";
private readonly CancellationTokenSource tokenSource;
public SelenuimSampleTests()
{
this.tokenSource = new CancellationTokenSource();
string projectName = typeof(Web.Startup).Assembly.GetName().Name;
string currentDirectory = Directory.GetCurrentDirectory();
string webProjectDirectory = Path.GetFullPath(Path.Combine(currentDirectory, $@"..\..\..\..\{projectName}"));
IWebHost webHost = WebHost.CreateDefaultBuilder(new string[0])
.UseSetting(WebHostDefaults.ApplicationKey, projectName)
.UseContentRoot(webProjectDirectory) // This will make appsettings.json to work.
.ConfigureServices(services =>
{
services.AddSingleton(typeof(IStartup), serviceProvider =>
{
IHostingEnvironment hostingEnvironment = serviceProvider.GetRequiredService<IHostingEnvironment>();
StartupMethods startupMethods = StartupLoader.LoadMethods(
serviceProvider,
typeof(TestStartup),
hostingEnvironment.EnvironmentName);
return new ConventionBasedStartup(startupMethods);
});
})
.UseEnvironment(EnvironmentName.Development)
.UseUrls(TestLocalHostUrl)
//// .UseStartup<TestStartUp>() // It's not working
.Build();
webHost.RunAsync(this.tokenSource.Token);
}
public void Dispose()
{
this.tokenSource.Cancel();
}
[Fact]
public void TestWithSelenium()
{
string assemblyLocation = System.Reflection.Assembly.GetExecutingAssembly().Location;
string currentDirectory = Path.GetDirectoryName(assemblyLocation);
using (ChromeDriver driver = new ChromeDriver(currentDirectory))
{
driver.Navigate().GoToUrl(TestLocalHostUrl);
IWebElement webElement = driver.FindElementByCssSelector("a.navbar-brand");
string expected = typeof(Web.Startup).Assembly.GetName().Name;
Assert.Equal(expected, webElement.Text);
string appSettingValue = driver.FindElementById("myvalue").Text;
const string ExpectedAppSettingsValue = "44";
Assert.Equal(ExpectedAppSettingsValue, appSettingValue);
}
}
} Very similar to the @bchavez 's example, I'm starting the I'm sure there is better approach somewhere, but after а few days of research, nothing helped me. So I share this solution in case someone need it. The test method is just for an example, there are different approaches to initialize browser driver. |
@M-Yankov I had your code work by using |
The idea of the |
This is a solution that worked for me in the mean time. It ensures that everything on WebApplicationFactory, like the Services property keep working as expected:
|
Just a note: with .net core 3.0 apps using IHostBuilder, @danroth27's workaround no longer works. Now I just have a test script that runs the server, then runs the tests, waits for them to finish, then kills the server. Have to start up the server manually when running in the Visual Studio test runner. Not a great experience. |
I'm not using |
There's an override you can define create a custom |
What is the status on this? @davidfowl did this ever make it to 2.2? In that case, how do we use it? |
@ffMathy not much progress has been made on this scenario, it's still in the backlog. |
Alright. Is there an ETA? Rough estimate? |
@ffMathy this is an uncommitted feature, there's no ETA until we decide to move it from the backlog to a milestone. |
@Sebazzz Your code above does not compile.
|
Yes it is possible, a potential approach is shown here #4892 (comment) |
Thanks for contacting us. We're moving this issue to the |
It's really sad that this is going on 5 years old now, and it's still not a priority. Automated testing of ASP.NET Core apps all up should be easy. The "testing" story for .NET app in general feels a bit like an after thought. With every new version of .NET, we have an opportunity to improve this, and I would certainly vote for it to become a priority. I hope that we will start shipping first-class NuGet testing packages, that we ourselves use - so that our customers can use the same packages. |
Just an update on my side, I've long since moved to container based E2E testing and this works perfectly with .net core. I just run the API, frontend, and database containers (i.e. sql server for linux) using the .net wrapper over the docker api. Works great and the app runs in an environment that more closely simulates the production environment. |
@JeroMiya that's not the problem though. The problem is that that way of testing is not really exposed via actual network connections. So if you want (for instance) puppeteer, playwright, selenium etc, it can't reach the server. Or if you're opening a reverse tunnel with ngrok to test that your webhooks are working properly in a test. That won't work either. |
I've been following this topic for quite some time. For the past year or two I've been running selenium tests against an angular app, you need to override some functionality of the WebApplicationFactory as illustrated in the sample code but it is possible. |
We've moved this issue to the Backlog milestone. This means that it is not going to be worked on for the coming release. We will reassess the backlog following the current release and consider this item at that time. To learn more about our issue management process and to have better expectation regarding different types of issues you can read our Triage Process. |
This is again removed from the backlog. That's sad. |
Ok, I've managed to get the code to work with .NET 6. I'll post most of it here, but I'm thinking about making it a NuGet pkg, to make it available to everyone. I'm running my tests and they are passing, as soon as I get it more refined I'll do that. I am having to use reflection on only a small location ( I have not tested it with Startup.cs, only with top level statements. This is rough code, it probably does not work in all cases, but as some asked here for help, I'm posting what I have, feel free to use, adapt, etc. using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
public class TestServer<TTypeAssemblyEntrypoint> : IAsyncDisposable where TTypeAssemblyEntrypoint : class
{
private readonly string connectionString;
public IHost Host { get; set; }
public TestServer(string connectionString) => this.connectionString = connectionString;
public async Task StartAsync()
{
Host = CreateWebHost(connectionString);
var lifetime = Host.Services.GetRequiredService<IHostLifetime>();
await lifetime.WaitForStartAsync(CancellationToken.None);
}
private Func<string[], object> ResolveHostFactory(Assembly assembly,
TimeSpan? waitTimeout = null,
bool stopApplication = true,
Action<object> configureHostBuilder = null,
Action<Exception> entrypointCompleted = null)
{
var hostFactoryResolver = typeof(Microsoft.AspNetCore.TestHost.TestServer).Assembly.GetType("Microsoft.Extensions.Hosting.HostFactoryResolver", true);
var method = hostFactoryResolver.GetMethod("ResolveHostFactory", BindingFlags.Public | BindingFlags.Static)
?? throw new InvalidOperationException("Could not find method 'ResolveHostFactory' on type 'HostFactoryResolver'.");
var hostFactory = (Func<string[], object>)method.Invoke(null, new object[] { assembly, waitTimeout, stopApplication, configureHostBuilder, entrypointCompleted });
return hostFactory;
}
private IHost ResolveHost(Assembly assembly,
IConfiguration configuration = null,
Action<IHostBuilder> configureHostBuilder = null)
{
configuration ??= new ConfigurationBuilder().Build();
var factory = ResolveHostFactory(assembly, null, false, (object o) => { configureHostBuilder((IHostBuilder)o); }, null);
var configurationManager = new ConfigurationManager();
configurationManager.AddConfiguration(configuration);
var args = new List<string>();
foreach (var (key, value) in configurationManager.AsEnumerable())
args.Add($"--{key}={value}");
var host = (IHost)factory(args.ToArray());
return host;
}
private IHost CreateWebHost(string connectionString)
{
var applicationPath = typeof(TTypeAssemblyEntrypoint).Assembly.Location;
var staticWebAssetsFile = Path.ChangeExtension(applicationPath, ".staticwebassets.runtime.json");
if (!File.Exists(staticWebAssetsFile))
throw new FileNotFoundException($"Static web assets file not found: {staticWebAssetsFile}");
var webrootPath = GetWebRootPath();
var wwwrootPath = Path.Combine(webrootPath, "wwwroot");
if (!Directory.Exists(wwwrootPath))
throw new DirectoryNotFoundException($"Directory does not exist at '{wwwrootPath}'.");
var hostConfiguration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string>
{
//[WebHostDefaults.StaticWebAssetsKey] = staticWebAssetsFile,
[HostDefaults.ApplicationKey] = typeof(TTypeAssemblyEntrypoint).Assembly.GetName()?.Name ?? string.Empty,
[HostDefaults.EnvironmentKey] = "Test",
[WebHostDefaults.ContentRootKey] = webrootPath,
[WebHostDefaults.WebRootKey] = wwwrootPath,
[WebHostDefaults.ServerUrlsKey] = AmbienteTestesFuncionais.PublicUrl,
})
.Build();
var host = ResolveHost(typeof(TTypeAssemblyEntrypoint).Assembly,
hostConfiguration,
configureHostBuilder: (IHostBuilder hostBuilder) =>
{
hostBuilder
.ConfigureAppConfiguration(configurationBuilder =>
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string>
{
["ConnectionStrings:X"] = connectionString,
})
.Build();
configurationBuilder.AddConfiguration(configuration);
})
.ConfigureServices(services =>
{
// add mocks
})
.ConfigureWebHost(webHostBuilder =>
{
webHostBuilder.UseKestrel();
});
});
return host;
}
private static string GetWebRootPath()
{
var currentExecutingAssemblyFile = new Uri(Assembly.GetExecutingAssembly().Location).LocalPath;
var dir = Path.GetDirectoryName(currentExecutingAssemblyFile);
var testCsproj = $"{Path.GetFileNameWithoutExtension(currentExecutingAssemblyFile)}.csproj";
var testCsprojFullPath = "";
while (!File.Exists(testCsprojFullPath))
{
testCsprojFullPath = Path.Combine(dir, testCsproj);
var root = Path.GetPathRoot(testCsprojFullPath);
if (root == dir)
throw new Exception($"File {testCsproj} does not exist.");
dir = Path.GetFullPath(Path.Combine(dir, ".."));
}
var testProjectDir = Path.GetDirectoryName(testCsprojFullPath);
var assetsDoc = JsonDocument.Parse(File.ReadAllText(Path.Combine(testProjectDir, "obj", "project.assets.json")));
var path = assetsDoc.RootElement.GetProperty("libraries").GetProperty("Web/1.0.0").GetProperty("path").GetString();
var siteWebCsrojFile = Path.GetFullPath(Path.Combine(testProjectDir, path));
var diretorioSiteWeb = Path.GetFullPath(Path.GetDirectoryName(siteWebCsrojFile));
if (!Directory.Exists(diretorioSiteWeb))
throw new Exception($"Directory does not exist at '{diretorioSiteWeb}'.");
return diretorioSiteWeb;
}
public async ValueTask DisposeAsync()
{
if (Host == null)
return;
await Host.StopAsync();
Host.Dispose();
}
} |
I've been looking into this and have found that there are actually 3 issues at play here:
The first and the last issues here are things I'm going to try to address. |
For anybody looking and this and unable to wait till net 10, here is the best hacky version I know for doing this today, that doesn’t cause the target app to start twice during a test run: https://github.com/egil/BlazorTestingAZ/blob/main/BlazorTestingAZ.Tests/BlazeWright/BlazorApplicationFactory.cs |
Here's the API proposal for this: #60758. Feedback is welcome! |
Feedback from @shanselman:
We should do better with testing. There's issues with moving from the inside out:
LEVELS OF TESTING
/cc @javiercn
The text was updated successfully, but these errors were encountered: