Backend
Testing

Testing

Type of software testing

/images/21-types-of-testing.png

Objective of PhpUnit

Improved code quality

By using PHPUnit, developers can easily write and execute automated tests, leading to more reliable and robust code. This helps catch bugs early on in the development cycle, reducing the chances of them being deployed to production.

Faster feedback loop

With automated testing, developers can get immediate feedback on whether their code changes have introduced any bugs or broken existing functionality. This leads to faster bug detection and resolution, improving the overall efficiency of the development process

Cost-effective

By catching bugs early on, PHPUnit helps reduce the cost of fixing them in later stages of development or even after deployment. Additionally, automated testing reduces the need for manual testing, which can be time-consuming and costly.

Continuous improvement

PHPUnit promotes a continuous improvement mindset by encouraging developers to write tests for new features and bug fixes. This helps maintain code quality over time and ensures that any changes made to the codebase do not introduce bugs or regressions.

Focus:

  • Data Flow
  • Control Follow
  • Branch Coverage
  • Statement Coverage

Concepts

**AAA pattern: Arrange - Act - Assert

Arrange-Act-Assert is a great way to structure test cases. It prescribes an order of operations:

  • Arrange inputs and targets. Arrange steps should set up the test case. Does the test require any objects or special settings? Does it need to prep a database? Does it need to log into a web app? Handle all of these operations at the start of the test.
  • Act on the target behavior. Act steps should cover the main thing to be tested. This could be calling a function or method, calling a REST API, or interacting with a web page. Keep actions focused on the target behavior.
  • Assert expected outcomes. Act steps should elicit some sort of response. Assert steps verify the goodness or badness of that response. Sometimes, assertions are as simple as checking numeric or string values. Other times, they may require checking multiple facets of a system. Assertions will ultimately determine if the test passes or fails.
class SampleTest extends TestCase
{
    protected function setUp(): void {
        parent::setUp();
    }
    
    public fuction testFunction(): void{
        $a = 1;
        $b = 2;
       
        $this->assert($a+$b, 2)
    }
    
    protected function tearDown(): void {
        parent::setUp();
        $this->controller = $this->app->make(CacheAdminController::class);
    }
}

Mocking

Mocking is a process used in unit testing when the unit being tested has external dependencies. The purpose of mocking is to isolate and focus on the code being tested and not on the behavior or state of external dependencies.

$mock = Artisan::spy();
$mock->shouldReceive('call')
    ->with('cache:reset')
    ->once()
    ->andReturn(0);

**Isolation **

Isolation testing is a software testing technique that isolates individual components or units of code from each other to test them independently. This helps to identify and fix defects early in the development process before they can cause problems in other parts of the system.

Configuration

Reference: https://docs.phpunit.de/en/10.5/configuration.html (opens in a new tab)

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.3/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
         backupGlobals="false"
         failOnEmptyTestSuite="false"
         failOnIncomplete="false"
         processIsolation="false"
         cacheResult="false"
         cacheDirectory=".phpunit.cache"
         backupStaticProperties="false">
    <testsuites>
        <testsuite name="tests">
            <directory suffix="Test.php">packages</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory suffix=".php">app</directory>
            <directory suffix=".php">packages</directory>
        </include>
        <exclude>
            <directory suffix=".php">packages/*/*/tests</directory>
            <directory>vendor</directory>
            <directory>tests</directory>
            <directory>config</directory>
            <directory>database</directory>
            <directory>zdocker</directory>
            <directory>public</directory>
            <directory>bootstrap</directory>
            <directory>app/Console</directory>
        </exclude>
    </source>
    <coverage>
        <report>
            <html outputDirectory="build/coverage"/>
        </report>
    </coverage>
    <php>
        <ini name="memory_limit" value="-1"/>
        <!--        <ini name="pcov.enabled" value="1"/>-->
        <env name="APP_KEY" value="AckfSECXIvnK5r28GVIWUAxmbBSjTsmF"/>
        <server name="APP_ENV" value="testing"/>
        <server name="BCRYPT_ROUNDS" value="4"/>
        <server name="MFOX_CACHE_DRIVER" value="array"/>
        <server name="MFOX_MAIL_PROVIDER" value="array"/>
        <server name="MFOX_MAIL_FROM" value="[email protected]"/>
        <server name="MFOX_SESSION_DRIVER" value="array"/>
        <server name="QUEUE_CONNECTION" value="sync"/>
        <server name="TELESCOPE_ENABLED" value="false"/>
    </php>
    <extensions>
        <bootstrap class="Qameta\Allure\PHPUnit\AllureExtension">
            <parameter name="config" value=".config/allure.config.php"/>
        </bootstrap>
    </extensions>
