--- title: Testing strategy — unit, integration, and Storybook suites last-updated: 2026-08-31 tags: - 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 | ## Running tests with Docker (recommended) To run all tests, use Docker Compose, which sets up an isolated test environment: ```bash 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 ```bash # 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 ```bash # 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/`: ```bash 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 ```bash cd web/frontend npm install npm run test:storybook ``` ### Integration tests (API.Specs) ```bash 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 ```bash 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: ```bash # 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**: ```gherkin 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) ```csharp public class LoginHandlerTests { [Fact] public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername() { // Arrange var authRepoMock = new Mock(); var passwordInfraMock = new Mock(); var tokenServiceMock = new Mock(); 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) ```gherkin 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: ```bash # 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](docker.md)) as part of that same compose run, so no separate frontend CI step is needed. To run them locally without Docker: ```bash cd web/frontend npm ci npm run test:storybook npm run test:storybook:playwright ``` ### Running the workflow locally with `act` [`act`](https://github.com/nektos/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): ```bash brew install act ``` **Run the workflow** from the repo root: ```bash 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: ```bash 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: ```bash 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: ```bash docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v ``` ### View container logs ```bash docker compose --env-file web/.env.test -f web/docker-compose.test.yaml logs ``` ## 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