Laravel Guardrails (์•ˆ์ „ ๋‚œ๊ฐ„)

๐Ÿ›ก๏ธ AI ๊ธฐ๋ฐ˜ Laravel ๊ฐœ๋ฐœ์„ ์œ„ํ•œ ํ”„๋กœ๋•์…˜ ์•ˆ์ „ ๋‚œ๊ฐ„ โ€” AI ์—์ด์ „ํŠธ๊ฐ€ ๊ต์ฐจ ํ…Œ๋„ŒํŠธ ๋ˆ„์ˆ˜, N+1 ์ฟผ๋ฆฌ, ํŒŒ๊ดด์ ์ธ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ๋ฐฐํฌํ•˜๊ธฐ ์ „์— ์ฐจ๋‹จํ•ฉ๋‹ˆ๋‹ค. Claude Code, Cursor ๋ฐ 40๊ฐœ ์ด์ƒ์˜ ์—์ด์ „ํŠธ์—์„œ ์ง€์›๋ฉ๋‹ˆ๋‹ค.

Laravel Guardrails (์•ˆ์ „ ๋‚œ๊ฐ„)๋ž€ ๋ฌด์—‡์ธ๊ฐ€์š”?

์ด ์Šคํ‚ฌ์€ AI ์—์ด์ „ํŠธ(Claude Code, Cursor, Codex ๋“ฑ)๊ฐ€ Laravel ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๊ฐœ๋ฐœํ•  ๋•Œ ํ”ํžˆ ๋ฐœ์ƒํ•˜๋Š” ์น˜๋ช…์ ์ธ ์‹ค์ˆ˜๋ฅผ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•œ ์—„๊ฒฉํ•œ ํ…Œ์ŠคํŠธ ๊ทœ์œจ์„ ๊ฐ•์ œํ•ฉ๋‹ˆ๋‹ค. ์ฃผ์š” ์ดˆ์ ์€ 'ํ–‰๋™ ๊ธฐ๋ฐ˜ ํ…Œ์ŠคํŠธ(behavioral testing)'๋ฅผ ํ†ตํ•ด ๊ต์ฐจ ํ…Œ๋„ŒํŠธ ๋ฐ์ดํ„ฐ ๋ˆ„์ˆ˜, ์ธ์ฆ/๊ถŒํ•œ ๋ถ€์—ฌ ๋ˆ„๋ฝ, N+1 ์ฟผ๋ฆฌ, ํŒŒ๊ดด์ ์ธ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๋“ฑ์˜ ํ”„๋กœ๋•์…˜ ๋ฌธ์ œ๋ฅผ ์‚ฌ์ „์— ์ฐจ๋‹จํ•˜๋Š” ๋ฐ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ์Šคํ‚ฌ์€ Pest 3/4 ํ…Œ์ŠคํŠธ ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์‹ค์ œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์™€ ๊ฒฝ๊ณ„๋ฅผ ๊ฒ€์ฆํ•˜๋ฉฐ, ๊ณผ๋„ํ•œ ๋ชจํ‚น(mocking)์„ ๊ธˆ์ง€ํ•˜๊ณ , ํŒฉํ† ๋ฆฌ(factory), ๋ฐ์ดํ„ฐ์…‹(dataset), ์•„ํ‚คํ…์ฒ˜ ํ…Œ์ŠคํŠธ(architecture test)๋ฅผ ํ™œ์šฉํ•œ ๋ชจ๋ฒ” ์‚ฌ๋ก€๋ฅผ ์ œ์‹œํ•ฉ๋‹ˆ๋‹ค. ๋˜ํ•œ CI ํŒŒ์ดํ”„๋ผ์ธ์—์„œ ์ ์šฉ ๊ฐ€๋Šฅํ•œ ์ตœ์†Œ ์ปค๋ฒ„๋ฆฌ์ง€(minimum coverage) ๋ฐ ์œ ํ˜• ์ปค๋ฒ„๋ฆฌ์ง€(type coverage) ๊ฒ€์ฆ ๋ช…๋ น์–ด๋ฅผ ์ œ๊ณตํ•˜์—ฌ, ๋ชจ๋“  ๊ธฐ๋Šฅ์ด ์•ˆ์ „ํ•˜๊ณ  ์œ ์ง€๋ณด์ˆ˜ ๊ฐ€๋Šฅํ•œ ์ƒํƒœ๋กœ ๋ฐฐํฌ๋˜๋„๋ก ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค.

์ง€์› ๋Œ€์ƒโœ“Claude Code~Codex CLIโœ“Cursor
npx skills add shaxzodbek-uzb/laravel-guardrails

Installed? Explore more ๋ผ์ดํŒ… & ์—๋””ํŒ… skills: steipete/notion, langchain-ai/langchain, bytedance/podcast-generation ยท View all 6 โ†’

์ฆ๊ฒจ ์‚ฌ์šฉํ•˜๋Š” AI์—๊ฒŒ ๋ฌผ์–ด๋ณด๊ธฐ

์ด ์—์ด์ „ํŠธ ์Šคํ‚ฌ์ด ๋ฏธ๋ฆฌ ๋กœ๋“œ๋œ ์ƒˆ ์ฑ„ํŒ…์„ ์—ฝ๋‹ˆ๋‹ค.

๋ฌธ์„œ

Pest ํ…Œ์ŠคํŠธ ๊ทœ์œจ

