AI 코딩 도구에 "테스트도 같이 짜줘"를 매번 말하는 대신, 테스트 코드 자동 생성 기준을 .test-policy.json 한 파일로 빼고 누락은 GitHub Actions가 잡도록 만들었습니다. React Native 모노레포에서 5개월 굴린 결과와, 그 과정에서 내린 설계 결정들을 공유합니다.


들어가며

테스트를 안 짜는 이유는 대부분 "귀찮아서"가 아니라 "지금 당장 급해서"입니다. 기능 하나 붙이는 데 30분, 테스트 짜는 데 30분이면 후자가 먼저 잘려 나갑니다. 그렇게 몇 달이 지나면 테스트 없는 코드가 기본값이 되고 이제 와서 다시 쌓기엔 너무 늦었다는 기분이 듭니다.

AI 코딩 도구를 쓰면 이 계산이 달라집니다. 테스트 작성 비용이 30분에서 0분에 가까워지니까요. 그런데 비용만 낮아진다고 테스트가 쌓이지는 않습니다. 매번 "테스트도 같이 짜줘"라고 말해야 하기 때문입니다. 그리고 기능 구현에 집중하고 있을 때 그 말은 잘 나오지 않습니다.

그래서 "말하지 않아도 짜게" 만드는 구조를 고민했고 지금은 그 구조가 5개월째 돌아가고 있습니다. 이 글에서는 ① 프롬프트에 규칙을 넣는 것만으로는 왜 부족했는지, ② 판단 기준을 설정 파일로 분리한 이유, ③ AI가 규칙을 어겼을 때 CI가 어떻게 잡는지, ④ 그리고 정책에 일부러 구멍을 뚫어둔 이유를 다룹니다.

React Native + Nx 모노레포 기준이지만 구조 자체는 스택과 무관합니다.


1. 규칙 한 줄로는 기준이 서지 않는다

가장 게으른 방법부터 생각해 봅니다. 팀 공용 규칙 파일에 한 줄 넣는 겁니다.

- 새 파일을 작성할 때 테스트 코드도 함께 생성한다

이 한 줄에는 정작 가장 중요한 게 빠져 있습니다. 무엇이 테스트 대상인가입니다. 테스트를 만들라는 지시만 있고 어디에 만들라는 기준이 없으니, 판단은 통째로 모델에게 남습니다. 남은 판단은 이런 질문들입니다.

  • 타입 선언만 있는 types.ts와 로직이 든 hooks/를 무엇으로 가르는가
  • 색상만 모아둔 theme.ts는 테스트 대상인가
  • 테스트 파일은 __tests__/인가 tests/인가 소스 옆인가
  • 파일명은 .test.ts인가 .spec.ts인가

사람에게 물어도 답이 갈리는 질문들입니다. 매번 새로 판단하게 두면 세션마다 다른 답이 나오고, 다른 답은 결국 리뷰어가 잡아줘야 합니다. 그럴 거면 처음부터 사람이 짜는 게 낫습니다.

정리하면 이 글의 전제는 하나입니다.

AI에게 위임해야 하는 건 판단이 아니라 작업이다. 판단 기준은 사람이 한 번 정해서 파일로 박아두어야 한다.


2. 판단 기준을 설정 파일로 분리하기

그래서 판단 기준만 따로 떼어 .test-policy.json으로 만들었습니다. 프로젝트 루트에 둡니다.

{
  "version": 1,
  "exclude": {
    "common": [
      "*.types.ts",
      "*.dto.ts",
      "*.config.*",
      "*.constants.ts",
      "*.enum.ts",
      "*.d.ts"
    ],
    "frontend": [
      "*.styles.ts",
      "*.theme.ts",
      "*.navigation.ts",
      "*/assets/**",
      "*/locale/data/**"
    ]
  },
  "frontend": {
    "test_location": "separate",
    "test_dir": "tests/unit",
    "naming": "{name}.test.{ext}",
    "rules": {
      "unit": [
        "src/services/**",
        "src/hooks/**",
        "src/utils/**",
        "src/stores/**",
        "src/storage/**"
      ],
      "e2e": [
        "src/screens/Join/**",
        "src/navigation/index.tsx"
      ]
    }
  }
}

