Skip to content

Commit 408e8a1

Browse files
authored
feat: improve unit tests (#219)
* feat: improve unit tests * fix: ensure proper exit after coverage checks
1 parent a8079c7 commit 408e8a1

24 files changed

Lines changed: 2228 additions & 1 deletion

.ddev/commands/web/phpunit

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,17 @@ if [[ ${coverage} == true ]]; then
3636
echo "Coverage reports: reports/coverage/index.html (HTML), reports/clover.xml (Clover)"
3737
# pcov.enabled=1: opt in per-run (globally off, see .ddev/php/pcov.ini).
3838
# opcache.jit=off: avoid the "JIT is incompatible" warning PCOV would trigger.
39-
exec php -d pcov.enabled=1 -d opcache.jit=off -d opcache.jit_buffer_size=0 vendor/bin/phpunit \
39+
php -d pcov.enabled=1 -d opcache.jit=off -d opcache.jit_buffer_size=0 vendor/bin/phpunit \
4040
--coverage-text \
4141
--coverage-html reports/coverage \
4242
--coverage-clover reports/clover.xml \
4343
"${args[@]}"
44+
# Quality ratchet, only meaningful for a full-suite run; keep the threshold
45+
# in sync with .github/workflows/phpunit.yml.
46+
if [[ ${#args[@]} -eq 0 ]]; then
47+
exec php tests/coverage-checker.php reports/clover.xml 20
48+
fi
49+
exit 0
4450
fi
4551

4652
exec vendor/bin/phpunit "${args[@]}"

.github/workflows/phpunit.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ jobs:
5454
--coverage-html reports/coverage \
5555
--coverage-clover reports/clover.xml
5656
57+
- name: Enforce minimum line coverage
58+
if: ${{ matrix.coverage }}
59+
# Quality ratchet — raise the threshold as coverage grows.
60+
run: php tests/coverage-checker.php reports/clover.xml 20
61+
5762
- name: Publish coverage summary
5863
if: ${{ matrix.coverage && always() }}
5964
run: |

infection.json5

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
"excludes": ["registration.php"],
66
},
77
"timeout": 10,
8+
// Quality ratchet: fails the run (locally and in CI) when the covered-code
9+
// MSI drops below this floor. Raise it as the test suite improves.
10+
"minCoveredMsi": 90,
811
"logs": {
912
"text": "reports/infection/infection.log",
1013
"html": "reports/infection/infection.html",
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenForgeProject\MageForge\Test\Unit\Exception;
6+
7+
use OpenForgeProject\MageForge\Exception\FetchLatestVersionException;
8+
use PHPUnit\Framework\TestCase;
9+
10+
class FetchLatestVersionExceptionTest extends TestCase
11+
{
12+
public function testIsRuntimeException(): void
13+
{
14+
$exception = new FetchLatestVersionException('fetch failed');
15+
16+
$this->assertInstanceOf(\RuntimeException::class, $exception);
17+
$this->assertSame('fetch failed', $exception->getMessage());
18+
}
19+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenForgeProject\MageForge\Test\Unit\Model\Config\Source;
6+
7+
use Magento\Framework\Data\OptionSourceInterface;
8+
use OpenForgeProject\MageForge\Model\Config\Source\InspectorTheme;
9+
use PHPUnit\Framework\TestCase;
10+
11+
class InspectorThemeTest extends TestCase
12+
{
13+
public function testImplementsOptionSourceInterface(): void
14+
{
15+
$this->assertInstanceOf(OptionSourceInterface::class, new InspectorTheme());
16+
}
17+
18+
public function testReturnsAllInspectorThemes(): void
19+
{
20+
$options = (new InspectorTheme())->toOptionArray();
21+
22+
$this->assertSame(['dark', 'light', 'auto'], array_column($options, 'value'));
23+
}
24+
25+
public function testEveryOptionHasNonEmptyLabel(): void
26+
{
27+
foreach ((new InspectorTheme())->toOptionArray() as $option) {
28+
$this->assertArrayHasKey('label', $option);
29+
$this->assertIsString($option['label']);
30+
$this->assertNotSame('', $option['label']);
31+
}
32+
}
33+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenForgeProject\MageForge\Test\Unit\Model\Config\Source;
6+
7+
use Magento\Framework\Data\OptionSourceInterface;
8+
use OpenForgeProject\MageForge\Model\Config\Source\ToolbarPosition;
9+
use PHPUnit\Framework\TestCase;
10+
11+
class ToolbarPositionTest extends TestCase
12+
{
13+
public function testImplementsOptionSourceInterface(): void
14+
{
15+
$this->assertInstanceOf(OptionSourceInterface::class, new ToolbarPosition());
16+
}
17+
18+
public function testReturnsAllToolbarPositions(): void
19+
{
20+
$options = (new ToolbarPosition())->toOptionArray();
21+
22+
$this->assertSame(
23+
['bottom-left', 'bottom-right', 'top-left', 'top-right'],
24+
array_column($options, 'value'),
25+
);
26+
}
27+
28+
public function testEveryOptionHasNonEmptyLabel(): void
29+
{
30+
foreach ((new ToolbarPosition())->toOptionArray() as $option) {
31+
$this->assertArrayHasKey('label', $option);
32+
$this->assertIsString($option['label']);
33+
$this->assertNotSame('', $option['label']);
34+
}
35+
}
36+
}

tests/Unit/Model/ThemeListTest.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenForgeProject\MageForge\Test\Unit\Model;
6+
7+
use Magento\Framework\View\Design\Theme\ThemeList as MagentoThemeList;
8+
use Magento\Framework\View\Design\ThemeInterface;
9+
use OpenForgeProject\MageForge\Model\ThemeList;
10+
use PHPUnit\Framework\TestCase;
11+
12+
class ThemeListTest extends TestCase
13+
{
14+
public function testReturnsAllThemesFromMagentoThemeList(): void
15+
{
16+
$themeOne = $this->createMock(ThemeInterface::class);
17+
$themeTwo = $this->createMock(ThemeInterface::class);
18+
19+
$magentoThemeList = $this->createMock(MagentoThemeList::class);
20+
$magentoThemeList
21+
->method('getItems')
22+
->willReturn(['frontend/Vendor/one' => $themeOne, 'frontend/Vendor/two' => $themeTwo]);
23+
24+
$themeList = new ThemeList($magentoThemeList);
25+
26+
$this->assertSame(
27+
['frontend/Vendor/one' => $themeOne, 'frontend/Vendor/two' => $themeTwo],
28+
$themeList->getAllThemes(),
29+
);
30+
}
31+
32+
public function testReturnsEmptyArrayWhenNoThemesExist(): void
33+
{
34+
$magentoThemeList = $this->createMock(MagentoThemeList::class);
35+
$magentoThemeList->method('getItems')->willReturn([]);
36+
37+
$themeList = new ThemeList($magentoThemeList);
38+
39+
$this->assertSame([], $themeList->getAllThemes());
40+
}
41+
}

tests/Unit/Model/ThemePathTest.php

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenForgeProject\MageForge\Test\Unit\Model;
6+
7+
use Magento\Framework\Component\ComponentRegistrar;
8+
use Magento\Framework\Component\ComponentRegistrarInterface;
9+
use OpenForgeProject\MageForge\Model\ThemePath;
10+
use PHPUnit\Framework\MockObject\MockObject;
11+
use PHPUnit\Framework\TestCase;
12+
13+
class ThemePathTest extends TestCase
14+
{
15+
private ComponentRegistrarInterface&MockObject $componentRegistrar;
16+
private ThemePath $themePath;
17+
18+
protected function setUp(): void
19+
{
20+
$this->componentRegistrar = $this->createMock(ComponentRegistrarInterface::class);
21+
$this->themePath = new ThemePath($this->componentRegistrar);
22+
}
23+
24+
public function testReturnsFrontendThemePath(): void
25+
{
26+
$this->componentRegistrar
27+
->method('getPaths')
28+
->with(ComponentRegistrar::THEME)
29+
->willReturn([
30+
'frontend/Vendor/theme' => '/app/design/frontend/Vendor/theme',
31+
'adminhtml/Vendor/theme' => '/app/design/adminhtml/Vendor/theme',
32+
]);
33+
34+
$this->assertSame('/app/design/frontend/Vendor/theme', $this->themePath->getPath('Vendor/theme'));
35+
}
36+
37+
public function testFallsBackToAdminhtmlThemePath(): void
38+
{
39+
$this->componentRegistrar
40+
->method('getPaths')
41+
->willReturn([
42+
'adminhtml/Vendor/backend' => '/app/design/adminhtml/Vendor/backend',
43+
]);
44+
45+
$this->assertSame('/app/design/adminhtml/Vendor/backend', $this->themePath->getPath('Vendor/backend'));
46+
}
47+
48+
public function testReturnsNullForUnknownTheme(): void
49+
{
50+
$this->componentRegistrar
51+
->method('getPaths')
52+
->willReturn([
53+
'frontend/Other/theme' => '/app/design/frontend/Other/theme',
54+
]);
55+
56+
$this->assertNull($this->themePath->getPath('Vendor/unknown'));
57+
}
58+
59+
public function testReturnsNullWhenNoThemesRegistered(): void
60+
{
61+
$this->componentRegistrar->method('getPaths')->willReturn([]);
62+
63+
$this->assertNull($this->themePath->getPath('Vendor/theme'));
64+
}
65+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenForgeProject\MageForge\Test\Unit\Service;
6+
7+
use Magento\Framework\Shell;
8+
use OpenForgeProject\MageForge\Service\CacheCleaner;
9+
use PHPUnit\Framework\MockObject\MockObject;
10+
use PHPUnit\Framework\TestCase;
11+
use Symfony\Component\Console\Style\SymfonyStyle;
12+
13+
class CacheCleanerTest extends TestCase
14+
{
15+
private Shell&MockObject $shell;
16+
private SymfonyStyle&MockObject $io;
17+
private CacheCleaner $cacheCleaner;
18+
19+
protected function setUp(): void
20+
{
21+
$this->shell = $this->createMock(Shell::class);
22+
$this->io = $this->createMock(SymfonyStyle::class);
23+
$this->cacheCleaner = new CacheCleaner($this->shell);
24+
}
25+
26+
public function testCleansFrontendCacheTypes(): void
27+
{
28+
$this->shell
29+
->expects($this->once())
30+
->method('execute')
31+
->with('bin/magento cache:clean full_page block_html layout translate');
32+
33+
$this->assertTrue($this->cacheCleaner->clean($this->io, false));
34+
}
35+
36+
public function testPrintsProgressInVerboseMode(): void
37+
{
38+
$this->io->expects($this->once())->method('text')->with('Cleaning cache...');
39+
$this->io->expects($this->once())->method('success')->with('Cache cleaned successfully.');
40+
41+
$this->assertTrue($this->cacheCleaner->clean($this->io, true));
42+
}
43+
44+
public function testStaysQuietWhenNotVerbose(): void
45+
{
46+
$this->io->expects($this->never())->method('text');
47+
$this->io->expects($this->never())->method('success');
48+
49+
$this->assertTrue($this->cacheCleaner->clean($this->io, false));
50+
}
51+
52+
public function testReturnsFalseAndPrintsErrorWhenShellFails(): void
53+
{
54+
$this->shell->method('execute')->willThrowException(new \RuntimeException('cache backend gone'));
55+
$this->io->expects($this->once())->method('error')->with('Failed to clean cache: cache backend gone');
56+
57+
$this->assertFalse($this->cacheCleaner->clean($this->io, true));
58+
}
59+
}

0 commit comments

Comments
 (0)