ํ…Œ์ŠคํŠธ๋˜์ง€ ์•Š์€ ๊ธฐ๋Šฅ, ํ˜น์€ ๊ณผ๋„ํ•˜๊ฒŒ ๋ชจํ‚น(mocking)๋˜์–ด ๊ตฌํ˜„ ์„ธ๋ถ€ ์‚ฌํ•ญ์„ ๋‹จ์–ธ(assert)ํ•˜๋Š” ํ…Œ์ŠคํŠธ ์Šค์œ„ํŠธ๋Š” ํ”„๋กœ๋•์…˜์—์„œ ์กฐ์šฉํžˆ ๋ง๊ฐ€์ง€๊ณ  ๋ชจ๋“  ๋ฆฌํŒฉํ† ๋ง ๋•Œ๋งˆ๋‹ค ์ฉ์–ด๊ฐ‘๋‹ˆ๋‹ค. ์ด ์Šคํ‚ฌ์€ ํ–‰๋™์„ ์ฆ๋ช…ํ•˜๊ณ , ์‹คํŒจ ๊ฒฝ๋กœ(unhappy path)์™€ ๊ถŒํ•œ ๋ถ€์—ฌ(authorization)๋ฅผ ๋‹ค๋ฃจ๋ฉฐ, ๋ฆฌํŒฉํ† ๋ง์—๋„ ์‚ด์•„๋‚จ๋Š” Pest 3/4 ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑํ•˜๋„๋ก ํ•ฉ๋‹ˆ๋‹ค.

ํ•จ์ • (The footgun)

๋‘ ๊ฐ€์ง€ ์‹คํŒจ ๋ชจ๋“œ๊ฐ€ ์žˆ์œผ๋ฉฐ, ๋‘˜ ๋‹ค ๋น„์šฉ์ด ๋งŽ์ด ๋“ญ๋‹ˆ๋‹ค:

  1. ํ…Œ์ŠคํŠธ ์—†์Œ / ํ•ดํ”ผ ํŒจ์Šค(happy-path) ์ „์šฉ ํ…Œ์ŠคํŠธ. ๋ฐ๋ชจ์—์„œ๋Š” ๊ธฐ๋Šฅ์ด ์ž‘๋™ํ•˜์ง€๋งŒ, ํ”„๋กœ๋•์…˜์—์„œ ๊ฒŒ์ŠคํŠธ๊ฐ€ ์—”๋“œํฌ์ธํŠธ์— ์ ‘๊ทผํ•˜๊ฑฐ๋‚˜, ํ…Œ๋„ŒํŠธ๊ฐ€ ๋‹ค๋ฅธ ํ…Œ๋„ŒํŠธ์˜ ๋ฐ์ดํ„ฐ๋ฅผ ์ฝ๊ฑฐ๋‚˜, ๊ฒ€์ฆ ๊ณต๋ฐฑ์œผ๋กœ ์“ฐ๋ ˆ๊ธฐ ๋ฐ์ดํ„ฐ๊ฐ€ ๋“ค์–ด๊ฐ€๊ฑฐ๋‚˜, 404๊ฐ€ 500์ด ๋ฉ๋‹ˆ๋‹ค. ๊ถŒํ•œ ๋ถ€์—ฌ ๊ตฌ๋ฉ(403/404 ๊ฒฝ๋กœ๊ฐ€ ๋‹จ์–ธ๋˜์ง€ ์•Š์Œ)์€ ๋ฐ์ดํ„ฐ ๋ˆ„์ถœ์ด ํ”„๋กœ๋•์…˜์œผ๋กœ ๋ฐฐ์†ก๋˜๋Š” ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค.
  2. ์ทจ์•ฝํ•˜๊ณ  ๊ณผ๋„ํ•˜๊ฒŒ ๋ชจํ‚น๋œ ํ…Œ์ŠคํŠธ. ์ž์ฒด UserRepository/์„œ๋น„์Šค๋ฅผ shouldReceive('save')->once()๋กœ ๋ชจํ‚นํ•˜๋ฉด ์‹ค์ œ ์ฟผ๋ฆฌ๊ฐ€ ์ž˜๋ชป๋˜์–ด๋„ ํ…Œ์ŠคํŠธ๋Š” ํ†ต๊ณผํ•˜๊ณ , ๋ˆ„๊ตฐ๊ฐ€ ๋ฉ”์„œ๋“œ ์ด๋ฆ„์„ ๋ฐ”๊พธ๋Š” ์ฆ‰์‹œ ํญ๋ฐœํ•ฉ๋‹ˆ๋‹ค. ์Šค์œ„ํŠธ๋Š” ๋…น์ƒ‰์ด๊ณ  ๊ฑฐ์ง“๋œ ์ž์‹ ๊ฐ์„ ์ฃผ๋ฉฐ, ๋‹ค์Œ ๋ฆฌํŒฉํ† ๋ง์—์„œ ์ขŒ์ ˆ๊ฐ์— ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค โ€” ๋‹ค์‹œ ๋ชจ๋“œ 1๋กœ ๋Œ์•„๊ฐ‘๋‹ˆ๋‹ค.

์–ด๋А ์ชฝ์ด๋“  ํ•ด๊ฒฐ์ฑ…์€ ๋™์ผํ•œ ๊ทœ์œจ์ž…๋‹ˆ๋‹ค: ์†Œ์œ ํ•˜์ง€ ์•Š์€ ๊ฒฝ๊ณ„๋Š” ๊ฐ€์งœ๋กœ(fake) ๋งŒ๋“ค๊ณ , ์†Œ์œ ํ•œ ์ฝ”๋“œ๋Š” ์‹ค์ œ๋กœ ์‹คํ–‰ํ•˜๋ฉฐ, ๊ด€์ฐฐ ๊ฐ€๋Šฅํ•œ ๊ฒฐ๊ณผ(DB ํ–‰, HTTP ์ƒํƒœ, ๋””์ŠคํŒจ์น˜๋œ ์ž‘์—…/๋ฉ”์ผ)๋ฅผ ๋‹จ์–ธํ•˜์„ธ์š” โ€” ์‹คํŒจ ๋ฐ ๊ถŒํ•œ ๋ถ€์—ฌ ๊ฒฐ๊ณผ๋ฅผ ํฌํ•จํ•˜์—ฌ.

