14. Testing. PHPUnit or Pest. Laravel Action testing. Test coverage.
Video version
(Leave your feedback on YouTube)
TL;DR
A set of tips for working with tests. Some tips relate specifically to the Action approach.
Maintenance is MUCH more expensive than development. Development with tests adds +20% to the time. Maintenance without tests is a cat in the bag.
Write tests. Even if you are creating an MVP. If you are a hired developer, chances are you won’t be allowed to rewrite the MVP. But you will be forced to maintain that ASAP code until the end of the project or until you quit. And this is usually the core code.
Pest vs PHPUnit - this is a matter of syntax, and that's all. It doesn’t matter what you choose for the project. But please use one thing. The developer should not have to make a choice every time.
Tests are also code. They also need to be maintained. Therefore, code style rules must be adhered to. And code complexity standards as well!
Test coverage is not a perfect metric, but it gamifies development - I recommend using it.
Feature
in Laravel are integration tests - all tests that test the final method.$this->get()
,$this->post()
,$this->getJson()
,$this->postJson()
...
Unit test - tests a specific class: action, filter, controller, form, etc.
Never test private methods. Even if you find libraries for this.
Never use in-memory databases (sqlite). Always test with the databases you work with, using the same versions!
For Action, do not be afraid to add decorators if it simplifies testing. For example, for complex AsController, test access to the method in feature tests, then add AsObject (even if it is not used as an object) and test the implementation in a unit test.
AsFake is a very useful decorator. Mock Action will make testing Action that use other Actions very comfortable. But this works if each Action is tested. And in general, mock is a very useful thing.
"One test - one condition" is a simple rule that will make your tests really concise.
Test one functionality at a time. Do not call the data save method before getting it. Use factories and mocks.
Tests do not replace Manual QA.
Test DB in Docker
Do not use in-memory
DB. This will either limit your ability to use the features of the main DB or force you to write many specific conditions.
Creating a test database in Docker will take 5 minutes.
db:
image: &db-image postgis/postgis:16-3.4-alpine
restart: unless-stopped
container_name: bh-db
environment:
POSTGRES_DB: ${DB_DATABASE}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_PASSWORD: ${DB_PASSWORD}
ports:
- 5432:5432
volumes:
- db_data:/var/lib/postgresql/data
networks:
- bh-db-network
healthcheck:
test: pg_isready -U ${DB_USERNAME} -d ${DB_DATABASE}
interval: 3s
timeout: 10s
retries: 3
db-test:
image: *db-image
container_name: bh-db-test
command: postgres -c 'max_connections=250' -c 'max_locks_per_transaction=128'
restart: unless-stopped
environment:
POSTGRES_DB: test_bh_db
POSTGRES_USER: test_bh_user
POSTGRES_PASSWORD: test_bh_user_password
networks:
- bh-db-network
&db-image
- reference to the main database image version.*db-image
- use the same version as the reference.POSTGRES_DB
,POSTGRES_USER
,POSTGRES_PASSWORD
- can be hardcoded, your test data is unlikely to interest anyone, and they are isolated in the container.command: postgres -c 'max_connections=250' -c 'max_locks_per_transaction=128'
- increase the number of simultaneous connections to the Postgres DB and the number of transactions. Important for the comfortable running of parallel tests.
Laravel config
<source>
<include>
<directory>./Modules/</directory>
</include>
<exclude>
<directory>./Modules/**/Routes</directory>
<directory>./Modules/**/Tests</directory>
<directory>./Modules/**/Database</directory>
</exclude>
</source>
<php>
<env name="LOG_CHANNEL" value="stderr"/>
<env name="DB_CONNECTION" value="pgsql"/>
<env name="DB_HOST" value="bh-db-test"/>
<env name="DB_DATABASE" value="test_bh_db"/>
<env name="DB_PORT" value="5432"/>
<env name="DB_USERNAME" value="test_bh_user"/>
<env name="DB_PASSWORD" value="test_bh_user_password"/>
</php>
- In the
php
section, database settings andLOG_CHANNEL
- tests write to your logs by default. - In the
source
section, directories to scan for test coverage. Add and exclude directories there - depending on the structure of the project.
use RefreshDatabase;
private bool $dropTypes = true;
RefreshDatabase
- each test runs in a transaction that is rolled back at the end of the test. The most productive way to isolate tests from each other.dropTypes
- removes custom data types during migration, mandatory for Postgres.- If you use views, set
dropViews
to true.
Test coverage setup
Test coverage is a heavy operation and takes a lot of time. It does not need to be set up on CI/CD. But it should be allowed locally. It will help the developer by reminding them that something is not tested.
XDebug.
Swoole doesn't work with Xdebug. In a strange way it falls and conflicts!
COPY php/xdebug.ini /usr/local/etc/php/conf.d
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS linux-headers autoconf linux-headers && \
pecl install -o -f xdebug && \
apk del .build-deps
Add to php ini Xdebug coverage mode
xdebug.mode=coverage
Pcov
COPY php/pcov.ini /usr/local/etc/php/conf.d
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS linux-headers && \
pecl install pcov && docker-php-ext-enable pcov && \
apk del .build-deps
Add to php ini enable pcov and set root directory
pcov.enabled = 1
pcov.directory = /var/www
CI/CD
I recommend setting up running tests as a composer command. This will allow you to add a command to run tests.
{
"scripts": {
"test": [
"@php artisan config:clear",
"@php artisan test --parallel --stop-on-failure --stop-on-error"
]
}
}
Also, run tests on the pre-push
git hook. This will reduce the load on the build server. And in fact, it is a convenience for developers who will know that their failures are not seen by the leader :)
docker exec -t bh-app npx validate-branch-name
docker exec -t bh-app composer test
And definitely as a CI/CD step.
Here, the popular option is to split CI/CD into different stages. The first stage is the installation of dependencies. In the second stage, we run tests and static analysis in parallel in different jobs. This sounds okay, but in practice, composer dependencies are usually up to 100 MB. And the Docker container is over 100 MB. And by "saving" time on downloading dependencies, we deploy Docker at least 3 times, and the containers in it at least 2 times (they cannot be passed as a cache or artifact). Therefore, I recommend running both tests and static analysis in one job. It's not as pretty, but much more productive.
stages:
- test
default:
image: docker:26.1.3-alpine3.19
services:
- docker:26.1.3-dind
test:
stage: test
before_script:
- cp .env.example .env
- echo UID=$(id -u) >> .env
- echo GID=$(id -g) >> .env
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker compose up app db-test -d --no-deps
- docker-compose exec app composer config -- gitlab-token.gitlab.com gitlab-ci-token "${CI_JOB_TOKEN}"
- docker compose exec app composer install --no-scripts
- docker compose exec app php artisan key:generate
script:
- docker compose exec app php artisan insights --no-interaction
- docker compose exec app vendor/bin/phpstan
- docker compose exec app php artisan migrate --seed --env=test
- docker compose exec app php artisan migrate:refresh --env=test
- docker compose exec app composer test
Note the migration commands here. You won’t find this in the recommendations, but I personally advise it. This check will immediately show migrations with broken down methods (if you use them). And check seed.
One problem is that it must be run in a separate .env file with minimal configuration (connection to the database). Be sure not to call env `testing' - because you will have to configure all the configs (for tests actually).
APP_NAME=Laravel
APP_ENV=test
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
DB_CONNECTION=pgsql
DB_HOST=bh-db-test
DB_PORT=5432
DB_DATABASE=test_bh_db
DB_USERNAME=test_bh_user
DB_PASSWORD=test_bh_user_password