Testování v Pest PHP: Rozbor našeho vlastního CurriculumIntegrityTest
Osvojte si moderní testování v PHP 8.4 a Pest PHP. Ukážeme si, jak píšeme integrační testy zabraňující halucinacím a testujeme Livewire komponenty v CoursePlatformTest.
Moderní testování v Laravelu a Pest PHP
Automatizované testování je základním pilířem profesionálního softwarového inženýrství. V moderním ekosystému Laravelu 12 a PHP 8.4 se standardem stal testovací framework Pest PHP a objektové Feature testy postavené na základech PHPUnit.
V naší výukové platformě nehrají testy pouze roli běžného ověření funkčnosti, ale slouží jako architektonický štít proti halucinacím (Zero-Hallucination Policy).
Anatomie testu CurriculumIntegrityTest
Prozkoumejte soubor tests/Feature/CurriculumIntegrityTest.php. Všimněte si tří klíčových testovacích metod:
1. test_curriculum_registry_json_is_valid_and_all_anatomy_files_exist()
Tento test načte konfigurační registr kurzu .agents/memory/curriculum_registry.json, zkontroluje existenci všech 10 modulů a pro každou jednotlivou lekci dynamicky ověří:
- Zda jsou vyplněny povinné identifikátory (
slug,lessons). - Zda každý odkazovaný soubor v
anatomy_filesreálně existuje na disku v našem repozitáři. - Zda má soubor nenulový počet řádků (
$node->lineCount > 0).
Pokud by jakýkoliv vývojář nebo AI subagent odkázal v lekci na neexistující třídu či vymyslel fiktivní soubor, test okamžitě selže s jasnou chybovou hláškou: „Halucinace odhalena: Soubor X v lekci Y v repozitáři neexistuje!“.
2. test_anatomy_inspector_blocks_sensitive_files_and_path_traversal()
Tento test prověřuje bezpečnostní vrstvu služby AnatomyInspectorService. Simuluje pokusy o útok typu Path Traversal (../../etc/passwd, ../.env, .git/config, storage/logs/laravel.log) a ověřuje, že inspektor tyto požadavky striktně odmítne a nevrátí jejich obsah studentovi.
3. test_seeded_curriculum_matches_registry_with_quizzes()
Tento test provede seeding celé databáze ($this->seed(DatabaseSeeder::class)) a porovná záznamy v tabulkách course_modules a course_lessons s JSON registrem. Dále ověří, že:
- Každá lekce má textový obsah o minimální délce.
- Každá lekce obsahuje minimálně 3 kvízové otázky.
Testování reaktivních komponent v CoursePlatformTest
V souboru tests/Feature/CoursePlatformTest.php testujeme reaktivní Livewire komponenty i koncové HTTP endpointy.
Díky knihovně Livewire Testing můžeme komponenty testovat bez nutnosti spouštět pomalý headless prohlížeč (jako Selenium či Playwright):
$lesson = CourseLesson::with('quizQuestions')->firstOrFail();
$component = Livewire::test(LessonQuiz::class, ['lesson' => $lesson]);
foreach ($lesson->quizQuestions as $question) {
$component->call('selectOption', $question->id, $question->correct_option);
}
$component->call('submitQuiz')
->assertSet('submitted', true)
->assertSet('scorePercent', 100);
Tento test během několika milisekund nasimuluje výběr správných odpovědí ve formuláři, odeslání kvízu a ověří, že interní reaktivní stav komponenty dosáhl 100% skóre.
Role traitu RefreshDatabase
Trait RefreshDatabase zajišťuje, že každý test běží v dokonale čistém a izolovaném prostředí. Při prvním spuštění provede migrace a každý jednotlivý test obalí do databázové transakce. Po skončení testu se provede ROLLBACK, takže žádný test neovlivňuje data ostatních testů.
// tests/Feature/CurriculumIntegrityTest.php
namespace Tests\Feature;
use App\Models\CourseLesson;
use App\Models\CourseModule;
use App\Services\AnatomyInspectorService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CurriculumIntegrityTest extends TestCase
{
use RefreshDatabase;
// Test ověřuje existenci všech souborů osnovy na disku (Zero Hallucination)
public function test_curriculum_registry_json_is_valid_and_all_anatomy_files_exist(): void
{
$registryPath = base_path('.agents/memory/curriculum_registry.json');
$this->assertFileExists($registryPath);
$registry = json_decode((string) file_get_contents($registryPath), true);
$this->assertCount(10, $registry['modules'] ?? []);
/** @var AnatomyInspectorService $inspector */
$inspector = app(AnatomyInspectorService::class);
foreach ($registry['modules'] as $moduleDef) {
foreach ($moduleDef['lessons'] as $lessonDef) {
foreach ($lessonDef['anatomy_files'] as $filePath) {
$node = $inspector->inspectFile($filePath);
$this->assertTrue(
$node->exists,
"Halucinace odhalena: Soubor '{$filePath}' v lekci '{$lessonDef['slug']}' v repozitáři neexistuje!"
);
$this->assertGreaterThan(0, $node->lineCount);
}
}
}
}
}
🎯 Ověření znalostí z lekce
Kvíz otestuje pochopení architektury a reálného kódu probíraného v této lekci.
Jak trait RefreshDatabase zajišťuje izolaci mezi jednotlivými testy?
Jak v našem projektu testujeme interaktivní komponentu Livewire 3 bez spouštění plného headless browseru?
K jakému účelu slouží test test_anatomy_inspector_blocks_sensitive_files_and_path_traversal?
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Tests\Feature; |
| 6 | |
| 7 | use App\Models\CourseLesson; |
| 8 | use App\Models\CourseModule; |
| 9 | use App\Services\AnatomyInspectorService; |
| 10 | use Database\Seeders\DatabaseSeeder; |
| 11 | use Illuminate\Foundation\Testing\RefreshDatabase; |
| 12 | use Tests\TestCase; |
| 13 | |
| 14 | class CurriculumIntegrityTest extends TestCase |
| 15 | { |
| 16 | use RefreshDatabase; |
| 17 | |
| 18 | public function test_curriculum_registry_json_is_valid_and_all_anatomy_files_exist(): void |
| 19 | { |
| 20 | $registryPath = base_path('.agents/memory/curriculum_registry.json'); |
| 21 | $this->assertFileExists($registryPath); |
| 22 | |
| 23 | $registry = json_decode((string) file_get_contents($registryPath), true); |
| 24 | $this->assertIsArray($registry); |
| 25 | $this->assertCount(10, $registry['modules'] ?? []); |
| 26 | |
| 27 | /** @var AnatomyInspectorService $inspector */ |
| 28 | $inspector = app(AnatomyInspectorService::class); |
| 29 | |
| 30 | foreach ($registry['modules'] as $moduleDef) { |
| 31 | $this->assertNotEmpty($moduleDef['slug']); |
| 32 | $this->assertNotEmpty($moduleDef['lessons']); |
| 33 | |
| 34 | foreach ($moduleDef['lessons'] as $lessonDef) { |
| 35 | $this->assertNotEmpty($lessonDef['anatomy_files']); |
| 36 | |
| 37 | foreach ($lessonDef['anatomy_files'] as $filePath) { |
| 38 | $node = $inspector->inspectFile($filePath); |
| 39 | $this->assertTrue( |
| 40 | $node->exists, |
| 41 | "Halucinace odhalena: Soubor '{$filePath}' v lekci '{$lessonDef['slug']}' v repozitáři neexistuje nebo není povolen!" |
| 42 | ); |
| 43 | $this->assertGreaterThan(0, $node->lineCount); |
| 44 | } |
| 45 | } |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | public function test_anatomy_inspector_blocks_sensitive_files_and_path_traversal(): void |
| 50 | { |
| 51 | /** @var AnatomyInspectorService $inspector */ |
| 52 | $inspector = app(AnatomyInspectorService::class); |
| 53 | |
| 54 | $forbiddenPaths = [ |
| 55 | '.env', |
| 56 | '../.env', |
| 57 | '../../etc/passwd', |
| 58 | '.git/config', |
| 59 | 'vendor/autoload.php', |
| 60 | 'storage/logs/laravel.log', |
| 61 | ]; |
| 62 | |
| 63 | foreach ($forbiddenPaths as $forbidden) { |
| 64 | $this->assertFalse($inspector->isAllowedPath($forbidden), "Cesta '{$forbidden}' musí být zablokována!"); |
| 65 | $node = $inspector->inspectFile($forbidden); |
| 66 | $this->assertFalse($node->exists); |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | public function test_seeded_curriculum_matches_registry_with_quizzes(): void |
| 71 | { |
| 72 | $this->seed(DatabaseSeeder::class); |
| 73 | |
| 74 | $registry = json_decode((string) file_get_contents(base_path('.agents/memory/curriculum_registry.json')), true); |
| 75 | |
| 76 | $this->assertSame(count($registry['modules']), CourseModule::count()); |
| 77 | |
| 78 | foreach ($registry['modules'] as $moduleDef) { |
| 79 | $module = CourseModule::where('slug', $moduleDef['slug'])->first(); |
| 80 | $this->assertNotNull($module, "Chybí modul '{$moduleDef['slug']}' v databázi."); |
| 81 | |
| 82 | foreach ($moduleDef['lessons'] as $lessonDef) { |
| 83 | $lesson = CourseLesson::where('slug', $lessonDef['slug'])->first(); |
| 84 | $this->assertNotNull($lesson, "Chybí lekce '{$lessonDef['slug']}' v databázi."); |
| 85 | $this->assertGreaterThan(150, strlen($lesson->content_markdown), "Lekce '{$lesson->slug}' má příliš krátký obsah."); |
| 86 | $this->assertGreaterThanOrEqual(3, $lesson->quizQuestions()->count(), "Lekce '{$lesson->slug}' musí mít alespoň 3 kvízové otázky."); |
| 87 | } |
| 88 | } |
| 89 | } |
| 90 | } |
| 91 | |