Предисловие

Говоря о разработке сайтов с использованием CMS 1C Bitrix вопрос покрытия тестами поднимается редко. Главная причина в том, что большинство проектов обходится штатным функционалом, который предоставляется системой - его сложно (да и, в общем-то, незачем) тестировать.

Но со временем проект разрастается, появляется необходимость интеграции со сторонними сервисами и службами (платежные системы, API служб доставки и другие), либо же разрабатывается все более и более специализированный функционал. И чем дальше, тем больше объем кода, контроль за которым лежит уже на разработчике.
Это и является предпосылкой для внедрения в CMS механизма тестирования.

Процесс подготовки окружения к написанию тестов состоит из нескольких шагов:

  1. установить Composer;

  2. настройка Bitrix для работы с Composer;

  3. установить PHPUnit;

  4. настроить PHPUnit для работы с Bitrix.

Composer

Установка

Установку composer проводим по инструкции.

cd ~
curl -sS https://getcomposer.org/installer -o composer-setup.php
HASH=Хеш файла
php -r "if (hash_file('SHA384', 'composer-setup.php') === '$HASH') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"

По завершению - в консоли видим сообщение, что установщик скачан успешно:

Installer verified

Переходим к установке:

sudo php composer-setup.php --install-dir=/usr/local/bin --filename=composer

По окончанию видим сообщение о успешной установке:

Output
All settings correct for using Composer
Downloading...

Composer (version 2.1.9) successfully installed to: /usr/local/bin/composer
Use it: php /usr/local/bin/composer

Всю работу с зависимостями организуем в каталоге local. Инициализируем проект:

cd local
composer init

Указываем нужные параметры, подтверждаем.
По завершению у нас появляется файл /local/composer.json с примерно таким содержимым:

{
    "name": "myproject/website",
    "type": "project",
    "authors": [
        {
            "name": "Andriy Kryvenko",
            "email": "krivenko.a.b@gmail.com"
        }
    ]
}

Теперь скажем битриксу, что надо использовать сторонние пакеты, установленные через Composer.
Открываем файл /local/php_interface/init.php (создаем, если не существует) и подключаем файл autoload:

<?php

include_once(__DIR__.'/../vendor/autoload.php');

