15 KiB
Testing
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:
- 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)
The easiest way to run all tests is using 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:
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)
Feature slice unit tests
Each feature slice has its own test project, covering its command/query handlers:
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
# Or run all of them at once via the solution:
for proj in Features/*.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
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
├── Steps/
│ ├── AuthSteps.cs # Step definitions for the Auth features
│ └── ApiGeneralSteps.cs # Shared/general step definitions
├── Mocks/
│ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher
│ └── MockEmailProvider.cs # Substitutes Infrastructure.Email's 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
│ ├── ConfirmUserHandlerTests.cs
│ ├── ResendConfirmationEmailHandlerTests.cs
│ ├── RefreshTokenHandlerTests.cs
│ ├── UpdateUsernameHandlerTests.cs
│ ├── UpdateEmailHandlerTests.cs
│ ├── UpdatePasswordHandlerTests.cs
│ ├── UpdateProfileHandlerTests.cs
│ └── DeleteAccountHandlerTests.cs
├── Queries/
│ ├── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
│ ├── GetAllUsersHandlerTests.cs
│ └── GetUserByIdHandlerTests.cs
├── Services/
│ ├── TokenServiceRefreshTests.cs
│ └── TokenServiceValidationTests.cs
└── TestSupport/ # shared test fixtures/helpers
Each of the other slices (Features.Breweries.Tests, Features.Emails.Tests,
Features.PhotoUpload.Tests) follows the same shape: a Commands//Queries/
folder with one test file per handler (Features.PhotoUpload.Tests also adds
a validator test file alongside its handler test).
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.
Earlier versions of this workflow used
up --abort-on-container-exit, which stops the entire stack the instant
any watched container exits. That's wrong here: the one-shot
database.migrations/database.seed jobs are expected to exit 0 partway
through, and even scoping the flag to just the three test services doesn't
help, since frontend.tests (no database dependency) reliably finishes
before api.specs/unit.tests even start, aborting them prematurely.
up -d + wait avoids both races by not tying container teardown to any
single container's exit.
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