Perl 테스트 패턴
Test2::V0, Test::More, prove 및 TDD 방법론을 사용한 Perl 애플리케이션을 위한 종합적인 테스트 전략입니다.
활성화 시점
- 새로운 Perl 코드 작성 시 (TDD: red, green, refactor 준수)
- Perl 모듈 또는 애플리케이션의 테스트 스위트 설계 시
- Perl 테스트 커버리지 검토 시
- Perl 테스트 인프라 설정 시
- Test::More에서 Test2::V0으로 마이그레이션 시
- 실패하는 Perl 테스트 디버깅 시
TDD 워크플로우
항상 RED-GREEN-REFACTOR 사이클을 따릅니다.
# 1단계: RED — 실패하는 테스트 작성
# t/unit/calculator.t
use v5.36;
use Test2::V0;
use lib 'lib';
use Calculator;
subtest 'addition' => sub {
my $calc = Calculator->new;
is($calc->add(2, 3), 5, '두 수를 더함');
is($calc->add(-1, 1), 0, '음수 처리');
};
done_testing;
# 2단계: GREEN — 최소 구현 작성
# lib/Calculator.pm
package Calculator;
use v5.36;
use Moo;
sub add($self, $a, $b) {
return $a + $b;
}
1;
# 3단계: REFACTOR — 테스트가 통과하는 동안 개선
# 실행: prove -lv t/unit/calculator.t
Test::More 기본
표준 Perl 테스트 모듈 — 널리 사용되며, 코어에 포함되어 있습니다.
기본 어설션
use v5.36;
use Test::More;
# 미리 계획하거나 done_testing 사용
# plan tests => 5; # 고정 계획 (선택사항)
# 동등성
is($result, 42, '올바른 값 반환');
isnt($result, 0, '0이 아님');
# 불리언
ok($user->is_active, '사용자가 활성 상태임');
ok(!$user->is_banned, '사용자가 차단되지 않음');
# 깊은 비교
is_deeply(
$got,
{ name => 'Alice', roles => ['admin'] },
'예상 구조 반환'
);
# 패턴 매칭
like($error, qr/not found/i, '오류에 not found 언급');
unlike($output, qr/password/, '출력에 password 숨김');
# 타입 체크
isa_ok($obj, 'MyApp::User');
can_ok($obj, 'save', 'delete');
done_testing;
SKIP 및 TODO
use v5.36;
use Test::More;
# 조건부로 테스트 건너뛰기
SKIP: {
skip '데이터베이스가 설정되지 않음', 2 unless $ENV{TEST_DB};
my $db = connect_db();
ok($db->ping, '데이터베이스에 연결 가능');
is($db->version, '15', '올바른 PostgreSQL 버전');
}
# 예상 실패 표시
TODO: {
local $TODO = '캐싱이 아직 구현되지 않음';
is($cache->get('key'), 'value', '캐시가 값을 반환');
}
done_testing;
Test2::V0 현대 프레임워크
Test2::V0은 Test::More의 현대적인 대체품입니다 — 더 풍부한 어설션, 더 나은 진단, 그리고 확장 가능합니다.
Test2를 선택해야 하는 이유
- 해시/배열 빌더를 사용한 탁월한 깊은 비교
- 실패 시 더 나은 진단 출력
- 더 깔끔한 스코프의 서브테스트
- Test2::Tools::* 플러그인을 통한 확장 가능
- Test::More 테스트와 하위 호환
빌더를 사용한 깊은 비교
use v5.36;
use Test2::V0;
# 해시 빌더 — 부분 구조 확인
is(
$user->to_hash,
hash {
field name => 'Alice';
field email => match(qr/\@example\.com$/);
field age => validator(sub { $_ >= 18 });
# 다른 필드는 무시
etc();
},
'사용자가 예상 필드를 가짐'
);
# 배열 빌더
is(
$result,
array {
item 'first';
item match(qr/^second/);
item DNE(); # 존재하지 않음 — 추가 항목이 없는지 확인
},
'결과가 예상 목록과 일치'
);
# Bag — 순서 무관 비교
is(
$tags,
bag {
item 'perl';
item 'testing';
item 'tdd';
},
'순서에 관계없이 필수 태그 모두 포함'
);
서브테스트
use v5.36;
use Test2::V0;
subtest '사용자 생성' => sub {
my $user = User->new(name => 'Alice', email => '[email protected]');
ok($user, '사용자 객체 생성됨');
is($user->name, 'Alice', '이름 설정됨');
is($user->email, '[email protected]', '이메일 설정됨');
};
subtest '사용자 유효성 검사' => sub {
my $warnings = warns {
User->new(name => '', email => 'bad');
};
ok($warnings, '잘못된 데이터에 대해 경고');
};
done_testing;
Test2로 예외 테스트
use v5.36;
use Test2::V0;
# 코드가 죽는지 테스트
like(
dies { divide(10, 0) },
qr/Division by zero/,
'0으로 나누면 죽음'
);
# 코드가 살아있는지 테스트
ok(lives { divide(10, 2) }, '나누기 성공') or note($@);
# 결합 패턴
subtest '오류 처리' => sub {
ok(lives { parse_config('valid.json') }, '유효한 설정 파싱');
like(
dies { parse_config('missing.json') },
qr/Cannot open/,
'누락된 파일은 메시지와 함께 죽음'
);
};
done_testing;
테스트 구성 및 prove
디렉터리 구조
t/
├── 00-load.t # 모듈 컴파일 확인
├── 01-basic.t # 핵심 기능
├── unit/
│ ├── config.t # 모듈별 단위 테스트
│ ├── user.t
│ └── util.t
├── integration/
│ ├── database.t
│ └── api.t
├── lib/
│ └── TestHelper.pm # 공유 테스트 유틸리티
└── fixtures/
├── config.json # 테스트 데이터 파일
└── users.csv
prove 명령어
# 모든 테스트 실행
prove -l t/
# 자세한 출력
prove -lv t/
# 특정 테스트 실행
prove -lv t/unit/user.t
# 재귀 검색
prove -lr t/
# 병렬 실행 (8개 작업)
prove -lr -j8 t/
# 마지막 실행에서 실패한 테스트만 실행
prove -l --state=failed t/
# 색상 출력 + 타이머
prove -l --color --timer t/
# CI용 TAP 출력
prove -l --formatter TAP::Formatter::JUnit t/ > results.xml
.proverc 설정
-l
--color
--timer
-r
-j4
--state=save
픽스처 및 설정/정리
서브테스트 격리
use v5.36;
use Test2::V0;
use File::Temp qw(tempdir);
use Path::Tiny;
subtest '파일 처리' => sub {
# 설정
my $dir = tempdir(CLEANUP => 1);
my $file = path($dir, 'input.txt');
$file->spew_utf8("line1\nline2\nline3\n");
# 테스트
my $result = process_file("$file");
is($result->{line_count}, 3, '줄 수 계산');
# 정리는 자동으로 발생 (CLEANUP => 1)
};
공유 테스트 헬퍼
재사용 가능한 헬퍼는 t/lib/TestHelper.pm에 배치하고 use lib 't/lib'로 로드하세요. create_test_db(), create_temp_dir(), fixture_path()와 같은 팩토리 함수를 Exporter를 통해 내보냅니다.
모의 객체
Test::MockModule
use v5.36;
use Test2::V0;
use Test::MockModule;
subtest '외부 API 모의' => sub {
my $mock = Test::MockModule->new('MyApp::API');
# 좋음: 모의 객체가 통제된 데이터 반환
$mock->mock(fetch_user => sub ($self, $id) {
return { id => $id, name => 'Mock User', email => '[email protected]' };
});
my $api = MyApp::API->new;
my $user = $api->fetch_user(42);
is($user->{name}, 'Mock User', '모의 사용자 반환');
# 호출 횟수 확인
my $call_count = 0;
$mock->mock(fetch_user => sub { $call_count++; return {} });
$api->fetch_user(1);
$api->fetch_user(2);
is($call_count, 2, 'fetch_user가 두 번 호출됨');
# $mock이 스코프를 벗어나면 모의 객체는 자동으로 복원됨
};
# 나쁨: 복원 없는 몽키 패칭
# *MyApp::API::fetch_user = sub { ... }; # 절대 — 테스트 간에 누수됨
가벼운 모의 객체의 경우 Test::MockObject를 사용하여 ->mock()으로 주입 가능한 테스트 대역을 만들고 ->called_ok()로 호출을 확인하세요.
Devel::Cover를 사용한 커버리지
커버리지 실행
# 기본 커버리지 보고서
cover -test
# 또는 단계별로
perl -MDevel::Cover -Ilib t/unit/user.t
cover
# HTML 보고서
cover -report html
open cover_db/coverage.html
# 특정 임계값
cover -test -report text | grep 'Total'
# CI 친화적: 임계값 미만이면 실패
cover -test && cover -report text -select '^lib/' \
| perl -ne 'if (/Total.*?(\d+\.\d+)/) { exit 1 if $1 < 80 }'
통합 테스트
데이터베이스 테스트에는 인메모리 SQLite를 사용하고, API 테스트에는 HTTP::Tiny를 모의 객체로 사용하세요.
use v5.36;
use Test2::V0;
use DBI;
subtest '데이터베이스 통합' => sub {
my $dbh = DBI->connect('dbi:SQLite:dbname=:memory:', '', '', {
RaiseError => 1,
});
$dbh->do('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)');
$dbh->prepare('INSERT INTO users (name) VALUES (?)')->execute('Alice');
my $row = $dbh->selectrow_hashref('SELECT * FROM users WHERE name = ?', undef, 'Alice');
is($row->{name}, 'Alice', '사용자 삽입 및 조회');
};
done_testing;
모범 사례
할 것
- TDD 준수: 구현 전에 테스트 작성 (red-green-refactor)
- Test2::V0 사용: 현대적인 어설션, 더 나은 진단
- 서브테스트 사용: 관련 어설션 그룹화, 상태 격리
- 외부 의존성 모의: 네트워크, 데이터베이스, 파일 시스템
prove -l사용: 항상@INC에lib/포함- 테스트 명확하게 이름 짓기:
'잘못된 비밀번호로 사용자 로그인 실패' - 경계 조건 테스트: 빈 문자열, undef, 0, 경계 값
- 80%+ 커버리지 목표: 비즈니스 로직 경로에 집중
- 테스트 속도 유지: I/O 모의, 인메모리 데이터베이스 사용
하지 말 것
- 구현을 테스트하지 마세요: 동작과 출력을 테스트하고, 내부는 테스트하지 마세요
- 서브테스트 간 상태를 공유하지 마세요: 각 서브테스트는 독립적이어야 합니다
done_testing을 생략하지 마세요: 계획된 모든 테스트가 실행되었는지 확인합니다- 과도하게 모의하지 마세요: 테스트 중인 코드가 아닌 경계에서만 모의하세요
- 새 프로젝트에
Test::More를 사용하지 마세요: Test2::V0 선호 - 테스트 실패를 무시하지 마세요: 병합 전에 모든 테스트가 통과해야 합니다
- CPAN 모듈을 테스트하지 마세요: 라이브러리가 올바르게 작동한다고 신뢰하세요
- 취약한 테스트를 작성하지 마세요: 지나치게 특정한 문자열 매칭 피하기
빠른 참조
| 작업 | 명령어 / 패턴 |
|---|---|
| 모든 테스트 실행 | prove -lr t/ |
| 하나의 테스트 자세히 실행 | prove -lv t/unit/user.t |
| 병렬 테스트 실행 | prove -lr -j8 t/ |
| 커버리지 보고서 | cover -test && cover -report html |
| 동등성 테스트 | is($got, $expected, 'label') |
| 깊은 비교 | is($got, hash { field k => 'v'; etc() }, 'label') |
| 예외 테스트 | like(dies { ... }, qr/msg/, 'label') |
| 예외 없음 테스트 | ok(lives { ... }, 'label') |
| 메서드 모의 | Test::MockModule->new('Pkg')->mock(m => sub { ... }) |
| 테스트 건너뛰기 | SKIP: { skip 'reason', $count unless $cond; ... } |
| TODO 테스트 | TODO: { local $TODO = 'reason'; ... } |
일반적인 함정
done_testing 잊기
# 나쁨: 테스트 파일이 실행되지만 모든 테스트가 실행되었는지 확인하지 않음
use Test2::V0;
is(1, 1, '작동');
# done_testing 누락 — 테스트 코드가 건너뛰어지면 조용한 버그 발생
# 좋음: 항상 done_testing으로 끝내기
use Test2::V0;
is(1, 1, '작동');
done_testing;
-l 플래그 누락
# 나쁨: lib/의 모듈을 찾을 수 없음
prove t/unit/user.t
# @INC에서 MyApp/User.pm을 찾을 수 없음
# 좋음: @INC에 lib/ 포함
prove -l t/unit/user.t
과도한 모의 객체
테스트 중인 코드가 아니라 의존성을 모의하세요. 테스트가 모의 객체가 알려준 대로 반환하는 것만 확인한다면 아무것도 테스트하지 않는 것입니다.
테스트 오염
서브테스트 내부에서는 my 변수를 사용하세요 — our는 절대 사용하지 마세요 — 상태가 테스트 간에 누수되는 것을 방지합니다.
기억하세요: 테스트는 안전망입니다. 빠르고, 집중적이며, 독립적으로 유지하세요. 새 프로젝트에는 Test2::V0을, 실행에는 prove를, 책임 추적에는 Devel::Cover를 사용하세요.