🐳 End-to-End Tests

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.

Table of contents

Requirements


Running

Option A β€” build the image automatically (default)

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.

Option B β€” pre-build the image (faster in CI)

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.


How it works

E2E tests use Testcontainers for .NET (DotNet.Testcontainers).

GrimoireApiFixture

1
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:


Test coverage (6 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

CI integration

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.


Debugging failures

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

Project structure

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.