๊ทœ์น™ (Rules)

  1. ๊ธฐ๋Šฅ๊ณผ ํ•จ๊ป˜ ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑํ•˜๊ณ , "๋‚˜์ค‘์—" ํ•˜์ง€ ๋งˆ์„ธ์š”. ์ปจํŠธ๋กค๋Ÿฌ/์ž‘์—…/๋ช…๋ น/์ •์ฑ… ๋ณ€๊ฒฝ์€ Pest ํ…Œ์ŠคํŠธ๊ฐ€ ์žˆ์„ ๋•Œ๊นŒ์ง€ ์™„๋ฃŒ๋˜์ง€ ์•Š์€ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ๊ธฐ๋Šฅ์— ํ•ด๋‹น ๋™์ž‘์„ ๋‹จ์–ธํ•˜๋Š” ํ…Œ์ŠคํŠธ ์—†์ด ์™„๋ฃŒ๋˜์—ˆ๋‹ค๊ณ  ๋ณด๊ณ ํ•˜์ง€ ๋งˆ์„ธ์š”.
  2. Pest ๊ด€์šฉ๊ตฌ๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”. ์ผ€์ด์Šค๋ฅผ it('...') ๋˜๋Š” test('...')๋กœ ์ •์˜ํ•˜๊ณ , expect(...)๋กœ ์œ ์ฐฝํ•˜๊ฒŒ ๋‹จ์–ธํ•˜๋ฉฐ, beforeEach()๋กœ ์„ค์ •์„ ๊ณต์œ ํ•˜์„ธ์š”. ํŒŒ์ผ์ด ์ด๋ฏธ PHPUnit ์Šคํƒ€์ผ์ด ์•„๋‹ˆ๋ผ๋ฉด class FooTest extends TestCase๋ฅผ ์ž‘์„ฑํ•˜์ง€ ๋งˆ์„ธ์š”.
  3. ์˜ฌ๋ฐ”๋ฅธ ์œ ํ˜•์„ ์„ ํƒํ•˜์„ธ์š”. HTTP/์—”๋“œ ํˆฌ ์—”๋“œ ๋™์ž‘ โ†’ ๊ธฐ๋Šฅ ํ…Œ์ŠคํŠธ(Feature test, ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ๋ถ€ํŒ…ํ•˜๊ณ  $this->get/post/...๋กœ ๋ผ์šฐํŠธ๋ฅผ ํ˜ธ์ถœ). ํ”„๋ ˆ์ž„์›Œํฌ/DB๊ฐ€ ์—†๋Š” ์ˆœ์ˆ˜ ๋กœ์ง โ†’ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ(Unit test). ์˜์‹ฌ์Šค๋Ÿฌ์šฐ๋ฉด ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ๊ธฐ๋Šฅ ํ…Œ์ŠคํŠธ๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š” โ€” ๋” ๋งŽ์€ ๊ฒƒ์„ ์žก์•„๋ƒ…๋‹ˆ๋‹ค.
  4. ํŠธ๋ ˆ์ดํŠธ(trait)์™€ uses()๋กœ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ๋ฆฌ์…‹ํ•˜์„ธ์š”. ์ „์ฒด Feature ๋””๋ ‰ํ† ๋ฆฌ์— ๋Œ€ํ•ด tests/Pest.php์—์„œ RefreshDatabase(ํŠธ๋žœ์žญ์…˜ ๋ž˜ํ•‘) ๋˜๋Š” LazilyRefreshDatabase(ํ…Œ์ŠคํŠธ๊ฐ€ DB์— ๋‹ฟ์„ ๋•Œ๋งŒ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜)๋ฅผ ์ ์šฉํ•˜๊ฑฐ๋‚˜, ํŒŒ์ผ ์ƒ๋‹จ์— uses(RefreshDatabase::class)๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”. ์ ˆ๋Œ€ DB ํ…Œ์ŠคํŠธ๋ฅผ ์ž”์—ฌ ์ƒํƒœ( leftover state)์— ์˜์กดํ•˜๊ฒŒ ๋‘์ง€ ๋งˆ์„ธ์š”.
  5. ๋ฐ์ดํ„ฐ๋ฅผ ํŒฉํ† ๋ฆฌ(factory)๋กœ ๊ตฌ์ถ•ํ•˜๊ณ , ์ˆ˜๋™ ์‚ฝ์ž…์€ ์ ˆ๋Œ€ ์‚ฌ์šฉํ•˜์ง€ ๋งˆ์„ธ์š”. User::factory()->create() / ->make() ๋ฐ ๊ด€๊ณ„ ํŒฉํ† ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”. ํ…Œ์ŠคํŠธ์—์„œ ์ˆ˜๋™์œผ๋กœ DB::table()->insert([...]) ๋˜๋Š” new Model([...])->save()๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์€ ์ทจ์•ฝํ•˜๊ณ  ์บ์ŠคํŠธ/๊ธฐ๋ณธ๊ฐ’์„ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค โ€” ๊ธˆ์ง€ํ•ฉ๋‹ˆ๋‹ค.
  6. ๋ฐ์ดํ„ฐ์…‹(datasets)์œผ๋กœ ์ค‘๋ณต ์ œ๊ฑฐํ•˜๊ณ , ๋ณต์‚ฌ-๋ถ™์—ฌ๋„ฃ๊ธฐํ•˜์ง€ ๋งˆ์„ธ์š”. ๋™์ผํ•œ ๋‹จ์–ธ์ด ๋งŽ์€ ์ž…๋ ฅ(์œ ํšจ/๋ฌดํšจ ํŽ˜์ด๋กœ๋“œ, ์—ญํ•  ๋งคํŠธ๋ฆญ์Šค)์— ๋Œ€ํ•ด ์‹คํ–‰๋  ๋•Œ๋Š” N๊ฐœ์˜ ๊ฑฐ์˜ ๋™์ผํ•œ ํ…Œ์ŠคํŠธ ๋Œ€์‹  ->with([...])(์ธ๋ผ์ธ ๋˜๋Š” tests/Datasets/์˜ ๋ช…๋ช…๋œ ๋ฐ์ดํ„ฐ์…‹)์„ ์‚ฌ์šฉํ•˜์„ธ์š”.
  7. ์†Œ์œ ํ•˜์ง€ ์•Š์€ ๊ฒฝ๊ณ„๋Š” ๊ฐ€์งœ๋กœ ๋งŒ๋“œ์„ธ์š”(fake). ์™ธ๋ถ€ ๋˜๋Š” ๋น„๋™๊ธฐ์ ์ธ ๊ฒƒ์— ๋Œ€ํ•ด, ์ž‘์—… ์ „์— ํŒŒ์‚ฌ๋“œ ํŽ˜์ดํฌ๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”: Http::fake(), Queue::fake(), Bus::fake(), Mail::fake(), Notification::fake(), Event::fake(), Storage::fake(). ์‹œ๊ฐ„(time)์— ๋Œ€ํ•ด์„œ๋Š” $this->travel(...), $this->travelTo(...), ๋˜๋Š” $this->freezeTime()์„ ์‚ฌ์šฉํ•˜์„ธ์š” โ€” ์ ˆ๋Œ€ ๊ณ ์ •๋˜์ง€ ์•Š์€ now()๋ฅผ ๋‹จ์–ธํ•˜์ง€ ๋งˆ์„ธ์š”.
  8. ์ž์ฒด ๋ชจ๋ธ/์„œ๋น„์Šค๋ฅผ ๋ฌด์˜๋ฏธํ•˜๊ฒŒ ๋ชจํ‚นํ•˜์ง€ ๋งˆ์„ธ์š”. ํ…Œ์ŠคํŠธ ๋Œ€์ƒ ์ฝ”๋“œ์— ๋Œ€ํ•ด Mockery::mock(MyService::class)->shouldReceive(...) ๋ฐ Model::shouldReceive(...)๋ฅผ ํ”ผํ•˜์„ธ์š”. ์‹ค์ œ ์ฝ”๋“œ๊ฐ€ ํ…Œ์ŠคํŠธ DB์™€ ๊ฐ€์งœ ๊ฒฝ๊ณ„์— ๋Œ€ํ•ด ์‹คํ–‰๋˜๋„๋ก ๋‘์„ธ์š”. ์ž์ฒด ํด๋ž˜์Šค๋ฅผ ๋ชจํ‚นํ•˜๋Š” ๊ฒƒ์€ ์‹ค์ œ๋กœ ์‹คํ–‰ํ•˜์ง€ ์•Š๋Š” ์ง„์ •ํ•œ ์ ‘ํ•ฉ์ (์˜ˆ: ๋А๋ฆฐ ํƒ€์‚ฌ SDK ๋ž˜ํผ)์—์„œ๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.
  9. ๊ฒฐ๊ณผ(outcome)๋ฅผ ๋‹จ์–ธํ•˜๊ณ , ๋‚ด๋ถ€(internals)๋ฅผ ๋‹จ์–ธํ•˜์ง€ ๋งˆ์„ธ์š”. assertDatabaseHas / assertDatabaseMissing / assertModelExists, assertStatus / assertOk / assertCreated, assertJson / assertJsonPath, assertRedirect, Mail::assertSent, Notification::assertSentTo, Queue::assertPushed, Bus::assertDispatched, Storage::disk()->assertExists๋ฅผ ์„ ํ˜ธํ•˜์„ธ์š”. ํ–‰๋™์˜ ๋Œ€๋ฆฌ์ž๋กœ์„œ "๋ฉ”์„œ๋“œ X๊ฐ€ ํ•œ ๋ฒˆ ํ˜ธ์ถœ๋˜์—ˆ๋‹ค"๋ฅผ ๋‹จ์–ธํ•˜์ง€ ๋งˆ์„ธ์š”.
  10. ํ•ญ์ƒ ์‹คํŒจ ๊ฒฝ๋กœ(unhappy path)์™€ ๊ถŒํ•œ ๋ถ€์—ฌ(authorization)๋ฅผ ํ…Œ์ŠคํŠธํ•˜์„ธ์š”. ๋ชจ๋“  ์—”๋“œํฌ์ธํŠธ/์ž‘์—…์—๋Š” ์ตœ์†Œํ•œ ๋‹ค์Œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค: ํ•ดํ”ผ ํŒจ์Šค, ๊ฒ€์ฆ/์‹คํŒจ ๊ฒฝ๋กœ, ๋ฐ ์ธ์ฆ ๊ฒฝ๋กœ โ€” ๊ฒŒ์ŠคํŠธ โ†’ 401/๋ฆฌ๋‹ค์ด๋ ‰ํŠธ, ์ž˜๋ชป๋œ ์‚ฌ์šฉ์ž/์—ญํ•  โ†’ 403, ๋ˆ„๋ฝ๋œ/์™ธ๋ถ€ ๋ฆฌ์†Œ์Šค โ†’ 404. ๋ณด์•ˆ์— ๋ฏผ๊ฐํ•œ ๊ธฐ๋Šฅ์—๋Š” ๋ช…์‹œ์ ์ธ "๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๊ฐ€ ์ด์— ์ ‘๊ทผ/์ˆ˜์ •ํ•  ์ˆ˜ ์—†์Œ" ํ…Œ์ŠคํŠธ๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”. ์ด๋Š” ์†Œ์œ ๊ถŒ ๋˜๋Š” ํ…Œ๋„Œ์‹œ์™€ ๊ด€๋ จ๋œ ๋ชจ๋“  ๊ฒƒ์— ๋Œ€ํ•ด ์–‘๋ณดํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
  11. ์ €๋ ดํ•œ ์ „์—ญ ์•ˆ์ „ ๋‚œ๊ฐ„์œผ๋กœ ์•„ํ‚คํ…์ฒ˜ ํ…Œ์ŠคํŠธ๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”. tests/Arch.php(๋˜๋Š” Pest.php์˜ arch ์ผ€์ด์Šค)์— ์ ํ•ฉํ•œ ํ”„๋ฆฌ์…‹์„ ์œ ์ง€ํ•˜์„ธ์š”: arch()->preset()->php() (die/var_dump/dd ์Šคํƒ€์ผ ๋””๋ฒ„๊ทธ ์ถœ๋ ฅ ๋ฐ ์‚ฌ์šฉ๋˜์ง€ ์•Š๋Š” PHP ํ•จ์ˆ˜ ๊ธˆ์ง€), arch()->preset()->security() (eval, extract, unserialize, ์•ฝํ•œ ํ•ด์‹ฑ/md5/sha1, ์•ˆ์ „ํ•˜์ง€ ์•Š์€ ๋ฌด์ž‘์œ„์„ฑ ๋“ฑ ๊ธˆ์ง€), ๋ฐ arch()->preset()->laravel() (Laravel ๊ด€ํ–‰ ๊ฐ•์ œ โ€” ์„ค์ • ์™ธ๋ถ€์—์„œ env() ์‚ฌ์šฉ ๊ธˆ์ง€ ํฌํ•จ). arch('controllers')->expect('App\Http\Controllers')->toExtend('App\Http\Controllers\Controller')์™€ ๊ฐ™์€ ํ”„๋กœ์ ํŠธ ๋ถˆ๋ณ€ ์กฐ๊ฑด(invariants)์„ ์ถ”๊ฐ€ํ•˜์„ธ์š”.
  12. ํ…Œ์ŠคํŠธ๋‹น ํ•˜๋‚˜์˜ ๋™์ž‘, ์„ค๋ช…์ ์ธ ์ด๋ฆ„. it('blocks guests from the dashboard'), ์ ˆ๋Œ€ it('works')๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค. ํ…Œ์ŠคํŠธ ์ด๋ฆ„์— "and"๊ฐ€ ํ•„์š”ํ•˜๋ฉด ๋ถ„ํ• ํ•˜์„ธ์š”.
  13. CI์—์„œ ๊ฒŒ์ดํŠธ ์ปค๋ฒ„๋ฆฌ์ง€(gate coverage). ./vendor/bin/pest --coverage --min=NN์„ ์‹คํ–‰ํ•˜์—ฌ ์ตœ์†Œ๊ฐ’์„ ๊ฐ•์ œํ•˜๊ณ , ./vendor/bin/pest --type-coverage (type-coverage ํ”Œ๋Ÿฌ๊ทธ์ธ ํ•„์š”)๋ฅผ ์‹คํ–‰ํ•˜์—ฌ ํƒ€์ž… ์‹œ๊ทธ๋‹ˆ์ฒ˜๋ฅผ ๊ฐ•์ œํ•˜์„ธ์š”. ์ƒˆ๋กœ์šด ํ…Œ์ŠคํŠธ๋˜์ง€ ์•Š์€ ์ค„์€ ๊ฒฐํ•จ์œผ๋กœ ์ฒ˜๋ฆฌํ•˜์„ธ์š”.
  14. ๋ธŒ๋ผ์šฐ์ €/E2E๋Š” ๋“œ๋ฌผ๊ฒŒ ์‚ฌ์šฉํ•˜์„ธ์š” (Pest 4). Pest 4๋Š” ๋ธŒ๋ผ์šฐ์ € ํ…Œ์ŠคํŠธ๋ฅผ visit('/')->...->assertSee(...) (Playwright ๊ธฐ๋ฐ˜)๋กœ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ์ง„์ •ํ•œ ํ’€์Šคํƒ/JS ํ๋ฆ„์„ ์œ„ํ•ด ์˜ˆ์•ฝํ•˜์„ธ์š”; HTTP ๋ ˆ์ด์–ด ๋‹จ์–ธ์„ ๋А๋ฆฐ ๋ธŒ๋ผ์šฐ์ € ํ…Œ์ŠคํŠธ๋กœ ๋‹ค์‹œ ๊ตฌํ˜„ํ•˜์ง€ ๋งˆ์„ธ์š”.