После этого скрываем каталог /local/vendor/ от системы контроля версий. В файл .gitignore добавляем /local/vendor/*

PHPUnit

Переходим к установке PHPUnit. На прод сервере он нам не нужен, поэтому устанавливаем только в качестве dev зависимости и создаем конфиг-файл. Для этого выполняем в командной строке:

composer require --dev phpunit/phpunit ^9.0
./vendor/bin/phpunit --generate-configuration

В dev-зависимости был добавлен phpunit, а так же создан файл /local/phpunit.xml
Помимо непосредственно PHPUnit, для более приятного вида результатов тестов я использую пакет sempro/phpunit-pretty-print.

composer require --dev sempro/phpunit-pretty-print ^1.4

Теперь нужно создать файл, который будет использоваться при тестировании для инициализации ядра продукта. Назовем его /local/tests/bootstrap.php

<?php

define("NOT_CHECK_PERMISSIONS", true);
define("NO_AGENT_CHECK", true);

$_SERVER["DOCUMENT_ROOT"] = __DIR__ . '/../..';

require($_SERVER["DOCUMENT_ROOT"]."/bitrix/modules/main/include/prolog_before.php");

Настроим PHPUnit, чтобы использовался наш файл инициализации и наш класс декорации результатов. Откроем файл /local/phpunit.xml и приведем его к следующему виду:

phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd"
         bootstrap="tests/bootstrap.php"
         cacheResultFile=".phpunit.cache/test-results"
         colors="true"
         printerClass="Sempro\PHPUnitPrettyPrinter\PrettyPrinterForPhpUnit9"
         executionOrder="random"
         forceCoversAnnotation="true"
         beStrictAboutCoversAnnotation="true"
         beStrictAboutOutputDuringTests="true"
         beStrictAboutTodoAnnotatedTests="true"
         convertDeprecationsToExceptions="true"
         failOnRisky="true"
         failOnWarning="true"
         verbose="true">

    <php>
        <ini name="memory_limit" value="-1"/>
        <ini name="display_errors" value="true"/>
    </php>

    <testsuites>
        <testsuite name="default">
            <directory suffix="Test.php">tests</directory>
            <exclude>tests/Stubs</exclude>
            <exclude>tests/Request</exclude>
            <exclude>tests/Response</exclude>
        </testsuite>
    </testsuites>

    <coverage cacheDirectory=".phpunit.cache/code-coverage"
              processUncoveredFiles="true">
        <include>
            <directory suffix=".php">classes</directory>
        </include>
    </coverage>
</phpunit>

И добавим команду для быстрого запуска тестов

composer.json
{
    "name": "myproject/website",
    "type": "project",
    "authors": [
        {
            "name": "Andriy Kryvenko",
            "email": "krivenko.a.b@gmail.com"
        }
    ],
    "require-dev": {
        "phpunit/phpunit": "^9",
        "sempro/phpunit-pretty-print": "^1.4"
    },
    "scripts": {
        "test": "phpunit"
    }
}

Теперь при выполнении команды

composer test

будут запускаться все тесты. На этом процесс настройки закончен и можно переходить к написанию тестов.

Перед тем, как продолжить

Для удобства свои классы лучше размещать в каталоге local/classes, примерно в следующем виде:

/local/classes/MyProject/Product.php
/local/classes/MyProject/Rests.php

И указать в файле /local/composer.json в секции autoload путь к каталогу:

{
    "name": "myproject/website",
    "type": "project",
    "authors": [
        {
            "name": "Andriy Kryvenko",
            "email": "krivenko.a.b@gmail.com"
        }
    ],
    "require-dev": {
        "phpunit/phpunit": "^9",
        "sempro/phpunit-pretty-print": "^1.4"
    },
    "scripts": {
        "test": "phpunit"
    },
    "autoload": {
        "psr-4": {
            "": "./classes/"
        }
    }
}

Пример теста

В качестве примера я покажу реальную ситуацию, ее решение и тесты, которые это решение покрывают (часть кода, не относящуюся непосредственно к преобразованиям, в пример не включаю).

Собственно, ситуация: из 1С на сайт в виде строк передается информация о доступных сроках поставки товара, например:

24 часа-7|до 2 дней-14|до 15 дней-неогр

При покупке до 7 штук - поставим за 24 часа, до 14 штук - за 2 дня, в другом случае - за 15 дней.

Нужно преобразовать их в объекты Leftover для дальнейшего использования. Преобразование выполняем с помощью LeftoverTransformer:

Leftover
<?php

namespace MyProject\Product\Requisites;

class Leftover
{
    public int $time = 0;

    /**
     * Доступное количество для данного интервала.
     * Если количество равно -1.0 - то подразумеваем, что товара неограниченное количество
     */
    public float $quantity = 0.0;

    public function __construct(int $time, float $quantity)
    {
        $this->time = $time;
        $this->quantity = $quantity;
    }

    public function isAvailable(): bool
    {
        return ($this->quantity > 0 || $this->quantity == -1.0);
    }
}

LeftoverTransformer
<?php

namespace MyProject\Product\Requisites\Transform;

use MyProject\Product\Requisites\Leftover;

class LeftoverTransformer
{
    /**
     * @param string $leftoversString
     * @return Leftover[]
     * Строку получаем в виде
     * 24 часа-7|до 2 дней-14|до 7 дней-22|до 15 дней-неогр
     */
    public static function transform(string $leftoversString): array
    {
        $leftovers = [];
        $intervals = explode('|', $leftoversString);
        foreach ($intervals as $v){
            $interval = explode('-', $v);
            $intervalValues = [];
            foreach ($interval as $k => $part) {
                $intervalValues[] = trim($part);
            }

            if (!empty($intervalValues[0]) && !empty($intervalValues[1])) {
                $leftovers[] = new Leftover(
                    self::getTimeFromString($intervalValues[0]),
                    self::getQuantityFromString($intervalValues[1])
                );
            }
        }

        return $leftovers;
    }

    private static function getTimeFromString(string $timeString): int
    {
        switch ($timeString) {
            case '24 часа':
                $time = 1;
                break;
            default:
                $parts = explode(' ', $timeString);
                $time = intval($parts[1]);
                break;
        }
        return $time;
    }