그리고 AI 도구가 읽는 규칙 파일(AGENTS.md)에는 규칙 본문 대신 판단 흐름만 남겼습니다.

## 테스트 규칙

새 파일 작성 시 `.test-policy.json`을 읽고 테스트 코드를 함께 생성한다.

### 판단 흐름
1. `exclude` 패턴에 매칭되면 생성하지 않는다
2. `rules.unit` 패턴에 매칭되면 Unit Test를 생성한다
3. `rules.e2e` 패턴에 매칭되면 E2E 검토가 필요함을 알린다
4. 어디에도 매칭되지 않으면 생성하지 않는다

### 테스트 작성 원칙
- UI snapshot 테스트는 지양한다
- 사용자 행동 기준으로 테스트한다
- API mock은 MSW를 사용한다
- 테스트 설명(it/describe)은 한글로 작성한다

이 분리가 만든 차이

첫째, exclude를 rules보다 먼저 평가합니다. 순서가 중요합니다. src/utils/colors.constants.ts는 rules.unit의 src/utils/**에도 걸리고 exclude의 *.constants.ts에도 걸립니다. 제외가 우선이어야 "일단 만들고 보는" 동작이 사라집니다. 포함 규칙을 먼저 보게 하면 제외 목록을 아무리 늘려도 새는 곳이 생깁니다.

둘째, 규칙을 바꿀 때 프롬프트를 고치지 않아도 됩니다. 새 디렉터리가 생겨서 테스트 대상에 넣고 싶으면 YAML에 한 줄 추가하면 끝입니다. 자연어 지침을 수정하면 다른 문장과의 상호작용을 매번 다시 확인해야 하는데, 목록에 한 줄 넣는 건 그런 부작용이 없습니다.

셋째, diff가 읽힙니다. "테스트 정책이 언제 어떻게 바뀌었는가"가 git 히스토리에 한 줄로 남습니다. 산문 규칙은 이게 안 됩니다.

넷째, 사람도 읽습니다. 신규 입사자에게 "우리는 뭘 테스트하나요"를 설명할 때 이 파일 하나를 보여주면 됩니다.

위치와 네이밍을 못 박은 이유

test_location을 separate로 두고 tests/unit/ 아래에 소스 경로 구조를 그대로 복사하도록 했습니다.

 

원본 파일  생성되는 테스트 파일
src/hooks/usePayment.ts tests/unit/hooks/usePayment.test.ts
src/utils/scale.ts tests/unit/utils/scale.test.ts
src/styles/theme.ts 생성 안 함 (exclude)

소스 옆에 두는 colocate 방식도 충분히 합리적이지만 여기서 분리를 택한 실질적인 이유는 CI에서 테스트 존재 여부를 경로 계산만으로 판정할 수 있기 때문입니다. 규칙이 하나로 고정되어 있으면 "이 파일에 대응하는 테스트가 있어야 할 자리"를 문자열 치환 한 번으로 구할 수 있습니다. 이게 다음 섹션의 전제가 됩니다.


3. AI가 규칙을 어길 때를 대비하기

설정 파일을 만든 뒤에도 테스트가 빠지는 경우는 남았습니다. 컨텍스트가 길어진 세션, 여러 파일을 한 번에 만든 작업, 급하게 커밋한 핫픽스에서 특히 그렇습니다.

여기서 중요한 판단을 하나 했습니다. LLM이 지시를 지킬 확률에 코드 품질을 걸지 않기로 한 것입니다. 확률이 낮아서가 아니라, 확률인 것 자체가 문제입니다. 95%를 지켜도 나머지 5%는 리뷰에서 사람이 발견해야 하는데, 사람은 그것도 놓칩니다.

그래서 같은 정책 파일을 CI도 읽게 만들었습니다. PR 워크플로에 검사 job을 하나 추가했습니다.

test-check:
  name: Test coverage check
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
      with:
        fetch-depth: 0

    - name: Check test coverage for new files
      uses: actions/github-script@v7
      with:
        script: |
          const { execSync } = require('child_process');
          const fs = require('fs');

          const policy = JSON.parse(fs.readFileSync('.test-policy.json', 'utf8'));
          const globToRegex = (p) => {
            const escaped = p.replace(/[.+^${}()|[\]\\]/g, '\\$&');
            const pattern = escaped
              .replace(/\*\*/g, '<<GLOBSTAR>>')
              .replace(/\*/g, '[^/]*')
              .replace(/<<GLOBSTAR>>/g, '.*');
            return `^${pattern}$`;
          };

          const excludePatterns = [
            ...policy.exclude.common,
            ...policy.exclude.frontend
          ].map(p => new RegExp(globToRegex(p)));

          const unitRules = policy.frontend.rules.unit.map(p =>
            new RegExp(globToRegex(p))
          );

          // PR에서 새로 추가된 파일만
          const added = execSync(
            `git diff --name-only --diff-filter=A origin/${{ github.base_ref }}...HEAD`
          ).toString().trim().split('\n').filter(Boolean);

          const missing = [];

          for (const file of added) {
            if (!file.startsWith('apps/')) continue;

            // /* @no-test */ 주석 체크 (파일 상단 5줄 이내)
            try {
              const content = fs.readFileSync(file, 'utf8');
              const head = content.split('\n').slice(0, 5).join('\n');
              if (head.includes('/* @no-test */')) continue;
            } catch { continue; }

            const appRelPath = file.replace(/^apps\/[^/]+\//, '');
            const basename = appRelPath.split('/').pop();
            if (excludePatterns.some(p => p.test(basename) || p.test(appRelPath))) continue;
            if (!unitRules.some(p => p.test(appRelPath))) continue;

            const appDir = file.split('/').slice(0, 2).join('/');
            const srcRelPath = appRelPath.replace(/^src\//, '');
            const testFile = `${appDir}/tests/unit/${srcRelPath}`
              .replace(/\.(ts|tsx)$/, '.test.$1');

            if (!fs.existsSync(testFile)) {
              missing.push({ file, testFile });
            }
          }

          if (missing.length > 0) {
            const body = `### ⚠️ 테스트 누락 감지\n\n` +
              `다음 파일에 대한 테스트가 없습니다:\n\n` +
              missing.map(m => `- \`${m.file}\`\n  → 예상 위치: \`${m.testFile}\``).join('\n') +
              `\n\n> 의도적으로 테스트를 만들지 않는 경우 파일 상단에 \`/* @no-test */\` 주석을 추가하세요.`;

            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body
            });
          }