์ข‹์€ ์˜ˆ vs ๋‚˜์œ ์˜ˆ

// tests/Feature/UpdatePostTest.php

use App\Models\Post;
use App\Models\User;

// โŒ ๊ณผ๋„ํ•˜๊ฒŒ ๋ชจํ‚น๋จ: ๋ฌด์Šจ ์ผ์ด ์ผ์–ด๋‚ฌ๋Š”์ง€๊ฐ€ ์•„๋‹ˆ๋ผ ๋ฉ”์„œ๋“œ๊ฐ€ ํ˜ธ์ถœ๋˜์—ˆ๋Š”์ง€ ํ…Œ์ŠคํŠธํ•ฉ๋‹ˆ๋‹ค.
//    ์ž˜๋ชป๋œ ํฌ์ŠคํŠธ๊ฐ€ ์—…๋ฐ์ดํŠธ๋˜๊ฑฐ๋‚˜ ์ธ์ฆ์ด ๋ˆ„๋ฝ๋˜์–ด๋„ ํ†ต๊ณผํ•ฉ๋‹ˆ๋‹ค.
it('updates a post', function () {
    $service = Mockery::mock(App\Services\PostService::class);
    $service->shouldReceive('update')->once()->andReturnTrue();
    $this->app->instance(App\Services\PostService::class, $service);

    $this->put('/posts/1', ['title' => 'New'])->assertOk();
});
// โœ… ํ…Œ์ŠคํŠธ DB์— ๋Œ€ํ•ด ์‹ค์ œ ์ฝ”๋“œ๋ฅผ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค; ๊ฒฐ๊ณผ์™€ ๊ถŒํ•œ ๋ถ€์—ฌ๋ฅผ ๋‹จ์–ธํ•ฉ๋‹ˆ๋‹ค.
use App\Models\Post;
use App\Models\User;

