Run your tests in GitLab CI
The GitLab variant, including the docker:dind difference that silently gives you an emulator with no Apex if you translate the GitHub recipe literally.
The GitLab variant of the GitHub Actions recipe. Same idea, one significant difference that will bite you if you translate the GitHub version literally.
What is verified about this page, and what is not
The GitHub recipe was executed as a real workflow run before it was published. This one was
not run on a GitLab runner — we do not have one. What was run, locally, is the mechanically
identical job: a node:22-alpine container talking to a docker:dind service over
DOCKER_HOST, starting the emulator, waiting on the census, and executing the same test file,
which passed 4/4.
So the shell logic, the dind interaction, the corpus-transfer approach and the service hostname
are all verified. GitLab’s own parsing of .gitlab-ci.yml and its service startup semantics
are not. If something here is wrong, that is where it will be, and we would like to hear about
it.
The pipeline
integration-tests:
image: node:22
services:
- docker:27-dind
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: "" # plain TCP to the dind service
SF_BASE_URL: http://docker:8080
before_script:
- apt-get update && apt-get install -y docker.io
script:
# THE CORPUS IS COPIED IN, NOT MOUNTED. See below — this is the one that bites.
- docker create --name fidelic -p 8080:8080 fidelic/emulator --apex-src /mdapi
- docker cp ./mdapi fidelic:/mdapi
- docker start fidelic
# Wait for LOADED, not merely up.
- |
for i in $(seq 1 60); do
docker logs fidelic 2>&1 | grep -q 'listening on' && break
sleep 1
done
CENSUS="$(docker logs fidelic 2>&1 | grep -o '[0-9]* triggers loaded' | head -1)"
if [ -z "$CENSUS" ]; then
echo "the emulator started but loaded NO Apex"; docker logs fidelic; exit 1
fi
echo "org ready: $CENSUS"
- npm test # or pytest, or go test
after_script:
- docker rm -f fidelic || true
The difference that matters: dind has its own filesystem
In GitHub Actions the Docker daemon is the runner’s own, so -v "$PWD/mdapi:/mdapi:ro" mounts
your checked-out metadata straight in. In GitLab with docker:dind, the daemon is a separate
container that cannot see your job’s working directory at all. The same -v flag will appear
to succeed and mount an empty or non-existent path, and the emulator will boot with no Apex.
That failure is quiet in the worst way: the container starts, the API answers, and your tests fail one by one with messages about your code.
Two ways around it, in order of preference:
docker create→docker cp→docker start, as above. The copy crosses into the dind daemon explicitly. This is what was validated.- Bake your metadata into an image and use it as a service:
FROM fidelic/emulator+COPY mdapi /mdapi, push to your GitLab registry, then list it underservices:withcommand: ["--apex-src", "/mdapi"]. More moving parts, but it removes dind entirely and starts faster.
Because the emulator runs inside the dind service rather than on the job container, your tests
reach it at http://docker:8080, not localhost. That is what SF_BASE_URL is set to
above.
The same readiness trap as everywhere else
/services/data/v62.0 returns 404 — it looks like an API version root and is not one.
And a 200 from any generic endpoint proves the server answers, not that your metadata loaded:
Account describes fine on a completely empty org, because it is built in. Gate on the
emulator’s own census —
org ready: 8 triggers loaded
— so a corpus that failed to transfer fails the pipeline immediately, at the step that is actually wrong, instead of producing a green setup and a page of misleading test failures.
Notes
- Parallel jobs each need their own dind service; that is GitLab’s default, one service set per job, so parallel legs are already isolated.
- Between tests,
POST /__emulator__/resetempties the org in milliseconds — cheaper than a fresh container. - The tests themselves are unchanged from the GitHub recipe: they are HTTP calls against
SF_BASE_URL. Seeexample/integration.test.mjs.