Пример использования PHPUnit в своем модуле для CMS 1C-Битрикс

Сегодня мы рассмотрим пример создания unit-теста для CMS 1С-Битрикс на примере модуля-заготовки. Вы можете склонировать его репозиторий с моего GitHub.

Как установить модуль, описано в файле справки самого репозитория. Для его установки также потребуется копия сайта на данной CMS (демо-версию можно бесплатно скачать с официального сайта и установить на каком-либо локальном сервере или, собственно, задействовать хостинг/VPS).

Перед тем как перейти к деталям, хотелось бы высказать пару мыслей. Некоторые разработчики модулей на данной платформе, по моим наблюдениям, не используют unit-тесты в принципе. Но подумайте вот о чем — если вы выкатываете новый функционал, вы всегда рискуете сломать старый и даже этого не заметить. Чтобы это предотвратить, вы можете вручную тестировать весь возможный функционал полностью, после каждой правки. Или вы можете автоматизировать часть ручного труда и использовать модульные, а также интеграционные тесты. По мере роста приложения вы будете экономить все больше времени, так как вам не придется проверять все аспекты вручную. Конечно, модульные тесты это не панацея, но, как считается, без них чистый код не возможен в принципе. Еще представьте, что ваш модуль приобретен несколькими десятками клиентов (не говоря о сотнях). При этом цена ошибок возрастает, но с авто-тестами их намного легче минимизировать.

Ну а теперь, перейдем к примеру реализации тестов, использованном в упомянутом выше репозитории. Отмечу важные моменты:

  1. Для того, чтобы не было проблем с статическими методами классов из ядра Битрикс при создании тестовых двойников (mock-объектов), мы сделаем над ними классы-обертки. Их можно видеть в директории lib/Wrappers.
  2. На примере класса lib/Example.php видно, что внедрение зависимостей происходит через конструктор. Благодаря такой схеме, мы можем заглушить любой используемый класс Битрикс в тестируемом методе.

Давайте рассмотрим детально сам тест в фаиле tests/unit/ExampleTest.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
namespace Somefirm\Emptymodule;
 
use Bitrix\Main\Loader;
 
class ExampleTest extends BitrixTestCase
{
    public function testExampleMethod()
    {
        // Входные параметры
        // Подготовка параметров для тестируемого метода, 
        // в данном случае используем пустой массив
        $param = []; 
 
        // Результат для проверки
        // Эталон, с которым будем сравнивать результат работы функции
        $expectedResult = [1, 2]; 
 
        // Заглушки
        // Тут мы подключаем модуль Битрикс iblock, потому что дальше
        // мы используем CIBlockResult для заглушки 
        // Этого класса нет в Wrappers, он и без обертки работает нормально
        Loader::includeModule('iblock'); 
 
        // Создаем заглушку для результата ответа CIBlockElement
        $CIBlockResultStub = $this->createMock(\CIBlockResult::class);
        // Тут мы видим массив, элементы которого будут отдаваться при
        // каждой итерации при вызове Fetch() в цикле
        // Последний элемент — false, то есть необходимо завершить цикл
        $fetchResults = [
            [
                'ID' => 1,
            ],
            [
                'ID' => 2,
            ],
            false,
        ];
        // Тут мы создаем заглушку метода Fetch()
        // С помощью метода onConsecutiveCalls() мы сообщаем PHPUnit,
        // что метод должен последовательно вернуть значения массива $fetchResults
        // при каждом новом вызове функции Fetch()
        $CIBlockResultStub->method('Fetch')
            ->will($this->onConsecutiveCalls(...$fetchResults));
        // Создаем заглушку CIBlockElement (через обертку)
        $CIBlockElementStub = $this->createMock(Wrappers\CIBlockElement::class);
        // И добавляем этой заглушке метод GetList(), который должен вернуть
        // другую заглушку $CIBlockResultStub, созданную выше
        $CIBlockElementStub->method('GetList')
            ->willReturn($CIBlockResultStub);
 
        // Вычисление результата
        // Внедряем подготовленную заглушку через конструктор класса Example
        $object = new Example([
            'CIBlockElement' => $CIBlockElementStub,
        ]);
        // Имитируем работу тестируемого метода exampleMethod()
        $result = $object->exampleMethod($param);
 
        // Проверка
        // Сравнение эталона с реальным результатом работы функции
        // Если результаты совпадут, то тест пройден
        $this->assertEquals($expectedResult, $result);
    }
}