it('lets the owner update their post', function () {
    $owner = User::factory()->create();
    $post  = Post::factory()->for($owner)->create(['title' => 'Old']);

    $this->actingAs($owner)
        ->put("/posts/{$post->id}", ['title' => 'New'])
        ->assertRedirect();

    $this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'New']);
});

it('forbids a non-owner from updating the post', function () {
    $post     = Post::factory()->create(['title' => 'Old']);
    $attacker = User::factory()->create();

    $this->actingAs($attacker)
        ->put("/posts/{$post->id}", ['title' => 'Hacked'])
        ->assertForbidden(); // 403

    $this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'Old']);
});

it('blocks guests', function () {
    $post = Post::factory()->create();

    $this->put("/posts/{$post->id}", ['title' => 'New'])
        ->assertRedirect('/login'); // ๋˜๋Š” API์˜ ๊ฒฝ์šฐ assertUnauthorized()
});
// โŒ ์‹ค์ œ ๋„คํŠธ์›Œํฌ์— ์ ‘์†ํ•ฉ๋‹ˆ๋‹ค (๋ถˆ์•ˆ์ •, ๋А๋ฆผ, ํ‚ค ๋ˆ„์ถœ ๊ฐ€๋Šฅ); ์œ ์šฉํ•œ ๊ฒƒ์„ ๋‹จ์–ธํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
it('notifies slack', function () {
    $resp = (new App\Services\Slack)->ping('deploy ok');
    expect($resp)->toBeArray();
});
// โœ… ์†Œ์œ ํ•˜์ง€ ์•Š์€ ๊ฒฝ๊ณ„๋ฅผ ๊ฐ€์งœ๋กœ ๋งŒ๋“ญ๋‹ˆ๋‹ค; ๋ณด๋‚ธ ์š”์ฒญ์„ ๋‹จ์–ธํ•ฉ๋‹ˆ๋‹ค.
use Illuminate\Support\Facades\Http;