</phpunit>

Commands

Update phpunit.xml

php artisan make:phpunit-xml

Run parallel testing

 
php artisan test -p10 -c phpunit.xml --no-coverage packages/metafox/blog/tests/Unit/
 

Run standalone test

 
./vendor/bin/phpunit -c phpunit.xml --no-coverage packages/metafox/blog/tests/Unit/
 

Run Test with coverage

./vendor/bin/phpunit -c phpunit.xml packages/metafox/blog/tests/Unit/

Mocking

1. Laravel Mocking (opens in a new tab)

2. Mockery (opens in a new tab)

phpFox uses laravel dependencies

namespace MetaFox\Cache\Http\Controllers\Api\v1;
 
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use MetaFox\Platform\Http\Controllers\Api\ApiController;
 
// source
class CacheAdminController extends ApiController
{
    public function clearCache(Request $request): JsonResponse
    {
        $optimize = $request->get('optimize');
 
        if ($optimize) {
            Artisan::call('cache:reset');
            Artisan::call('optimize');
            Artisan::call('queue:restart');
        } else {
            Artisan::call('cache:reset');
        }
 
        return $this->success(['id' => 1], [], __p('cache::phrase.cache_is_cleared_successfully'));
    }
}
// test
class CacheAdminControllerTest extends TestCase
{
    public function testClearCacheOptimize()
    {
        $this->partialMock(\Illuminate\Http\Request::class)
            ->shouldReceive('get')
            ->with('optimize')
            ->andReturn(true);
 
        // @link https://laravel.com/docs/9.x/mocking#facade-spies
        $mock = Artisan::spy();
 
        $mock->shouldReceive('call')
            ->with('cache:reset')
            ->once()
            ->andReturn(0);
 
        $mock->shouldReceive('call')
            ->with('optimize')
            ->once()
            ->andReturn(0);
 
        /** @var JsonResponse $response */
        $response = app()->call([$this->controller, 'clearCache']);
 
        $this->assertInstanceOf(JsonResponse::class, $response);
    }
    
    
    public function testClearCacheWithoutOptimize()
    {
        $this->partialMock(\Illuminate\Http\Request::class)
            ->shouldReceive('get')
            ->with('optimize')
            ->andReturn(false);
 
        $mock = Artisan::spy();
 
        $mock->shouldReceive('call')
            ->with('cache:reset')
            ->once()
            ->andReturn(0);
 
        $mock->shouldReceive('call')
            ->with('optimize')
            ->once()
            ->andReturn(0);
 
        /** @var JsonResponse $response */
        $response = app()->call([$this->controller, 'clearCache']);
 
        $this->assertInstanceOf(JsonResponse::class, $response);
    }
}

TestRepository

reference: packages/metafox/blog/tests/Unit/Repositories/Eloquent/BlogRepositoryTest.php

<?php
 
namespace Tests\TestCases;
 
use ReflectionClass;
use Tests\TestCase;
 
class TestRepository extends TestCase
{
    public function createRepositoryPartialMock(string $repositoryClass)
    {
        $mock = $this->mock($repositoryClass)->makePartial();
        $reflection = new ReflectionClass($repositoryClass);
        $property = $reflection->getProperty('app');
        $property->setAccessible(true);
        $property->setValue($mock, $this->app);
        $mock->makeModel();
        $mock->makePresenter();
        $mock->makeValidator();
        $mock->boot();
 
        return $mock;
    }
}

Browse

<?php
 
class BlogRepositoryTest extends TestRepository
{
    protected function setUp(): void
    {
        parent::setUp();
        $this->repository = $this->createRepositoryPartialMock(Repository::class);
    }
    
    /**
     * @return void
     * @throws \Illuminate\Auth\Access\AuthorizationException
     * @dataProvider provide_viewMode
     * @testdox View blog view=$view, sort=$sort, q=$q
     */
    public function testViewBlogs($view, $sort, $q = null)
    {
        $user = $this->createMockUser(1);
        $owner = $user;
 
        $this->actingAs($user);
 
        $attributes = [
            'q'           => '',
            'user_id'     => 1,
            'view'        => $view,
            'limit'       => 10,
            'sort'        => $sort,
            'sort_type'   => 'desc',
            'category_id' => 0,
        ];
 
        $response = $this->repository->viewBlogs($user, $owner, $attributes);
 
        $this->assertInstanceOf(Paginator::class, $response);
    }
}

Test Permission

<?php
 
