Files
the-biergarten-app/docs/web/testing.md

18 KiB

title, last-updated, tags
title last-updated tags
Testing strategy — unit, integration, and Storybook suites 2026-08-31
testing
ci
xunit
reqnroll
storybook

This document describes the testing strategy and how to run tests for The Biergarten App.

Overview

The project uses a multi-layered testing approach across backend and frontend:

Suite Purpose
API.Specs BDD integration tests using Reqnroll (Gherkin), run against a live, seeded database
Features.*.Tests One unit test project per backend feature slice (Features.Users.Tests, Features.Breweries.Tests, Features.Emails.Tests, Features.PhotoUpload.Tests), covering that slice's command/query handlers, with dependencies mocked via Moq (no real database required)
Storybook Vitest project Browser-based interaction tests for shared website stories
Storybook Playwright suite Browser checks against Storybook-rendered components

To run all tests, use Docker Compose, which sets up an isolated test environment:

docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up -d
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml wait api.specs unit.tests frontend.tests

This command:

  1. Starts a fresh SQL Server instance
  2. Runs database migrations
  3. Seeds test data
  4. Executes all test suites in parallel, including the frontend's Storybook Vitest and Playwright suites (frontend.tests, which needs no database)
  5. Exports results to ./test-results/
  6. Returns once all three test containers have exited

wait blocks until api.specs, unit.tests, and frontend.tests have all stopped, regardless of which one finishes first. Long-running services (sqlserver, seaweedfs) and the one-shot database.migrations / database.seed jobs keep running in the background alongside them and are torn down separately (see below); wait doesn't touch them.

View test results

# List test result files
ls -la test-results/

# View specific test results
cat test-results/api-specs/results.trx
cat test-results/Features.Users.Tests.trx
cat test-results/Features.Breweries.Tests.trx
cat test-results/Features.Emails.Tests.trx
cat test-results/Features.PhotoUpload.Tests.trx
cat test-results/frontend/vitest-storybook.xml
cat test-results/frontend/playwright-storybook.xml

Clean up

# Remove test containers and volumes
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v

Running tests locally

You can run individual test projects locally without Docker:

Feature slice and shared unit tests

Each feature slice has its own test project, covering its command/query handlers. Shared cross-cutting code (e.g. MediatR pipeline behaviors) has its own test project under Shared/:

cd web/backend
dotnet test Features/Features.Users.Tests/Features.Users.Tests.csproj
dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj
dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj
dotnet test Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj
dotnet test Shared/Shared.Application.Tests/Shared.Application.Tests.csproj

