-
Notifications
You must be signed in to change notification settings - Fork 557
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* Refactor functional tests - Split the functional Components Integration tests out of Aspire.Hosting.Tests into a new EndToEnd project. - Start the test AppHost project using `dotnet run` instead of running the DistributedAppBuilder inline in the tests. This allows for the tests to run on a separate machine than where the tests are built. - Along with this, we need to pass information from the test AppHost to the test about its endpoints, and give it a signal to "stop". Instead of trying to find a way to CTRL+C cross-platform, read from stdin for a "Stop" command. * Collapse integration tests into one class. * Make common tests into a theory * Remove timeouts from tests. The tests will run for as long as they need to. The larger timeout will handle any real hangs. Also handle retries in Cosmos. * Write output when running the AppHost times out. * Disable the TestProject's dashboard during test run. Also set up resilience handler for the HttpClient. * Log stderr and handle waiting better. Also set EnableRaisingEvents so Exited fires. * Make EndToEnd tests local-only Until they can be run in Helix
- Loading branch information
Showing
26 changed files
with
495 additions
and
549 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
<Project Sdk="Microsoft.NET.Sdk"> | ||
|
||
<PropertyGroup> | ||
<TargetFramework>$(NetCurrent)</TargetFramework> | ||
</PropertyGroup> | ||
|
||
<ItemGroup> | ||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" /> | ||
</ItemGroup> | ||
|
||
</Project> |
159 changes: 159 additions & 0 deletions
159
tests/Aspire.EndToEnd.Tests/IntegrationServicesFixture.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,159 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
using System.Diagnostics; | ||
using System.Text; | ||
using System.Text.Json; | ||
using Microsoft.Extensions.DependencyInjection; | ||
using Xunit; | ||
|
||
namespace Aspire.EndToEnd.Tests; | ||
|
||
/// <summary> | ||
/// This fixture ensures the TestProject.AppHost application is started before a test is executed. | ||
/// | ||
/// Represents the the IntegrationServiceA project in the test application used to send HTTP requests | ||
/// to the project's endpoints. | ||
/// </summary> | ||
public sealed class IntegrationServicesFixture : IAsyncLifetime | ||
{ | ||
private Process? _appHostProcess; | ||
private Dictionary<string, ProjectInfo>? _projects; | ||
|
||
public Dictionary<string, ProjectInfo> Projects => _projects!; | ||
|
||
public ProjectInfo IntegrationServiceA => Projects["integrationservicea"]; | ||
|
||
public async Task InitializeAsync() | ||
{ | ||
var appHostDirectory = Path.Combine(GetRepoRoot(), "tests", "testproject", "TestProject.AppHost"); | ||
|
||
var output = new StringBuilder(); | ||
var appExited = new TaskCompletionSource(); | ||
var projectsParsed = new TaskCompletionSource(); | ||
var appRunning = new TaskCompletionSource(); | ||
var stdoutComplete = new TaskCompletionSource(); | ||
var stderrComplete = new TaskCompletionSource(); | ||
_appHostProcess = new Process(); | ||
_appHostProcess.StartInfo = new ProcessStartInfo("dotnet", "run -- --disable-dashboard") | ||
{ | ||
RedirectStandardOutput = true, | ||
RedirectStandardError = true, | ||
RedirectStandardInput = true, | ||
UseShellExecute = false, | ||
CreateNoWindow = true, | ||
WorkingDirectory = appHostDirectory | ||
}; | ||
_appHostProcess.OutputDataReceived += (sender, e) => | ||
{ | ||
if (e.Data is null) | ||
{ | ||
stdoutComplete.SetResult(); | ||
return; | ||
} | ||
|
||
output.AppendLine(e.Data); | ||
|
||
if (e.Data?.StartsWith("$ENDPOINTS: ") == true) | ||
{ | ||
_projects = ParseProjectInfo(e.Data.Substring("$ENDPOINTS: ".Length)); | ||
projectsParsed.SetResult(); | ||
} | ||
|
||
if (e.Data?.Contains("Distributed application started") == true) | ||
{ | ||
appRunning.SetResult(); | ||
} | ||
}; | ||
_appHostProcess.ErrorDataReceived += (sender, e) => | ||
{ | ||
if (e.Data is null) | ||
{ | ||
stderrComplete.SetResult(); | ||
return; | ||
} | ||
|
||
output.AppendLine(e.Data); | ||
}; | ||
|
||
EventHandler appExitedCallback = (sender, e) => appExited.SetResult(); | ||
_appHostProcess.EnableRaisingEvents = true; | ||
_appHostProcess.Exited += appExitedCallback; | ||
|
||
_appHostProcess.Start(); | ||
_appHostProcess.BeginOutputReadLine(); | ||
_appHostProcess.BeginErrorReadLine(); | ||
|
||
var successfulTask = Task.WhenAll(appRunning.Task, projectsParsed.Task); | ||
var failedTask = appExited.Task; | ||
var timeoutTask = Task.Delay(TimeSpan.FromMinutes(5)); | ||
|
||
var resultTask = await Task.WhenAny(successfulTask, failedTask, timeoutTask); | ||
if (resultTask == failedTask) | ||
{ | ||
// wait for all the output to be read | ||
var allOutputComplete = Task.WhenAll(stdoutComplete.Task, stderrComplete.Task); | ||
await Task.WhenAny(allOutputComplete, timeoutTask); | ||
} | ||
Assert.True(resultTask == successfulTask, $"App run failed: {Environment.NewLine}{output}"); | ||
|
||
_appHostProcess.Exited -= appExitedCallback; | ||
|
||
var client = CreateHttpClient(); | ||
foreach (var project in Projects.Values) | ||
{ | ||
project.Client = client; | ||
} | ||
} | ||
|
||
private static HttpClient CreateHttpClient() | ||
{ | ||
var services = new ServiceCollection(); | ||
services.AddHttpClient() | ||
.ConfigureHttpClientDefaults(b => | ||
{ | ||
b.UseSocketsHttpHandler((handler, sp) => | ||
{ | ||
handler.PooledConnectionLifetime = TimeSpan.FromSeconds(5); | ||
handler.ConnectTimeout = TimeSpan.FromSeconds(5); | ||
}); | ||
|
||
// Ensure transient errors are retried for up to 5 minutes | ||
b.AddStandardResilienceHandler(options => | ||
{ | ||
options.AttemptTimeout.Timeout = TimeSpan.FromMinutes(1); | ||
options.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(2); // needs to be at least double the AttemptTimeout to pass options validation | ||
options.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(5); | ||
}); | ||
}); | ||
|
||
return services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>().CreateClient(); | ||
} | ||
|
||
private static Dictionary<string, ProjectInfo> ParseProjectInfo(string json) => | ||
JsonSerializer.Deserialize<Dictionary<string, ProjectInfo>>(json)!; | ||
|
||
public async Task DisposeAsync() | ||
{ | ||
if (_appHostProcess is not null) | ||
{ | ||
if (!_appHostProcess.HasExited) | ||
{ | ||
_appHostProcess.StandardInput.WriteLine("Stop"); | ||
} | ||
await _appHostProcess.WaitForExitAsync(); | ||
} | ||
} | ||
|
||
private static string GetRepoRoot() | ||
{ | ||
var directory = AppContext.BaseDirectory; | ||
|
||
while (directory != null && !Directory.Exists(Path.Combine(directory, ".git"))) | ||
{ | ||
directory = Directory.GetParent(directory)!.FullName; | ||
} | ||
|
||
return directory!; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
using Xunit; | ||
|
||
namespace Aspire.EndToEnd.Tests; | ||
|
||
public class IntegrationServicesTests : IClassFixture<IntegrationServicesFixture> | ||
{ | ||
private readonly IntegrationServicesFixture _integrationServicesFixture; | ||
|
||
public IntegrationServicesTests(IntegrationServicesFixture integrationServicesFixture) | ||
{ | ||
_integrationServicesFixture = integrationServicesFixture; | ||
} | ||
|
||
[LocalOnlyTheory] | ||
[InlineData("cosmos")] | ||
[InlineData("mongodb")] | ||
[InlineData("mysql")] | ||
[InlineData("pomelo")] | ||
[InlineData("oracledatabase")] | ||
[InlineData("postgres")] | ||
[InlineData("rabbitmq")] | ||
[InlineData("redis")] | ||
[InlineData("sqlserver")] | ||
public async Task VerifyComponentWorks(string component) | ||
{ | ||
var response = await _integrationServicesFixture.IntegrationServiceA.HttpGetAsync("http", $"/{component}/verify"); | ||
var responseContent = await response.Content.ReadAsStringAsync(); | ||
|
||
Assert.True(response.IsSuccessStatusCode, responseContent); | ||
} | ||
|
||
[LocalOnlyFact] | ||
public async Task KafkaComponentCanProduceAndConsume() | ||
{ | ||
string topic = $"topic-{Guid.NewGuid()}"; | ||
|
||
var response = await _integrationServicesFixture.IntegrationServiceA.HttpGetAsync("http", $"/kafka/produce/{topic}"); | ||
var responseContent = await response.Content.ReadAsStringAsync(); | ||
Assert.True(response.IsSuccessStatusCode, responseContent); | ||
|
||
response = await _integrationServicesFixture.IntegrationServiceA.HttpGetAsync("http", $"/kafka/consume/{topic}"); | ||
responseContent = await response.Content.ReadAsStringAsync(); | ||
Assert.True(response.IsSuccessStatusCode, responseContent); | ||
} | ||
|
||
[LocalOnlyFact] | ||
public async Task VerifyHealthyOnIntegrationServiceA() | ||
{ | ||
// We wait until timeout for the /health endpoint to return successfully. We assume | ||
// that components wired up into this project have health checks enabled. | ||
await _integrationServicesFixture.IntegrationServiceA.WaitForHealthyStatusAsync("http"); | ||
} | ||
} | ||
|
||
// TODO: remove these attributes when the above tests are running in CI | ||
|
||
public class LocalOnlyFactAttribute : FactAttribute | ||
{ | ||
public override string Skip | ||
{ | ||
get | ||
{ | ||
// BUILD_BUILDID is defined by Azure Dev Ops | ||
|
||
if (Environment.GetEnvironmentVariable("BUILD_BUILDID") != null) | ||
{ | ||
return "LocalOnlyFactAttribute tests are not run as part of CI."; | ||
} | ||
|
||
return null!; | ||
} | ||
} | ||
} | ||
|
||
public class LocalOnlyTheoryAttribute : TheoryAttribute | ||
{ | ||
public override string Skip | ||
{ | ||
get | ||
{ | ||
// BUILD_BUILDID is defined by Azure Dev Ops | ||
|
||
if (Environment.GetEnvironmentVariable("BUILD_BUILDID") != null) | ||
{ | ||
return "LocalOnlyTheoryAttribute tests are not run as part of CI."; | ||
} | ||
|
||
return null!; | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
using System.Text.Json.Serialization; | ||
|
||
namespace Aspire.EndToEnd.Tests; | ||
|
||
public sealed class ProjectInfo | ||
{ | ||
[JsonIgnore] | ||
public HttpClient Client { get; set; } = default!; | ||
|
||
public EndpointInfo[] Endpoints { get; set; } = default!; | ||
|
||
/// <summary> | ||
/// Sends a GET request to the specified resource and returns the response message. | ||
/// </summary> | ||
public Task<HttpResponseMessage> HttpGetAsync(string bindingName, string path, CancellationToken cancellationToken = default) | ||
{ | ||
var allocatedEndpoint = Endpoints.Single(e => e.Name == bindingName); | ||
var url = $"{allocatedEndpoint.Uri}{path}"; | ||
|
||
return Client.GetAsync(url, cancellationToken); | ||
} | ||
|
||
/// <summary> | ||
/// Sends a GET request to the specified resource and returns the response body as a string. | ||
/// </summary> | ||
public Task<string> HttpGetStringAsync(string bindingName, string path, CancellationToken cancellationToken = default) | ||
{ | ||
var allocatedEndpoint = Endpoints.Single(e => e.Name == bindingName); | ||
var url = $"{allocatedEndpoint.Uri}{path}"; | ||
|
||
return Client.GetStringAsync(url, cancellationToken); | ||
} | ||
|
||
public async Task WaitForHealthyStatusAsync(string bindingName, CancellationToken cancellationToken = default) | ||
{ | ||
while (true) | ||
{ | ||
try | ||
{ | ||
var status = await HttpGetStringAsync(bindingName, "/health", cancellationToken); | ||
if (status == "Healthy") | ||
{ | ||
return; | ||
} | ||
} | ||
catch | ||
{ | ||
await Task.Delay(100, cancellationToken); | ||
} | ||
} | ||
} | ||
} | ||
|
||
public record EndpointInfo(string Name, string Uri); |
32 changes: 0 additions & 32 deletions
32
tests/Aspire.Hosting.Tests/Cosmos/CosmosFunctionalTests.cs
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.