class BlogRepositoryTest extends TestRepository
{
    public function testCreateFailureBecauseOfPermission()
    {
        // user post a blog.
        $context = $this->createMockUser();
 
        $this->partialMock(BlogPolicy::class)
            ->shouldReceive('create')
            ->with($context, $context)
            ->andReturn(false);
 
        $this->expectException(\Illuminate\Auth\Access\AuthorizationException::class);
 
        $this->repository->createBlog($context, $context, []);
    }
}

Create

<?php
 
class BlogRepositoryTest extends TestRepository
{
 
    public static function provide_testMethodCreateBlog()
    {
        yield 'User post blog' => [
            fn() => [
                'userId'      => 1,
                'ownerId'     => 1,
                'title'       => 'blog title 01',
                'privacy'     => 0,
                'temp_file'   => null,
                'attachments' => null,
            ], function (Blog $blog) {
                static::assertSame(1, $blog->userId());
                static::assertSame(1, $blog->ownerId());
                static::assertSame('blog title 01', $blog->title);
                static::assertSame(0, $blog->privacy);
            },
        ];
        
    /**
     * @see          \MetaFox\Blog\Repositories\Eloquent\BlogRepository::createBlog
     * @dataProvider provide_testMethodCreateBlog
     */
    public function testCreateBlog($fn, $assertion)
    {
        $data = is_callable($fn) ? $fn() : $fn;
        $guards = ['userId', 'ownerId'];
        extract(Arr::only($data, $guards));
        $attributes = Arr::except($data, $guards);
 
        // disable event, test logic that defined on repository only.
        Event::fake();
 
        // user post a blog.
        $context = $this->createMockUser($userId);
        $owner = $this->createMockUser($ownerId);
 
        $this->be($context);
 
        $this->mock(BlogPolicy::class)->allows(['create' => true, 'view' => true, 'autoApprove'=>true]);
 
        $this->mock(\MetaFox\Platform\Contracts\UploadFile::class)
            ->shouldReceive('getFileId')
            ->with($attributes['temp_file'], true)
            ->atMost()
            ->andReturn($attributes['temp_file']);
 
        $this->mock(\MetaFox\Core\Repositories\AttachmentRepositoryInterface::class)
            ->shouldReceive('updateItemId')
            ->withAnyArgs()
            ->once();
 
        $blog = $this->repository->createBlog($context, $owner, $attributes);
 
        $this->assertInstanceOf(Blog::class, $blog);
 
        if ($assertion) {
            $assertion($blog);
        }
 
        $blog->delete();
    }
}

Test Model Patten

reference: packages/metafox/blog/tests/Unit/Models/BlogTest.php

<?php
 
/**
 * @group resource.content
 */
class BlogTest extends TestContentModel
{
    public function modelName(): string
    {
        return Blog::class;
    }
}
 

Test Resource Pattern

reference: packages/metafox/blog/tests/Unit/Http/Resources/v1/Blog/BlogItemTest.php

<?php
 
 
class BlogItemTest extends TestCase
{
    /**
     * @return array<mixed>
     */
    public function testCreate()
    {
        $model = Model::factory()->create();
        $user = $this->createUser()->assignRole(UserRole::NORMAL_USER);
        $model->refresh();
        $this->assertNotEmpty($model->id);
        $this->assertInstanceOf(User::class, $user);
        
        return [$model, $user];
    }
 
    /**
     * @depends testCreate
     *
     * @param array<mixed> $params
     */
    public function testResource(array $params)
    {
        [$model, $user] = $params;
        $this->be($user);
        $resource = new Resource($model);
        $resource->toJson();
        $this->assertTrue(true);
    }
 
    /**
     * @depends testCreate
     *
     * @param array<mixed> $params
     */
    public function testCollection(array $params)
    {
        [$model, $user] = $params;
        $this->be($user);
        $collection = new ResourceCollection([$model]);
        $collection->toJson();
        $this->assertTrue(true);
    }
}

Test Controller Pattern

reference: packages/metafox/blog/tests/Unit/Http/Controllers/Api/v1/BlogControllerTest.php

<?php
 
 

Test Api Pattern

reference: packages/framework/log/tests/Api/v1/LogApiTest.php

./vendor/bin/phpunit -c phpunit.xml --no-coverage packages/framework/log/tests/Api/v1/LogApiTest.php
 
class LogApiTest extends TestApiFixture
{
    /**
     * @link \Tests\TestCases\TestApiFixture::testRequest
     */
    public static function provideFixtures()
    {
        /*
        * Directory packages/framework/log/tests/fixtures
        */
        return static::loadFixtures([
            //            'api/v1/channel-admin.php',
            'api/v1/log-message-admin.php',
            'api/v1/file-admin.php',
        ]);
    }
}
// file: packages/framework/log/tests/fixtures/api/v1/log-message-admin.php
 
namespace Tests;
 
return function () {
    $state = State::factory([]);
 
    /*
     * @see \MetaFox\Log\Http\Controllers\Api\v1\LogMessageAdminController::index
     */
    yield 'GET api/v1/admincp/log/db/msg' => fn () => [
        'url'      => $state->url('api/v1/admincp/log/db/msg'),
        'method'   => 'GET',
        'user'     => 'admin',
        'data'     => [],
        'status'   => 200,
        'skipTest' => false,
    ];
};

Advance usages

reference:

