Merge remote-tracking branch 'origin/support/3.1' into support/3.2

# Conflicts:
#	datamodels/2.x/itop-files-information/dictionaries/zh_cn.dict.itop-files-information.php
#	datamodels/2.x/itop-oauth-client/dictionaries/zh_cn.dict.itop-oauth-client.php
#	datamodels/2.x/itop-portal-base/dictionaries/zh_cn.dict.itop-portal-base.php
#	dictionaries/cs.dictionary.itop.core.php
#	dictionaries/cs.dictionary.itop.ui.php
#	dictionaries/zh_cn.dictionary.itop.core.php
#	dictionaries/zh_cn.dictionary.itop.ui.php
#	tests/php-unit-tests/README.md
This commit is contained in:
Pierre Goiffon
2024-02-14 14:51:59 +01:00
127 changed files with 1021 additions and 950 deletions

View File

@@ -3,34 +3,24 @@
Documentation on creating and maintaining tests in iTop.
Table of content:
<!-- TOC -->
* [Prerequisites](#prerequisites)
* [Create an iTop PHPUnit test](#create-an-itop-phpunit-test)
* [Tips: generic PHPUnit](#tips-generic-phpunit)
* [Tips: iTop tests](#tips-itop-tests)
* [Test performances](#test-performances)
* [PHPUnit process isolation](#phpunit-process-isolation)
<!-- TOC -->
## Prerequisites
### PHPUnit configuration file
### PHPUnit configuration file
A default file is located in `/tests/php-unit-tests/phpunit.xml.dist`
If you need to customize it, copy it to `phpunit.xml` (not versioned).
If you need to customize it, copy it to `phpunit.xml` (not versioned).
### PHP configuration
* PHPUnit configuration file
- `memory_limit`: as the tests are for the most part ran in the same process, memory usage may become an issue! A default value is set in default PHPUnit configuration XML file, don't hesitate to update it if needed
- `memory_limit`: as the tests are for the most part ran in the same process, memory usage may become an issue! A default value is set in default PHPUnit configuration XML file, don't hesitate to update it if needed
* PHP CLI php.ini
- enable OpCache
- disable Xdebug (xdebug.mode=off) : huge performance improvements (between X2 and X3), and we can still debug using PHPStorm !
- enable OpCache
- disable Xdebug (xdebug.mode=off) : huge performance improvements (between X2 and X3), and we can still debug using PHPStorm !
### Dependencies
Whereas iTop dependencies are bundled inside its repository, the tests dependencies are not, and must be added manually. To do so, run `composer install` in the `/tests/php-unit-tests` directory.
Whereas iTop dependencies are bundled inside its repository, the tests dependencies are not, and must be added manually. To do so, run `composer install` in the `/tests/php-unit-tests` directory.
### iTop instance prerequisites to run its test suite
Install iTop with default setup options :
@@ -40,11 +30,7 @@ Install iTop with default setup options :
- Simple Change Management
Plus :
- Additional ITIL tickets : check "Known Errors Management and FAQ"
- Additional ITIL tickets : check Known Errors Management and FAQ
## Create an iTop PHPUnit test
@@ -53,7 +39,7 @@ Plus :
- Covers the consistency of some data through the app? => Most likely in `integration-tests` directory
### iTop test parent classes
iTop provides PHPUnit TestCase children that provides some helpers and setUp/tearDown overrides :
iTop provides PHPUnit TestCase children that provides some helpers and setUp/tearDown overrides :
- `\Combodo\iTop\Test\UnitTest\ItopTestCase` : for the most simple iTop tests
- `\Combodo\iTop\Test\UnitTest\ItopDataTestCase` : to get a started metamodel and have cleanup of CRUD operations on iTop objects (transactions by default)
- `\Combodo\iTop\Test\UnitTest\ItopCustomDatamodelTestCase` : to test a non standard datamodel (available since iTop 2.7.9, 3.0.4, 3.1.0 N°6097)
@@ -65,7 +51,10 @@ iTop provides PHPUnit TestCase children that provides some helpers and setUp/tea
Source [PHPUnit Manual Chapter 2. Writing Tests for PHPUnit](https://docs.phpunit.de/en/9.6/writing-tests-for-phpunit.html#writing-tests-for-phpunit)
### What about skipped tests ?
A test can be marked as skipped by using the `markTestAsSkipped()` PHPUnit method. Please use it only for temporary disabled tests, for example the ones that are pushed before their corresponding fix.
For other cases like non-relevant data provider cases, just mark the test valid with `assertTrue(true)` and `return`.
@@ -80,7 +69,7 @@ $this->markTestSkipped('explanation');
### Test an exception
Just before calling the code throwing the exception, call `\PHPUnit\Framework\TestCase::expectException`. You might also use `expectExceptionMessage` and/or `expectExceptionMessageMatches`.
Example :
Example :
```php
// Try to delete the tag, must complain !
@@ -91,7 +80,7 @@ Example :
Warning : when the condition is met the test is finished and following code will be ignored !
Another way to do is using try/catch blocks, for example :
Another way to do is using try/catch blocks, for example :
```php
$validator = new FormValidator();
@@ -136,10 +125,19 @@ Use `UserRights::Login()`
## Test performances
### Memory limit
As the tests are run in the same process, memory usage
may become an issue as soon as tests are all executed at once.
Fix that in the XML configuration in the PHP section
```xml
<ini name="memory_limit" value="512M"/>
```
### Measure the time spent in a test
Simply cut'n paste the following line at several places within the test function:
@@ -189,8 +187,6 @@ If you can't, then ok you will have to isolate it!
## PHPUnit process isolation
### Understand tests interactions
@@ -240,7 +236,6 @@ the exact same effect as `@runTestsInSeparateProcesses`.
Note : this option is documented only in the [attributes part of the documentation](https://docs.phpunit.de/en/10.0/attributes.html).
### Traps
#### Doc block comment format : when it is a matter of stars
```php
/*

View File

@@ -16,6 +16,10 @@
namespace Combodo\iTop\Test\UnitTest\Integration;
use Combodo\iTop\Test\UnitTest\ItopTestCase;
use Error;
use Exception;
use const ARRAY_FILTER_USE_BOTH;
use const DIRECTORY_SEPARATOR;
/**
@@ -26,11 +30,54 @@ use Combodo\iTop\Test\UnitTest\ItopTestCase;
*/
class Dict
{
/**
* @var bool if true will keep entries in {@see m_aData}
*/
private static $bLoadEntries = false;
private static $bSaveKeyDuplicates = false;
/**
* @var array same as the real Dict class : language code as key, value containing array of dict key / label
*/
public static $m_aData = [];
public static $aKeysDuplicate = [];
public static $sLastAddedLanguageCode = null;
public static function EnableLoadEntries(bool $bSaveKeyDuplicates = false) :void {
self::$sLastAddedLanguageCode = null;
self::$m_aData = [];
self::$aKeysDuplicate = [];
self::$bLoadEntries = true;
self::$bSaveKeyDuplicates = $bSaveKeyDuplicates;
}
public static function Add($sLanguageCode, $sEnglishLanguageDesc, $sLocalizedLanguageDesc, $aEntries)
{
if (false === static::$bLoadEntries) {
return;
}
static::$sLastAddedLanguageCode = $sLanguageCode;
foreach ($aEntries as $sDictKey => $sDictLabel) {
if (self::$bSaveKeyDuplicates) {
if (isset(static::$m_aData[$sLanguageCode][$sDictKey])) {
if (array_key_exists($sDictKey, self::$aKeysDuplicate)) {
self::$aKeysDuplicate[$sDictKey]++;
} else {
self::$aKeysDuplicate[$sDictKey] = 1;
}
}
}
static::$m_aData[$sLanguageCode][$sDictKey] = $sDictLabel;
}
}
}
/**
* For tests on compiled dict files, see {@see CompiledDictionariesConsistencyTest}
* @group beforeSetup
@@ -152,23 +199,35 @@ class DictionariesConsistencyTest extends ItopTestCase
$this->CheckDictionarySyntax(__DIR__.'/dictionaries-test/fr.dictionary.itop.core.OK.php', true);
}
/**
* @param string $sDictFile complete path for the file to check
* @param bool $bIsSyntaxValid expected assert value
*/
private function CheckDictionarySyntax(string $sDictFile, $bIsSyntaxValid = true): void
{
private function GetPhpCodeFromDictFile(string $sDictFile) : string {
$sPHP = file_get_contents($sDictFile);
// Strip php tag to allow "eval"
$sPHP = substr(trim($sPHP), strlen('<?php'));
// Make sure the Dict class is the one declared in the current file
$sPHP = 'namespace '.__NAMESPACE__.";\n".$sPHP;
$iLineShift = 1; // Cope with the shift due to the namespace statement
// we are replacing instead of defining the constant so that if the constant is inside the string it will trigger an error
// eg `'UI:Audit:Title' => 'ITOP_APPLICATION_SHORT - CMDB Audit',`
// which should be `'UI:Audit:Title' => ITOP_APPLICATION_SHORT.' - CMDB Audit',`
// Also we are replacing with - instead of _ as ITOP_APPLICATION_SHORT contains ITOP_APPLICATION and we don't want this replacement to occur
$sPHP = str_replace(
['ITOP_APPLICATION_SHORT', 'ITOP_APPLICATION', 'ITOP_VERSION_NAME'],
['\'itop\'', '\'itop\'', '\'1.2.3\''],
['\'CONST__ITOP-APPLICATION-SHORT\'', '\'CONST__ITOP-APPLICATION\'', '\'CONST__ITOP-VERSION-NAME\''],
$sPHP
);
return $sPHP;
}
/**
* @param string $sDictFile complete path for the file to check
* @param bool $bIsSyntaxValid expected assert value
*/
private function CheckDictionarySyntax(string $sDictFile, bool $bIsSyntaxValid = true): void
{
$sPHP = $this->GetPhpCodeFromDictFile($sDictFile);
$iLineShift = 1; // Cope with the shift due to the namespace statement added in GetPhpCodeFromDictFile
try {
eval($sPHP);
// Reaching this point => No syntax error
@@ -176,13 +235,13 @@ class DictionariesConsistencyTest extends ItopTestCase
$this->fail("Failed to detect syntax error in dictionary `{$sDictFile}` (which is known as being INCORRECT)");
}
}
catch (\Error $e) {
catch (Error $e) {
if ($bIsSyntaxValid) {
$iLine = $e->getLine() - $iLineShift;
$this->fail("Invalid dictionary: {$e->getMessage()} in {$sDictFile}:{$iLine}");
}
}
catch (\Exception $e) {
catch (Exception $e) {
if ($bIsSyntaxValid) {
$iLine = $e->getLine() - $iLineShift;
$sExceptionClass = get_class($e);
@@ -270,4 +329,139 @@ EOF
'templates-base',
];
}
/**
* @dataProvider DictionaryFileProvider
*/
public function testDictKeyDefinedOncePerFile(string $sDictFileToTestFullPath): void {
Dict::EnableLoadEntries(true);
$sDictFileToTestPhp = $this->GetPhpCodeFromDictFile($sDictFileToTestFullPath);
eval($sDictFileToTestPhp);
$aDictKeysDefinedMultipleTimes = [];
foreach (Dict::$aKeysDuplicate as $sDictKey => $iNumberOfDuplicates) {
$sFirstKeyDeclaration = $this->FindDictKeyLineNumberInContent($sDictFileToTestPhp, $sDictKey);
$aDictKeysDefinedMultipleTimes[$sDictKey] = $this->MakeFilePathClickable($sDictFileToTestFullPath, $sFirstKeyDeclaration);
}
$this->assertEmpty(Dict::$aKeysDuplicate, 'Some keys are defined multiple times in this file:'.var_export($aDictKeysDefinedMultipleTimes, true));
}
/**
* @dataProvider DictionaryFileProvider
*/
public function testNoRemainingTildesInTranslatedKeys(string $sDictFileToTestFullPath): void
{
Dict::EnableLoadEntries();
$sReferenceLangCode = 'EN US';
$sReferenceDictName = 'en';
$sDictFileToTestPhp = $this->GetPhpCodeFromDictFile($sDictFileToTestFullPath);
eval($sDictFileToTestPhp);
$sLanguageCodeToTest = Dict::$sLastAddedLanguageCode;
if (is_null($sLanguageCodeToTest)) {
$this->assertTrue(true, 'No Dict::Add call in this file !');
return;
}
if ($sLanguageCodeToTest === $sReferenceLangCode) {
$this->assertTrue(true, 'Not testing reference lang !');
return;
}
if (empty(Dict::$m_aData[$sLanguageCodeToTest])) {
$this->assertTrue(true, 'No Dict key defined in this file !');
return;
}
$oDictFileToTestInfo = pathinfo($sDictFileToTestFullPath);
$sDictFilesDir = $oDictFileToTestInfo['dirname'];
$sDictFileToTestFilename = $oDictFileToTestInfo['basename'];
$sDictFileReferenceFilename = preg_replace('/^[^.]*./', $sReferenceDictName.'.', $sDictFileToTestFilename);
$sDictFileReferenceFullPath = $sDictFilesDir.DIRECTORY_SEPARATOR.$sDictFileReferenceFilename;
$sDictFileReferencePhp = $this->GetPhpCodeFromDictFile($sDictFileReferenceFullPath);
eval($sDictFileReferencePhp);
if (empty(Dict::$m_aData[$sReferenceLangCode])) {
$this->assertTrue(true, 'No Dict key defined in the reference file !');
return;
}
$aLangToTestDictEntries = Dict::$m_aData[$sLanguageCodeToTest];
$aReferenceLangDictEntries = Dict::$m_aData[$sReferenceLangCode];
$this->assertGreaterThan(0, count($aLangToTestDictEntries), 'There should be at least one entry in the dictionary file to test');
$aLangToTestDictEntriesNotEmptyValues = array_filter(
$aLangToTestDictEntries,
static function ($value, $key) {
return !empty($value);
},
ARRAY_FILTER_USE_BOTH
);
$this->assertNotEmpty($aLangToTestDictEntriesNotEmptyValues);
$aTranslatedKeysWithTildes = [];
foreach ($aReferenceLangDictEntries as $sDictKey => $sReferenceLangLabel) {
if (false === array_key_exists($sDictKey, $aLangToTestDictEntries)) {
continue;
}
$sTranslatedLabel = $aLangToTestDictEntries[$sDictKey];
$bTranslatedLabelHasTildes = preg_match('/~~$/', $sTranslatedLabel) === 1;
if (false === $bTranslatedLabelHasTildes) {
continue;
}
$sTranslatedLabelWithoutTildes = preg_replace('/~~$/', '', $sTranslatedLabel);
if ($sTranslatedLabelWithoutTildes === '') {
continue;
}
if ($sTranslatedLabelWithoutTildes === $sReferenceLangLabel) {
continue;
}
$sDictKeyLineNumberInDictFileToTest = $this->FindDictKeyLineNumberInContent($sDictFileToTestPhp, $sDictKey);
$sDictKeyLineNumberInDictFileReference = $this->FindDictKeyLineNumberInContent($sDictFileReferencePhp, $sDictKey);
$aTranslatedKeysWithTildes[$sDictKey] = [
$sLanguageCodeToTest.'_file_location' => $this->MakeFilePathClickable($sDictFileToTestFullPath, $sDictKeyLineNumberInDictFileToTest),
$sLanguageCodeToTest => $sTranslatedLabel,
$sReferenceLangCode.'_file_location' => $this->MakeFilePathClickable($sDictFileReferenceFullPath, $sDictKeyLineNumberInDictFileReference),
$sReferenceLangCode => $sReferenceLangLabel
];
}
$sPathRoot = static::GetAppRoot();
$sDictFileToTestRelativePath = str_replace($sPathRoot, '', $sDictFileToTestFullPath);
$this->assertEmpty($aTranslatedKeysWithTildes, "In {$sDictFileToTestRelativePath} \n following keys are different from their '{$sReferenceDictName}' counterpart (translated ?) but have tildes at the end:\n" . var_export($aTranslatedKeysWithTildes, true));
}
/**
* @param string $sFullPath
* @param int $iLineNumber
*
* @return string a path that is clickable in PHPStorm 🤩
* For this to happen we need full path with correct dir sep + line number
* If it is not, check in File | Settings | Tools | Terminal the hyperlink option is checked
*/
private function MakeFilePathClickable(string $sFullPath, int $iLineNumber):string {
return str_replace(array('//', '/'), array('/', DIRECTORY_SEPARATOR), $sFullPath).':'.$iLineNumber;
}
private function FindDictKeyLineNumberInContent(string $sFileContent, string $sDictKey): int
{
$aContentLines = explode("\n", $sFileContent);
$sDictKeyToFind = "'{$sDictKey}'"; // adding string delimiters to match exact dict key (eg if not we would match 'Core:AttributeDateTime?SmartSearch' for 'Core:AttributeDateTime')
foreach($aContentLines as $iLineNumber => $line) {
if(strpos($line, $sDictKeyToFind) !== false){
return $iLineNumber;
}
}
return 1;
}
}