it('posts the deploy message to slack', function () {
    Http::fake(['hooks.slack.com/*' => Http::response(['ok' => true])]);

    (new App\Services\Slack)->ping('deploy ok');

    Http::assertSent(fn ($request) =>
        $request->url() === 'https://hooks.slack.com/services/T/B/x'
        && $request['text'] === 'deploy ok'
    );
});
// โŒ ๋ณต์‚ฌ-๋ถ™์—ฌ๋„ฃ๊ธฐ๋œ ๊ฑฐ์˜ ๋™์ผํ•œ ๊ฒ€์ฆ ํ…Œ์ŠคํŠธ.
it('rejects empty email', function () {
    $this->post('/register', ['email' => ''])->assertSessionHasErrors('email');
});
it('rejects bad email', function () {
    $this->post('/register', ['email' => 'nope'])->assertSessionHasErrors('email');
});
// โœ… ํ•˜๋‚˜์˜ ํ…Œ์ŠคํŠธ, ๋ฐ์ดํ„ฐ์…‹์„ ์‚ฌ์šฉํ•œ ํ…Œ์ด๋ธ” ๊ธฐ๋ฐ˜.
it('rejects invalid emails', function (string $email) {
    $this->post('/register', ['email' => $email])
        ->assertSessionHasErrors('email');
})->with([
    'empty'       => '',
    'no at-sign'  => 'nope',
    'no domain'   => 'a@',
]);
// โœ… ๋น„๋™๊ธฐ + ์‹œ๊ฐ„ ๊ฒฝ๊ณ„๊ฐ€ ๊ฐ€์งœ๋กœ ๋งŒ๋“ค์–ด์ง; ๋””์ŠคํŒจ์น˜์™€ ๋ถ€์ž‘์šฉ์„ ๋‹จ์–ธํ•ฉ๋‹ˆ๋‹ค.
use App\Jobs\SendReminder;
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;

