Skip to content
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

Refactor functional tests #2037

Merged
merged 11 commits into from
Feb 5, 2024
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