Most testing guides show you how to write tests. Fewer show you which tests are worth writing for an ecommerce application on a deadline. This is the latter.
Part 9 of the Angular Ecommerce Playbook covers the TechShop testing strategy: what we test, why we test it, and the specific commands to run each layer. We use Vitest for Angular (default in Angular 21), xUnit for .NET, and Playwright for E2E.
📊 Download: Testing Pyramid Diagram (draw.io)
The Testing Pyramid for Ecommerce
╔═══════════╗
║ E2E ║ ← Playwright (5–10 critical paths)
╔═══════════════╗
║ Integration ║ ← .NET EF Core tests (20–30)
╔═════════════════════╗
║ Component / Service ║ ← Angular + .NET unit tests (200+)
╔═══════════════════════════╗
║ Static Analysis ║ ← TypeScript strict, ESLint, dotnet-format
╚═════════════════════════════╝
The business-critical paths for ecommerce:
- Product search → product detail → add to cart
- Cart → checkout (shipping + payment) → order confirmation
- User login → order history → cancel order
- Inventory update pushes to product detail in real-time
These four define our E2E test suite. Everything below is the support structure.
Angular: Vitest Unit Tests
Angular 21 uses Vitest by default. Here’s the testing pattern for our Signal-based services.
Testing the CartService
// libs/cart/data-access/src/lib/cart.service.spec.ts
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { CartService } from './cart.service';
const mockProduct = {
productId: 'prod-1',
slug: 'macbook-pro',
name: 'MacBook Pro 16"',
price: 2499.00,
imageUrl: 'https://example.com/mb.jpg',
qty: 1,
maxQty: 5,
};
describe('CartService', () => {
let service: CartService;
beforeEach(() => {
// Mock localStorage
vi.spyOn(Storage.prototype, 'getItem').mockReturnValue('[]');
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {});
TestBed.configureTestingModule({});
service = TestBed.inject(CartService);
});
afterEach(() => { vi.restoreAllMocks(); });
it('should start with empty cart', () => {
expect(service.items()).toEqual([]);
expect(service.itemCount()).toBe(0);
expect(service.isEmpty()).toBe(true);
});
it('should add a product to cart', () => {
TestBed.runInInjectionContext(() => {
service.add(mockProduct);
});
expect(service.items()).toHaveLength(1);
expect(service.items()[0].productId).toBe('prod-1');
expect(service.itemCount()).toBe(1);
});
it('should increment qty when adding existing product', () => {
TestBed.runInInjectionContext(() => {
service.add(mockProduct);
service.add(mockProduct);
});
expect(service.items()).toHaveLength(1);
expect(service.items()[0].qty).toBe(2);
});
it('should not exceed maxQty', () => {
TestBed.runInInjectionContext(() => {
for (let i = 0; i < 10; i++) service.add(mockProduct);
});
expect(service.items()[0].qty).toBe(5); // maxQty is 5
});
it('should compute subtotal correctly', () => {
TestBed.runInInjectionContext(() => {
service.add(mockProduct);
service.updateQty('prod-1', 3);
});
expect(service.subtotal()).toBe(7497.00); // 2499 * 3
});
it('should remove product from cart', () => {
TestBed.runInInjectionContext(() => {
service.add(mockProduct);
service.remove('prod-1');
});
expect(service.items()).toHaveLength(0);
expect(service.isEmpty()).toBe(true);
});
it('should persist cart to localStorage on add', () => {
TestBed.runInInjectionContext(() => {
service.add(mockProduct);
});
expect(localStorage.setItem).toHaveBeenCalledWith('cart', expect.any(String));
});
});
Testing Angular Components with Angular Testing Library
Angular Testing Library (built on top of the DOM Testing Library) is the recommended way to test Angular 21 components — test behavior, not implementation.
// libs/catalog/feature-product-list/src/lib/product-list.component.spec.ts
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/angular';
import { userEvent } from '@testing-library/user-event';
import { ProductListComponent } from './product-list.component';
import { ProductStore } from '@techshop/catalog/data-access';
import { signal } from '@angular/core';
// Factory for mock ProductStore
function createMockStore(products = []) {
return {
products: signal(products),
isLoading: signal(false),
error: signal(null),
filter: signal({ category: null, page: 1 }),
setCategory: vi.fn(),
nextPage: vi.fn(),
};
}
describe('ProductListComponent', () => {
it('should display loading skeleton when isLoading is true', async () => {
const mockStore = createMockStore([]);
mockStore.isLoading = signal(true);
await render(ProductListComponent, {
providers: [{ provide: ProductStore, useValue: mockStore }],
});
expect(screen.getByTestId('product-grid-skeleton')).toBeTruthy();
});
it('should display empty state when no products', async () => {
await render(ProductListComponent, {
providers: [{ provide: ProductStore, useValue: createMockStore([]) }],
});
expect(screen.getByText('No products match your filters.')).toBeTruthy();
});
it('should render correct number of product cards', async () => {
const products = [
{ id: '1', name: 'MacBook Pro', price: 2499, slug: 'macbook-pro', imageUrl: '', isInStock: true },
{ id: '2', name: 'iPad Air', price: 799, slug: 'ipad-air', imageUrl: '', isInStock: true },
];
await render(ProductListComponent, {
providers: [{ provide: ProductStore, useValue: createMockStore(products) }],
});
expect(screen.getAllByRole('article')).toHaveLength(2);
expect(screen.getByText('MacBook Pro')).toBeTruthy();
expect(screen.getByText('iPad Air')).toBeTruthy();
});
it('should call setCategory when filter changes', async () => {
const user = userEvent.setup();
const mockStore = createMockStore([]);
await render(ProductListComponent, {
providers: [{ provide: ProductStore, useValue: mockStore }],
});
await user.click(screen.getByRole('button', { name: 'Laptops' }));
expect(mockStore.setCategory).toHaveBeenCalledWith('laptops');
});
});
Run Angular tests:
nx test catalog-feature-product-list # Single library
nx affected --target=test # Only changed libraries
nx run-many --target=test --all --parallel=4 # All libraries in parallel
.NET: xUnit Integration Tests
For .NET, the most valuable tests are integration tests that test the full stack from HTTP request to database response using an in-memory or real PostgreSQL test database.
// tests/TechShop.IntegrationTests/Products/GetProductsEndpointTests.cs
using Microsoft.AspNetCore.Mvc.Testing;
using TechShop.Api;
using TechShop.Infrastructure.Persistence;
using Xunit;
using FluentAssertions;
using System.Net.Http.Json;
public class GetProductsEndpointTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client = factory.CreateClient();
[Fact]
public async Task GetProducts_ShouldReturnPaginatedResults()
{
// Act
var response = await _client.GetAsync("/api/products?page=1&pageSize=10");
// Assert
response.Should().HaveStatusCode(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<PagedResult<ProductDto>>();
result.Should().NotBeNull();
result!.Items.Should().HaveCountLessOrEqualTo(10);
result.TotalCount.Should().BeGreaterOrEqualTo(0);
}
[Fact]
public async Task GetProduct_WithInvalidSlug_ShouldReturnProblemDetails()
{
// Act
var response = await _client.GetAsync("/api/products/non-existent-slug");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
// Verify Problem Details format (RFC 9457)
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Status.Should().Be(404);
problem.Title.Should().NotBeNullOrEmpty();
}
[Theory]
[InlineData("laptops")]
[InlineData("tablets")]
[InlineData("accessories")]
public async Task GetProducts_ByCategory_ShouldFilterCorrectly(string category)
{
// Act
var response = await _client.GetAsync($"/api/products?category={category}");
var result = await response.Content.ReadFromJsonAsync<PagedResult<ProductDto>>();
// Assert
response.Should().HaveStatusCode(HttpStatusCode.OK);
result!.Items.Should().AllSatisfy(p =>
p.Category.Slug.Should().Be(category)
);
}
}
Run .NET tests:
dotnet test --filter "Category=Integration"
dotnet test --collect:"XPlat Code Coverage"
Playwright E2E: Critical Purchase Flow
E2E tests are expensive to write and maintain. We write them only for the paths that, if broken, cost money or customers. For TechShop: the purchase flow is the only path that matters.
// apps/shell-e2e/src/add-to-cart.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Purchase Flow', () => {
test.beforeEach(async ({ page }) => {
// Reset test database via API
await page.request.post('/api/test/reset');
});
test('should complete full purchase: search → detail → cart → checkout', async ({ page }) => {
// 1. Navigate to product catalog
await page.goto('/products');
await expect(page).toHaveTitle(/TechShop/);
// 2. Search for a product
await page.getByRole('searchbox', { name: 'Search products' }).fill('MacBook Pro');
await page.keyboard.press('Enter');
await page.waitForURL('**/products?q=MacBook+Pro');
// 3. Click on first product
const firstProduct = page.getByRole('article').first();
const productName = await firstProduct.getByRole('heading').textContent();
await firstProduct.getByRole('link').click();
// 4. Verify product detail page
await expect(page.getByRole('heading', { name: productName! })).toBeVisible();
await expect(page.getByRole('button', { name: 'Add to Cart' })).toBeEnabled();
// 5. Add to cart
await page.getByRole('button', { name: 'Add to Cart' }).click();
// 6. Verify cart badge updated
await expect(page.getByTestId('cart-badge')).toHaveText('1');
// 7. Navigate to cart
await page.getByRole('link', { name: 'Cart' }).click();
await expect(page.getByRole('heading', { name: 'Your Cart' })).toBeVisible();
await expect(page.getByText(productName!)).toBeVisible();
// 8. Proceed to checkout (requires auth)
await page.getByRole('button', { name: 'Proceed to Checkout' }).click();
// Should redirect to login if not authenticated
await expect(page).toHaveURL(/\/login/);
await page.getByLabel('Email').fill('test@techshop.com');
await page.getByLabel('Password').fill('Test@123456');
await page.getByRole('button', { name: 'Sign In' }).click();
// 9. Fill shipping
await expect(page).toHaveURL(/\/checkout/);
await page.getByLabel('Full Name').fill('Jane Smith');
await page.getByLabel('Address').fill('123 Main St');
await page.getByLabel('City').fill('San Francisco');
await page.getByLabel('Postal Code').fill('94105');
await page.getByRole('button', { name: /Next|Continue/i }).click();
// 10. Order confirmation
await expect(page.getByRole('heading', { name: /Order Confirmed/i })).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId('order-id')).toBeVisible();
});
test('should show out-of-stock product correctly', async ({ page }) => {
await page.goto('/products/out-of-stock-test-product');
await expect(page.getByRole('button', { name: 'Add to Cart' })).toBeDisabled();
await expect(page.getByText('Out of Stock')).toBeVisible();
});
test('should update cart badge via SignalR when stock changes', async ({ page, request }) => {
// Navigate to product detail
await page.goto('/products/test-limited-stock-product');
const initialStock = await page.getByTestId('stock-qty').textContent();
// Trigger inventory update via test API (simulates another user buying)
await request.post('/api/test/deplete-stock', {
data: { productId: 'limited-stock-test', qty: 1 }
});
// Assert real-time update received via SignalR
await expect(page.getByTestId('stock-qty')).not.toHaveText(initialStock!);
});
});
Playwright configuration:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './apps/shell-e2e/src',
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
reporter: [['html', { open: 'never' }]],
use: {
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'Mobile Safari', use: { ...devices['iPhone 15'] } },
],
webServer: {
command: 'nx serve shell',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
},
});
Run E2E tests:
npx playwright test # All tests (headless)
npx playwright test --ui # Interactive mode
npx playwright test add-to-cart.spec.ts # Specific file
npx playwright show-report # View last report
Coverage Targets
| Layer | Target | Tool |
|---|---|---|
| Angular services | 85% | Vitest coverage |
| Angular components | 75% | Vitest + Angular Testing Library |
| .NET Application layer | 90% | xUnit |
| .NET Domain layer | 95% | xUnit |
| E2E critical paths | 100% | Playwright |
# Angular coverage report
nx test --coverage --coverageReporter=lcov
# .NET coverage
dotnet test --collect:"XPlat Code Coverage" --results-directory ./coverage
reportgenerator -reports:./coverage/**/coverage.cobertura.xml -targetdir:coverage/report
References
- Angular Testing — angular.dev
- Vitest — vitest.dev
- Angular Testing Library
- Playwright — playwright.dev
- xUnit — xunit.net
- FluentAssertions
- ASP.NET Core Integration Tests
- WebApplicationFactory — Microsoft Docs
- Draw.io Diagram: Testing Pyramid
This is Part 9 of 11 in the Angular Ecommerce Playbook. ← Part 8: Copilot Workflow | Part 10: CI/CD & Automation →