# Or run all of them at once via the solution:
for proj in Features/*.Tests Shared/*.Tests; do dotnet test "$proj"; done

Requirements:

  • No database required (handlers are tested with their repository/service dependencies mocked via Moq)

Frontend Storybook tests

cd web/frontend
npm install
npm run test:storybook

Integration tests (API.Specs)

cd web/backend
dotnet test API/API.Specs/API.Specs.csproj

Requirements:

  • SQL Server instance running
  • Database migrated and seeded
  • Environment variables set (DB connection, JWT secret)

Purpose:

  • Verifies shared stories such as form fields, submit buttons, navbar states, toasts, and the theme gallery
  • Runs in browser mode via Vitest and Storybook integration

Frontend Playwright Storybook tests

cd web/frontend
npm install
npm run test:storybook:playwright

Requirements:

  • Storybook dependencies installed
  • Playwright browser dependencies installed
  • The command will start or reuse the Storybook server defined in playwright.storybook.config.ts

Running Storybook tests with a visible browser

Both of the above run headless by default. To watch the browser while debugging a failure:

# Playwright Storybook suite
npx playwright test -c playwright.storybook.config.ts --headed
# or the interactive step-through runner
npx playwright test -c playwright.storybook.config.ts --ui

# Vitest/story play-function suite
npx vitest run --project storybook --browser.headless=false
# or Vitest's own UI (drop `run` for watch mode)
npx vitest --project storybook --browser.headless=false --ui

The default (headless: true) is set in web/frontend/vite.config.ts.

Test coverage

Current coverage

Features.Users.Tests:

  • User registration with validation
  • User login with JWT token generation
  • Password hashing and verification (Argon2id)
  • Email confirmation and confirmation-email resend
  • Refresh token exchange
  • JWT token generation, validation, and claims handling
  • Invalid credentials and 404 error responses (via API.Specs)
  • Account management: username, email, password, and profile updates, and account deletion (blocked while dependent posts, comments, photos, or follows exist)
  • User get-by-id/get-all queries

Features.Breweries.Tests:

  • Brewery create/update/delete commands and get-by-id/get-all queries
  • Brewery location queries: all locations, and locations within a given range of a coordinate

Features.Emails.Tests:

  • Registration and resend-confirmation email dispatch handlers

Features.PhotoUpload.Tests:

  • Upload command handler: storage key construction, content type passthrough, and photo persistence via the mocked storage provider and repository
  • Upload validator: min/max file size bounds, and PNG/JPEG/WebP signature checks (including rejection of files with a mismatched or missing signature)

Frontend UI Coverage:

  • Shared submit button states
  • Form field happy path and error presentation
  • Navbar guest, authenticated, and mobile behavior
  • Theme gallery rendering across Biergarten themes
  • Toast interactions and themed notification display

Planned coverage

  • Password reset functionality
  • Beer post operations
  • User follow/unfollow
  • Frontend route integration coverage beyond Storybook stories

Testing frameworks and tools

xUnit

  • Primary unit testing framework
  • Used for handler and service layer tests
  • Supports parallel test execution

Reqnroll (Gherkin/BDD)

  • Behavior-driven development framework
  • Used for API integration tests
  • Human-readable test scenarios in .feature files

FluentAssertions

  • Expressive assertion library
  • Makes test assertions more readable
  • Used across all test projects

Moq

  • Mocking framework for .NET
  • Used in Service layer tests
  • Enables isolated unit testing

Test structure

API.Specs (integration tests)

API.Specs/
├── Features/
│   ├── Registration.feature            # Registration scenarios
│   ├── Login.feature                   # Login scenarios
│   ├── Confirmation.feature            # Email confirmation scenarios
│   ├── ResendConfirmation.feature      # Resend-confirmation scenarios
│   ├── TokenRefresh.feature            # Refresh token scenarios
│   ├── AccessTokenValidation.feature   # Protected endpoint access scenarios
│   ├── NotFound.feature                # 404 handling
│   └── PublicUserProfile.feature       # Public profile & authenticated account scenarios
├── Steps/
│   ├── AuthSteps.cs                    # Step definitions for the Auth features
│   ├── UserSteps.cs                    # Step definitions for the public profile & user-account features
│   └── ApiGeneralSteps.cs              # Shared/general step definitions
├── Mocks/
│   ├── MockEmailDispatcher.cs          # Substitutes Features.Emails' IEmailDispatcher
│   ├── MockEmailProvider.cs            # Substitutes Features.Emails' IEmailProvider
│   └── MockFileStorageProvider.cs      # Substitutes Features.ImageUploads' IFileStorageProvider
└── TestApiFactory.cs                   # Test server setup (swaps in the mocks above)

Example Feature:

Feature: User Registration
  As a user
  I want to register
  So that I can access the platform

Scenario: Successful user registration
  Given I have valid registration details
  When I register a new account
  Then I should receive a JWT token
  And my account should be created

Features.Users.Tests

Features.Users.Tests/
├── Commands/
│   ├── RegisterUserHandlerTests.cs
│   ├── RegisterUserValidatorTests.cs
│   ├── ConfirmUserHandlerTests.cs
│   ├── ResendConfirmationEmailHandlerTests.cs
│   ├── RefreshTokenHandlerTests.cs
│   ├── RefreshTokenValidatorTests.cs
│   ├── LoginValidatorTests.cs          # tests LoginCommand's validator
│   ├── UpdateUsernameHandlerTests.cs
│   ├── UpdateUsernameValidatorTests.cs
│   ├── UpdateEmailHandlerTests.cs
│   ├── UpdateEmailValidatorTests.cs
│   ├── UpdatePasswordHandlerTests.cs
│   ├── UpdatePasswordValidatorTests.cs
│   ├── UpdateProfileHandlerTests.cs
│   ├── UpdateProfileValidatorTests.cs
│   └── DeleteAccountHandlerTests.cs
├── Queries/
│   ├── LoginHandlerTests.cs            # tests LoginCommand, despite the folder name
│   ├── GetAllUsersHandlerTests.cs
│   ├── GetPublicUserProfileByIdHandlerTests.cs
│   └── GetUserByIdHandlerTests.cs
├── Services/
│   ├── TokenServiceRefreshTests.cs
│   └── TokenServiceValidationTests.cs
└── TestSupport/                        # shared test fixtures/helpers

Features.Breweries.Tests and Features.PhotoUpload.Tests follow the same shape: a Commands//Queries/ folder with one test file per handler, plus a *ValidatorTests.cs file beside each handler whose command or query has a FluentValidation validator (Features.Breweries.Tests has one of these in Queries/ as well, for the nearby-search query). Features.Emails.Tests differs: its handlers respond to MediatR notifications, so its test files live under Notifications/.

Writing tests

Unit test example (xUnit)

public class LoginHandlerTests
{
    [Fact]
    public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
    {
        // Arrange
        var authRepoMock = new Mock<IAuthRepository>();
        var passwordInfraMock = new Mock<IPasswordInfrastructure>();
        var tokenServiceMock = new Mock<ITokenService>();
        var handler = new LoginHandler(authRepoMock.Object, passwordInfraMock.Object, tokenServiceMock.Object);
        // ...set up mocks for a known user/credential...

        // Act
        var result = await handler.Handle(new LoginCommand("testuser", "password123"), CancellationToken.None);

        // Assert
        result.Should().NotBeNull();
        result.AccessToken.Should().NotBeNullOrEmpty();
    }
}

Integration test example (Reqnroll)

Scenario: User login with valid credentials
  Given a registered user with username "testuser"
  When I POST to "/api/auth/login" with valid credentials
  Then the response status should be 200
  And the response should contain a JWT token

Continuous integration

The .github/workflows/tests.yml GitHub Actions workflow runs on every push and pull request to main. It runs the same test Docker Compose configuration used locally, so backend and frontend tests execute in the same containerized environment in CI as they do on a developer machine:

# CI/CD command
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml build
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up -d
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml wait api.specs unit.tests frontend.tests
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v

The workflow inspects the exit code of each test container (test-env-api-specs, test-env-unit-tests, test-env-frontend-tests) after wait returns and fails the job if any of them is non-zero. ./test-results/ is uploaded as a build artifact regardless of outcome.

up -d + wait keeps container teardown independent of any single container's exit. The one-shot database.migrations/database.seed jobs exit 0 partway through the run, and frontend.tests (which has no database dependency) reliably finishes before api.specs/unit.tests start; because wait blocks on all three test containers, neither early exit ends the run prematurely.

Exit codes:

  • 0 - All tests passed
  • Non-zero - Test failures occurred

The frontend's Storybook Vitest and Playwright suites run inside the frontend.tests container (see Docker Guide) as part of that same compose run, so no separate frontend CI step is needed. To run them locally without Docker:

cd web/frontend
npm ci
npm run test:storybook
npm run test:storybook:playwright

Running the workflow locally with act

act replays .github/workflows/*.yml on your own machine using Docker, so you can reproduce a CI run (or a fix for one) without pushing a branch.

Install (macOS):

brew install act

Run the workflow from the repo root:

act push \
  -j containerized-tests \
  -W .github/workflows/tests.yml \
  -P ubuntu-latest=catthehacker/ubuntu:act-latest \
  --container-architecture linux/amd64
  • -j containerized-tests runs only that job (there's currently just the one).
  • -P ubuntu-latest=catthehacker/ubuntu:act-latest pins the runner image act uses to impersonate ubuntu-latest. Without it, act's first run prompts interactively to choose a default image size, which hangs/fails under a non-interactive shell.
  • --container-architecture linux/amd64 is required on Apple Silicon: the sqlserver service's image is amd64-only, and act needs to know to emulate that platform for the whole job container, not just that one service.
  • No secrets or .actrc are needed: generate-env.sh creates its own .env.test with freshly randomized values, same as in real CI.

Known act-only limitation: the "Upload test results" step (actions/upload-artifact@v4) fails locally with Unable to get the ACTIONS_RUNTIME_TOKEN env variable. act doesn't provide a real Actions artifact backend, so this step, and only this step, is expected to fail under act even when everything else passes. It works normally on GitHub-hosted runners.

Stale local state: docker-compose.test.yaml uses fixed container names and named volumes (sqlserverdata-test, seaweedfsdata-test) scoped to the web compose project. If a previous local run (via act or a manual docker compose invocation) didn't get torn down, its SQL Server volume can persist with an old SA_PASSWORD baked in, while a fresh .env.test generates a new one each run, causing Login failed for user 'sa' errors that look like a test bug but are really a leftover volume. Clear it before re-running:

docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v

Troubleshooting

Tests failing due to database connection

Ensure SQL Server is running and environment variables are set:

docker compose --env-file web/.env.test -f web/docker-compose.test.yaml ps

Port conflicts

If port 1433 is in use, stop other SQL Server instances or modify the port in docker-compose.test.yaml.

Stale test data

Clean up test database:

docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v

View container logs

docker compose --env-file web/.env.test -f web/docker-compose.test.yaml logs <service-name>

Best practices

  1. Isolation: Each test should be independent and not rely on other tests
  2. Cleanup: Use fixtures and dispose patterns for resource cleanup
  3. Mocking: Mock external dependencies in unit tests
  4. Descriptive Names: Use clear, descriptive test method names
  5. Arrange-Act-Assert: Follow AAA pattern in unit tests
  6. Given-When-Then: Follow GWT pattern in BDD scenarios