| name | add-integration-test |
| description | Guide a contributor through adding a new integration test fixture to FantasyCritic.IntegrationTests. Use when the user wants to add a new integration test, test a new API endpoint, or asks about writing tests against the live HTTP stack. |
Add an Integration Test
Project layout
src/FantasyCritic.IntegrationTests/
IntegrationTestBase.cs ← base class, always inherit this
FantasyCriticWebApplicationFactory.cs
NullEmailSender.cs
Helpers/
ApiSession.cs ← HTTP client wrapper + typed client properties
AntiForgeryHelper.cs
LeagueFixture.cs ← LeagueFixture + LeagueFixtureBuilder for league scenarios
LeagueScenario.cs ← league configuration presets
Tests/
Auth/AuthTests.cs ← existing example
Royale/RoyaleTests.cs ← existing example (typed client pattern)
League/
Setup/ ← league creation + member management
Draft/ ← draft scenarios (LeagueDraftTestBase)
Actions/ ← post-draft bid/drop processing
<FeatureArea>/ ← add other areas here
src/FantasyCritic.ApiClient/
Generated/FantasyCriticClients.cs ← NSwag-generated typed clients + DTOs (do not edit)
nswag.json ← NSwag config
How the typed clients work
ApiSession exposes one typed client property per controller, all generated by NSwag:
| Property | Client class | Controller |
|---|
session.Royale | RoyaleClient | RoyaleController |
session.RoyaleGroup | RoyaleGroupClient | RoyaleGroupController |
session.League | LeagueClient | LeagueController |
session.LeagueManager | LeagueManagerClient | LeagueManagerController |
session.Account | AccountClient | AccountController |
session.Game | GameClient | GameController |
session.Admin | AdminClient | AdminController |
session.General | GeneralClient | GeneralController |
session.CombinedData | CombinedDataClient | CombinedDataController |
session.Conference | ConferenceClient | ConferenceController |
session.FactChecker | FactCheckerClient | FactCheckerController |
session.ActionRunner | ActionRunnerClient | ActionRunnerController |
All request and response types used by these clients come from the FantasyCritic.ApiClient namespace (the generated file). Do not reference FantasyCritic.Web.Models.Requests.* or FantasyCritic.Lib.SharedSerialization.API.* in test files.
Step 1 – Check (and fix) the controller's response type annotations
Before writing any test code, check that every action you want to call has an explicit [ProducesResponseType] attribute for its success response. NSwag only generates a typed Task<T> return when it can see the response type in the OpenAPI spec — without the annotation it falls back to Task (void).
If the generated method returns Task but you need a value back:
Add [ProducesResponseType<YourViewModel>(StatusCodes.Status200OK)] (or Status201Created, etc.) to the controller action, then regenerate.
[HttpPost]
public async Task<IActionResult> CreateThing([FromBody] CreateThingRequest request) { ... }
[HttpPost]
[ProducesResponseType<ThingViewModel>(StatusCodes.Status200OK)]
public async Task<IActionResult> CreateThing([FromBody] CreateThingRequest request) { ... }
If fixing the annotation would require more than just adding the attribute (restructuring the return path, etc.), flag it and move on — decide together how to handle it.
Step 2 – Find the right typed client method
Look at the controller you want to test (e.g. GameController.cs), then check the corresponding generated client in Generated/FantasyCriticClients.cs for the method names. They follow the pattern <ActionName>Async(...).
Step 3 – Create the fixture
For simple API smoke tests, inherit IntegrationTestBase directly.
For league scenarios (draft, bids, drops), use LeagueFixtureBuilder:
private LeagueFixture _league = null!;
[OneTimeSetUp]
public async Task SetUp()
{
_league = await LeagueFixtureBuilder.CreateAndStartDraftAsync(
Factory, LeagueScenarios.Standard, NewUser);
await _league.DraftToCompletionAsync();
}
[OneTimeTearDown]
public async Task TearDown() => await _league.DisposeAsync();
Use _league.Manager for the league manager's session (e.g. LeagueManager, draft pause). Index _league.Publishers by draft order when you need a specific player's publisher — do not assume a fixed list index is the manager.
For full-draft completion with shared assertions, inherit LeagueDraftTestBase
in Tests/League/Draft/ instead.
using System;
using System.Threading.Tasks;
using FantasyCritic.ApiClient;
using FantasyCritic.IntegrationTests.Helpers;
using NUnit.Framework;
namespace FantasyCritic.IntegrationTests.Tests.<Area>;
[TestFixture]
public class <Area>Tests : IntegrationTestBase
{
[Test]
public async Task <Scenario>()
{
var (email, password, displayName) = NewUser();
using var session = new ApiSession(Factory);
await session.RegisterAsync(email, password, displayName);
var result = await session.<Area>.Create<X>Async(new Create<X>Request
{
});
Assert.That(result, Is.Not.EqualTo(Guid.Empty));
var vm = await session.<Area>.Get<X>Async(result);
Assert.That(vm.Name, Is.EqualTo("expected name"));
}
}
Real example (from RoyaleTests.cs)
var activeQuarter = await session.Royale.ActiveRoyaleQuarterAsync();
var publisherID = await session.Royale.CreateRoyalePublisherAsync(
new CreateRoyalePublisherRequest
{
Year = activeQuarter.Year,
Quarter = activeQuarter.Quarter,
PublisherName = $"Pub-{Guid.NewGuid():N}"[..20],
});
var publisher = await session.Royale.GetRoyalePublisherAsync(publisherID);
Assert.That(publisher.PublisherName, Is.Not.Null);
Step 4 – Regenerate the client (if you added/changed an endpoint)
# From repo root
dotnet build src/FantasyCritic.Web/FantasyCritic.Web.csproj
scripts/Regenerate-ApiClient.ps1
dotnet build
Step 5 – Run
# From repo root — confirm Docker is running first
docker compose -f infrastructure/docker-compose-mysql.yaml up -d
# -c Release avoids Debug DLL lock conflicts with the dev web server
dotnet test src/FantasyCritic.IntegrationTests/FantasyCritic.IntegrationTests.csproj -c Release
Expected: all tests pass, no 5xx responses.
ApiSession helpers reference
| Member | Use for |
|---|
RegisterAsync(email, pw, name) | Create a user and sign in (session is authenticated after this) |
LoginAsync(email, pw) | Sign in on a fresh session; returns true on success |
session.Royale.*Async(...) | All Royale API calls via typed client |
session.Game.*Async(...) | All Game/MasterGame API calls via typed client |
session.RoyaleGroup.*Async(...) | All RoyaleGroup API calls via typed client |
session.FactChecker.*Async(...) | All FactChecker admin-role API calls via typed client |
| (other typed clients) | Same pattern for every controller |
GetAsync(path) | Raw GET fallback — only use if no typed client method exists |
PostJsonAsync<T>(path, body) | Raw POST fallback — only use if no typed client method exists |
Rules
- Always use
NewUser() — never hardcode credentials
- Always use the typed client properties (
session.Royale.*, session.Game.*, etc.) — no raw URL strings
- All request/response types come from
FantasyCritic.ApiClient — no anonymous objects or JObject
- Always build state through the API — no direct DB inserts
- No teardown needed — GUID-based names never collide