  • packages/metafox/activity/tests/Api/v1/ActivityApiTest.php
  • packages/metafox/activity/tests/fixtures/api/v1/type-admin.php
./vendor/bin/phpunit -c phpunit.xml --no-coverage packages/metafox/activity/tests/Api/v1/ActivityApiTest.php

Code Coverage

https://cloudcall-s01.phpfox.com/coverage/php/ (opens in a new tab)

Allure Report

https://laravel.com/docs/9.x/mocking#facade-spies (opens in a new tab)

CI Integration

name: unitest-pgsql

concurrency: unitest-pgsql

on:
  push:
    #    branches: [ develop ]
    branches: [ fix/review_blog ]

  pull_request:
    branches: [ ]

  repository_dispatch:
    types: [ phpunit, test-pgsql ]

  workflow_dispatch:
    inputs:
      clean_allure_results:
          type: boolean
          default: false
          description: Clean allure results

env:
  ALLURE_SERVER: https://cloudcall-s01.phpfox.com
  ALLURE_PROJECT: phpunit
  FPM_IMAGE: foxsystem/metafox-fpm
  MFOX_DAT_DRIVER: pgsql
  MFOX_CACHE_DRIVER: array
  ENV_FILE: ".env.testing.example"

jobs:
  test_postgres:
    runs-on: self-hosted
    services:
#      redis:
#        image: bitnami/redis:6.2
#        env:
#          ALLOW_EMPTY_PASSWORD: "yes"
#        options: >-
#          --health-cmd "redis-cli ping"
#          --health-interval 10s
#          --health-timeout 5s
#          --health-retries 5

      pgsql:
        image: foxsystem/postgres:13.2
        env:
          POSTGRES_DB: metafox
          POSTGRES_USER: metafox
          POSTGRES_PASSWORD: 123456
          POSTGRES_HOST_AUTH_METHOD: trust
          POSTGRES_MAX_CONNECTIONS: 500
          POSTGRES_SHARED_BUFFERS: 256MB
        options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
      #needs: [ code_quality ]

    container:
      image: foxsystem/metafox-fpm
      credentials:
        username: foxsystem
        password: ${{  secrets.DOCKER_CONTAINER_REGISTRY_TOKEN }}

    steps:
      - uses: actions/checkout@v3

      - uses: actions/cache@v3
        with:
          path: vendor
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}

      - name: Setup dependencies
        timeout-minutes: 1
        run: |
          cp ${{ env.ENV_FILE }} .env.testing
          cp ${{ env.ENV_FILE }} .env
          mkdir -p storage/framework/cache/data
          chmod -R 777 storage/framework/cache/data
          php -d memory_limit=-1 /opt/bitnami/php/bin/composer install --ignore-platform-reqs --no-interaction

      - name: Installation
        timeout-minutes: 2
        run: |
          php composer metafox:install
          php composer dump-autoload
          php artisan optimize

      - name: PHPUnit without coverage
        timeout-minutes: 5
        run: |
          mkdir -p ./build/allure-results
          export GIT_HASH=$(git rev-parse --short "$GITHUB_SHA")
          export GIT_BRANCH=${GITHUB_REF#refs/heads/}
          php artisan test -p10 -c phpunit.xml --testsuite=tests --no-coverage

      - name: Calculate allure results
        if: ${{ inputs.clean_allure_results }}
        run: |
          curl -X GET "${{env.ALLURE_SERVER}}/allure-docker-service/clean-results?project_id=${{env.ALLURE_PROJECT}}" -H  "accept: */*"
          curl -X GET "${{env.ALLURE_SERVER}}/allure-docker-service/clean-history?project_id=${{env.ALLURE_PROJECT}}" -H  "accept: */*"

      - name: Send allure results
        if: always()
        run: |
          php ./send_results.php