Full end-to-end tests that build and run the real Docker image and exercise the API over HTTP from the outside β no in-process shortcuts.
1
dotnet test tests/Grimoire.E2eTests
The test fixture builds the Docker image from the solution root Dockerfile on first run. This adds ~30β60 seconds on a cold cache.
1
2
3
4
5
# Build once
docker build -t grimoire-api:e2e .
# Point the tests at the pre-built image
GRIMOIRE_TEST_IMAGE=grimoire-api:e2e dotnet test tests/Grimoire.E2eTests
Setting GRIMOIRE_TEST_IMAGE skips the image-build step entirely.
E2E tests use Testcontainers for .NET (DotNet.Testcontainers).
GrimoireApiFixture1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public sealed class GrimoireApiFixture : IAsyncLifetime
{
public async Task InitializeAsync()
{
var imageName = Environment.GetEnvironmentVariable("GRIMOIRE_TEST_IMAGE");
if (string.IsNullOrEmpty(imageName))
{
// Build the image from the solution Dockerfile
_builtImage = new ImageFromDockerfileBuilder()
.WithDockerfileDirectory(CommonDirectoryPath.GetSolutionDirectory(), string.Empty)
.WithDockerfile("Dockerfile")
.WithName($"grimoire-api:e2e-{Guid.NewGuid():N}"[..30])
.Build();
await _builtImage.CreateAsync();
imageName = _builtImage.FullName;
}
_container = new ContainerBuilder()
.WithImage(imageName)
.WithPortBinding(8080, true) // random host port
.WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
.WithEnvironment("Management__AdminApiKey", AdminKey)
.WithEnvironment("Encryption__MasterKey", MasterKey)
.WithEnvironment("Cors__AllowedOrigins__0", "http://localhost:5173")
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilHttpRequestIsSucceeded(r => r.ForPath("/health").ForPort(8080)))
.Build();
await _container.StartAsync();
BaseUrl = $"http://{_container.Hostname}:{_container.GetMappedPublicPort(8080)}";
}
}
Key points:
WithPortBinding(8080, true) lets Docker assign a free port, so tests donβt conflict with each other or with a locally running APIGET /health returns 200 before running any tests| Test | What it verifies |
|---|---|
HealthEndpoint_Returns200 |
Container started successfully; /health is reachable |
FullLifecycle_CreateApp_SetSecret_ConsumeSecret |
Create app β create secret β set value β consumer reads decrypted value |
FullLifecycle_CreateApp_SetConfig_ConsumeConfig |
Create app β create config β consumer reads value with label |
ManagementApi_RequiresAuthentication |
Unauthenticated management request returns 401 |
ConsumerApi_RequiresApiKey |
Unauthenticated consumer request returns 401 |
RotateKey_OldKeyInvalid_NewKeyWorks |
Post-rotation: old key 401, new key 200 |
In the GitHub Actions e2e job, the image is built before the tests run:
1
2
3
4
5
6
7
- name: Build Docker image
run: docker build -t grimoire-api:e2e .
- name: Run E2E tests
env:
GRIMOIRE_TEST_IMAGE: grimoire-api:e2e
run: dotnet test tests/Grimoire.E2eTests --configuration Release
This is equivalent to Option B above β one docker build, then the tests reuse the named image via the env var.
If a test fails, Testcontainers does not automatically remove the container. Find it with:
1
docker ps -a | grep grimoire
Inspect the logs:
1
docker logs <container-id>
The containerβs SQLite database lives in /data/grimoire.db inside the container. You can copy it out:
1
docker cp <container-id>:/data/grimoire.db ./debug.db
1
2
3
4
tests/Grimoire.E2eTests/
βββ Grimoire.E2eTests.csproj β net10.0, Testcontainers 3.*, no project refs
βββ GrimoireApiFixture.cs β IAsyncLifetime fixture, builds/starts container
βββ E2eTests.cs β 6 test methods
The E2E test project has no ProjectReference to any Grimoire source project. It communicates with the API entirely over HTTP. This ensures that the tests verify the production Docker image, not the dev build.