GuyStore — Testing Guide
This guide covers how to run, understand, and extend the three-layer test suite for the GuyStore ASP.NET Core 9 e-commerce platform.
Overview
| Project | Type | Framework | Scope |
|---|---|---|---|
GuyStore.Tests.Unit |
Unit | xUnit + Moq | Models, services, controller logic in isolation |
GuyStore.Tests.Integration |
Integration | xUnit + WebApplicationFactory | Full HTTP pipeline, Identity auth, in-memory EF Core |
GuyStore.Tests.E2E |
E2E | xUnit + Playwright (Chromium) | Real browser automation against a live Kestrel server |
Prerequisites
- .NET 9 SDK — required for all three projects.
- Playwright browsers — required for E2E only; install once with the command below.
No external services are needed. All tests use an in-memory EF Core database, no-op SMTP, and simulation-mode translation.
Running All Tests
From the solution root (the folder containing GuyStore.sln):
dotnet test
Unit Tests Only
dotnet test GuyStore.Tests.Unit/GuyStore.Tests.Unit.csproj
Integration Tests Only
dotnet test GuyStore.Tests.Integration/GuyStore.Tests.Integration.csproj
E2E Tests
Step 1 — Install Playwright browsers (one-time)
cd GuyStore.Tests.E2E
dotnet build
pwsh bin/Debug/net9.0/playwright.ps1 install chromium
Step 2 — Run
dotnet test GuyStore.Tests.E2E/GuyStore.Tests.E2E.csproj
Useful filters
# Run tests matching a class name
dotnet test --filter "FullyQualifiedName~ReviewControllerTests"
# Run tests matching a method name
dotnet test --filter "FullyQualifiedName~Submit_AuthenticatedUser"
# Skip E2E (unit + integration only)
dotnet test --filter "FullyQualifiedName!~GuyStore.Tests.E2E"
# Verbose output
dotnet test --logger "console;verbosity=detailed"
Unit Test Structure
Pure in-process tests — no network, no database, no file system. Dependencies are replaced with Moq mocks or lightweight fakes.
| File | What is tested |
|---|---|
Models/ApplicationUserTests.cs | FullName computed property, IsApproved default |
Models/CartItemTests.cs | LineTotal = UnitPrice × Quantity |
Models/DiscountCodeTests.cs | IsValid() — expiry, max uses, active flag |
Models/OrderTests.cs | OrderStatus enum values, default status, financial fields |
Models/PaginatedListTests.cs | Page slicing, HasNextPage / HasPreviousPage |
Models/ProductReviewTests.cs | Rating range 1–5, required Body |
Models/ProductTests.cs | IsPublished default, collection initialization, timestamps |
Models/ProductVariantTests.cs | Price adjustment math (positive, negative, zero delta) |
Models/ShippingAddressTests.cs | IsDefault, CreatedAt, DataAnnotations validation |
Controllers/NotificationsControllerTests.cs | SignalR broadcast, message validation, type guard |
Services/CartServiceTests.cs | Add/update/remove/clear items, ItemCount, Total |
Services/EmailServiceTests.cs | SMTP no-op when server is unconfigured |
Services/InvoiceServiceTests.cs | PDF generation returns valid PDF bytes |
Services/TranslationServiceTests.cs | LanguageOption record, ITranslationService mock contract |
Integration Test Structure
Tests send real HTTP requests through the ASP.NET Core pipeline. Each test class
gets its own WebApplicationFactory instance with a fresh in-memory database,
automatically seeded by DataSeeder.
Helpers
| Helper | Purpose |
|---|---|
AuthHelper.LoginAsync(client, email, pw) |
Logs in via POST and stores the auth cookie in the client's cookie jar. |
AuthHelper.PostFormAsync(client, url, fields) |
GETs url to extract the CSRF token, then POSTs with all fields injected. |
AuthHelper.GetAntiForgeryTokenAsync(client, url) |
Extracts __RequestVerificationToken from any form page. |
Controller Coverage
| File | Coverage |
|---|---|
AccountControllerTests.cs | Login, register, pending approval, wrong password |
AddressControllerTests.cs | Index, Create (valid/invalid/unauthenticated), Delete 404, set default |
AdminAccountsControllerTests.cs | User list, pending list, approval, role-based access |
AdminDiscountsControllerTests.cs | Create, duplicate code error, uppercase normalisation, Delete |
AdminFeedbackControllerTests.cs | Index (filtered), Details 404, Delete, API→admin visibility flow |
AdminOrdersControllerTests.cs | List, status filter, status update, access control |
AdminProductsControllerTests.cs | CRUD, publish toggle, role-based denial |
AdminSettingsControllerTests.cs | Index, Update existing/non-existent key, access control |
AdminPaginationTests.cs | Accounts/Discounts/Feedback/PaymentMethods page 1 + page 2, no-nav on single page, out-of-range 200 |
ApiControllerTests.cs | Auth login/me, paginated products + feedback/mine (envelope shape, page 2, pageSize clamp) |
CartControllerTests.cs | Add, checkout, My Orders, empty-cart guard |
ReviewControllerTests.cs | Submit (auth/unauth/duplicate/404), Delete (auth/unauth/404) |
ShopControllerTests.cs | Listing, search, category filter, detail, image endpoint |
ThemeControllerTests.cs | Cookie switching, defaults, open-redirect guard |
Stage2IntegrationTests.cs | Pagination, self-service password, support, roles, invoices |
Stage3IntegrationTests.cs | Discounts, addresses, reviews, variants, 2FA, chat, OAuth |
PasskeyControllerTests.cs | BeginRegistration returns JSON options, BeginAuthentication returns JSON options, session round-trip. CompleteRegistration and CompleteAuthentication require a real WebAuthn authenticator — verify via E2E with Playwright's fake authenticator API instead. |
E2E Test Structure
All E2E test classes share a single PlaywrightFixture via
[Collection("E2E")]. The fixture starts a Kestrel server, seeds data,
and opens a headless Chromium browser. Each test method opens its own page
and closes it when done.
| File | Scenarios |
|---|---|
AdminTests.cs | Dashboard stats, product list, notification broadcast, role-based access |
AddressTests.cs | Address list, create/validate, set-default, unauthenticated redirect |
CheckoutTests.cs | Add-to-cart, full checkout flow, My Orders page |
DiscountTests.cs | Admin creates code, apply coupon at checkout, invalid code error |
InvoiceTests.cs | 404 for non-existent order, Invoice link after checkout, Admin orders page |
LoginTests.cs | Login success/failure, pending account block, logout |
ReviewTests.cs | Submit review, duplicate prevention, unauthenticated hides form |
PaginationTests.cs | Shop page 1 next-link, page 2 indicator, pages show different products; Admin Products + Discounts page 2 |
ShopTests.cs | Published products, search, category filter, product detail |
Stage2Tests.cs | Forest theme, password form, support widget/inbox, Viewer/SalesRep roles |
Stage3Tests.cs | Sales reports, discount list, address book, reviews, variants, 2FA, import |
ThemeTests.cs | Dark/corporate theme cookie, persistence across navigation |
Seeded Test Data
Every test database is pre-populated by DataSeeder.SeedAsync():
| Resource | Details |
|---|---|
| Admin user | [email protected] / Admin@123 — role: Administrator |
| Account manager | [email protected] / Acct@123 — role: AccountManagement |
| Products | 30 products; 29 published, 1 draft (15 Electronics, 8 Office, 7 Accessories) |
| Roles | Administrator, AccountManagement, Registered, Viewer, SalesRep |
| Payment methods | Purchase Order (PO), Bank Transfer |
| App settings | EmailOrderConfirmation = false |
Guid.NewGuid()
to avoid collisions between tests in the same class.
Test Isolation
| Layer | Database | Shared between |
|---|---|---|
| Unit | None (pure) | Fully isolated per test |
| Integration | In-memory EF Core (one per test class) | Tests within same class share the DB; different classes have separate DBs |
| E2E | In-memory EF Core (one for entire collection) | All E2E tests share one DB + browser instance; use fresh pages per test |
Continuous Integration
GitHub Actions example
- name: Install Playwright browsers
run: |
cd GuyStore.Tests.E2E
dotnet build --configuration Release
pwsh bin/Release/net9.0/playwright.ps1 install chromium --with-deps
- name: Run unit + integration tests
run: dotnet test --filter "FullyQualifiedName!~GuyStore.Tests.E2E" --configuration Release
- name: Run E2E tests
run: dotnet test GuyStore.Tests.E2E --configuration Release
Adding New Tests
New unit test
*.cs in GuyStore.Tests.Unit/Models/, Services/, or Controllers/.Moq to mock any interface dependencies.FakeSession for session-dependent code.// Example
public class MyModelTests
{
[Fact]
public void Property_CanBeSet() { ... }
[Theory]
[InlineData(1, true)]
[InlineData(0, false)]
public void Condition_ReturnsExpected(int value, bool expected) { ... }
}
New integration test
GuyStore.Tests.Integration/Controllers/ that implements IClassFixture<GuyStoreWebApplicationFactory>.client.LoginAsync(email, password) to authenticate.client.PostFormAsync(url, fields) for form submissions — CSRF tokens are injected automatically.public class MyControllerTests : IClassFixture<GuyStoreWebApplicationFactory>
{
private readonly GuyStoreWebApplicationFactory _factory;
public MyControllerTests(GuyStoreWebApplicationFactory factory) => _factory = factory;
private HttpClient FreshClient() =>
_factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = true });
[Fact]
public async Task SomeAction_Returns200()
{
var client = FreshClient();
await client.LoginAsync("[email protected]", "Admin@123");
var response = await client.GetAsync("/SomeController/SomeAction");
response.EnsureSuccessStatusCode();
}
}
New E2E test
GuyStore.Tests.E2E/Tests/ decorated with [Collection("E2E")].PlaywrightFixture in the constructor._fixture.NewPageAsync() at the start and page.CloseAsync() at the end of each test.[Collection("E2E")]
public class MyFeatureTests
{
private readonly PlaywrightFixture _fixture;
public MyFeatureTests(PlaywrightFixture fixture) => _fixture = fixture;
[Fact]
public async Task FeaturePage_IsAccessible()
{
var page = await _fixture.NewPageAsync();
await LoginAsAdminAsync(page);
await page.GotoAsync($"{_fixture.BaseUrl}/MyFeature");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var html = await page.ContentAsync();
Assert.Contains("My Feature", html);
await page.CloseAsync();
}
private async Task LoginAsAdminAsync(IPage page)
{
await page.GotoAsync($"{_fixture.BaseUrl}/Account/Login");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.FillAsync("input[name='Email']", "[email protected]");
await page.FillAsync("input[name='Password']", "Admin@123");
await page.RunAndWaitForNavigationAsync(
() => page.ClickAsync("button[type='submit']"),
new PageRunAndWaitForNavigationOptions { WaitUntil = WaitUntilState.NetworkIdle });
}
}
Testing Passkey / WebAuthn endpoints
The BeginRegistration and BeginAuthentication endpoints are testable
with a standard HttpClient — they just return JSON and write to session. Use an
integration test that calls GET /Passkey/BeginRegistration (authenticated) and
asserts the response contains challenge and rp fields.
CompleteRegistration and CompleteAuthentication require a real
WebAuthn authenticator response, which can only be produced by a browser. Use Playwright's
virtual authenticator API (page.AddVirtualAuthenticatorAsync()) in an E2E test to
create and assert a full passkey registration → sign-in flow without real biometrics.
"UseInMemoryDatabase" appsettings toggle is ignored in the Testing
environment — GuyStoreWebApplicationFactory always gets an InMemory database with a
unique name, keeping tests isolated from each other and from the development SQL Server.