코드보다 중요한 건 여기 담긴 세 가지 결정입니다.

결정 1 — 신규 파일만 검사한다

git diff --name-only --diff-filter=A origin/${BASE}...HEAD

--diff-filter=A로 추가된(Added) 파일만 봅니다. 수정된 파일은 검사하지 않습니다.

기존 코드 전체에 소급 적용하는 건 현실적으로 불가능합니다. 수백 개 파일에 대한 테스트를 한 번에 만들 수도 없고 만든다 해도 아무도 안 읽는 형식적인 테스트만 쌓입니다. 그래서 도입 시점에 선을 긋고 그 이후 파일부터 의무화했습니다.

이 결정이 도입 성공의 절반이라고 생각합니다. "전부 다"를 요구하는 정책은 첫 주에 무시당하고 끝납니다. 기존 코드는 건드릴 때 필요하면 추가하는 정도로 두었습니다.

결정 2 — 빌드를 깨지 않고 코멘트만 단다

missing이 있어도 process.exit(1)을 하지 않습니다. PR에 코멘트만 답니다.

강제 게이트로 만들고 싶은 유혹이 있었지만 참았습니다. 정책이 아직 정교하지 않은 상태에서 머지를 막으면 두 가지 중 하나가 일어납니다. 사람들이 의미 없는 빈 테스트를 만들어서 통과시키거나, 검사 자체를 꺼달라고 요구합니다. 둘 다 정책이 죽는 길입니다.

코멘트는 "봤는데 이번엔 안 만들래"가 가능합니다. 그 여지가 있어야 정책이 살아남습니다. 정책이 충분히 신뢰받은 다음에 조여도 늦지 않습니다.

