Tests Before Deployment: How I Designed the integration of PHPUnit and Bruno into the CI/CD Pipeline
Hi! In this article, I'll share how I organized the integration of automated tests into the Jenkins pipeline for oninvest-backend.
The task was simple in concept and quite careful in implementation: do not deploy code until PHPUnit and Bruno confirm everything works.
It was framed broadly — "add automated tests before deployment" — with no ready-made recipe. I thought through the entire mechanics from scratch: I proposed the approach with a temporary directory and separate test containers, so that the main project directory stays untouched until tests are green. First on dev environments, with production in mind.
In short: now on every build, the code first goes into a temporary directory, a test environment is brought up, PHPUnit and Bruno are run, and only after success is the code "promoted" into the main directory and deployed. If tests fail, deployment is blocked, but reports are still saved in Jenkins.
1. Why We Needed This
Before tests were introduced, the pipeline was straightforward: build and deploy. Fast, but risky. Especially when changes touched APIs, migrations, or configs. We wanted **fail-fast**: find out something broke before rollout, not after.
What we wanted:
- PHPUnit and Bruno run automatically before deployment.
- Main projectfiles/ directory is untouched until tests pass.
- Bruno runs on the host and hits the test container on port **8081**.
- PHPUnit runs inside the container.
- JUnit reports are always published to Jenkins, even if tests fail.
- There is a way to quickly skip tests via RUN_TESTS, but by default they are enabled.
2. Pipeline Architecture
The pipeline consists of 4 Shell steps:
Git push / Jenkins build
|
v
[1] Rsync code
|
|-- RUN_TESTS=true -> projectfiles-test/
|-- RUN_TESTS=false -> projectfiles/
v
[2] build-test-runner.sh
docker build + up + DB migrations
|
v
[3] run-tests.sh
PHPUnit + Bruno (fail-fast)
|
|-- success -> promote: down -v + mv projectfiles-test -> projectfiles/
|-- failure -> deployment blocked, promote not executed
v
[4] build-{env}-runner
deploy to stage/prod
Key idea: **temporary directory** `projectfiles-test/. Until tests pass, main projectfiles/ remains untouched. Only after a successful run is promote performed: test containers are shut down, and code moves to projectfiles/.
## 3. What Was Already Done on the Development Side
All created new files in the oninvest-deploy-config repository.
File | Purpose |
|---|---|
Dockerfile.test | Test Docker image. Inherits from oninvest-img-php8.2, includes PHPUnit |
docker-compose-test.yml | Brings up oninvest-backend-test on port **8081** and postgres-test on port **5436** |
conf/.env.test | Test env: APP_ENV=test, DB_DATABASE=oninvest_test |
build-test-runner.sh | Builds and starts test environment: checks base image, build, kill/rm old containers, up, migrations, fixtures |
scripts/run-tests.sh | Runs PHPUnit and Bruno. No build or migrate — only test execution |
So I prepared everything so that DevOps only had to integrate it correctly into the Jenkins job.
4. What Needed to Be Done on the Jenkins Side
4.1. Jenkins Plugins
In Manage Jenkins → Plugins, make sure the following plugin is installed:
Plugin | Minimum version | Why |
|---|---|---|
JUnit | 1.60+ | Parses test-reports/*.xml, builds trend graph |
Without it, reports won't show on the build page.
4.2. Bruno CLI on the Target Server
Bruno (bru) runs on the host, not in a container. It needs to send HTTP requests to http://localhost:8081`, where the test backend is running.
Install:
```bash
npm install -g @usebruno/cli
bru --version
```4.3. Four Shell Steps in Jenkins
In the Build section there should be 4 steps. The logic is:
Step | What it does | Condition |
|---|---|---|
1 | Rsync code | If `RUN_TESTS=true — to projectfiles-test/, otherwise directly to projectfiles/ |
2 | Build test environment | build-test-runner.sh: build + up + migrations |
3 | Tests + promote | run-tests.sh: PHPUnit + Bruno, then down -v and mv projectfiles-test → projectfiles |
4 | Deploy | As usual, via build-{env}-runne |
If RUN_TESTS=false, steps 2 and 3 are skipped, code is rsynced directly to projectfiles/ and deployed.
4.4. RUN_TESTS Parameter
In General → This project is parameterized, add a Boolean Parameter:
- Name: RUN_TESTS
- Default Value: ✅ checked
- Description: Enable PHPUnit + Bruno run before deployment
By default, tests are enabled. If you urgently need to ship a hotfix without tests, you can uncheck it. But that's a deliberate exception, not the normal mode.
4.5. Publishing Reports
In Post-build Actions, add:
- Publish JUnit test result report
- Test report XMLs: test-reports/*.xml
Reports are saved always, even if tests fail. Jenkins keeps the last 30 builds and builds the Test Result Trend graph.
5. DevOps Checklist
- [ ] Install Bruno CLI: npm install -g @usebruno/cli, check bru --version
- [ ] Verify Jenkins JUnit plugin 1.60+
- [ ] Update the job: 4 Shell steps
- [ ] Configure RUN_TESTS parameter (Boolean Parameter, default checked)
- [ ] Add Post-build Action: Publish JUnit test result report with path test-reports/*.xml
- [ ] Run the first build and make sure all steps pass
- [ ] Check that the **Test Result Trend** graph appears on the build page
6. The Result
The main result: deployment became safer. Now you can't accidentally ship code with failing tests: the pipeline stops, promote isn't executed, and the main directory remains untouched.
What else matters:
- Isolation. The test environment lives separately: its own containers, its own DB, its own ports.
- Fail-fast. PHPUnit or Bruno fails — deployment is blocked.
- Observability. JUnit reports and trend graph in Jenkins.
- Flexibility. RUN_TESTS lets you skip tests, but they are enabled by default.
- Separation of responsibilities. The developer prepared the test harness, DevOps integrated it into Jenkins.
On dev environments, the scheme already works. The next step is to carefully enable it in production, add more tests, and monitor execution time so feedback remains fast.