Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion TournamentAPI.LoadTests/AuthenticationLoadTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public void AuthenticationFlow_ShouldMeetBudgets_UnderSteadyLogins()
var emails = _factory.UserEmails;
var password = _factory.UserPassword;

var scenario = Scenario.Create("authenticate_and_refresh_session", async context =>
var scenario = GraphQLLoadScenario.Create("authenticate_and_refresh_session", async context =>
{
var email = emails[(int)(context.InvocationNumber % emails.Count)];
string? accessToken = null;
Expand Down
95 changes: 95 additions & 0 deletions TournamentAPI.LoadTests/BracketGenerationLoadTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using NBomber.Contracts;
using NBomber.Contracts.Stats;
using NBomber.CSharp;
using TournamentAPI.Brackets;
using TournamentAPI.Data;
using TournamentAPI.Data.Models;
using BracketMutations = TournamentAPI.Shared.MutationExamples.Mutations.Bracket;

namespace TournamentAPI.LoadTests;

[Trait("Category", "Load")]
public class BracketGenerationLoadTests : BaseLoadTest, IClassFixture<LatencyWebAppFactory>
{
private const int TournamentsPerScenario = 24;
private const int CallsPerTournament = 2;

private static readonly int[] ParticipantCounts = [8, 16, 32];
private static readonly TournamentFormat[] Formats = [TournamentFormat.SingleElimination, TournamentFormat.RoundRobin];

private readonly LatencyWebAppFactory _factory;

public BracketGenerationLoadTests(LatencyWebAppFactory factory) : base(factory)
{
_factory = factory;
}

[Fact]
public async Task GenerateBracket_ShouldMeetBudgets_AndCreateOneBracketPerTournament()
{
// Arrange
var owners = await WriteScenarioProvisioner.CreateUsersAsync(_factory.Services, "bracketowner", 1, _factory.UserPassword);
var players = await WriteScenarioProvisioner.CreateUsersAsync(_factory.Services, "bracketplayer", ParticipantCounts.Max(), _factory.UserPassword);
var owner = (await VirtualUserSessions.LoginAsync(CreateClient, owners, _factory.UserPassword, 1))[0];

var runs = new List<ScenarioRun>();
var tournamentIds = new List<int>();

foreach (var format in Formats)
{
foreach (var participantCount in ParticipantCounts)
{
var stepName = StepNames.GenerateBracket(format, participantCount);

var ids = await WriteScenarioProvisioner.CreateClosedTournamentsAsync(
_factory.Services, owners[0], players, $"Bracket {stepName}", TournamentsPerScenario, participantCount, format);

tournamentIds.AddRange(ids);

var scenario = GraphQLLoadScenario.Create(stepName, async context =>
await GraphQLLoadStep.RunAsync(
stepName,
context,
owner.Client,
BracketMutations.GenerateBracket,
new { input = new { tournamentId = ids[(int)(context.InvocationNumber / CallsPerTournament % ids.Count)] } },
GraphQLLoadStep.NonNullField("generateBracket"),
expectedRejections: new HashSet<string> { BracketErrorCodes.BracketAlreadyExists },
bearerToken: owner.AccessToken))
.WithLoadSimulations(Simulation.Inject(
rate: LoadTestBudgets.Rates.GenerateBracketPerSecond,
interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromSeconds(10)));

var thresholds = new List<LoadThreshold>
{
LoadThresholds.ErrorBudget(0),
LoadThresholds.StatusMix("200", 100.0 / CallsPerTournament - 1, [])
};
thresholds.AddRange(LoadThresholds.ForBudget(LoadTestBudgets.Steps.For(LoadTestDataSize.Default, stepName)));

runs.Add(new ScenarioRun(scenario, thresholds.ToArray()));
}
}

// Act
var stats = LoadTestRun.ExecuteAll(runs.ToArray());

// Assert
using var scope = _factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

var withBracket = await context.Tournaments
.AsNoTracking()
.CountAsync(t => tournamentIds.Contains(t.Id) && t.Bracket != null);

var successful = stats.Sum(s => SuccessfulRequests(s.Value, s.Key));

Assert.Equal(successful, withBracket);
}

private static int SuccessfulRequests(ScenarioStats stats, string step)
=> stats.StepStats.Get(step).Ok.StatusCodes.Find("200")?.Count ?? 0;
}
2 changes: 1 addition & 1 deletion TournamentAPI.LoadTests/ConcurrencyLimiterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public void ConcurrencyLimiter_Should_RejectRequests_AboveThePermitLimit()
// Arrange
using var client = CreateClient();

var scenario = Scenario.Create("concurrent_tournament_browsing", async context =>
var scenario = GraphQLLoadScenario.Create("concurrent_tournament_browsing", async context =>
await GraphQLLoadStep.RunAsync(
LoadTestBudgets.ListTournaments.Step,
context,
Expand Down
65 changes: 65 additions & 0 deletions TournamentAPI.LoadTests/CreateTournamentLoadTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using NBomber.CSharp;
using TournamentMutations = TournamentAPI.Shared.MutationExamples.Mutations.Tournaments;

namespace TournamentAPI.LoadTests;

[Trait("Category", "Load")]
public class CreateTournamentLoadTests : BaseLoadTest, IClassFixture<LatencyWebAppFactory>
{
private const string UserPrefix = "creator";
private const int Creators = 10;

private readonly LatencyWebAppFactory _factory;

public CreateTournamentLoadTests(LatencyWebAppFactory factory) : base(factory)
{
_factory = factory;
}

[Fact]
public void CreateTournament_ShouldMeetBudgets_UnderSteadyWrites()
{
// Arrange
VirtualUserSession[] sessions = [];

var scenario = GraphQLLoadScenario.Create("create_tournament", async context =>
{
var session = VirtualUserSessions.Pick(sessions, context.InvocationNumber);

return await GraphQLLoadStep.RunAsync(
StepNames.CreateTournament,
context,
session.Client,
TournamentMutations.CreateTournamentWithBasicFieldsReturn,
new
{
input = new
{
name = $"Write Load Tournament {context.InvocationNumber}",
startDate = DateTime.UtcNow.AddDays(7).ToString("o"),
status = "OPEN",
maxParticipants = 8
}
},
GraphQLLoadStep.NonNullField("createTournament"),
bearerToken: session.AccessToken);
})
.WithInit(async _ =>
{
var emails = await WriteScenarioProvisioner.CreateUsersAsync(
_factory.Services, UserPrefix, Creators, _factory.UserPassword);

sessions = await VirtualUserSessions.LoginAsync(CreateClient, emails, _factory.UserPassword, Creators);
})
.WithWarmUpDuration(TimeSpan.FromSeconds(10))
.WithLoadSimulations(Simulation.Inject(
rate: LoadTestBudgets.Rates.CreateTournamentPerSecond,
interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromSeconds(30)));

// Act & Assert
LoadTestRun.Execute(
scenario,
LoadThresholds.ForSteps(LoadTestDataSize.Default, "200", StepNames.CreateTournament));
}
}
21 changes: 21 additions & 0 deletions TournamentAPI.LoadTests/GraphQLLoadScenario.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using NBomber.Contracts;
using NBomber.CSharp;

namespace TournamentAPI.LoadTests;

internal static class GraphQLLoadScenario
{
/// <summary>
/// Creates a scenario whose own result only reports success or failure of the last step.
/// Returning the step response directly makes NBomber count its status code and payload a second time at scenario level.
/// </summary>
public static ScenarioProps Create(string name, Func<IScenarioContext, Task<Response<object>>> run)
{
return Scenario.Create(name, async context =>
{
var last = await run(context);

return last.IsError ? Response.Fail() : Response.Ok();
});
}
}
7 changes: 7 additions & 0 deletions TournamentAPI.LoadTests/GraphQLLoadStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ public static Func<JsonElement, bool> NonNullField(string field)
return data => data.TryGetProperty(field, out var value) && value.ValueKind == JsonValueKind.Object;
}

public static Func<JsonElement, bool> TrueResult(string mutation)
{
return data => data.TryGetProperty(mutation, out var result)
&& result.TryGetProperty("boolean", out var value)
&& value.ValueKind == JsonValueKind.True;
}

public static Func<JsonElement, string?> RejectionWhenErrorHasExtension(string extensionName, string statusCode)
{
return error => error.TryGetProperty("extensions", out var extensions)
Expand Down
105 changes: 105 additions & 0 deletions TournamentAPI.LoadTests/JoinTournamentContentionLoadTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using NBomber.CSharp;
using TournamentAPI.Data;
using TournamentAPI.Tournaments;
using TournamentMutations = TournamentAPI.Shared.MutationExamples.Mutations.Tournaments;

namespace TournamentAPI.LoadTests;

[Trait("Category", "Load")]
public class JoinTournamentContentionLoadTests : BaseLoadTest, IClassFixture<LatencyWebAppFactory>
{
private const string OwnerPrefix = "joinowner";
private const string JoinerPrefix = "joiner";
private const string TournamentNamePrefix = "Join Contention Tournament";
private const int Joiners = 40;
private const int Tournaments = 120;
private const int MaxParticipants = 3;
private const int ContendersPerTournament = 8;
private const double MinSuccessfulJoinPercent = 5;

private static readonly string[] ExpectedRejections =
[
TournamentErrorCodes.TournamentFull,
TournamentErrorCodes.UserAlreadyParticipant
];

private readonly LatencyWebAppFactory _factory;

public JoinTournamentContentionLoadTests(LatencyWebAppFactory factory) : base(factory)
{
_factory = factory;
}

[Fact]
public async Task JoinTournament_ShouldNeverOverfillATournament_UnderContention()
{
// Arrange
VirtualUserSession[] sessions = [];
IReadOnlyList<int> tournamentIds = [];

var scenario = GraphQLLoadScenario.Create("join_tournament_contention", async context =>
{
var session = VirtualUserSessions.Pick(sessions, context.InvocationNumber);
var tournamentId = tournamentIds[(int)(context.InvocationNumber / ContendersPerTournament % tournamentIds.Count)];

return await GraphQLLoadStep.RunAsync(
StepNames.JoinTournament,
context,
session.Client,
TournamentMutations.JoinTournament,
new { input = new { tournamentId } },
GraphQLLoadStep.TrueResult("joinTournament"),
expectedRejections: ExpectedRejections.ToHashSet(),
bearerToken: session.AccessToken);
})
.WithInit(async _ =>
{
var owners = await WriteScenarioProvisioner.CreateUsersAsync(
_factory.Services, OwnerPrefix, 1, _factory.UserPassword);

var joiners = await WriteScenarioProvisioner.CreateUsersAsync(
_factory.Services, JoinerPrefix, Joiners, _factory.UserPassword);

tournamentIds = await WriteScenarioProvisioner.CreateOpenTournamentsAsync(
_factory.Services, owners[0], TournamentNamePrefix, Tournaments, MaxParticipants);

sessions = await VirtualUserSessions.LoginAsync(CreateClient, joiners, _factory.UserPassword, Joiners);
})
.WithWarmUpDuration(TimeSpan.FromSeconds(5))
.WithLoadSimulations(Simulation.Inject(
rate: LoadTestBudgets.Rates.JoinTournamentPerSecond,
interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromSeconds(15)));

var thresholds = new List<LoadThreshold>
{
LoadThresholds.ErrorBudget(0),
LoadThresholds.StatusMix("200", MinSuccessfulJoinPercent, [])
};
thresholds.AddRange(LoadThresholds.ForBudget(LoadTestBudgets.Steps.For(LoadTestDataSize.Default, StepNames.JoinTournament)));

// Act
LoadTestRun.Execute(scenario, thresholds.ToArray());

// Assert
using var scope = _factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

var overfilled = await context.Tournaments
.AsNoTracking()
.Where(t => tournamentIds.Contains(t.Id) && t.Participants.Count > t.MaxParticipants)
.Select(t => t.Id)
.ToListAsync();

Assert.Empty(overfilled);

var joined = await context.Tournaments
.AsNoTracking()
.Where(t => tournamentIds.Contains(t.Id))
.SumAsync(t => t.Participants.Count - 1);

Assert.True(joined > 0, "No join succeeded, so the contention run proved nothing.");
}
}
4 changes: 2 additions & 2 deletions TournamentAPI.LoadTests/LatencyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public void Api_ShouldMeetLatencyBudget_UnderNormalLoad()
{
// Arrange
using var client = CreateClient();
var scenario = Scenario.Create("browse_tournaments", async context =>
var scenario = GraphQLLoadScenario.Create("browse_tournaments", async context =>
await GraphQLLoadStep.RunAsync(
ListBudget.Step,
context,
Expand Down Expand Up @@ -62,7 +62,7 @@ public void Api_ShouldDegradeGracefully_AsLoadIncreases_AndRecover_AsItDrops()
{
// Arrange
using var client = CreateClient();
var scenario = Scenario.Create("browse_tournaments_ramp", async context =>
var scenario = GraphQLLoadScenario.Create("browse_tournaments_ramp", async context =>
await GraphQLLoadStep.RunAsync(
PhaseAt(context.GetScenarioTimerTime()),
context,
Expand Down
27 changes: 27 additions & 0 deletions TournamentAPI.LoadTests/LoadTestBudgets.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using TournamentAPI.Data.Models;

namespace TournamentAPI.LoadTests;

internal sealed record StepBudget(
Expand Down Expand Up @@ -27,6 +29,17 @@ internal static class StepNames
public const string ViewProfile = "view_profile";
public const string RefreshToken = "refresh_token";
public const string Logout = "logout";
public const string CreateTournament = "create_tournament";
public const string JoinTournament = "join_tournament";
public const string PlayMatch = "play_match";
public const string CorrectMatchResult = "correct_match_result";

public static string GenerateBracket(TournamentFormat format, int participants)
{
var formatName = format == TournamentFormat.RoundRobin ? "round_robin" : "single_elimination";

return $"generate_bracket_{formatName}_{participants}";
}
}

internal static class LoadTestBudgets
Expand All @@ -38,6 +51,10 @@ public static class Rates
public const int DiscoverTournamentsPerSecond = 4;
public const int InsightPerSecond = 5;
public const int AuthFlowPerSecond = 5;
public const int CreateTournamentPerSecond = 10;
public const int JoinTournamentPerSecond = 40;
public const int MatchContentionPerSecond = 40;
public const int GenerateBracketPerSecond = 4;
}

public static class Steps
Expand All @@ -59,6 +76,16 @@ public static class Steps
[(LoadTestDataSize.Default, StepNames.ViewTournament)] = new(18.96, 23.97, 30.28, 0.592),
[(LoadTestDataSize.Default, StepNames.SearchTournamentsByName)] = new(8.26, 11.89, 12.15, 0.314),
[(LoadTestDataSize.Default, StepNames.SortTournaments)] = new(19.54, 21.01, 22.43, 2.678),
[(LoadTestDataSize.Default, StepNames.CreateTournament)] = new(25.09, 426.75, 469.95, 0.177),
[(LoadTestDataSize.Default, StepNames.JoinTournament)] = new(24.59, 46.24, 1141.50, 0.198),
[(LoadTestDataSize.Default, StepNames.PlayMatch)] = new(171.39, 741.38, 963.21, 0.138),
[(LoadTestDataSize.Default, StepNames.CorrectMatchResult)] = new(87.68, 164.22, 206.74, 0.186),
[(LoadTestDataSize.Default, StepNames.GenerateBracket(TournamentFormat.SingleElimination, 8))] = new(203.65, 503.04, 502.94, 0.130),
[(LoadTestDataSize.Default, StepNames.GenerateBracket(TournamentFormat.SingleElimination, 16))] = new(218.88, 412.93, 412.90, 0.130),
[(LoadTestDataSize.Default, StepNames.GenerateBracket(TournamentFormat.SingleElimination, 32))] = new(235.52, 398.34, 398.16, 0.130),
[(LoadTestDataSize.Default, StepNames.GenerateBracket(TournamentFormat.RoundRobin, 8))] = new(300.80, 386.30, 386.08, 0.130),
[(LoadTestDataSize.Default, StepNames.GenerateBracket(TournamentFormat.RoundRobin, 16))] = new(530.94, 849.92, 849.89, 0.131),
[(LoadTestDataSize.Default, StepNames.GenerateBracket(TournamentFormat.RoundRobin, 32))] = new(1304.58, 2053.12, 2052.77, 0.131),

[(LoadTestDataSize.Medium, StepNames.ListTournaments)] = new(8.56, 13.29, 35.31, 1.410),
[(LoadTestDataSize.Medium, StepNames.ViewTournament)] = new(23.52, 47.84, 60.71, 1.425),
Expand Down
Loading
Loading