결정 3 — glob 라이브러리를 넣지 않는다

globToRegex를 20줄 직접 짰습니다. minimatch를 설치하면 더 정확하겠지만 여기서 쓰는 패턴은 *와 ** 두 가지뿐입니다. CI 스텝 하나 때문에 의존성과 설치 시간을 늘릴 이유가 없었습니다.

actions/github-script는 별도 설치 없이 Node 런타임과 GitHub API 클라이언트를 주는데, 이 정도 검사에는 그걸로 충분합니다.

글을 쓰다가 고친 것 하나

사실 이 정책 파일은 얼마 전까지 두 벌이었습니다. 사람과 AI가 읽는 .test-policy.yml, 그리고 CI가 읽는 .test-policy.json. github-script 런타임에 YAML 파서가 없어서 같은 내용을 JSON으로 복사해 두고 쓰고 있었습니다.

이 글을 쓰면서 두 파일을 나란히 펼쳐 놓고서야 YAML이 하는 일이 없다는 걸 알았습니다. 사람이 읽기 편하라고 둔 건데 정작 팀원들은 AGENTS.md의 설명을 읽지 이 파일을 직접 열지 않습니다. YAML에만 있던 주석도 "테스트 생성 제외" 같은 두 줄이 전부였습니다.

그래서 YAML을 지우고 JSON 하나로 합쳤습니다. CI는 원래부터 JSON을 읽고 있었으니 건드릴 게 없었고 AI 도구가 읽는 경로 한 줄만 바꿨습니다. 정책을 글로 옮겨 적는 것만으로 정책의 군더더기가 보인다는 게 이번에 얻은 부수입입니다.


4. 탈출구가 없는 정책은 무시당한다

정책에 일부러 구멍을 하나 뚫어뒀습니다. 파일 맨 위에 주석 한 줄을 넣으면 검사를 건너뜁니다.

/* @no-test */

정책을 무력화할 구멍처럼 보이지만 실제로는 반대쪽으로 작동합니다. 현재 50개 파일에 쓰이고 있고 사용처는 크게 두 갈래입니다.

외부 SDK 래퍼. CodePush 업데이트를 감싼 훅 같은 경우입니다. 테스트를 짜면 결국 SDK 모킹을 검증하게 됩니다. 모킹이 실제 SDK와 다르면 테스트는 통과하고 앱은 깨집니다. 이런 코드는 테스트가 있는 게 없는 것보다 위험합니다.

/* @no-test */
import CodePush from '@ipixel-corporation/react-native-code-push';

이름 규칙을 벗어난 상수 파일. 이게 더 흥미로운 사례입니다. SNS 공유 이미지의 좌표 상수를 모아둔 파일인데, 이름이 statics.ts입니다.

/* @no-test */
// SNS 공유 결과 화면(1080x1080 캔버스) 공통 상수.
export const CANVAS = 1080;

exclude에는 *.constants.ts가 있지만 statics.ts는 안 걸립니다. 그렇다고 *.statics.ts를 목록에 추가하면, 다음엔 metrics.ts가 나오고 그다음엔 defs.ts가 나옵니다. 파일명 기반 패턴 매칭으로는 절대 다 못 거릅니다.

패턴을 무한히 늘리는 대신, 사람이 한 줄로 빠져나갈 수 있게 두는 편이 훨씬 쌉니다. 예외가 코드에 명시적으로 남으니 나중에 "왜 이건 테스트가 없지"를 다시 묻지 않아도 됩니다.


5. 5개월 뒤

2026년 4월 중순에 도입했고 그 뒤로 이렇게 쌓였습니다.

항목 수치
테스트 파일 136개
@no-test 명시적 제외 50개 파일
도입 후 커밋 146개

생성된 테스트의 품질

가장 궁금했던 부분입니다. AI가 만든 테스트가 형식만 갖춘 껍데기면 의미가 없으니까요. 실제로 생성된 것 하나를 그대로 가져오면 이렇습니다.

import {maskEmail} from '@app/utils/maskEmail';