it('queues a reminder and welcomes the user on signup', function () {
    Queue::fake();
    Mail::fake();
    $this->freezeTime();

    $this->post('/register', [
        'name' => 'Ada', 'email' => '[email protected]', 'password' => 'secret-pass',
    ])->assertRedirect();

    Queue::assertPushed(SendReminder::class);
    Mail::assertSent(WelcomeMail::class, fn ($m) => $m->hasTo('[email protected]'));
    $this->assertDatabaseHas('users', ['email' => '[email protected]']);
});
// tests/Arch.php โ€” โœ… ์ €๋ ดํ•œ ์ „์—ญ ์•ˆ์ „ ๋‚œ๊ฐ„
arch()->preset()->php();        // die/var_dump/dd ์Šคํƒ€์ผ ๋””๋ฒ„๊ทธ ์ถœ๋ ฅ + ์‚ฌ์šฉ๋˜์ง€ ์•Š๋Š” PHP ํ•จ์ˆ˜ ๊ธˆ์ง€
arch()->preset()->security();   // eval, extract, unserialize, md5/sha1, ์•ฝํ•œ ๋ฌด์ž‘์œ„์„ฑ ๋“ฑ ๊ธˆ์ง€
arch()->preset()->laravel();    // Laravel ๊ด€ํ–‰ ๊ฐ•์ œ, ์„ค์ • ์™ธ๋ถ€์—์„œ env() ์‚ฌ์šฉ ๊ธˆ์ง€ ํฌํ•จ

arch('controllers extend the base controller')
    ->expect('App\Http\Controllers')
    ->toExtend('App\Http\Controllers\Controller');

arch('models live in the right namespace')
    ->expect('App\Models')
    ->toExtend('Illuminate\Database\Eloquent\Model');

ํ™•์ธ ๋ฐฉ๋ฒ• (How to verify)

๊ธฐ๋Šฅ๊ณผ ํ…Œ์ŠคํŠธ๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ๋‹ค๊ณ  ์ฃผ์žฅํ•˜๊ธฐ ์ „์— ๋‹ค์Œ์„ ์‹คํ–‰ํ•˜์„ธ์š”:

# 1. ์ „์ฒด ์Šค์œ„ํŠธ๊ฐ€ ๋…น์ƒ‰์ด์–ด์•ผ ํ•จ.
./vendor/bin/pest

# 2. ๋ฐ˜๋ณต ์ž‘์—… ์ค‘์—๋Š” ์ƒˆ๋กœ ์ถ”๊ฐ€/๋ณ€๊ฒฝ๋œ ํŒŒ์ผ๋งŒ ์‹คํ–‰.
./vendor/bin/pest tests/Feature/UpdatePostTest.php

# 3. ์ตœ์†Œ ์ปค๋ฒ„๋ฆฌ์ง€ ๊ฐ•์ œ (Xdebug ๋˜๋Š” PCOV ํ•„์š”).
./vendor/bin/pest --coverage --min=80

# 4. ์œ ํ˜• ์ปค๋ฒ„๋ฆฌ์ง€ (pestphp/pest-plugin-type-coverage ํ•„์š”).
./vendor/bin/pest --type-coverage --min=100

# 5. ์Šคํƒ€์ผ ์œ ์ง€.
./vendor/bin/pint --test

๊ทธ๋Ÿฐ ๋‹ค์Œ ํ•จ์ •์„ ๋‹ค์‹œ ๋„์ž…ํ•˜์ง€ ์•Š์•˜๋Š”์ง€ ๊ฐ์‚ฌํ•˜์„ธ์š”:

# Authz ์ปค๋ฒ„๋ฆฌ์ง€: ๊ฐ ๋ณดํ˜ธ๋œ ๊ธฐ๋Šฅ ํ…Œ์ŠคํŠธ๋Š” 401/403/404 ๊ฒฝ๋กœ๋ฅผ ๋‹จ์–ธํ•ด์•ผ ํ•จ.
grep -rEn "assertForbidden|assertUnauthorized|assertNotFound|->assertStatus\\((401|403|404)\\)" tests/

# ๊ณผ๋„ํ•œ ๋ชจํ‚น ๋ƒ„์ƒˆ: ์ž์ฒด APP ํด๋ž˜์Šค๋ฅผ ๋ชจํ‚นํ•˜๋Š” ๊ฒƒ์€ ์œ„ํ—˜ ์‹ ํ˜ธ์ž…๋‹ˆ๋‹ค โ€” ๊ฐ ํžˆํŠธ๋ฅผ ๊ฒ€ํ† ํ•˜์„ธ์š”.
grep -rEn "Mockery::|->shouldReceive\(|::partialMock\(|::spy\\(" tests/

# ํŒฉํ† ๋ฆฌ ๋Œ€์‹  ์ˆ˜๋™ ์‚ฝ์ž… โ€” ->factory()๋กœ ๊ต์ฒด.
grep -rEn "DB::table\(.*\)->insert|new App\\\\Models" tests/

# ์™ธ๋ถ€ ๊ฒฝ๊ณ„๋Š” ๊ฐ€์งœ๋กœ ๋งŒ๋“ค์–ด์•ผ ํ•จ โ€” ์ฝ”๋“œ๊ฐ€ ์™ธ๋ถ€๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ๊ณณ์— ํŽ˜์ดํฌ๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ.
grep -rEn "Http::fake|Queue::fake|Bus::fake|Mail::fake|Notification::fake|Storage::fake|Event::fake" tests/

# ๋ณด์•ˆ + php ์•„ํ‚คํ…์ฒ˜ ํ”„๋ฆฌ์…‹ (์ €๋ ดํ•œ ์ „์—ญ ์•ˆ์ „ ๋‚œ๊ฐ„)์ด ์–ด๋”˜๊ฐ€์— ์žˆ์–ด์•ผ ํ•จ.
grep -rEn "preset\(\)->(security|php|laravel)\(\)" tests/

