How the test suites are built and why. How to run them, and the variables they need, is in CONTRIBUTING.md.
test/WebApi.UnitTests: pure logic, mocked repositories, stubbed HTTP handlers.test/BlazorApp.UnitTests: pure component and helper logic.test/WebApi.IntegrationTests: a real Kestrel host (KestrelWebAppFactory<Program>) against real MongoDB and real Firebase authentication.test/BlazorApp.PlaywrightTests: a real browser driving both apps.test/Testing.Shared: hosting and Firebase infrastructure for both, not a test project.KestrelWebAppFactory<TEntryPoint>takes its environment variable name and configuration overrides as constructor parameters.
All use xunit v3 on the Microsoft.Testing.Platform runner, AwesomeAssertions for assertions and Bogus for data.
Mapper validation is compile-time (Mapperly diagnostics), not a test.
A mocked-repository test cannot prove MongoDB semantics: filters, collation, null storage and indexes need a real-MongoDB integration test.
ResourceTestBasegives typed HTTP helpers,Authenticate()(a real Firebase bearer) andAuthenticatedUserId.- Every fixture uses
KestrelWebAppFactory<Program>, which setsFeatures:IsReferenceSyncEnabledtofalsethroughConfigureAppConfiguration(UseSettingdoes not work with a top-level-statementProgram.cs), or the host fires real provider calls at startup. TvTimeFixtureZipBuilderbuilds a synthetic export in memory, and a real personal export is never committed.- A test whose subject is a live provider calls it through
GetThroughLiveProviderAsync/PostNoContentThroughLiveProviderAsync, which skip on a 502 and on nothing wider, as inBookProviderSearchAndLinkResourceTest's[Theory]over several providers. An automatic match shows an outage only as a missing link, since the background one is fire-and-forget and a degraded Google Books answers 200 with no volume. The host records each match whose provider failed or answered no candidate (AutomaticMatchFailureLog), andReferenceMatchResourceTestskips on it, since its cases only use titles their provider holds. Searching the provider again afterwards proves nothing during a partial outage, when some calls succeed. - A test that starts a background job must not let it do real work: the
sync-nowtests useProviderlessWebAppFactory(every provider credential blanked), except the opt-inReferenceSyncPollingResourceTest(REFERENCE_SYNC_POLL_ENABLED). CancellationTokenPropagationTestproves the generic CRUD path cancels a real MongoDB call.
Microsoft.Playwright.Xunit.v3's PageTest with plain xunit facts, self-skipping unless E2E_ENABLED=true, so a solution-wide dotnet test stays green.
Gherkin was rejected as twice the code for the same coverage.
End2EndFixture([AssemblyFixture]) hosts both apps, signs in once and seeds a synthetic book reference.- Sign-in is programmatic: the login page is OAuth popup only, which Playwright cannot drive, so the fixture signs in over Firebase REST and posts the ID token to
/auth/callback, then reuses the cookie as storage state. WithoutE2E_USERNAME, it creates an ephemeral admin user through the Firebase Admin SDK and deletes it at the end. - In self-hosted mode both apps run in the test process, so breakpoints in
BlazorAppandWebApiare hit during a browser click. Pages/PageBaseholds the nav locators andOpen<X>Async(),ListPageis one class for all list pages, and detail titles share.kt-title-input. Locators are role and label first, and only a few unlabeled fields carrydata-testid.- A page object waits for an interactive signal, never for static markup or
NetworkIdle: Blazor prerenders before the circuit connects, and the circuit's WebSocket keeps the network busy. WatchNextSmokeTestis in aDisableParallelizationcollection, andApiHttpClientusesLazyInitializer.EnsureInitialized, since??=raced across classes.QuickAddSmokeTestcovers one media type and one record type, which exercises the whole of Quick Add: the per-type fields are already covered by each type's own detail-page smoke test.ExploreSmokeTestseedsexplore_cataloguedirectly (End2EndFixture.SeedExploreCatalogueAsync, self-hosted only), since the e2e host never runs the refresh. It seeds every rankingExploreRankings.Rankingsdeclares for a domain (the one read follows a stored admin setting), uses real provider ids only for the twoAddcases (TMDB 278, IGDB 72), and its[Theory]s cover movies and video games, which differ in tab gating, ranking and id space.MobileScreenshotTestis an assertion-free visual harness behindE2E_MOBILE_CHECK=true, capturing every page at a phone viewport, then a sample at 1280px and at 2560px, for review.
PageBase.WaitForReadyAsync reloads once and re-asserts (ExpectWithReloadAsync, also behind ListPage.ExpectRowThumbnailAsync), for two cases where waiting never helps:
- the URL changed but enhanced navigation never swapped the content;
- a list rendered before a detail page's save landed, a save the server issues over its circuit, so the browser has nothing to wait for.
The reload only runs after a failed assertion, and firing often is the signal to investigate rather than raise a timeout.
Gotcha: a smoke test stays in the default list view, since ItemGridCard's stretched-link anchor has no size and Playwright refuses to click it.
A failing test's evidence is in test/BlazorApp.PlaywrightTests/bin/<config>/net10.0/e2e-diagnostics, written by SmokeTestBase.DisposeAsync for every context and page the test opened:
a full-page screenshot and the trace, with their paths printed in the test output.
A test needing a clean browser opens it through SmokeTestBase.NewAnonymousPageAsync, since a context disposed inside the test body is already closed when the capture runs.
Capturing never throws, so diagnosing a failure never replaces the failure.
The integration and Playwright suites run against a real, long-lived MongoDB, with no throwaway database per test.
Each suite uses its own database, never keeptrack_dev.
IntegrationTestDatabase.Name (keeptrack_integrationtests) and End2EndConfiguration.DatabaseName (keeptrack_e2e) resolve it (overridable by Infrastructure__MongoDB__DatabaseName and E2E_MONGODB_DATABASE respectively),
and push it into the host configuration, since otherwise the in-process Development host falls back to appsettings.Development.json.
TestDatabaseGuard.EnsureTestDatabaseName refuses a name containing dev, prod, staging or preprod.
These are defaults rather than requirements, because an IDE sets test variables once for the whole solution.
Every test removes what it created, on success and on failure, since unique indexes make yesterday's leftover fail today's run.
Cleanup is registered at the moment of creation, never in a per-test try/finally.
DatabaseTestBase:TrackCleanup(Func<Task>),TrackDocument(collection, id),TrackDocumentsWhere(collection, filter).ResourceTestBase:CreateAsync(POST plus register),TrackResource,TrackResourcesMatching<TDto>.SmokeTestBase:TrackOpenItem,CreateItemAsync,TrackItemsMatching.DisposeAsyncdrains the registry, sinceTrackResourcesMatchingregisters ids at cleanup time.- Cleanups run under
CancellationToken.None, since the test token is cancelled exactly on a timeout. - Deleting what a test did not create is forbidden, except
ExploreSmokeTest's add, whose precondition is "the tenant does not hold this item" (SmokeTestBase.RemoveItemsMatchingAsync, full title).
Gotcha: a delete filter that matches nothing looks exactly like one that worked.
Builders<TEntity>.Filter.Eq("_id", id) with a string id against an ObjectId matches nothing, while the typed Eq(x => x.Id, id) works, so TrackDocument converts a parseable id to ObjectId.
Cleanup is verified with a document-count diff across a run, never by exit status.
- Reference documents linked from a real provider title are left in place (shared, deduplicated by provider id), while synthetic ones are always removed and use a per-test external id (
TestExternalId.New()). - Deleting a TV show cascades to its episodes.
- A document with no list page is the easiest to leak:
explore_dismissalappears in no UI, so tests register the undo (DELETE /api/explore/{type}/dismiss/{externalId}) as they dismiss. - A reference created by the server in the background is not tracked by the test that triggered it.
Creating a book starts a background match (
BookController.OnCreatedAsync), which can insert the same Google Books reference as the test's own explicit link. The unique index then rejects one insert and the link answers 500 before the test reachesTrackDocument, so that reference stays in the test database. A leftover Google Books id inbook_referencetherefore points at this race, and the next run tolerates it since the link reuses the document. ServerDerivedDataSweep([assembly: AssemblyFixture]) emptiesexplore_catalogueandleaseafter the run, neverprovider_quota, the ledger of OMDb calls really spent.