Skip to content

Commit

Permalink
Refactor functional tests (#2037)
Browse files Browse the repository at this point in the history
* 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
eerhardt authored Feb 5, 2024
1 parent fc206c5 commit 7151a30
Show file tree
Hide file tree
Showing 26 changed files with 495 additions and 549 deletions.
7 changes: 7 additions & 0 deletions Aspire.sln
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mongo.ApiService", "playgro
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CatalogModel", "playground\eShopLite\CatalogModel\CatalogModel.csproj", "{83267206-9438-42CD-860C-C92E7DBAA4C3}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.EndToEnd.Tests", "tests\Aspire.EndToEnd.Tests\Aspire.EndToEnd.Tests.csproj", "{C1483B79-4FE9-47FF-B544-EB5DBB7A0A3E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ParameterEndToEnd", "ParameterEndToEnd", "{F1387494-34DE-4B31-B587-699B2E9A20CA}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ParameterEndToEnd.AppHost", "playground\ParameterEndToEnd\ParameterEndToEnd.AppHost\ParameterEndToEnd.AppHost.csproj", "{54B66163-016D-4122-9364-409AB61BC36B}"
Expand Down Expand Up @@ -597,6 +599,10 @@ Global
{83267206-9438-42CD-860C-C92E7DBAA4C3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{83267206-9438-42CD-860C-C92E7DBAA4C3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{83267206-9438-42CD-860C-C92E7DBAA4C3}.Release|Any CPU.Build.0 = Release|Any CPU
{C1483B79-4FE9-47FF-B544-EB5DBB7A0A3E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C1483B79-4FE9-47FF-B544-EB5DBB7A0A3E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C1483B79-4FE9-47FF-B544-EB5DBB7A0A3E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C1483B79-4FE9-47FF-B544-EB5DBB7A0A3E}.Release|Any CPU.Build.0 = Release|Any CPU
{54B66163-016D-4122-9364-409AB61BC36B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{54B66163-016D-4122-9364-409AB61BC36B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{54B66163-016D-4122-9364-409AB61BC36B}.Release|Any CPU.ActiveCfg = Release|Any CPU
Expand Down Expand Up @@ -716,6 +722,7 @@ Global
{8F132275-233C-4121-AC6F-352C902FA064} = {C544D8A6-977E-40EA-8B1A-1FB2146A2108}
{40EC38A2-69DB-4759-81C8-13F31090FEA6} = {C544D8A6-977E-40EA-8B1A-1FB2146A2108}
{83267206-9438-42CD-860C-C92E7DBAA4C3} = {A68BA1A5-1604-433D-9778-DC0199831C2A}
{C1483B79-4FE9-47FF-B544-EB5DBB7A0A3E} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60}
{F1387494-34DE-4B31-B587-699B2E9A20CA} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0}
{54B66163-016D-4122-9364-409AB61BC36B} = {F1387494-34DE-4B31-B587-699B2E9A20CA}
{FD63D574-8512-421D-B7FC-310AFA974361} = {F1387494-34DE-4B31-B587-699B2E9A20CA}
Expand Down
11 changes: 11 additions & 0 deletions tests/Aspire.EndToEnd.Tests/Aspire.EndToEnd.Tests.csproj
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 tests/Aspire.EndToEnd.Tests/IntegrationServicesFixture.cs
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!;
}
}
94 changes: 94 additions & 0 deletions tests/Aspire.EndToEnd.Tests/IntegrationServicesTests.cs
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!;
}
}
}
57 changes: 57 additions & 0 deletions tests/Aspire.EndToEnd.Tests/ProjectInfo.cs
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 tests/Aspire.Hosting.Tests/Cosmos/CosmosFunctionalTests.cs

This file was deleted.

Loading

0 comments on commit 7151a30

Please sign in to comment.