    private static function getQuantityFromString(string $quantityString): int
    {
        switch ($quantityString) {
            case 'неогр':
                $quantity = -1;
                break;
            default:
                $quantity = intval($quantityString);
                break;
        }
        return $quantity;
    }
}

И покрываем эти классы соответствующими тестами:

LeftoverTest
<?php

namespace MyProject\Product\Requisites;

use PHPUnit\Framework\TestCase;

/**
 * @covers Leftover
 */
class LeftoverTest extends TestCase
{
    public function testIsAvailable(): void
    {
        $leftover = new Leftover(1, 12.0);

        $this->assertTrue($leftover->isAvailable());
    }
  
    public function testAvailableUnlimited(): void
    {
        $leftover = new Leftover(1, -1.0);

        $this->assertTrue($leftover->isAvailable());
    }

    public function testUnavailable(): void
    {
        $leftover = new Leftover(1, 0.0);

        $this->assertFalse($leftover->isAvailable());
    }
}

LeftoverTransformerTest
<?php

namespace MyProject\Product\Requisites\Transform;

use PHPUnit\Framework\TestCase;

/**
 * @covers LeftoverTransformer
 */
class LeftoverTransformerTest extends TestCase
{
    public function testEmpty(): void
    {
        $this->assertEmpty(LeftoverTransformer::transform(''));
    }

    public function testLeftoversCount(): void
    {
        $leftoverString = '24 часа-7|до 2 дней-14|до 7 дней-22|до 15 дней-неогр';
        $leftovers = LeftoverTransformer::transform($leftoverString);

        $this->assertCount(4, $leftovers);
    }

    /**
     * @param string $leftoverString
     * @param int $expectedTime
     * @param float $expectedQuantity
     * @return void
     * @dataProvider leftoversProvider
     */
    public function testLeftovers(string $leftoverString, int $expectedTime, float $expectedQuantity): void
    {
        $leftovers = LeftoverTransformer::transform($leftoverString);

        $this->assertEquals($expectedTime, $leftovers[0]->time);
        $this->assertEquals($expectedQuantity, $leftovers[0]->quantity);
    }

    public function leftoversProvider(): array
    {
        return [
            '24 часа-7' => [
                '24 часа-7',
                1, 7.0
            ],
            'до 2 дней-14' => [
                'до 2 дней-14',
                2, 14.0
            ],
            'до 7 дней-22' => [
                'до 7 дней-22',
                7, 22.0
            ],
            'до 15 дней-неогр' => [
                'до 15 дней-неогр',
                15, -1.0
            ]
        ];
    }
}

Приведенный пример теста позволяет быть уверенным в том, что при работе с товарами мы всегда точно знаем, доступно ли к покупке то или иное количество товара и в какой срок.

upd

По совету из комментариев убрал ручную регистрацию автозагрузки и указал путь к классам в /local/composer.json

