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.

docker-compose.yml
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

phpunit.xml
<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 and LOG_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.
tests/TestCase.php
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!

dev.Dockerfile
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.ini
xdebug.mode=coverage

Pcov

dev.Dockerfile
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.ini
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.

composer.json
{
  "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 :)

.husky/pre-push
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.

.gitlab-ci.yml
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).

.env.test
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

Code Code Code