Итак, из кода выше видно, что мы не просто написали простейший тест, но изолировали реальные классы Битрикс, которые использует тестируемая функция. Делается это для того, чтобы тесты не делали запросы к БД и чтобы они не зависели от наличия нужных данных. Если при рефакторинге этой функции мы случайно что-то сломаем, об этом быстро сообщит unit-тест. А когда покрытие программы тестами достигает значительной величины, наша уверенность в надежности кода существенно возрастает.

Кстати, если отключить заглушки, у нас получится уже интеграционный тест, который тестирует не только текущую функцию, но и ее интеграцию с Битрикс. Однако, для таких тестов необходимо подготовить необходимое состояние БД и потом, при необходимости, вернуть обратно в состояние, которое было до начала теста. Для этого нужно написать соответствующий код, который сделает это перед/после запуска теста. Но это уже выходит за рамки этой статьи.

SQLite в Unity

Хранение данных в базе данных, извините за тавтологию — это очень удобно. Сегодня мы рассмотрим как организовать подключение к встраиваемой БД SQLite в Unity. Это, на мой взгляд, гораздо лучше чем использовать PlayerPrefs. В примере используется Unity 5.3.1f1.

  1. Создаем БД любым доступным инструментом. Я использовал менеджер SQLite в виде плагина к Firefox. Файл БД надо сохранить в папке [путь_к_вашему_проекту_Unity]\Assets\StreamingAssets\. В примере используется имя файла db.bytes.
  2. Нам понадобятся библиотеки для доступа к SQLite. Скачать их можно отсюда. Скачиваем и распаковываем куда нибудь архив SQLite4Unity3d. Копируем папку Assets\Plugins\ из распакованного архива в [путь_к_вашему_проекту_Unity]\Assets\Plugins\.
  3. Теперь создаем в папке [путь_к_вашему_проекту_Unity]\Assets\Scripts\ файл DataService.cs:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    
    using SQLite4Unity3d;
    using UnityEngine;
    #if !UNITY_EDITOR
    using System.Collections;
    using System.IO;
    #endif
    using System.Collections.Generic;
     
    public class DataService  {
    	public SQLiteConnection _connection;
     
    	public DataService(string DatabaseName){
    #if UNITY_EDITOR
    		var dbPath = string.Format(@"Assets/StreamingAssets/{0}", DatabaseName);
    #else
            	// check if file exists in Application.persistentDataPath
            	var filepath = string.Format("{0}/{1}", Application.persistentDataPath, DatabaseName);
     
            	if (!File.Exists(filepath))
    		{
    			Debug.Log("Database not in Persistent path");
    			// if it doesn't ->
    			// open StreamingAssets directory and load the db ->
    #if UNITY_ANDROID 
    			var loadDb = new WWW("jar:file://" + Application.dataPath + "!/assets/" + DatabaseName);  // this is the path to your StreamingAssets in android
    			while (!loadDb.isDone) { }  // CAREFUL here, for safety reasons you shouldn't let this while loop unattended, place a timer and error check
    			// then save to Application.persistentDataPath
    			File.WriteAllBytes(filepath, loadDb.bytes);
    #elif UNITY_IOS
    			var loadDb = Application.dataPath + "/Raw/" + DatabaseName;  // this is the path to your StreamingAssets in iOS
    			// then save to Application.persistentDataPath
    			File.Copy(loadDb, filepath);
    #elif UNITY_WP8
    			var loadDb = Application.dataPath + "/StreamingAssets/" + DatabaseName;  // this is the path to your StreamingAssets in iOS
    			// then save to Application.persistentDataPath
    			File.Copy(loadDb, filepath);
    #elif UNITY_WINRT
    			var loadDb = Application.dataPath + "/StreamingAssets/" + DatabaseName;  // this is the path to your StreamingAssets in iOS
    			// then save to Application.persistentDataPath
    			File.Copy(loadDb, filepath);
    #endif
    			Debug.Log("Database written");
    		}
    		var dbPath = filepath;
    #endif
    		_connection = new SQLiteConnection(dbPath, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create);
    		Debug.Log("Final PATH: " + dbPath);     
    	}
    }

    Этот код взят практически без изменений из проекта SQLite4Unity3d. Оттуда убраны лишние методы которые приведены там просто для примера. А так же переменная _connection сделана публичной. Как видно класс кроссплатформенный. Его можно использовать как под Windows, так и под Android или iOS. Я тестировал только на Android.

  4. Далее надо создать классы таблиц БД, к которым будем обращаться из Unity. Ниже приведен пример класса для таблицы Items. Вам нужно будет создать свои классы для каждой таблицы в согласии с вашими наименованиями полей и типами хранимых данных. Названия классов повторяют имена таблиц в БД, а наименования переменных-членов класса соответственно идентичны полям в таблице. Называем как нибудь файл скрипта, например Tables.cs, и сохраняем также в папку [путь_к_вашему_проекту_Unity]\Assets\Scripts\ :
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    using SQLite4Unity3d;
     
    public class Items  {
     
    	[PrimaryKey, AutoIncrement]
    	public int Id { get; set; }
    	public string Item { get; set; }
    	public string Type { get; set; }
    	public string Subtype { get; set; }
    	public int Weight { get; set; }
    }
  5. Для иллюстрации работы с БД привожу пример:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    
    using UnityEngine;
    using System.Collections.Generic;
     
    public class PlayerScript : MonoBehaviour {
    	public DataService ds;
     
    	// Use this for initialization
    	void Start () {
    		// подключение к БД
    		ds = new DataService("db.bytes");
     
    		// создание таблицы (таблица будет создана на основании данных класса Items, код которого приведен выше)
    		ds._connection.CreateTable();
     
    		// аналог запроса INSERT INTO `Items` (`Item`, `Type`, `Sybtype`, `Weight`) VALUES ("Flyer", "aircraft", "glider", 500)
    		ds._connection.Insert (new Items{
    			Item = "Flyer",
    			Type = "aircraft",
    			Sybtype = "glider",
    			Weight = 500
    		});
     
    		// аналог запроса SELECT * FROM `Items` WHERE `Id`=1
    		Items item1 = ds._connection.Table ().Where (x => x.Id == 1).FirstOrDefault ();
    		// для примера выводим в консоль отладки значение поля Weight у записи с Id = 1
    		Debug.Log(item1.Weight);
     
    		// удаление таблицы Items
    		ds._connection.DropTable();
    	}
     
    	// Update is called once per frame
    	void Update () {
     
    	}
    }

    Как видно из комментариев, сначала создается таблица Items на основании одноименного класса. Затем в нее вставляется запись. Следующим запросом из записи извлекаются данные и выводятся в консоль отладки. В конце таблица уничтожается.
    Чтобы протестировать этот код в вашем проекте, прикрепите его к какому нибудь объекту на сцене.