Комментарии (10)


  1. DyoMin
    23.05.2022 05:58
    +2

    У Битрикс в курсе разработчика есть рекомендации по работе с composer. В чём причина использования своего подхода? Может какие-то несостыковки с рекомендациями.

    Если тесты добавляют/изменяют/удаляют записи в базе данных (в т.ч. настройки модулей), то есть механизм отката таких изменений? Или для каждого теста создавать новую базу? Стурктура БД битрикса довольно упоротая, чистить вручную как-то не радует.


    1. topuserman
      23.05.2022 07:58

      У Битрикса в курсах тонны кода и рекомендаций с запашком, и не нужно всё впитывать из этих источников.


    1. undersunn Автор
      23.05.2022 11:05
      +1

      По первой части вопроса - подход в курсе рабочий, но (на мой взгляд) противоречит идеологии продукта. Каталог "bitrix" - это штатный функционал CMS. Там находятся базовые модули и компоненты. А свои наработки нужно от него дистанцировать (для того и существует каталог "local").

      А по второму вопросу - можно через механизм транзакций. Работает для Oracle, MSSQL, MySQL (для типа таблиц InnoDB). Выглядеть будет примерно так:

      SampleTest
      <?php
      
      namespace MyProject;
      
      use PHPUnit\Framework\TestCase;
      use Bitrix\Main\Application;
      use CUser;
      
      class SampleTest extends TestCase
      {
      	public function testDatabaseWriting(): void
      	{
      		$email = 'ivanov@microsoft.com';
      
      		$connection = Application::getConnection();
      		$connection->StartTransaction();
      
      		$fields = [
      			'NAME'              => 'Сергей',
      			'LAST_NAME'         => 'Иванов',
      			'EMAIL'             => $email,
      			'LOGIN'             => 'ivan',
      			'LID'               => 'ru',
      			'ACTIVE'            => 'Y',
      			'GROUP_ID'          => [1],
      			'PASSWORD'          => '123456',
      			'CONFIRM_PASSWORD'  => '123456'
      		];
      		$user = new CUser();
      		$userId = $user->Add($fields);
      
      		$userIdFromDatabase = 0;
      		$filter = ['EMAIL' => $email];
      		$dbResult = CUser::GetList(($by='id'), ($order='asc'), $filter);
      		if ($record = $dbResult->fetch()) {
      			$userIdFromDatabase = $record['ID'];
      		}
          
      		$connection->rollbackTransaction();
      		
      		$this->assertEquals($userId, $userIdFromDatabase);
      	}
      }
      

      Если же используются MySQL MyISAM - то тут ничего разумного в голову не приходит. Только руками подчищать.


      1. k0rinf
        23.05.2022 16:40

        На сколько я понял, вопрос был не про local, а про то что Битрикс сам использует composer. И вам надо ваш composer смержить с composer битрикса, чтобы вы не получили конфликт версий пересекающихся пакетов.

        Более того вы размещаете vendor в публичной части сайта. Кто знает какой пакет вы там затяните, злоумышленник может получить бекдор, через публичный путь сайт.рф/local/vendor/mypackage/backdoor.php


        1. undersunn Автор
          25.05.2022 11:48

          По поводу второй части - действительно, local/vendor имеет смысл закрыть в nginx, либо вовсе выносить vendor за пределы публичной части.

          А со штатным composer-bx.json - выглядит он вот так:

          composer-bx.json
          {
            "require-dev": {
              "symfony/console": "4.1.*"
            }
          }

          И исходя из документации, используется только ради одного действия из коробки - генерация аннотаций ORM для модулей.
          В целом же - да, наверное имеет смысл смержить модулем Composer Merge Plugin, указанным в документации к битриксу.


  1. topuserman
    23.05.2022 07:56

    Вы используете композер, зачем вручную писать автозагрузку?


    1. undersunn Автор
      23.05.2022 10:32

      действительно, не имеет смысла - убрал ручную автозагрузку и добавил в composer путь к классам. Спасибо


  1. 0x131315
    23.05.2022 10:29

    Инициатива хорошая, но почва неблагодатная.

    В свое время не раз наблюдал, как vendor добавляли в git при разработке на cms только по той причине, что для разработки/релизной команды что-то сложнее git pull было уже проблемой. Та небольшая часть команды, которая понимала и использовала composer, вынуждена была также и обслуживать vendor, раскатывая результат для всех через git. Такие вот костыли. Вряд ли с битриксом ситуация лучше: на cms не от хорошей жизни уходят, технический уровень там намного ниже, настолько, что даже composer воспринимается как что-то сложное. Решительно непонятно кто/как будет поддерживать vendor в такой схеме, кроме автора: все упирается в людей, которых придется обучать. Зачастую в необучаемых людей.


    1. Atton
      23.05.2022 12:44

      Вы слишком драматизируете ситуацию. Предположу что описанному Вами примеру проблемы очень много лет. В настоящее время с множеством курсов и доступностью информации разработчики понимают базовые вещи (git, composer и т.д.).

      Технический уровень чаще всего от сложности проекта. Если это типичный интернет-магазин то возможна описанная ситуация с сложностью composer. В таком проекте, если нет своих кастомных модулей, ни к чему использовать phpunit.

      Если же проект на подобии какого-либо ЛК для определенного домена знаний, где Битрикс используется как фреймворк и описано много собственной логики - phpunit успешно используется, а команда знает что это за инструмент и как с ним работать. Так и с другими библиотеками обстоят дела.


    1. k0rinf
      23.05.2022 16:45

      Используем composer в Битрикс проектах очень давно, и никогда такие проблемы как вы описали не испытывали. Положить vendor в гит такая себе идея. Этим вы убиваете вообще весь смысл использования composer. Потом в добавок будете наблюдать кучу изменений vendor в мержреквестах.