17 KiB
title, last-updated, tags
| title | last-updated | tags | |||||
|---|---|---|---|---|---|---|---|
| Testing strategy — unit, integration, and Storybook suites | 2026-08-31 |
|
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:
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:
- Starts a fresh SQL Server instance
- Runs database migrations
- Seeds test data
- Executes all test suites in parallel, including the frontend's Storybook
Vitest and Playwright suites (
frontend.tests, which needs no database) - Exports results to
./test-results/ - 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
.featurefiles
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
└── 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-testsruns only that job (there's currently just the one).-P ubuntu-latest=catthehacker/ubuntu:act-latestpins the runner image act uses to impersonateubuntu-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/amd64is required on Apple Silicon: thesqlserverservice'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
.actrcare needed:generate-env.shcreates its own.env.testwith 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
- Isolation: Each test should be independent and not rely on other tests
- Cleanup: Use fixtures and dispose patterns for resource cleanup
- Mocking: Mock external dependencies in unit tests
- Descriptive Names: Use clear, descriptive test method names
- Arrange-Act-Assert: Follow AAA pattern in unit tests
- Given-When-Then: Follow GWT pattern in BDD scenarios