Files
Gnuboard7/tests/Feature/Upgrade/CoreUpgradeStepSpawnTest.php
HeuJung 7e569efe4c test(core): 개발 환경 파일을 건드리는 테스트 격리
테스트가 개발자의 `.env` 를 삭제한 사고 이후 부트스트랩에 복원 안전망을 넣었지만,
그것은 프로세스 종료 시점 복원이라 같은 실행 안에서 뒤 테스트가 훼손된 파일을 읽는 구간이
남아 있었다. 복원이 아니라 애초에 건드리지 않는 구조로 바꾼다.

앱 루트를 임시 디렉토리로 돌리면 `base_path` 를 쓰는 제품 코드는 한 줄도 바뀌지 않은 채
격리된 파일만 다룬다. 공용 트레이트로 묶어 세 클래스가 같은 방식을 쓰게 했다.

격리 자체를 증명하기 위해 안전망을 임시로 끄고 돌렸다. 안전망 없이도 `.env` 가 바이트
단위로 그대로였다 — "green 이니 됐다" 로는 안전망이 뒤에서 복원해 준 것과 구분되지 않는다.

이 과정에서 격리로는 막을 수 없는 유형을 하나 더 찾았다. 별도 프로세스를 띄워 업그레이드
커맨드를 실행하는 테스트가 자식 쪽에서 `.env` 의 버전 값을 덮어쓰고 있었다. 부모의 앱 루트
전환도, 테스트 부트스트랩의 안전망도 자식에는 닿지 않는다. 테스트 환경 지정은 읽는 파일만
바꿀 뿐 쓰기 대상은 언제나 프로젝트 루트의 `.env` 다. 그 테스트가 검증하는 것은 자식이 방금
쓴 클래스를 읽어들이는지 뿐이므로, 커맨드가 이미 제공하는 부수효과 차단 옵션을 붙였다.

같은 구조의 문제가 업그레이드 스텝 임시 파일에도 있었다 — 실제 `upgrades/` 에 쓰고 정리를
tearDown 에 의존하므로 중단되면 개발 체크아웃에 정체불명의 스텝이 남는다. 실제로 두 번
발견해 지웠고, 부트스트랩 셧다운 훅이 그 패턴을 쓸어내도록 했다.

검증: 세 클래스 개별 green, 안전망을 끈 통합 실행 28건 green + `.env` 불변,
자식 프로세스 테스트 green, 전 구간 `.env` 해시 불변, audit 위반 0.

알려진 잔여 문제: 인스톨러와 업그레이드 스위트를 한 프로세스로 합쳐 돌리면 모듈 클래스
중복 선언 fatal 로 중단된다. 각각 따로 돌리면 297 / 301 건 모두 통과하며, 이번 변경 이전에도
같은 증상이었다. 별도 조사가 필요해 이번 범위에서 다루지 않았다.
2026-07-23 11:39:59 +09:00

154 lines
4.9 KiB
PHP

<?php
namespace Tests\Feature\Upgrade;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
/**
* Test-1: 경로 B — `core:execute-upgrade-steps` 가 별도 PHP 프로세스로 실행될 때
* 최신 파일 기준으로 클래스를 로드함을 실증한다.
*
* 시나리오:
* 1. 임시 Service 클래스(`app/Services/TestUpgradeSpawnMarkerService.php`) 작성 —
* 프로세스 메모리 캐시와 무관한 마커 파일을 생성하는 정적 메서드 보유
* 2. 임시 upgrade 파일(`upgrades/Upgrade_0_0_1_test_spawn.php`) 작성 — run() 이
* 신규 Service 의 정적 메서드 호출
* 3. `proc_open` 으로 `php artisan core:execute-upgrade-steps --from=0.0.0 --to=0.0.1 --force`
* 실행 → 새 PHP 프로세스가 방금 작성한 파일들을 autoload 로 읽어 실행
* 4. 마커 파일 존재로 정상 호출 검증
* 5. tearDown: 임시 파일 3종(Service, Upgrade, 마커) 삭제
*
* 본 테스트가 통과하면 경로 B 구조(별도 프로세스 spawn → 최신 클래스 로드)가
* 실제로 작동함을 보장한다.
*/
class CoreUpgradeStepSpawnTest extends TestCase
{
private string $tmpServicePath;
private string $tmpUpgradePath;
private string $markerPath;
protected function setUp(): void
{
parent::setUp();
$this->tmpServicePath = base_path('app/Services/TestUpgradeSpawnMarkerService.php');
$this->tmpUpgradePath = base_path('upgrades/Upgrade_0_0_1_test_spawn.php');
$this->markerPath = storage_path('app/test-upgrade-spawn-marker-'.uniqid().'.txt');
}
protected function tearDown(): void
{
foreach ([$this->tmpServicePath, $this->tmpUpgradePath, $this->markerPath] as $p) {
if (File::exists($p)) {
File::delete($p);
}
}
parent::tearDown();
}
public function test_spawned_process_loads_fresh_classes(): void
{
if (! function_exists('proc_open')) {
$this->markTestSkipped('proc_open 미지원 환경');
}
$markerPath = addslashes($this->markerPath);
$serviceCode = <<<PHP
<?php
namespace App\\Services;
class TestUpgradeSpawnMarkerService
{
public static function writeMarker(): void
{
file_put_contents('{$markerPath}', 'spawned:'.getmypid());
}
}
PHP;
$upgradeCode = <<<'PHP'
<?php
namespace App\Upgrades;
use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\UpgradeContext;
class Upgrade_0_0_1_test_spawn implements UpgradeStepInterface
{
public function version(): string
{
return '0.0.1';
}
public function description(): string
{
return 'Test spawn marker';
}
public function run(UpgradeContext $context): void
{
\App\Services\TestUpgradeSpawnMarkerService::writeMarker();
}
}
PHP;
File::put($this->tmpServicePath, $serviceCode);
File::put($this->tmpUpgradePath, $upgradeCode);
$this->assertFileDoesNotExist($this->markerPath, '사전: 마커 파일이 없어야 한다');
$phpBinary = PHP_BINARY;
$artisan = base_path('artisan');
$commandLine = implode(' ', array_map('escapeshellarg', [
$phpBinary,
$artisan,
'core:execute-upgrade-steps',
'--from=0.0.0',
'--to=0.0.1',
'--force',
'--env=testing',
// 본 테스트가 보는 것은 "spawn 된 자식이 방금 쓴 클래스를 로드하는가" 뿐이다.
// 이 옵션이 없으면 자식이 `.env` 의 APP_VERSION 을 0.0.1 로 덮어쓴다 —
// `--env=testing` 은 읽는 파일만 바꿀 뿐 쓰기 대상은 언제나 `base_path('.env')`
// 이므로 개발자의 실제 `.env` 가 훼손된다. 자식 프로세스라 부모의 base path
// 격리도, 테스트 부트스트랩의 복원 안전망도 닿지 않는다.
'--skip-version-env',
])).' 2>&1';
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$process = proc_open($commandLine, $descriptors, $pipes, base_path());
$this->assertIsResource($process, 'proc_open 으로 별도 프로세스 실행 성공해야 한다');
fclose($pipes[0]);
$stdout = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
$this->assertSame(
0,
$exitCode,
"spawn 프로세스가 exit 0 으로 종료되어야 한다. output:\n{$stdout}"
);
$this->assertFileExists(
$this->markerPath,
'별도 프로세스가 최신 Service 를 autoload 로 로드하여 마커를 작성해야 한다'
);
$marker = File::get($this->markerPath);
$this->assertStringStartsWith('spawned:', $marker, '마커 내용이 예상 형식이어야 한다');
}
}