Stack: Blazor Server · ASP.NET Core Identity · EF Core (SQL Server) · Stripe.net · Blazored.Toast
Test pyramid target: Unit (fast, isolated) → Integration (real DB, real DI) → E2E (real browser, real app)
| Layer | Tool | Reason |
|---|---|---|
| Unit | bUnit + xUnit + Moq |
bUnit is the standard Blazor component testing library; renders components in a headless DOM and exposes full Blazor lifecycle |
| Unit assertions | FluentAssertions |
Readable, expressive assertion syntax |
| Integration | Microsoft.AspNetCore.Mvc.Testing + EF Core InMemory or Testcontainers (SQL Server) |
Spins up the full Blazor Server host in-process; InMemory for speed, Testcontainers for fidelity |
| E2E | Playwright for .NET (Microsoft.Playwright) |
First-class .NET API, reliable auto-waiting, network interception for Stripe mocking |
| Code coverage | Coverlet + ReportGenerator |
Produces HTML coverage reports, integrates with CI |
Nooksy.sln
├── Nooksy.Client/ ← app under test
├── Nooksy.Models/
├── Nooksy.DataAccess/
├── Nooksy.Utility/
│
└── tests/
├── Nooksy.Tests.Unit/ ← bUnit + xUnit + Moq
│ ├── Components/
│ │ ├── UI/
│ │ │ ├── NooksyButtonTests.cs
│ │ │ ├── NooksyBadgeTests.cs
│ │ │ ├── NooksyModalTests.cs
│ │ │ ├── NooksyAlertTests.cs
│ │ │ ├── DataTableTests.cs
│ │ │ ├── CartBadgeTests.cs
│ │ │ ├── ImageUploaderTests.cs
│ │ │ └── LoadingSpinnerTests.cs
│ │ ├── Layout/
│ │ │ ├── MainLayoutTests.cs
│ │ │ └── AdminLayoutTests.cs
│ │ └── Pages/
│ │ ├── Admin/
│ │ │ ├── DashboardTests.cs
│ │ │ ├── CategoriesIndexTests.cs
│ │ │ ├── CategoriesUpsertTests.cs
│ │ │ ├── ProductsIndexTests.cs
│ │ │ ├── ProductsUpsertTests.cs
│ │ │ ├── OrdersIndexTests.cs
│ │ │ ├── OrderDetailTests.cs
│ │ │ └── UsersIndexTests.cs
│ │ └── Customer/
│ │ ├── HomeTests.cs
│ │ ├── ProductDetailsTests.cs
│ │ ├── CartTests.cs
│ │ ├── CheckoutTests.cs
│ │ └── OrderConfirmationTests.cs
│ └── State/
│ └── CartStateTests.cs
│
├── Nooksy.Tests.Integration/ ← WebApplicationFactory + real DI
│ ├── Setup/
│ │ ├── NooksyWebAppFactory.cs
│ │ └── DatabaseSeeder.cs
│ ├── Admin/
│ │ ├── CategoryCrudTests.cs
│ │ ├── ProductCrudTests.cs
│ │ ├── OrderWorkflowTests.cs
│ │ └── UserManagementTests.cs
│ └── Customer/
│ ├── CartFlowTests.cs
│ └── CheckoutFlowTests.cs
│
└── Nooksy.Tests.E2E/ ← Playwright, real browser
├── Setup/
│ ├── PlaywrightFixture.cs
│ └── TestDataSeeder.cs
├── Pages/
│ ├── HomePageTests.cs
│ ├── ShopPageTests.cs
│ ├── CartPageTests.cs
│ ├── CheckoutPageTests.cs
│ └── AdminDashboardTests.cs
└── Flows/
├── GuestBrowsingFlow.cs
├── CustomerPurchaseFlow.cs
└── AdminOrderWorkflowFlow.cs
<PackageReference Include="bunit" Version="1.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
<PackageReference Include="Moq" Version="4.*" />
<PackageReference Include="FluentAssertions" Version="6.*" />
<PackageReference Include="coverlet.collector" Version="6.*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<!-- For Blazor Server auth context in bUnit -->
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="8.*" /><PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.*" />
<!-- OR for SQL-fidelity: -->
<PackageReference Include="Testcontainers.MsSql" Version="3.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="FluentAssertions" Version="6.*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" /><PackageReference Include="Microsoft.Playwright" Version="1.*" />
<PackageReference Include="Microsoft.Playwright.MSTest" Version="1.*" />
<!-- or xunit runner: -->
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />// Nooksy.Tests.Unit/State/CartStateTests.cs
public class CartStateTests
{
// TEST: Initial CartCount is 0
[Fact]
public void InitialCartCount_IsZero()
// TEST: SetCount sets exact value and fires OnChange
[Fact]
public void SetCount_SetsValueAndNotifies()
// TEST: Increment adds 1 by default
[Fact]
public void Increment_DefaultAmount_AddsOne()
// TEST: Increment adds custom amount
[Theory, InlineData(3), InlineData(10)]
public void Increment_CustomAmount_AddsCorrectly(int amount)
// TEST: Decrement subtracts correctly
[Fact]
public void Decrement_ReducesCount()
// TEST: Decrement cannot go below 0
[Fact]
public void Decrement_NeverGoesNegative()
// TEST: OnChange NOT fired when value is set to same value (no spurious renders)
[Fact]
public void SetCount_SameValue_DoesNotFireOnChange()
}What to verify: variant CSS class, loading spinner, disabled state, type attribute, OnClick callback.
// Nooksy.Tests.Unit/Components/UI/NooksyButtonTests.cs
public class NooksyButtonTests : TestContext
{
// TEST: Default render — has btn-nooksy-primary class
[Fact]
public void Renders_WithPrimaryVariantByDefault()
// TEST: Accent variant → btn-nooksy-accent class
[Theory]
[InlineData(NooksyButton.NooksyButtonVariant.Accent, "btn-nooksy-accent")]
[InlineData(NooksyButton.NooksyButtonVariant.Danger, "btn-nooksy-danger")]
[InlineData(NooksyButton.NooksyButtonVariant.Outline, "btn-nooksy-outline")]
public void Renders_CorrectClassForVariant(NooksyButtonVariant variant, string expectedClass)
// TEST: IsLoading=true shows spinner, hides ChildContent
[Fact]
public void WhenLoading_ShowsSpinner_HidesContent()
// TEST: IsLoading=true disables the button element
[Fact]
public void WhenLoading_ButtonIsDisabled()
// TEST: Disabled=true disables without showing spinner
[Fact]
public void WhenDisabled_ButtonIsDisabledWithoutSpinner()
// TEST: Type="submit" sets correct HTML attribute
[Fact]
public void Type_Submit_SetsHtmlAttribute()
// TEST: OnClick fires when button is clicked
[Fact]
public void OnClick_Fires_WhenClicked()
// TEST: OnClick does NOT fire when disabled
[Fact]
public void OnClick_DoesNotFire_WhenDisabled()
// TEST: ChildContent renders text correctly
[Fact]
public void ChildContent_RendersInButton()
// TEST: Size="sm" adds btn-sm class
[Theory, InlineData("sm"), InlineData("lg")]
public void Size_AddsCorrectSizeClass(string size)
}public class NooksyBadgeTests : TestContext
{
// TEST: "Pending" status → nooksy-badge-pending
// TEST: "Approved" status → nooksy-badge-approved
// TEST: "Processing" status → nooksy-badge-inprocess (SD.StatusInProcess = "Processing")
// TEST: "Shipped" → nooksy-badge-shipped
// TEST: "Cancelled" → nooksy-badge-cancelled
// TEST: "Refunded" → nooksy-badge-refunded
// TEST: "ApprovedForDelayedPayment" → nooksy-badge-approved (B2B mapping)
// TEST: Unknown status → nooksy-badge-pending (fallback)
// TEST: Empty status string → nooksy-badge-pending (null guard)
// TEST: Text parameter renders inside the span
[Theory]
[InlineData("Pending", "nooksy-badge-pending")]
[InlineData("Approved", "nooksy-badge-approved")]
[InlineData("Processing", "nooksy-badge-inprocess")]
[InlineData("Shipped", "nooksy-badge-shipped")]
[InlineData("Cancelled", "nooksy-badge-cancelled")]
[InlineData("Refunded", "nooksy-badge-refunded")]
[InlineData("ApprovedForDelayedPayment", "nooksy-badge-approved")]
[InlineData("unknown-garbage", "nooksy-badge-pending")]
[InlineData("", "nooksy-badge-pending")]
public void Status_MapsToCorrectCssClass(string status, string expectedClass)
}public class NooksyModalTests : TestContext
{
// TEST: IsOpen=false → modal wrapper NOT rendered in DOM
[Fact]
public void WhenClosed_ModalIsNotInDom()
// TEST: IsOpen=true → modal wrapper IS rendered
[Fact]
public void WhenOpen_ModalIsRendered()
// TEST: Title parameter renders in the header h3
[Fact]
public void Title_RendersInHeader()
// TEST: ChildContent renders inside nooksy-modal-body
[Fact]
public void ChildContent_RendersInModalBody()
// TEST: Clicking close button invokes OnClose callback
[Fact]
public async Task CloseButton_Click_InvokesOnClose()
// TEST: Clicking backdrop invokes OnClose callback
[Fact]
public async Task BackdropClick_InvokesOnClose()
// REGRESSION (from audit item 17): CloseModal must NOT mutate IsOpen directly
[Fact]
public void CloseModal_DoesNotMutateIsOpenParameter()
// Verify: after close button click, IsOpen [Parameter] still has the original value passed in
// (only OnClose is invoked — parent owns state)
// TEST: Width parameter sets max-width inline style
[Fact]
public void Width_SetsMaxWidthStyle()
}public class NooksyAlertTests : TestContext
{
// TEST: IsVisible=true by default → alert rendered
[Fact]
public void Alert_IsVisibleByDefault()
// TEST: Message parameter renders text
[Fact]
public void Message_RendersTextContent()
// TEST: ChildContent renders when Message is empty
[Fact]
public void ChildContent_RendersWhenMessageEmpty()
// TEST: Dismissible=true shows close button
[Fact]
public void Dismissible_ShowsCloseButton()
// TEST: Dismissible=false hides close button
[Fact]
public void NonDismissible_HidesCloseButton()
// TEST: Clicking dismiss hides the alert (IsVisible→false)
[Fact]
public async Task DismissButton_Click_HidesAlert()
// TEST: Type mapping — "Success"→alert-success, "Warning"→alert-warning, etc.
[Theory]
[InlineData("Success", "alert-success", "check-circle-fill")]
[InlineData("Warning", "alert-warning", "exclamation-triangle-fill")]
[InlineData("Error", "alert-danger", "x-circle-fill")]
[InlineData("Info", "alert-info", "info-circle-fill")]
public void Type_MapsToCorrectAlertClassAndIcon(string type, string cssClass, string iconClass)
}public class DataTableTests : TestContext
{
// TEST: Renders correct number of rows for given Items
[Fact]
public void Renders_CorrectNumberOfRows()
// TEST: Shows "No matching records" when Items is empty
[Fact]
public void EmptyItems_ShowsNoRecordsMessage()
// TEST: Search filters rows matching query (uses SearchMatch)
[Fact]
public void SearchQuery_FiltersRows()
// TEST: Search sets CurrentPage to 1
[Fact]
public void SearchQuery_ResetsToFirstPage()
// TEST: Search with no match → empty state row
[Fact]
public void SearchQuery_NoMatch_ShowsNoRecordsMessage()
// TEST: Pagination — only PageSize rows on page 1 when Items > PageSize
[Fact]
public void Pagination_ShowsOnlyPageSizeRowsOnFirstPage()
// TEST: Next page shows next batch
[Fact]
public void NextPage_Button_ShowsNextBatch()
// TEST: Previous page disabled on page 1
[Fact]
public void PreviousPage_DisabledOnFirstPage()
// TEST: Next page disabled on last page
[Fact]
public void NextPage_DisabledOnLastPage()
// TEST: Changing PageSize selector updates row count (REGRESSION from audit item 16)
// Verify no Blazor "Parameter mutated" warning — use private backing field
[Fact]
public void PageSizeSelect_DoesNotMutateParameter()
// TEST: Correct "Showing X to Y of Z entries" footer text
[Fact]
public void ShowsCorrectEntryCountFooter()
// TEST: HeaderTemplate renders in <thead>
[Fact]
public void HeaderTemplate_RendersInThead()
// TEST: RowTemplate renders per item
[Fact]
public void RowTemplate_RendersPerItem()
}public class CartBadgeTests : TestContext
{
// TEST: CartCount=0 → badge NOT rendered
[Fact]
public void WhenCartIsEmpty_BadgeNotRendered()
// TEST: CartCount=5 → badge renders "5"
[Fact]
public void WhenCartHasItems_BadgeRendersCount()
// TEST: CartState.OnChange → StateHasChanged called (badge updates)
[Fact]
public async Task OnCartStateChange_BadgeUpdates()
// TEST: Component subscribes to CartState.OnChange on init
[Fact]
public void OnInitialized_SubscribesToCartStateOnChange()
// TEST (REGRESSION from audit item 29): Component implements IDisposable
// and unsubscribes on Dispose to prevent memory leak
[Fact]
public void Dispose_UnsubscribesFromCartStateOnChange()
// TEST: After Dispose, CartState.OnChange no longer triggers renders
[Fact]
public async Task AfterDispose_CartStateChanges_DoNotTriggerStateHasChanged()
}public class LoadingSpinnerTests : TestContext
{
// TEST: Message parameter renders in the paragraph
[Fact]
public void Message_RendersInParagraph()
// TEST: Default message used when Message not set
[Fact]
public void DefaultMessage_UsedWhenNotProvided()
// TEST: Contains spinner-border element
[Fact]
public void ContainsSpinnerBorderElement()
}Setup: Mock IUnitOfWork, return seed categories. Mock IToastService.
public class CategoriesIndexTests : TestContext
{
// TEST: Page renders category list after load
[Fact]
public async Task OnLoad_ShowsCategories()
// TEST: While isLoading=true, LoadingSpinner rendered
// (hard to catch timing in bUnit — assert LoadingSpinner in initial render before OnInitializedAsync completes)
[Fact]
public void BeforeLoad_ShowsLoadingSpinner()
// TEST: Create Category button links to /admin/categories/upsert
[Fact]
public void CreateButton_HasCorrectHref()
// TEST: Edit button per row links to /admin/categories/upsert/{id}
[Fact]
public void EditButton_HasCorrectHrefPerRow()
// TEST: Clicking Delete button opens modal
[Fact]
public async Task DeleteButton_Click_OpensModal()
// TEST: Modal shows category name in confirmation message
[Fact]
public async Task DeleteModal_ShowsCategoryName()
// TEST: Confirming delete calls UnitOfWork.Category.Remove + UnitOfWork.Save
[Fact]
public async Task ConfirmDelete_CallsRemoveAndSave()
// TEST: Successful delete shows success toast
[Fact]
public async Task ConfirmDelete_ShowsSuccessToast()
// TEST: Cancelling delete closes modal without calling Remove
[Fact]
public async Task CancelDelete_ClosesModal_NoRemoveCalled()
// TEST: UnitOfWork throws → shows error toast, modal stays closed
[Fact]
public async Task DeleteException_ShowsErrorToast()
// TEST: Page requires Admin or Employee role ([Authorize] attribute is present)
[Fact]
public void Page_HasAuthorizeAttributeWithAdminEmployeeRoles()
}REGRESSION coverage for audit item 4 — NooksyButtonVariant enum reference.
public class CategoriesUpsertTests : TestContext
{
// TEST: Id=0 → renders "Create Category" heading and button text
[Fact]
public void WhenIdIsZero_ShowsCreateMode()
// TEST: Id=5 → loads existing category, renders "Edit Category"
[Fact]
public void WhenIdProvided_LoadsExistingCategory()
// TEST: NooksyButton uses NooksyButton.NooksyButtonVariant.Primary (not bare NooksyButtonVariant)
// Verify component compiles and renders — this is the regression test for audit item 4
[Fact]
public void SubmitButton_RendersWithoutEnumReferenceError()
// TEST: Submitting empty form shows validation messages
[Fact]
public async Task EmptyForm_ShowsValidationMessages()
// TEST: Valid form (create) → calls UnitOfWork.Category.Add + Save + navigates back
[Fact]
public async Task ValidCreate_CallsAddAndNavigates()
// TEST: Valid form (edit) → calls UnitOfWork.Category.Update + Save + navigates back
[Fact]
public async Task ValidEdit_CallsUpdateAndNavigates()
// TEST: IsLoading=true on submit → button shows spinner
[Fact]
public async Task OnSubmit_ButtonShowsSpinner()
// TEST: Cancel link has href to /admin/categories
[Fact]
public void CancelLink_HasCorrectHref()
}public class ProductsIndexTests : TestContext
{
// TEST: Products load and display in DataTable
[Fact]
public async Task OnLoad_ShowsProductsInTable()
// TEST: Cover image shown when ProductImages is non-empty
[Fact]
public async Task WithImages_ShowsCoverImage()
// TEST: Fallback book icon shown when ProductImages is empty
[Fact]
public async Task WithoutImages_ShowsFallbackIcon()
// TEST (REGRESSION audit item 12): LoadProducts does NOT make second GetAll call for images
// Verify UnitOfWork.ProductImage.GetAll is called 0 times after the fix
[Fact]
public async Task LoadProducts_DoesNotCallProductImageGetAllSeparately()
// TEST: Delete modal flow (same pattern as Categories)
[Fact]
public async Task DeleteFlow_RemovesImagesAndProduct()
// TEST: Delete removes ProductImages first, then Product
[Fact]
public async Task Delete_RemovesImagesBeforeProduct()
}public class OrdersIndexTests : TestContext
{
// TEST: All orders shown when selectedStatus=null (All tab active)
[Fact]
public async Task AllTab_ShowsAllOrders()
// TEST: Filter by "Pending" → only Pending orders shown
[Theory]
[InlineData(SD.StatusPending)]
[InlineData(SD.StatusApproved)]
[InlineData(SD.StatusInProcess)]
[InlineData(SD.StatusShipped)]
[InlineData(SD.StatusCancelled)]
public async Task StatusFilter_ShowsOnlyMatchingOrders(string status)
// TEST: Active tab has "active" CSS class
[Fact]
public async Task ActiveFilterTab_HasActiveCssClass()
// TEST: View Details button links to /admin/orders/{id}
[Fact]
public async Task ViewDetails_LinkHasCorrectHref()
// TEST: NooksyBadge renders for both OrderStatus and PaymentStatus
[Fact]
public async Task Rows_ShowStatusBadges()
}public class OrderDetailTests : TestContext
{
// TEST: Order not found (null) → shows "Order not found" message, not crash
[Fact]
public async Task WhenOrderNotFound_ShowsNotFoundMessage()
// TEST: Order header fields rendered (customer, address, dates)
[Fact]
public async Task OrderHeader_RendersCustomerAndShippingInfo()
// TEST: Order items table renders all OrderDetail rows
[Fact]
public async Task OrderItems_RendersAllDetails()
// TEST (REGRESSION audit item 9): Load does NOT call ProductImage.GetAll separately
[Fact]
public async Task LoadOrder_UsesIncludePropertiesForImages()
// TEST: StartProcessing button shown when status is Approved/Pending
[Theory, InlineData(SD.StatusApproved), InlineData(SD.StatusPending)]
public async Task StartProcessing_Button_ShownForCorrectStatuses(string status)
// TEST: Ship Order inputs shown only when status is InProcess
[Fact]
public async Task ShipOrderInputs_ShownOnlyWhenInProcess()
// TEST: ShipOrder with empty carrier/tracking shows warning toast
[Fact]
public async Task ShipOrder_EmptyCarrier_ShowsWarningToast()
// TEST: ShipOrder with valid inputs calls UpdateStatus + Save
[Fact]
public async Task ShipOrder_Valid_UpdatesStatusAndSaves()
// TEST (REGRESSION audit item 10): CancelOrder does NOT set StripeConfiguration.ApiKey mid-call
// Verify StripeConfiguration.ApiKey is already set from Program.cs — CancelOrder must not override it
[Fact]
public async Task CancelOrder_DoesNotMutateStripeConfigApiKey()
// TEST: Cancel button NOT shown for Shipped/Cancelled/Refunded orders
[Theory, InlineData(SD.StatusShipped), InlineData(SD.StatusCancelled), InlineData(SD.StatusRefunded)]
public async Task CancelButton_HiddenForFinalStatuses(string status)
}public class UsersIndexTests : TestContext
{
// TEST: Users list renders with Name, Email, Role columns
[Fact]
public async Task OnLoad_ShowsUserList()
// TEST (REGRESSION audit item 13): Null email does not crash search
[Fact]
public async Task SearchWithNullEmail_DoesNotThrowNullReferenceException()
// TEST: GetRoleClass maps correctly to CSS suffix
[Theory]
[InlineData("Admin", "admin")]
[InlineData("Customer", "customer")]
[InlineData("Company", "company")]
[InlineData("Employee", "employee")]
[InlineData(null, "customer")]
public void GetRoleClass_ReturnsCorrectCssSuffix(string role, string expected)
// TEST: IsUserLocked=true → shows Lock badge and "Unlock" button
[Fact]
public async Task LockedUser_ShowsLockBadgeAndUnlockButton()
// TEST: IsUserLocked=false → shows Active badge and "Lock" button
[Fact]
public async Task ActiveUser_ShowsActiveBadgeAndLockButton()
// TEST: ToggleLock — unlocking calls UserManager.SetLockoutEndDateAsync(user, null)
[Fact]
public async Task ToggleLock_Unlock_SetsLockoutEndToNull()
// TEST: ToggleLock — locking calls SetLockoutEndDateAsync with 100 years from now
[Fact]
public async Task ToggleLock_Lock_SetsLockoutEndFarFuture()
// TEST: Company name resolved from companies list (no FK navigation needed in view)
[Fact]
public async Task CompanyName_ResolvedFromCompaniesList()
}public class DashboardTests : TestContext
{
// TEST: Stat cards render correct totals from UnitOfWork
[Fact]
public async Task StatCards_ShowCorrectTotals()
// TEST: Revenue only sums Approved + DelayedPayment orders
[Fact]
public async Task Revenue_OnlySumsApprovedAndDelayedPaymentOrders()
// TEST (REGRESSION audit item 9): No second ProductImage.GetAll call
[Fact]
public async Task OnLoad_NoRedundantProductImageQuery()
// TEST: Warning alert shown when products with no images exist
[Fact]
public async Task WithProductsWithNoImages_ShowsWarningAlert()
// TEST: No warning when all products have images
[Fact]
public async Task WhenAllProductsHaveImages_NoWarningAlert()
// TEST: Recent orders table shows last 10 by descending date
[Fact]
public async Task RecentOrders_ShowsLast10OrdersByDate()
// TEST: UnitOfWork throws → shows error toast, page does not crash
[Fact]
public async Task OnLoadException_ShowsErrorToast()
}Note: MainLayout injects IUnitOfWork, AuthStateProvider, and CartState. Mock all three.
public class MainLayoutTests : TestContext
{
// TEST: Unauthenticated user — cart icon links to /Account/Login
[Fact]
public async Task UnauthenticatedUser_CartLinkGoesToLogin()
// TEST: Authenticated user — cart icon links to /cart
[Fact]
public async Task AuthenticatedUser_CartLinkGoesToCart()
// TEST: Authenticated user — CartState.SetCount called with DB cart item count on init
[Fact]
public async Task OnInit_CartState_SetCountCalledWithCartItemCount()
// TEST: Admin/Employee sees "Admin Portal" link
[Fact]
public async Task AdminUser_SeesAdminPortalLink()
// TEST: Customer role — no "Admin Portal" link
[Fact]
public async Task CustomerUser_NoAdminPortalLink()
// TEST: Mobile menu toggle — ToggleMobileMenu shows/hides drawer
[Fact]
public async Task HamburgerButton_TogglesDrawer()
// TEST (REGRESSION audit item 8): Mobile drawer slides from LEFT (translateX(-100%))
[Fact]
public void MobileDrawer_SlideInAnimation_IsFromLeft()
// TEST (REGRESSION audit item 7): HandleSearch with key "Enter" navigates to /shop?q=
[Fact]
public async Task HandleSearch_EnterKey_NavigatesToShopWithQuery()
// TEST: HandleSearch with non-Enter key does nothing
[Fact]
public async Task HandleSearch_NonEnterKey_DoesNotNavigate()
// TEST: Unauthenticated — shows Login and Register buttons
[Fact]
public async Task Unauthenticated_ShowsLoginAndRegisterButtons()
// TEST: Authenticated — shows user dropdown with Name initial
[Fact]
public async Task Authenticated_ShowsUserDropdown()
}Integration tests use WebApplicationFactory<Program> with a real DI container and either EF Core InMemory or a SQL Server Testcontainer. They do not use bUnit — they test the full Blazor Server request/response pipeline.
// Nooksy.Tests.Integration/Setup/NooksyWebAppFactory.cs
public class NooksyWebAppFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Remove real DbContext, replace with InMemory
var descriptor = services.Single(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
services.Remove(descriptor);
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("NooksyTestDb_" + Guid.NewGuid()));
// Stripe — use a test/mock key so no real charges
services.Configure<StripeSettings>(opts => opts.SecretKey = "sk_test_fake");
// Seed test data
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
DatabaseSeeder.Seed(db);
});
}
}// Nooksy.Tests.Integration/Setup/DatabaseSeeder.cs
public static class DatabaseSeeder
{
public static void Seed(AppDbContext db)
{
if (!db.Categories.Any())
{
db.Categories.AddRange(
new Category { Id = 1, Name = "Fiction", DisplayOrder = 1 },
new Category { Id = 2, Name = "Non-Fiction", DisplayOrder = 2 }
);
}
if (!db.Products.Any())
{
db.Products.Add(new Product
{
Id = 1, Title = "Test Book", Author = "Author A", ISBN = "1234567890123",
Description = "A test book", ListPrice = 19.99, Price = 14.99,
Price50 = 12.99, Price100 = 10.99, CategoryId = 1
});
}
db.SaveChanges();
}
}// Nooksy.Tests.Integration/Admin/CategoryCrudTests.cs
public class CategoryCrudTests : IClassFixture<NooksyWebAppFactory>
{
// TEST: GET /admin/categories returns 200 (when authenticated as Admin)
[Fact]
public async Task GetCategories_Returns200_WhenAdmin()
// TEST: GET /admin/categories returns 302 redirect (when unauthenticated)
[Fact]
public async Task GetCategories_Redirects_WhenUnauthenticated()
// TEST: Category added via UnitOfWork persists in DB
[Fact]
public async Task AddCategory_PersistsToDatabase()
// TEST: Category updated via UnitOfWork reflects in subsequent GetAll
[Fact]
public async Task UpdateCategory_ReflectsInGetAll()
// TEST: Category removed via UnitOfWork no longer appears in GetAll
[Fact]
public async Task RemoveCategory_NoLongerInGetAll()
// TEST: Category.Name MaxLength(30) validation enforced at DB level
[Fact]
public async Task CategoryName_ExceedingMaxLength_ThrowsDbException()
// TEST: Category.DisplayOrder out of range fails DataAnnotations
[Fact]
public async Task CategoryDisplayOrder_OutOfRange_FailsValidation()
}public class ProductCrudTests : IClassFixture<NooksyWebAppFactory>
{
// TEST: Add product with all required fields → persists with correct FK
[Fact]
public async Task AddProduct_WithValidData_PersistsWithCategoryFk()
// TEST: Add ProductImage → associated to correct ProductId
[Fact]
public async Task AddProductImage_AssociatesToProduct()
// TEST: GetAll with includeProperties:"Category,ProductImages" → navigations loaded
[Fact]
public async Task GetAllWithIncludes_LoadsNavigationProperties()
// TEST: Remove product → associated ProductImages cascade deleted (check DB constraint)
[Fact]
public async Task RemoveProduct_CascadeDeletesProductImages()
// TEST: Price fields accept 0.01–1000 range; out-of-range throws
[Theory, InlineData(0), InlineData(1001)]
public async Task ProductPrice_OutOfRange_FailsRangeValidation(double price)
}public class OrderWorkflowTests : IClassFixture<NooksyWebAppFactory>
{
// TEST: Create OrderHeader with Status=Pending → persists
[Fact]
public async Task CreateOrder_PersistsWithPendingStatus()
// TEST: UpdateStatus → Approved changes OrderStatus in DB
[Fact]
public async Task UpdateStatus_ToApproved_ChangesDbRecord()
// TEST: UpdateStatus → Shipped updates Carrier, TrackingNumber, ShippingDate
[Fact]
public async Task UpdateStatus_ToShipped_UpdatesShippingFields()
// TEST: Revenue sum only counts Approved + ApprovedForDelayedPayment statuses
[Fact]
public async Task Revenue_ExcludesPendingAndCancelledOrders()
// TEST: OrderHeader without shipping address fails required validation
[Fact]
public async Task OrderHeader_MissingRequiredFields_FailsValidation()
}public class CartFlowTests : IClassFixture<NooksyWebAppFactory>
{
// TEST: Add item to cart → ShoppingCart record created with correct ProductId + UserId
[Fact]
public async Task AddToCart_CreatesShoppingCartRecord()
// TEST: Increment cart item count → Count updated in DB
[Fact]
public async Task IncrementCartItem_UpdatesCount()
// TEST: Remove cart item → record deleted from DB
[Fact]
public async Task RemoveCartItem_DeletesRecord()
// TEST: GetAll with includeProperties:"Product,Product.ProductImages" → all loaded in one query
[Fact]
public async Task GetCartWithIncludes_LoadsProductAndImages()
// TEST: Price computed via PricingRules for tier 1–49
[Theory, InlineData(1, 14.99), InlineData(49, 14.99)]
public async Task CartPrice_UsesCorrectTier_For1To49(int count, double expectedPrice)
// TEST: Price computed for tier 50–99
[Theory, InlineData(50, 12.99), InlineData(99, 12.99)]
public async Task CartPrice_UsesCorrectTier_For50To99(int count, double expectedPrice)
// TEST: Price computed for tier 100+
[Theory, InlineData(100, 10.99)]
public async Task CartPrice_UsesCorrectTier_For100Plus(int count, double expectedPrice)
}E2E tests boot a real Blazor Server app (via WebApplicationFactory or a local dotnet run), open a real browser (Chromium by default), and assert UI state.
// Nooksy.Tests.E2E/Setup/PlaywrightFixture.cs
public class PlaywrightFixture : IAsyncLifetime
{
public IBrowser Browser { get; private set; } = null!;
public string BaseUrl { get; } = "http://localhost:5100";
public async Task InitializeAsync()
{
var playwright = await Playwright.CreateAsync();
Browser = await playwright.Chromium.LaunchAsync(new()
{
Headless = true,
SlowMo = 0 // increase locally for debugging
});
}
public async Task DisposeAsync() => await Browser.CloseAsync();
}Network interception for Stripe: Use page.RouteAsync("https://api.stripe.com/**", ...) to return mock successful responses and avoid real charges in E2E.
// Nooksy.Tests.E2E/Flows/GuestBrowsingFlow.cs
public class GuestBrowsingFlow : IClassFixture<PlaywrightFixture>
{
// TEST: Home page loads — hero heading visible
[Fact]
public async Task HomePage_HeroHeading_IsVisible()
// TEST: Clicking "Browse Books" navigates to /shop
[Fact]
public async Task HomePageCTA_BrowseBooks_NavigatesToShop()
// TEST: Product cards render on shop page (at least 1 visible)
[Fact]
public async Task ShopPage_ShowsProductCards()
// TEST: Clicking a product card navigates to /product/{id}
[Fact]
public async Task ProductCard_Click_NavigatesToDetailPage()
// TEST: Product detail page shows title, author, pricing table
[Fact]
public async Task ProductDetailPage_ShowsTitleAndPricingTable()
// TEST: Clicking "Add to Cart" as guest redirects to /Account/Login
[Fact]
public async Task AddToCart_AsGuest_RedirectsToLogin()
// TEST: Cart icon for guest links to /Account/Login (not /cart)
[Fact]
public async Task CartIcon_AsGuest_LinksToLogin()
// TEST: Navbar shows Login + Register buttons when unauthenticated
[Fact]
public async Task Navbar_Unauthenticated_ShowsLoginAndRegisterButtons()
// TEST: Mobile nav hamburger opens drawer
[Fact]
public async Task MobileNav_HamburgerClick_OpensDrawer()
// TEST: Search Enter key navigates to /shop?q=<term>
[Fact]
public async Task SearchBar_EnterKey_NavigatesToShopWithQuery()
}// Nooksy.Tests.E2E/Flows/CustomerPurchaseFlow.cs
public class CustomerPurchaseFlow : IClassFixture<PlaywrightFixture>
{
// SETUP: Register a test customer user (or seed one and log in)
// TEST: Authenticated customer — Add to Cart shows success toast
[Fact]
public async Task AddToCart_AsCustomer_ShowsSuccessToast()
// TEST: Cart badge increments after Add to Cart
[Fact]
public async Task AddToCart_CartBadgeIncrements()
// TEST: Cart page (/cart) shows added item
[Fact]
public async Task CartPage_ShowsAddedItem()
// TEST: Cart — quantity +/- updates line total
[Fact]
public async Task CartPage_QuantityChange_UpdatesLineTotal()
// TEST: Cart — Remove item removes row and updates badge
[Fact]
public async Task CartPage_RemoveItem_UpdatesBadge()
// TEST: Checkout page — pre-filled from user profile
[Fact]
public async Task CheckoutPage_PreFillsUserAddress()
// TEST: Checkout page — validation errors shown on empty submit
[Fact]
public async Task CheckoutPage_EmptySubmit_ShowsValidationErrors()
// TEST: Checkout with mocked Stripe → redirected to Order Confirmation
[Fact]
public async Task CheckoutFlow_WithMockedStripe_ReachesConfirmation()
// TEST: Order Confirmation page shows order number and "Continue Shopping" button
[Fact]
public async Task OrderConfirmationPage_ShowsOrderNumber()
// TEST: Cart badge is 0 after successful checkout
[Fact]
public async Task AfterCheckout_CartBadgeIsZero()
}// Nooksy.Tests.E2E/Flows/AdminOrderWorkflowFlow.cs
public class AdminOrderWorkflowFlow : IClassFixture<PlaywrightFixture>
{
// SETUP: Seed a Pending order; log in as Admin
// TEST: Admin dashboard loads with stat cards
[Fact]
public async Task Dashboard_LoadsWithStatCards()
// TEST: Recent orders table shows last order
[Fact]
public async Task Dashboard_RecentOrdersTable_ShowsOrder()
// TEST: Admin can filter orders by status on /admin/orders
[Fact]
public async Task OrdersList_StatusFilter_FiltersCorrectly()
// TEST: Admin clicks View Details → Order Detail page loads
[Fact]
public async Task ViewDetails_NavigatesToOrderDetail()
// TEST: "Start Processing" button changes status badge to Processing
[Fact]
public async Task StartProcessing_UpdatesStatusBadge()
// TEST: Carrier + Tracking Number inputs appear after Processing
[Fact]
public async Task AfterProcessing_ShippingInputsAppear()
// TEST: Shipping without carrier → warning toast
[Fact]
public async Task ShipOrder_NoCarrier_ShowsWarning()
// TEST: Ship order with valid inputs → status badge changes to Shipped
[Fact]
public async Task ShipOrder_Valid_UpdatesStatusToShipped()
// TEST: Cancel button disappears after order is Shipped
[Fact]
public async Task AfterShipped_CancelButtonHidden()
// TEST: Sidebar active state — Products link highlighted on /admin/products
[Fact]
public async Task Sidebar_ActiveState_HighlightsCurrentPage()
// TEST (REGRESSION audit item 5): Breadcrumb shows "Products" not "Portal" on /admin/products
[Fact]
public async Task Breadcrumb_ShowsCorrectSectionName()
}// Nooksy.Tests.E2E/Pages/AdminDashboardTests.cs
public class AdminCrudTests : IClassFixture<PlaywrightFixture>
{
// TEST: Create category → appears in category list
[Fact]
public async Task CreateCategory_AppearsInList()
// TEST: Edit category → name updated in list
[Fact]
public async Task EditCategory_UpdatesInList()
// TEST: Delete category → confirmation modal appears, confirm removes from list
[Fact]
public async Task DeleteCategory_ModalAndRemovesFromList()
// TEST: Create product → appears in product list with cover image
[Fact]
public async Task CreateProduct_AppearsInList()
// TEST: Image uploader — drag zone visible on product upsert
[Fact]
public async Task ProductUpsert_ImageUploader_IsVisible()
// TEST: Delete product → confirmation modal, removed from list
[Fact]
public async Task DeleteProduct_RemovedFromList()
}| Area | Unit | Integration | E2E |
|---|---|---|---|
CartState |
100% | — | — |
UI Components (NooksyButton, NooksyBadge, NooksyModal, NooksyAlert, DataTable, CartBadge, LoadingSpinner, ImageUploader) |
≥ 90% | — | — |
| Admin Pages (all 6) | ≥ 80% | ≥ 70% | Key flows |
| Customer Pages (all 5 — once built) | ≥ 80% | ≥ 60% | Full purchase flow |
| Models / Data annotations | — | ≥ 80% | — |
| Auth / Role guards | Unit (attribute check) | redirect test | login flow |
| Stripe integration | mocked at unit level | mocked | network-intercepted |
| Overall branch coverage target | ≥ 80% | ≥ 65% | Critical paths only |
# .github/workflows/test.yml
name: Nooksy Tests
on: [push, pull_request]
jobs:
unit-and-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '8.x' }
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Unit Tests + Coverage
run: |
dotnet test tests/Nooksy.Tests.Unit \
--collect:"XPlat Code Coverage" \
--results-directory ./coverage \
--no-build --configuration Release
- name: Integration Tests
run: |
dotnet test tests/Nooksy.Tests.Integration \
--no-build --configuration Release
- name: Coverage Report
uses: danielpalme/ReportGenerator-GitHub-Action@5
with:
reports: coverage/**/coverage.cobertura.xml
targetdir: coverage/report
reporttypes: HtmlInline_AzurePipelines;Cobertura
- name: Upload Coverage
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/report
e2e:
runs-on: ubuntu-latest
needs: unit-and-integration
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '8.x' }
- name: Install Playwright Browsers
run: pwsh tests/Nooksy.Tests.E2E/bin/Release/net8.0/playwright.ps1 install --with-deps chromium
- name: Start App Server
run: |
dotnet run --project Nooksy.Client --configuration Release &
sleep 10 # wait for server to start
- name: E2E Tests
run: dotnet test tests/Nooksy.Tests.E2E --no-build --configuration Release
env:
BASE_URL: http://localhost:5000
ConnectionStrings__AppDbContextConnection: "Server=...;..."These helpers reduce boilerplate across test classes.
// Nooksy.Tests.Unit/Helpers/TestHelpers.cs
/// <summary>Sets up bUnit context with a fake authenticated user.</summary>
public static void SetAuthenticatedUser(
TestContext ctx,
string userId = "user-1",
string name = "Test User",
string role = SD.Role_Customer)
{
var authState = Task.FromResult(new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(ClaimTypes.Name, name),
new Claim(ClaimTypes.Role, role),
}, "TestAuth"))));
ctx.Services.AddSingleton<AuthenticationStateProvider>(
new FakeAuthStateProvider(authState));
ctx.Services.AddAuthorizationCore();
}
/// <summary>Creates a mocked IUnitOfWork returning given seed data.</summary>
public static Mock<IUnitOfWork> CreateMockUnitOfWork(
List<Category>? categories = null,
List<Product>? products = null,
List<OrderHeader>? orders = null)
{
var mock = new Mock<IUnitOfWork>();
// Categories
var catRepo = new Mock<ICategoryRepository>();
catRepo.Setup(r => r.GetAll(It.IsAny<Expression<Func<Category, bool>>>(), It.IsAny<string>(), It.IsAny<Func<IQueryable<Category>, IOrderedQueryable<Category>>>()))
.Returns(categories ?? new List<Category>());
mock.Setup(u => u.Category).Returns(catRepo.Object);
// Products
var prodRepo = new Mock<IProductRepository>();
prodRepo.Setup(r => r.GetAll(It.IsAny<Expression<Func<Product, bool>>>(), It.IsAny<string>(), It.IsAny<Func<IQueryable<Product>, IOrderedQueryable<Product>>>()))
.Returns(products ?? new List<Product>());
mock.Setup(u => u.Product).Returns(prodRepo.Object);
// Orders
var orderRepo = new Mock<IOrderHeaderRepository>();
orderRepo.Setup(r => r.GetAll(It.IsAny<Expression<Func<OrderHeader, bool>>>(), It.IsAny<string>(), It.IsAny<Func<IQueryable<OrderHeader>, IOrderedQueryable<OrderHeader>>>()))
.Returns(orders ?? new List<OrderHeader>());
mock.Setup(u => u.orderHeader).Returns(orderRepo.Object);
return mock;
}These tests directly target the 30 bugs found in the audit. They must all pass before merging any fix PR.
| Audit # | Bug | Test Class | Test Method |
|---|---|---|---|
| 4 | NooksyButtonVariant enum ref error |
CategoriesUpsertTests |
SubmitButton_RendersWithoutEnumReferenceError |
| 7 | Search handler empty | MainLayoutTests |
HandleSearch_EnterKey_NavigatesToShopWithQuery |
| 8 | Mobile drawer wrong direction | MainLayoutTests |
MobileDrawer_SlideInAnimation_IsFromLeft |
| 9 | N+1 image query Dashboard | DashboardTests |
OnLoad_NoRedundantProductImageQuery |
| 9 | N+1 image query Products | ProductsIndexTests |
LoadProducts_DoesNotCallProductImageGetAllSeparately |
| 9 | N+1 image query OrderDetail | OrderDetailTests |
LoadOrder_UsesIncludePropertiesForImages |
| 10 | Stripe key set in method | OrderDetailTests |
CancelOrder_DoesNotMutateStripeConfigApiKey |
| 13 | Null email crash | UsersIndexTests |
SearchWithNullEmail_DoesNotThrowNullReferenceException |
| 15 | CartState not hydrated | MainLayoutTests |
OnInit_CartState_SetCountCalledWithCartItemCount |
| 16 | [Parameter] PageSize mutated |
DataTableTests |
PageSizeSelect_DoesNotMutateParameter |
| 17 | [Parameter] IsOpen mutated |
NooksyModalTests |
CloseModal_DoesNotMutateIsOpenParameter |
| 29 | CartBadge memory leak | CartBadgeTests |
Dispose_UnsubscribesFromCartStateOnChange |
| 5 | Breadcrumb hardcoded | AdminOrderWorkflowFlow |
Breadcrumb_ShowsCorrectSectionName |
| 6 | Sidebar active state | AdminOrderWorkflowFlow |
Sidebar_ActiveState_HighlightsCurrentPage |
Total planned tests: ~130 unit · ~35 integration · ~40 E2E = ~205 tests across the three layers.