Кстати, вместо команды Insert можно использовать Update для обновления данных или InsertOrReplace для вставки или замены. Только в этом случае нужно добавить в объект еще и поле Id, для того чтобы можно было идентифицировать запись:

1
2
3
4
5
6
7
ds._connection.InsertOrReplace (new Items{
	Id = 1,
	Item = "Flyer",
	Type = "aircraft",
	Sybtype = "glider",
	Weight = 1000
});

Понятно что в примере приведены простейшие запросы, но этого уже достаточно чтобы начать использовать SQLite в Unity.
В заключение ещё один момент. При компиляции проекта для Andriod, Unity выдал ошибку — ему не понравились «лишние» кроссплатформенные библиотеки SQLite в папке Assets\Plugins\. Пришлось на время компиляции убрать оттуда вложенные папки WP8, x64 и x86. После компиляции возвращаем их назад, а то теперь проект не будет запускаться в отладчике Unity. Может есть более удобное решение, но я пока его не нашел (да и не искал).

1062: Duplicate entry ‘2147483647’ for key ‘PRIMARY’ или Проблема 2038 года

Наверное помните раскрученную проблему 2000? А все из-за того что кто-то заранее не подумал о том что будет через каких то 30 лет и использовал в программном обеспечении 2-ух значное обозначение года. И надо сказать, что проблема 2000 не единственная в своем роде. Наверное еще рановато об этом говорить, но назревает еще и проблема 2038 года. Поясню что имеется в виду. Попробуйте запустить следующий PHP-код:

1
<?php echo date("d.m.Y H:i", 2147483647); ?>

На выходе получим: 19.01.2038 06:14. А теперь увеличьте число на 1. Получилось 13.12.1901 23:15 что не соответствует истине. А все потому что метка времени UNIX при этом выходит за рамки диапазона типа данных Integer.

Хотя проблема 2038 года — это конечно больше шутка. Но однажды при записи данных в MySQL базу данных я получил ошибку:

1062: Duplicate entry ‘2147483647’ for key ‘PRIMARY’

Дело в том что для первичного ключа использовалось поле с типом данных INT. И так получилось, что AUTO INCREMENT вышел за пределы числа 2147483647, максимального для INT. Решение простое в данном случае — пришлось использовать тип данных BIGINT. Так что думайте сами, решайте сами …

Переход на PHP 5.4 и Fatal error: Call-time pass-by-reference has been removed in

Ошибка подобного вида может появится на сайте, если хостинг сам обновит PHP до версии 5.4. Особенно часто такие обновления «без спросу» делают бесплатные хостинги. На платных чаще всего самому пользователю предоставляется выбор версии PHP.

Как бы то ни было — вы сами обновили версию или это сделали без вашего ведома — некоторый несовместимый PHP-код нуждается в доработке.

Что требует PHP 5.4

Теперь указание знака амперсанда (&) перед переменной при вызове функции вызывает фатальную ошибку и, как следствие, остановку выполнения дальнейшего кода. В PHP 5.3 это вызывало лишь предупреждение, а сейчас стало все строже. Как вы наверное помните, амперсанд указывает на передачу аргумента по ссылке. Такая передача, естественно, возможна и в новой версии PHP, но указывать знак амперсанда теперь нужно только в определении функции. То есть, например, здесь все оставляем как было:

1
2
3
function userFunc(&$arg) {
  return $arg++;
}

А вот здесь амперсанд убираем:

1
userFunc(&$arg);

Должно быть только так:

1
userfunc($arg);

Очевидно, что это не все ограничения новой версии. Это лишь то, с чем я лично столкнулся.

Осторожнее с предупреждениями PHP!

Можно сделать вывод что предупреждения, которые выдает интерпретатор PHP, все же надо учитывать. Кто знает, что вызовет фатальную ошибку в следующей версии? Например в PHP 5.4 статический вызов функции класса, если в определении данной функции не указано слово static, вызывает предупреждение. Пример:

1
2
3
4
5
6
class userClass {
  public function userFunc(&$arg) {
    return $arg++;
  }
}
userClass::userFunc($arg);

Лучше доработать такой код сейчас, не дожидаясь фатальных ошибок в будущем. Добавляем ключевое слово static:

1
2
3
4
5
6
class userClass {
  public static function userFunc(&$arg) {
    return $arg++;
  }
}
userClass::userFunc($arg);

Так гораздо надежнее.

Изменение значений ячеек Excel с помощью макросов VBA — часть 2

Еще несколько полезных скриптов VBA для Excel. Для вставки в код макроса не забудьте изменить название листа и диапазон ячеек на свой.

Убрать всё после любого символа (в примере после знака ‘+’). Обратите внимание, если вы хотите поменять символ, необходимо сделать это в двух строках — во 2-ой и 3-ей. Вторая строка была добавлена для того, чтобы не было ошибок в случае отсутствия искомого символа:

1
2
3
4
5
6
For Each c In Worksheets("list1").Range("B2:B72").Cells
  c.Value = CStr(c.Value) + "+"
  spacePos = InStr(1, c.Value, "+")
  artLen = spacePos - 1
  c.Value = Left(c.Value, artLen)
Next

Сделать все первые буквы в ячейках прописными:

1
2
3
For Each c In Worksheets("list1").Range("F2:F72").Cells
  c.Value = UCase(Mid(c.Value, 1, 1)) + Mid(c.Value, 2)
Next

Добавить плюс перед ‘7’ (может пригодится при редактировании списка номеров телефонов):

1
2
3
4
5
For Each c In Worksheets("list1").Range("F2:F72").Cells
  if Mid(c.Value, 1, 1) = "7" then
    c.Value = "+" + CStr(c.Value)
  endif
Next

Если здесь нет нужного вам макроса, посмотрите ещё часть 1.