describe('maskEmail', () => {
  it('로컬파트가 4자 초과면 앞 4자만 남기고 나머지를 마스킹한다', () => {
    expect(maskEmail('asdfghjk@gmail.com')).toBe('asdf********@gmail.com');
  });

  it('로컬파트가 4자 이하면 마스킹하지 않고 그대로 보여준다', () => {
    expect(maskEmail('abc@gmail.com')).toBe('abc@gmail.com');
    expect(maskEmail('abcd@gmail.com')).toBe('abcd@gmail.com');
  });

  it('이메일이 없으면 null을 반환한다', () => {
    expect(maskEmail(null)).toBeNull();
    expect(maskEmail(undefined)).toBeNull();
    expect(maskEmail('')).toBeNull();
  });

  it('이메일 형식이 아니면 null을 반환한다', () => {
    expect(maskEmail('noatsign')).toBeNull();
    expect(maskEmail('@gmail.com')).toBeNull();
    expect(maskEmail('local@')).toBeNull();
  });
});

경계값(4자 초과 / 이하)과 비정상 입력을 나눠서 잡고 있습니다. 사람이 짜도 이 이상 짜지 않을 것 같습니다.

규칙 중에 "테스트 설명은 한글로 작성한다"가 있습니다. 스타일 취향처럼 보이지만 리뷰에서 차이가 납니다. 테스트 이름이 it('should return null when email is invalid')이면 리뷰어가 코드를 읽어야 의도를 압니다. 한글로 "이메일 형식이 아니면 null을 반환한다"라고 쓰여 있으면 테스트 이름만 훑어도 이 함수의 명세가 읽힙니다. 그리고 명세가 읽히면 빠진 케이스도 눈에 띕니다.

숫자가 말해주는 것

테스트 136개보다 @no-test 50개가 더 많은 걸 말해줍니다. 테스트를 안 만드는 쪽이 명시적인 행동이 됐다는 뜻이기 때문입니다. 예전 구조에서 테스트를 건너뛰는 비용은 0이었습니다. 아무것도 안 하면 됐으니까요. 지금은 파일을 열고 주석을 한 줄 적어야 합니다.

기본값을 뒤집은 게 이 구조의 핵심입니다. 테스트를 만드는 쪽이 아니라 안 만드는 쪽에 비용을 붙였습니다. 그 비용이 주석 한 줄로 아주 작다는 것도 중요합니다. 크면 사람들이 우회할 방법을 찾습니다.

남은 것

훅과 스토어에 편중돼 있습니다. rules.unit에 걸어둔 경로가 hooks, utils, stores, services, storage라 화면 단위 로직은 거의 비어 있습니다. 의도한 범위지만 화면에 로직이 쌓이는 건 이 규칙으로 막지 못합니다.

E2E는 아직 목록만 있습니다. rules.e2e에 가입 플로우와 네비게이션을 적어뒀지만 "검토 필요를 알린다" 수준이고 실제 E2E는 아직 안 붙였습니다. 여기가 다음 숙제입니다.


마무리

정리하면 구조는 세 겹입니다.

  1. 정책 파일 — 무엇을 테스트할지에 대한 판단 기준을 사람이 한 번 정해서 박아둔다
  2. AI 도구 — 그 기준을 읽고 실제 테스트 코드를 작성한다
  3. CI — AI가 놓친 걸 잡되, 막지 않고 알려만 준다

이 중에 특별한 기술은 없습니다. 설정 파일 하나, 프롬프트 몇 줄, CI 스크립트 60줄이 전부입니다. 그런데도 5개월간 유지된 이유를 굳이 꼽자면, AI를 신뢰하지 않는 자리에 검증을 두고 사람을 강제하지 않는 자리에 탈출구를 둔 것 정도입니다.

AI 코딩 도구를 팀에 도입할 때 "프롬프트를 잘 쓰는 법"에 관심이 쏠리기 쉬운데, 실제로 오래가는 건 프롬프트가 아니라 프롬프트가 틀렸을 때 잡아주는 장치 쪽이었습니다. 도구는 계속 바뀌지만 정책 파일과 CI 검사는 그대로 남습니다.

비슷한 고민을 하고 계신 분께 참고가 되면 좋겠습니다.