์ฝ”๋“œ๋ฟ๋งŒ ์•„๋‹ˆ๋ผ ํ…Œ์ŠคํŠธ์—์„œ๋„ ๋‹จ์–ธํ•˜์„ธ์š”: ๋ชจ๋“  ์ƒˆ ์—”๋“œํฌ์ธํŠธ์— ๋Œ€ํ•ด (a) ํ•ดํ”ผ ํŒจ์Šค ๊ฒฐ๊ณผ ๋‹จ์–ธ(assertDatabaseHas/assertOk/assertJsonPath), (b) ์ตœ์†Œ ํ•˜๋‚˜์˜ ์‹คํŒจ/๊ฒ€์ฆ ๋‹จ์–ธ, (c) ๊ฒŒ์ŠคํŠธ + ์ž˜๋ชป๋œ ์‚ฌ์šฉ์ž ๊ถŒํ•œ ๋ถ€์—ฌ ๋‹จ์–ธ์ด ์žˆ๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”. ํ•˜๋‚˜๋ผ๋„ ๋น ์ง€๋ฉด ๊ธฐ๋Šฅ์ด ์™„๋ฃŒ๋œ ๊ฒƒ์ด ์•„๋‹™๋‹ˆ๋‹ค.

๊ทœ์น™์„ ์˜ˆ์™ธ์ ์œผ๋กœ ์ ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒฝ์šฐ (When it's OK to bend the rule)

  • ์ง„์ •ํ•œ ์™ธ๋ถ€ ์ ‘ํ•ฉ์ ์—์„œ ์ž์ฒด ์ฝ”๋“œ ๋ชจํ‚น. HTTP ๊ณ„์ธต์—์„œ ๊ฐ€์งœ๋กœ ๋งŒ๋“ค ์ˆ˜ ์—†๋Š” ์œ ๋ฃŒ ํƒ€์‚ฌ SDK(SMS, ๊ฒฐ์ œ ๊ฒŒ์ดํŠธ์›จ์ด)๋ฅผ ๊ฐ์‹ผ ์–‡์€ ๋ž˜ํผ๋Š” ํ•ฉ๋ฒ•์ ์ธ Mockery ๋Œ€์ƒ์ž…๋‹ˆ๋‹ค โ€” ํ…Œ์ŠคํŠธ ๋Œ€์ƒ ๋™์ž‘์„ ๊ฐ€์งœ๋กœ ๋งŒ๋“œ๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ ๊ทธ๋“ค์˜ ์˜์กด์„ฑ์„ ๊ฒฉ๋ฆฌํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.
  • DB ์—†์ด ์ˆœ์ˆ˜ ๋กœ์ง ๋‹จ์œ„ ํ…Œ์ŠคํŠธ. ๊ฐ’ ๊ฐ์ฒด, ํฌ๋งทํ„ฐ, ๊ณ„์‚ฐ๊ธฐ๋Š” RefreshDatabase์™€ ํŒฉํ† ๋ฆฌ๊ฐ€ ํ•„์š” ์—†์Šต๋‹ˆ๋‹ค โ€” ์ง์ ‘ ์ธ์Šคํ„ด์Šคํ™”ํ•˜๊ณ  expect() ๊ฒฐ๊ณผ๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”; ๋” ๋น ๋ฅด๊ณ  ์ •ํ™•ํ•ฉ๋‹ˆ๋‹ค.
  • ํ…Œ์ŠคํŠธ๊ฐ€ ์ง€์†๋˜์ง€ ์•Š์„ ๋•Œ create() ๋Œ€์‹  make() โ€” User::factory()->make()๋Š” ์ˆœ์ˆ˜ ๋กœ์ง ๋‹จ์–ธ์„ ์œ„ํ•ด DB ์“ฐ๊ธฐ๋ฅผ ํ”ผํ•ฉ๋‹ˆ๋‹ค.
  • ๋ ˆ๊ฑฐ์‹œ ์ฝ”๋“œ์— ๋Œ€ํ•œ ๋‚ฎ์€ ์ปค๋ฒ„๋ฆฌ์ง€ ์ž„๊ณ„๊ฐ’. ํ˜„์‹ค์ ์ธ --min์„ ์„ค์ •ํ•˜๊ณ  ์‹œ๊ฐ„์ด ์ง€๋‚จ์— ๋”ฐ๋ผ ์ ์ฐจ ๋†’์—ฌ ์ž‘์—…์„ ์ฐจ๋‹จํ•˜์ง€ ๋งˆ์„ธ์š”; "100% ์•„๋‹ˆ๋ฉด ์•ˆ ๋œ๋‹ค"๊ฐ€ ํ…Œ์ŠคํŠธ๋ฅผ ์ „ํ˜€ ๋ฐฐ์†กํ•˜์ง€ ์•Š๋Š” ํ•‘๊ณ„๊ฐ€ ๋˜์–ด์„œ๋Š” ์•ˆ ๋ฉ๋‹ˆ๋‹ค.
  • ์ง„์ •ํ•œ JS ๊ธฐ๋ฐ˜ ํ๋ฆ„( Livewire/Inertia/Vue ์ƒํ˜ธ์ž‘์šฉ, ํŒŒ์ผ ์„ ํƒ๊ธฐ)์— ๋Œ€ํ•œ ๋ธŒ๋ผ์šฐ์ € ํ…Œ์ŠคํŠธ โ€” ์—ฌ๊ธฐ์„œ๋Š” visit()์ด ์˜ฌ๋ฐ”๋ฅธ ๋„๊ตฌ์ด๋ฉฐ, ๊ณผ๋„ํ•œ ์‚ฌ์šฉ์ด ์•„๋‹™๋‹ˆ๋‹ค.

์ฐธ๊ณ  ์ž๋ฃŒ (References)

๊ด€๋ จ ์Šคํ‚ฌ